Repository navigation
feat(dingtalk): support outbound file delivery #9167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
7807f2d
cab867a
092ff52
05f7957
c2fafd8
c357358
6d98447
2d326f4
7d5d383
68490fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ interface SegmentPresentation { | |
| run: RunPresentation; | ||
| context: ChannelOutputSegmentContext; | ||
| content: string; | ||
| displayContent: string; | ||
| } | ||
|
|
||
| export interface DingtalkInteractionPresenterOptions { | ||
|
|
@@ -135,16 +136,19 @@ export class DingtalkInteractionPresenter { | |
| run, | ||
| context: segment, | ||
| content: '', | ||
| displayContent: '', | ||
| }; | ||
| presentation.content = this.boundContent(presentation.content + chunk); | ||
| const accumulated = presentation.content + chunk; | ||
| presentation.content = this.boundContent(accumulated); | ||
| presentation.displayContent = this.boundDisplayContent(accumulated); | ||
| this.segments.set(segment.segmentId, presentation); | ||
| run.activeSegmentId = segment.segmentId; | ||
| const statusContext = this.ensureStatusContext(run, segment); | ||
| void this.enqueue(run, () => { | ||
| this.options.statusCards?.replace( | ||
| statusContext, | ||
| this.cardTarget(statusContext.target), | ||
| presentation.content, | ||
| accumulated, | ||
| ); | ||
| }); | ||
| } | ||
|
|
@@ -193,15 +197,17 @@ export class DingtalkInteractionPresenter { | |
| (await statusCards.flushPending(statusContext.segmentId)); | ||
| if (deliveredViaCard) { | ||
| run.cardDelivered = { | ||
| text: sanitizeFallbackOutput(text || presentation.content), | ||
| text: text | ||
| ? this.boundDisplayContent(text) | ||
| : presentation.displayContent, | ||
| chatId: presentation.context.target.chatId, | ||
| sessionId: presentation.context.sessionId, | ||
| }; | ||
| return true; | ||
| } | ||
| const fallbackText = sanitizeFallbackOutput( | ||
| text || presentation.content, | ||
| ); | ||
| const fallbackText = text | ||
| ? this.boundDisplayContent(text) | ||
| : presentation.displayContent; | ||
| if (!fallbackText || !this.options.sendFallback) return false; | ||
| await this.options.sendFallback( | ||
| presentation.context.target.chatId, | ||
|
|
@@ -215,13 +221,13 @@ export class DingtalkInteractionPresenter { | |
| statusCards !== undefined && | ||
| (await statusCards.complete( | ||
| statusContext.segmentId, | ||
| this.withSenderPrefix(run, text || presentation.content), | ||
| this.withSenderPrefix(run, text || presentation.displayContent), | ||
| )); | ||
| run.statusContext = undefined; | ||
| if (completed) return true; | ||
| const fallbackText = sanitizeFallbackOutput( | ||
| text || presentation.content, | ||
| ); | ||
| const fallbackText = text | ||
| ? this.boundDisplayContent(text) | ||
| : presentation.displayContent; | ||
| if (!fallbackText || !this.options.sendFallback) return false; | ||
| await this.options.sendFallback( | ||
| presentation.context.target.chatId, | ||
|
|
@@ -235,10 +241,12 @@ export class DingtalkInteractionPresenter { | |
| statusCards !== undefined && | ||
| (await statusCards.complete( | ||
| statusContext.segmentId, | ||
| this.withSenderPrefix(run, text || presentation.content), | ||
| this.withSenderPrefix(run, text || presentation.displayContent), | ||
| )); | ||
| if (completed) return true; | ||
| const fallbackText = sanitizeFallbackOutput(text || presentation.content); | ||
| const fallbackText = text | ||
| ? this.boundDisplayContent(text) | ||
| : presentation.displayContent; | ||
| if (!fallbackText || !this.options.sendFallback) return false; | ||
| await this.options.sendFallback( | ||
| presentation.context.target.chatId, | ||
|
|
@@ -395,6 +403,10 @@ export class DingtalkInteractionPresenter { | |
| } | ||
|
|
||
| private boundContent(content: string, limit = CONTENT_LIMIT): string { | ||
| return truncateOutboundMediaText(content, limit, TRUNCATION_MARKER); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复。验证证据:commit cab867a;相关 5 个 Vitest 文件 118/118 通过;@qwen-code/channel-dingtalk build、Prettier、ESLint 通过。 |
||
| } | ||
|
|
||
| private boundDisplayContent(content: string, limit = CONTENT_LIMIT): string { | ||
| return truncateOutboundMediaText( | ||
| sanitizeFallbackOutput(content), | ||
|
Comment on lines
461
to
+467
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复:累积缓冲保持原始文本,卡片与回退只在输出边界生成安全快照,跨 chunk marker 可完整重组。验证:独立 split-marker harness 通过;DingTalk Vitest 342/342、package build、ESLint、Prettier、git diff --check 均通过。 |
||
| limit, | ||
|
|
@@ -403,15 +415,15 @@ export class DingtalkInteractionPresenter { | |
| } | ||
|
|
||
| private withSenderPrefix(run: RunPresentation, content: string): string { | ||
| if (!run.senderPrefix) return this.boundContent(content); | ||
| if (!run.senderPrefix) return this.boundDisplayContent(content); | ||
| const body = this.withoutExistingSenderPrefix(run, content); | ||
| if (!body) return run.senderPrefix; | ||
| const separator = '\n\n'; | ||
| const bodyLimit = Math.max( | ||
| 0, | ||
| CONTENT_LIMIT - run.senderPrefix.length - separator.length, | ||
| ); | ||
| return `${run.senderPrefix}${separator}${this.boundContent( | ||
| return `${run.senderPrefix}${separator}${this.boundDisplayContent( | ||
| body, | ||
| bodyLimit, | ||
| )}`; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,33 +85,75 @@ function maskCode(text: string): string { | |
| return masked.join(''); | ||
| } | ||
|
|
||
| const MEDIA_MARKER_PREFIXES = ['IMAGE:', 'FILE:']; | ||
| const MEDIA_MARKER_PATTERN = /\[(IMAGE|FILE):\s*/giu; | ||
|
|
||
| function previousOpenBracket(text: string, open: number): number { | ||
| return open === 0 ? -1 : text.lastIndexOf('[', open - 1); | ||
| } | ||
|
|
||
| function isMarkerCloseBoundary( | ||
| text: string, | ||
| close: number, | ||
| boundary: number, | ||
| ): boolean { | ||
| let cursor = close + 1; | ||
| if (cursor >= boundary || /\s/u.test(text[cursor]!)) return true; | ||
| while ( | ||
| cursor < boundary && | ||
| /[.,;:!?)}\],。;:!?)】]/u.test(text[cursor]!) | ||
| ) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复。验证证据:DingTalk 12 files/409 tests、ChannelBase 19 files/1038 tests、root build/typecheck、ESLint/Prettier、git diff --check 均通过;修复 commit 68490fb。 |
||
| cursor++; | ||
| } | ||
| return ( | ||
| cursor > close + 1 && (cursor >= boundary || /\s/u.test(text[cursor]!)) | ||
| ); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复。验证:Node 22 下 DingTalk 全包 12 files / 363 tests、ChannelBase 定向 608 tests、root build/typecheck/bundle、ESLint、Prettier、git diff --check 均通过;独立 generated-dist 复验 11/11 通过。 |
||
| } | ||
|
|
||
| function findMarkerClose( | ||
| text: string, | ||
| pathStart: number, | ||
| boundary: number, | ||
| ): number { | ||
| let close = text.indexOf(']', pathStart); | ||
| let lastClose = -1; | ||
| while (close !== -1 && close < boundary) { | ||
| lastClose = close; | ||
| if (isMarkerCloseBoundary(text, close, boundary)) return close; | ||
| close = text.indexOf(']', close + 1); | ||
| } | ||
| return lastClose; | ||
| } | ||
|
|
||
| function markerBoundary( | ||
| visibleText: string, | ||
| pathStart: number, | ||
| nextOpening: number | undefined, | ||
| ): number { | ||
| let boundary = nextOpening ?? visibleText.length; | ||
| const lineBreak = visibleText.slice(pathStart, boundary).search(/[\r\n]/u); | ||
| if (lineBreak !== -1) boundary = pathStart + lineBreak; | ||
| return boundary; | ||
| } | ||
|
|
||
| function markerSafeTruncationStart(text: string, start: number): number { | ||
| const before = text.slice(0, start); | ||
| const lastClose = before.lastIndexOf(']'); | ||
| const visibleText = maskCode(text); | ||
| const before = visibleText.slice(0, start); | ||
| let open = before.lastIndexOf('['); | ||
| while (open > lastClose) { | ||
| const candidate = before.slice(open + 1); | ||
| if (!/[\r\n]/u.test(candidate)) { | ||
| const normalized = candidate.toUpperCase(); | ||
| if ( | ||
| MEDIA_MARKER_PREFIXES.some( | ||
| (prefix) => | ||
| prefix.startsWith(normalized) || normalized.startsWith(prefix), | ||
| ) | ||
| ) { | ||
| const close = text.indexOf(']', start); | ||
| const newline = text | ||
| .slice(start, close === -1 ? undefined : close) | ||
| .search(/[\r\n]/u); | ||
| if (close === -1 || newline !== -1) return text.length; | ||
| return close + 1; | ||
| } | ||
| while (open !== -1) { | ||
| const opening = visibleText.slice(open).match(/^\[(IMAGE|FILE):\s*/iu); | ||
| if (opening) { | ||
| const pathStart = open + opening[0].length; | ||
| const nextOpeningOffset = visibleText | ||
| .slice(pathStart) | ||
| .search(MEDIA_MARKER_PATTERN); | ||
| const boundary = markerBoundary( | ||
| visibleText, | ||
| pathStart, | ||
| nextOpeningOffset === -1 ? undefined : pathStart + nextOpeningOffset, | ||
| ); | ||
| const close = findMarkerClose(visibleText, pathStart, boundary); | ||
| if (close >= start) return close + 1; | ||
| if (close === -1 && boundary >= start) return boundary; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复。验证:DingTalk 全包 12 files / 353 tests 通过;package build、root build + bundle、ESLint、Prettier、git diff --check 均通过。 |
||
| } | ||
| open = previousOpenBracket(before, open); | ||
| } | ||
|
|
@@ -140,8 +182,7 @@ export function findOutboundMediaMarkers( | |
| markerName: 'IMAGE' | 'FILE', | ||
| ): OutboundMediaMarker[] { | ||
| const visibleText = maskCode(text); | ||
| const markerPattern = /\[(IMAGE|FILE):\s*/giu; | ||
| const openings = [...visibleText.matchAll(markerPattern)]; | ||
| const openings = [...visibleText.matchAll(MEDIA_MARKER_PATTERN)]; | ||
| const markers: OutboundMediaMarker[] = []; | ||
|
|
||
| for (let i = 0; i < openings.length; i++) { | ||
|
|
@@ -150,11 +191,13 @@ export function findOutboundMediaMarkers( | |
| continue; | ||
| } | ||
| const pathStart = match.index + match[0].length; | ||
| let boundary = openings[i + 1]?.index ?? visibleText.length; | ||
| const lineBreak = visibleText.slice(pathStart, boundary).search(/[\r\n]/u); | ||
| if (lineBreak !== -1) boundary = pathStart + lineBreak; | ||
| const close = visibleText.lastIndexOf(']', boundary - 1); | ||
| if (close < pathStart) continue; | ||
| const boundary = markerBoundary( | ||
| visibleText, | ||
| pathStart, | ||
| openings[i + 1]?.index, | ||
| ); | ||
| const close = findMarkerClose(visibleText, pathStart, boundary); | ||
| if (close === -1) continue; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复:不完整外层 marker 只清理到下一媒体 marker 边界,保留并投递内层有效文件;完整带中括号路径不再误判。验证:nested bracket 探针及 webhook 端到端回归通过,输出不含本地路径。 |
||
| const path = text.slice(pathStart, close).trim(); | ||
| if (!path) continue; | ||
| markers.push({ | ||
|
|
@@ -199,7 +242,10 @@ export function stripPartialOutboundMediaMarker( | |
| while (open !== -1) { | ||
| const candidate = visibleText.slice(open + 1); | ||
| if (/[\r\n]/u.test(candidate)) break; | ||
| if (candidate.includes(']')) { | ||
| const nextOpen = visibleText.indexOf('[', open + 1); | ||
| const ownSegment = | ||
| nextOpen === -1 ? candidate : candidate.slice(0, nextOpen - open - 1); | ||
| if (ownSegment.includes(']')) { | ||
| open = previousOpenBracket(visibleText, open); | ||
| continue; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
已修复:marker-safe 截断不再受被截断 fence opener 的代码区遮罩影响,展示链路仍 fail-closed 清理。验证:chunked fence 的 fallback、live card snapshots、redelivery 三条路径均
leaked=false;DingTalk 347/347 通过。