@qwen-code/channel-dingtalk 0.22.2 → 0.22.3-nightly.20260831.3a0c4c6108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DingtalkAdapter.d.ts +27 -2
- package/dist/DingtalkAdapter.d.ts.map +1 -1
- package/dist/DingtalkAdapter.js +236 -73
- package/dist/DingtalkAdapter.js.map +1 -1
- package/dist/interaction-presenter.d.ts +5 -2
- package/dist/interaction-presenter.d.ts.map +1 -1
- package/dist/interaction-presenter.js +50 -14
- package/dist/interaction-presenter.js.map +1 -1
- package/dist/markdown.d.ts +4 -2
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +33 -9
- package/dist/markdown.js.map +1 -1
- package/dist/outbound-file.d.ts +33 -0
- package/dist/outbound-file.d.ts.map +1 -0
- package/dist/outbound-file.js +109 -0
- package/dist/outbound-file.js.map +1 -0
- package/dist/question-card-controller.d.ts +2 -1
- package/dist/question-card-controller.d.ts.map +1 -1
- package/dist/question-card-controller.js +16 -7
- package/dist/question-card-controller.js.map +1 -1
- package/package.json +2 -2
|
@@ -36,6 +36,9 @@ export declare class DingtalkChannel extends ChannelBase {
|
|
|
36
36
|
private readonly inboundCardOwners;
|
|
37
37
|
private readonly cardRunBySession;
|
|
38
38
|
private readonly cardRuns;
|
|
39
|
+
private readonly fileProjectors;
|
|
40
|
+
private readonly blockFileProjectors;
|
|
41
|
+
private readonly blockProjectionArmed;
|
|
39
42
|
constructor(name: string, config: ChannelConfig, bridge: ChannelAgentBridge, options?: ChannelBaseOptions);
|
|
40
43
|
private createClient;
|
|
41
44
|
private installStructuredDownstreamHandler;
|
|
@@ -52,9 +55,11 @@ export declare class DingtalkChannel extends ChannelBase {
|
|
|
52
55
|
* dropped on ingestion. Exposed for testing the drop rule.
|
|
53
56
|
*/
|
|
54
57
|
static isUnroutableGroupMessage(isGroup: boolean, conversationId: string | undefined): boolean;
|
|
58
|
+
private projectOutgoingFileText;
|
|
55
59
|
private prepareOutgoingText;
|
|
56
60
|
private sendReply;
|
|
57
61
|
sendMessage(chatId: string, text: string): Promise<void>;
|
|
62
|
+
protected sendThreadMessage(chatId: string, _threadId: string | undefined, text: string, sourceLabel?: string): Promise<void>;
|
|
58
63
|
supportsProactiveSend(): boolean;
|
|
59
64
|
protected supportsProactiveTarget(target: SessionTarget): boolean;
|
|
60
65
|
protected supportsProactiveDeliveryTarget(target: SessionTarget): boolean;
|
|
@@ -63,7 +68,7 @@ export declare class DingtalkChannel extends ChannelBase {
|
|
|
63
68
|
* Single-shot cold send: a failed chunk aborts the remainder (already-sent
|
|
64
69
|
* chunks are not recalled) and the error surfaces in the loop's lastError.
|
|
65
70
|
*/
|
|
66
|
-
protected pushProactive(target: SessionTarget, text: string): Promise<void>;
|
|
71
|
+
protected pushProactive(target: SessionTarget, text: string, sourceLabel?: string): Promise<void>;
|
|
67
72
|
private getProactiveToken;
|
|
68
73
|
private sendCardInteractionFeedback;
|
|
69
74
|
private sendProactiveChunk;
|
|
@@ -90,11 +95,31 @@ export declare class DingtalkChannel extends ChannelBase {
|
|
|
90
95
|
protected processInbound(envelope: Envelope): Promise<void>;
|
|
91
96
|
private untrackBufferedMentionTarget;
|
|
92
97
|
protected onPromptEnd(chatId: string, sessionId: string, messageId?: string): void;
|
|
93
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Turn end is the only point where the block projector's held state can be
|
|
100
|
+
* settled: ChannelBase drains the turn's queued block sends before calling
|
|
101
|
+
* onPromptEnd, so everything already appended belongs to this turn. Flush
|
|
102
|
+
* the held candidate bytes (a trailing `[FILE:` prefix the stream never
|
|
103
|
+
* completed) before deleting the entry — a later delete-without-settle
|
|
104
|
+
* would silently drop them from the delivered answer. Also disarm the
|
|
105
|
+
* session: /clear eviction and session death settle WITHOUT draining the
|
|
106
|
+
* turn's send chain, and any block that lands afterwards must be dropped
|
|
107
|
+
* rather than recreating projector state.
|
|
108
|
+
*/
|
|
109
|
+
private settleBlockFileProjector;
|
|
110
|
+
/**
|
|
111
|
+
* Out-of-turn one-shot sends (background responses) must not flow through
|
|
112
|
+
* the session's block-streaming projector: a second sender interleaving with
|
|
113
|
+
* mid-projection state can swallow the send or split a held marker.
|
|
114
|
+
*/
|
|
115
|
+
protected deliverBackgroundReply(chatId: string, text: string, sessionId: string, sourceLabel?: string): Promise<void>;
|
|
116
|
+
protected sendResponseMessage(chatId: string, text: string, sessionId: string, sourceLabel?: string): Promise<void>;
|
|
117
|
+
private projectBlockStreamChunk;
|
|
94
118
|
private sendFallbackReply;
|
|
95
119
|
protected onResponseComplete(chatId: string, text: string, sessionId: string, segment?: ChannelOutputSegmentContext): Promise<void>;
|
|
96
120
|
protected onOutputSegmentEnd(_chatId: string, _sessionId: string, segment: ChannelOutputSegmentContext, reason: ChannelOutputSegmentEndReason): void | Promise<void>;
|
|
97
121
|
protected onResponseChunk(_chatId: string, chunk: string, _sessionId: string, segment?: ChannelOutputSegmentContext): void;
|
|
122
|
+
private deleteFileProjectorsForRun;
|
|
98
123
|
protected presentUserInputRequest(context: ChannelUserInputRequestContext): Promise<UserInputPresentationResult>;
|
|
99
124
|
/**
|
|
100
125
|
* Extract quoted/referenced message context from a reply.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DingtalkAdapter.d.ts","sourceRoot":"","sources":["../src/DingtalkAdapter.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,WAAW,EAMZ,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"DingtalkAdapter.d.ts","sourceRoot":"","sources":["../src/DingtalkAdapter.ts"],"names":[],"mappings":"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"}
|
package/dist/DingtalkAdapter.js
CHANGED
|
@@ -5,9 +5,10 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { Buffer } from 'node:buffer';
|
|
6
6
|
import { DWClient, TOPIC_CARD, TOPIC_ROBOT, EventAck, } from 'dingtalk-stream-sdk-nodejs';
|
|
7
7
|
import { ChannelBase, isTerminalTaskLifecycleType, sanitizeLogText, sanitizePromptText, sanitizeSenderName, truncateUtf16Units, } from '@qwen-code/channel-base';
|
|
8
|
-
import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js';
|
|
8
|
+
import { DINGTALK_CHUNK_LIMIT, escapeDingTalkMarkdown, normalizeDingTalkMarkdown, extractTitle, } from './markdown.js';
|
|
9
9
|
import { downloadMedia } from './media.js';
|
|
10
10
|
import { DingTalkMediaUploadError, findImageMarkers, readValidatedImage, replaceImageMarkers, uploadDingTalkImage, } from './outbound-image.js';
|
|
11
|
+
import { OutboundFileProjector, projectFileText, withFileUnavailableNotice, } from './outbound-file.js';
|
|
11
12
|
import { DingtalkConnectionManager, } from './DingtalkConnectionManager.js';
|
|
12
13
|
import { DingtalkInteractiveCardClient } from './interactive-card-client.js';
|
|
13
14
|
import { parseDingtalkCardActorId, parseDingtalkCardCallback, parseDingtalkInteractiveCardConfig, } from './interactive-card-types.js';
|
|
@@ -418,6 +419,7 @@ const DIRECT_MSG_API = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSen
|
|
|
418
419
|
const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
|
|
419
420
|
const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
|
|
420
421
|
const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
|
|
422
|
+
const REPLY_FETCH_TIMEOUT_MS = 15_000;
|
|
421
423
|
// Extensions for generated media store names, keyed by the download's mime
|
|
422
424
|
// type. The agent reads stored media via `read_file`, whose type detection is
|
|
423
425
|
// extension-first: an extensionless name falls through to the binary content
|
|
@@ -495,6 +497,18 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
495
497
|
inboundCardOwners = new Map();
|
|
496
498
|
cardRunBySession = new Map();
|
|
497
499
|
cardRuns = new Map();
|
|
500
|
+
// Keyed by runId, not segmentId: a mid-turn segment reset (response
|
|
501
|
+
// boundary, input requested) mints a fresh segment UUID but the projection
|
|
502
|
+
// state must survive it, or a marker split across the reset leaks.
|
|
503
|
+
fileProjectors = new Map();
|
|
504
|
+
blockFileProjectors = new Map();
|
|
505
|
+
// Sessions armed for block projection by onPromptStart and disarmed when
|
|
506
|
+
// the turn settles (or the session dies). A block send that finds NO
|
|
507
|
+
// projector state is only legitimate as a turn's FIRST block, which always
|
|
508
|
+
// lands while armed: late sends from an evicted (/clear) or dead session
|
|
509
|
+
// must be dropped, because recreating state would post the tail of a
|
|
510
|
+
// force-split [FILE: ...] marker verbatim.
|
|
511
|
+
blockProjectionArmed = new Set();
|
|
498
512
|
constructor(name, config, bridge, options) {
|
|
499
513
|
super(name, config, bridge, options);
|
|
500
514
|
this.atSender =
|
|
@@ -542,7 +556,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
542
556
|
this.questionCardController = new QuestionCardController({
|
|
543
557
|
client: this.interactiveCardClient,
|
|
544
558
|
timeoutMs: this.interactiveCardConfig.questionCard.timeoutMs,
|
|
545
|
-
sendFallback: (chatId, text) => this.
|
|
559
|
+
sendFallback: (chatId, text, sourceLabel) => this.sendReply(chatId, text, undefined, sourceLabel),
|
|
546
560
|
reserveRunProjection: (runId) => this.interactionPresenter?.reserveProjection(runId),
|
|
547
561
|
onError: (operation, error) => {
|
|
548
562
|
process.stderr.write(`[DingTalk:${this.name}] ${operation} failed: ${sanitizeLogText(String(error), 300)}\n`);
|
|
@@ -555,7 +569,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
555
569
|
questionCards: this.questionCardController,
|
|
556
570
|
...(config.blockStreaming !== 'on'
|
|
557
571
|
? {
|
|
558
|
-
sendFallback: (chatId, text, sessionId) => this.sendFallbackReply(chatId, text, sessionId),
|
|
572
|
+
sendFallback: (chatId, text, sessionId, sourceLabel) => this.sendFallbackReply(chatId, text, sessionId, sourceLabel),
|
|
559
573
|
}
|
|
560
574
|
: {}),
|
|
561
575
|
});
|
|
@@ -744,10 +758,27 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
744
758
|
static isUnroutableGroupMessage(isGroup, conversationId) {
|
|
745
759
|
return isGroup && !conversationId;
|
|
746
760
|
}
|
|
747
|
-
|
|
748
|
-
const
|
|
761
|
+
projectOutgoingFileText(text, streamed) {
|
|
762
|
+
const projection = projectFileText(text);
|
|
763
|
+
// Markers are counted when their opening bytes arrive, so the streamed
|
|
764
|
+
// count also fails closed when the final text no longer carries a marker
|
|
765
|
+
// the stream already delivered. Whole-turn hash comparison is NOT
|
|
766
|
+
// viable: the bridges return only post-last-boundary chunks as the final
|
|
767
|
+
// text, so any routine multi-tool turn would diverge by construction.
|
|
768
|
+
const streamedMarkers = streamed ? streamed.result('').markerCount : 0;
|
|
769
|
+
if (projection.markerCount === 0 && streamedMarkers === 0) {
|
|
770
|
+
return projection.text;
|
|
771
|
+
}
|
|
772
|
+
// Counts only — never paths — so a redaction event stays debuggable
|
|
773
|
+
// without leaking what was redacted.
|
|
774
|
+
process.stderr.write(`[DingTalk:${this.name}] file markers redacted (final=${projection.markerCount}, streamed=${streamedMarkers})\n`);
|
|
775
|
+
return withFileUnavailableNotice(projection.text);
|
|
776
|
+
}
|
|
777
|
+
async prepareOutgoingText(text, streamed) {
|
|
778
|
+
const fileSafeText = this.projectOutgoingFileText(text, streamed);
|
|
779
|
+
const markers = findImageMarkers(fileSafeText);
|
|
749
780
|
if (markers.length === 0)
|
|
750
|
-
return
|
|
781
|
+
return fileSafeText;
|
|
751
782
|
const replacements = [];
|
|
752
783
|
for (const marker of markers) {
|
|
753
784
|
const fileName = basename(marker.path)
|
|
@@ -784,9 +815,9 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
784
815
|
replacements.push(`[Image delivery failed: ${fileName}]`);
|
|
785
816
|
}
|
|
786
817
|
}
|
|
787
|
-
return replaceImageMarkers(
|
|
818
|
+
return replaceImageMarkers(fileSafeText, markers, replacements);
|
|
788
819
|
}
|
|
789
|
-
async sendReply(chatId, text, atUserId) {
|
|
820
|
+
async sendReply(chatId, text, atUserId, sourceLabel) {
|
|
790
821
|
// chatId is a conversationId — resolve to the latest sessionWebhook
|
|
791
822
|
const webhook = this.webhooks.get(chatId);
|
|
792
823
|
if (!webhook) {
|
|
@@ -795,7 +826,14 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
795
826
|
}
|
|
796
827
|
const outgoingText = await this.prepareOutgoingText(text);
|
|
797
828
|
const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
|
|
798
|
-
const
|
|
829
|
+
const sourcePrefix = sourceLabel && outgoingText.trim().length > 0
|
|
830
|
+
? `${escapeDingTalkMarkdown(sourceLabel)}\n\n`
|
|
831
|
+
: '';
|
|
832
|
+
const contentLimit = DINGTALK_CHUNK_LIMIT - mentionPrefix.length - sourcePrefix.length;
|
|
833
|
+
if (contentLimit <= 0) {
|
|
834
|
+
throw new Error('DingTalk source label exceeds the message limit.');
|
|
835
|
+
}
|
|
836
|
+
const chunks = normalizeDingTalkMarkdown(outgoingText, contentLimit).map((chunk, index) => `${index === 0 ? mentionPrefix : ''}${sourcePrefix}${chunk}`);
|
|
799
837
|
const title = extractTitle(outgoingText);
|
|
800
838
|
for (let i = 0; i < chunks.length; i++) {
|
|
801
839
|
const chunk = chunks[i];
|
|
@@ -808,11 +846,19 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
808
846
|
},
|
|
809
847
|
...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
|
|
810
848
|
};
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
849
|
+
let resp;
|
|
850
|
+
try {
|
|
851
|
+
resp = await fetch(webhook, {
|
|
852
|
+
method: 'POST',
|
|
853
|
+
headers: { 'Content-Type': 'application/json' },
|
|
854
|
+
body: JSON.stringify(body),
|
|
855
|
+
signal: AbortSignal.timeout(REPLY_FETCH_TIMEOUT_MS),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 300)}\n`);
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
816
862
|
if (isMention && process.env['QWEN_CHANNEL_DEBUG_MENTIONS'] === '1') {
|
|
817
863
|
const payload = (await resp
|
|
818
864
|
.clone()
|
|
@@ -836,6 +882,9 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
836
882
|
async sendMessage(chatId, text) {
|
|
837
883
|
await this.sendReply(chatId, text);
|
|
838
884
|
}
|
|
885
|
+
async sendThreadMessage(chatId, _threadId, text, sourceLabel) {
|
|
886
|
+
await this.sendReply(chatId, text, undefined, sourceLabel);
|
|
887
|
+
}
|
|
839
888
|
supportsProactiveSend() {
|
|
840
889
|
return true;
|
|
841
890
|
}
|
|
@@ -860,11 +909,18 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
860
909
|
* Single-shot cold send: a failed chunk aborts the remainder (already-sent
|
|
861
910
|
* chunks are not recalled) and the error surfaces in the loop's lastError.
|
|
862
911
|
*/
|
|
863
|
-
async pushProactive(target, text) {
|
|
912
|
+
async pushProactive(target, text, sourceLabel) {
|
|
864
913
|
if (!text.trim())
|
|
865
914
|
return;
|
|
866
915
|
const outgoingText = await this.prepareOutgoingText(text);
|
|
867
|
-
const
|
|
916
|
+
const sourcePrefix = sourceLabel
|
|
917
|
+
? `${escapeDingTalkMarkdown(sourceLabel)}\n\n`
|
|
918
|
+
: '';
|
|
919
|
+
const contentLimit = DINGTALK_CHUNK_LIMIT - sourcePrefix.length;
|
|
920
|
+
if (contentLimit <= 0) {
|
|
921
|
+
throw new Error('DingTalk source label exceeds the message limit.');
|
|
922
|
+
}
|
|
923
|
+
const chunks = normalizeDingTalkMarkdown(outgoingText, contentLimit).map((chunk) => `${sourcePrefix}${chunk}`);
|
|
868
924
|
const title = extractTitle(outgoingText);
|
|
869
925
|
for (let i = 0; i < chunks.length; i++) {
|
|
870
926
|
await this.sendProactiveChunk(target, i === 0 ? title : `${title} (cont.)`, chunks[i], `chunk ${i + 1}/${chunks.length}`);
|
|
@@ -1120,6 +1176,12 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1120
1176
|
}
|
|
1121
1177
|
/** Recall reactions left behind when a session dies without terminal lifecycle events. */
|
|
1122
1178
|
onSessionDied(sessionId) {
|
|
1179
|
+
this.blockProjectionArmed.delete(sessionId);
|
|
1180
|
+
this.blockFileProjectors.delete(sessionId);
|
|
1181
|
+
for (const [runId, state] of this.fileProjectors) {
|
|
1182
|
+
if (state.sessionId === sessionId)
|
|
1183
|
+
this.fileProjectors.delete(runId);
|
|
1184
|
+
}
|
|
1123
1185
|
const bufferedTargets = this.bufferedMentionTargetsBySession.get(sessionId);
|
|
1124
1186
|
if (bufferedTargets) {
|
|
1125
1187
|
this.bufferedMentionTargetsBySession.delete(sessionId);
|
|
@@ -1161,7 +1223,8 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1161
1223
|
inboundOwner?.ownerId === event.owner.id) {
|
|
1162
1224
|
this.cardRuns.set(event.runId, inboundOwner);
|
|
1163
1225
|
this.cardRunBySession.set(event.sessionId, event.runId);
|
|
1164
|
-
this.
|
|
1226
|
+
const sourceLabel = this.getResponseSourceLabel(event.sessionId);
|
|
1227
|
+
this.interactionPresenter?.registerRun(event.runId, event.owner.id, inboundOwner.target, event.sessionId, inboundOwner.sender, ...(sourceLabel ? [sourceLabel] : []));
|
|
1165
1228
|
this.interactionPresenter?.startStatusCard(event.runId);
|
|
1166
1229
|
}
|
|
1167
1230
|
return;
|
|
@@ -1171,6 +1234,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1171
1234
|
this.mentionTargets.delete(event.messageId);
|
|
1172
1235
|
this.stopReaction(event.chatId, event.messageId, event.sessionId);
|
|
1173
1236
|
if (event.runId) {
|
|
1237
|
+
this.deleteFileProjectorsForRun(event.runId);
|
|
1174
1238
|
if (event.type === 'failed') {
|
|
1175
1239
|
this.interactionPresenter?.terminalizeRun(event.runId, 'failed', event.error);
|
|
1176
1240
|
}
|
|
@@ -1215,6 +1279,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1215
1279
|
}
|
|
1216
1280
|
}
|
|
1217
1281
|
onPromptStart(chatId, sessionId, messageId) {
|
|
1282
|
+
this.blockProjectionArmed.add(sessionId);
|
|
1218
1283
|
if (messageId) {
|
|
1219
1284
|
this.bufferedMentionTargets.delete(messageId);
|
|
1220
1285
|
this.untrackBufferedMentionTarget(sessionId, messageId);
|
|
@@ -1229,34 +1294,36 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1229
1294
|
async handleInbound(envelope) {
|
|
1230
1295
|
if (!(await this.preflightInbound(envelope)))
|
|
1231
1296
|
return;
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1297
|
+
await this.processPreflightedInbound(envelope, async () => {
|
|
1298
|
+
const messageId = envelope.messageId;
|
|
1299
|
+
if (messageId && envelope.senderId) {
|
|
1300
|
+
this.inboundCardOwners.delete(messageId);
|
|
1301
|
+
this.inboundCardOwners.set(messageId, {
|
|
1302
|
+
ownerId: envelope.senderId,
|
|
1303
|
+
target: {
|
|
1304
|
+
chatId: envelope.chatId,
|
|
1305
|
+
isGroup: envelope.isGroup,
|
|
1306
|
+
},
|
|
1307
|
+
...(this.atSender && envelope.isGroup
|
|
1308
|
+
? {
|
|
1309
|
+
sender: {
|
|
1310
|
+
senderName: envelope.senderName,
|
|
1311
|
+
},
|
|
1312
|
+
}
|
|
1313
|
+
: {}),
|
|
1314
|
+
});
|
|
1315
|
+
if (this.inboundCardOwners.size > 1000) {
|
|
1316
|
+
const oldest = this.inboundCardOwners.keys().next().value;
|
|
1317
|
+
if (oldest !== undefined)
|
|
1318
|
+
this.inboundCardOwners.delete(oldest);
|
|
1319
|
+
}
|
|
1253
1320
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1321
|
+
const atUserId = envelope[mentionTarget];
|
|
1322
|
+
if (this.atSender && messageId && atUserId) {
|
|
1323
|
+
this.mentionTargets.set(messageId, atUserId);
|
|
1324
|
+
}
|
|
1325
|
+
await this.processInbound(envelope);
|
|
1326
|
+
});
|
|
1260
1327
|
}
|
|
1261
1328
|
async processInbound(envelope) {
|
|
1262
1329
|
const messageId = envelope.messageId;
|
|
@@ -1278,35 +1345,116 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1278
1345
|
this.bufferedMentionTargetsBySession.delete(sessionId);
|
|
1279
1346
|
}
|
|
1280
1347
|
onPromptEnd(chatId, sessionId, messageId) {
|
|
1348
|
+
this.settleBlockFileProjector(chatId, sessionId);
|
|
1281
1349
|
this.sessionMentionTargets.delete(sessionId);
|
|
1282
1350
|
this.stopReaction(chatId, messageId, sessionId);
|
|
1283
1351
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1352
|
+
/**
|
|
1353
|
+
* Turn end is the only point where the block projector's held state can be
|
|
1354
|
+
* settled: ChannelBase drains the turn's queued block sends before calling
|
|
1355
|
+
* onPromptEnd, so everything already appended belongs to this turn. Flush
|
|
1356
|
+
* the held candidate bytes (a trailing `[FILE:` prefix the stream never
|
|
1357
|
+
* completed) before deleting the entry — a later delete-without-settle
|
|
1358
|
+
* would silently drop them from the delivered answer. Also disarm the
|
|
1359
|
+
* session: /clear eviction and session death settle WITHOUT draining the
|
|
1360
|
+
* turn's send chain, and any block that lands afterwards must be dropped
|
|
1361
|
+
* rather than recreating projector state.
|
|
1362
|
+
*/
|
|
1363
|
+
settleBlockFileProjector(chatId, sessionId) {
|
|
1364
|
+
this.blockProjectionArmed.delete(sessionId);
|
|
1365
|
+
const state = this.blockFileProjectors.get(sessionId);
|
|
1366
|
+
if (!state)
|
|
1367
|
+
return;
|
|
1368
|
+
this.blockFileProjectors.delete(sessionId);
|
|
1369
|
+
const tail = state.projector.complete();
|
|
1370
|
+
if (!tail.trim())
|
|
1371
|
+
return;
|
|
1372
|
+
// Deliberately fire-and-forget: complete() can only return a strict
|
|
1373
|
+
// prefix of '[FILE:' (at most 5 chars, no path bytes), so the worst case
|
|
1374
|
+
// is a stray fragment landing out of order with the next turn — not a
|
|
1375
|
+
// leak — and blocking settle on a delivery that may hang is worse.
|
|
1376
|
+
void this.sendReply(chatId, tail, undefined, this.getResponseSourceLabel(sessionId)).catch((err) => {
|
|
1377
|
+
process.stderr.write(`[DingTalk:${this.name}] projector tail delivery failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Out-of-turn one-shot sends (background responses) must not flow through
|
|
1382
|
+
* the session's block-streaming projector: a second sender interleaving with
|
|
1383
|
+
* mid-projection state can swallow the send or split a held marker.
|
|
1384
|
+
*/
|
|
1385
|
+
async deliverBackgroundReply(chatId, text, sessionId, sourceLabel) {
|
|
1386
|
+
if (this.config.blockStreaming !== 'on') {
|
|
1387
|
+
return super.deliverBackgroundReply(chatId, text, sessionId, sourceLabel);
|
|
1388
|
+
}
|
|
1389
|
+
await this.sendReply(chatId, text, undefined, sourceLabel);
|
|
1390
|
+
}
|
|
1391
|
+
async sendResponseMessage(chatId, text, sessionId, sourceLabel) {
|
|
1392
|
+
let outgoingText = text;
|
|
1393
|
+
let consumesMention = true;
|
|
1394
|
+
if (this.config.blockStreaming === 'on') {
|
|
1395
|
+
const projected = this.projectBlockStreamChunk(text, sessionId);
|
|
1396
|
+
if (!projected.text.trim())
|
|
1397
|
+
return;
|
|
1398
|
+
outgoingText = projected.text;
|
|
1399
|
+
// A notice-only block must not consume the prompt's mention target:
|
|
1400
|
+
// the @mention belongs to the block carrying the actual answer.
|
|
1401
|
+
consumesMention = projected.hasContent;
|
|
1402
|
+
}
|
|
1403
|
+
const atUserId = consumesMention && this.atSender
|
|
1286
1404
|
? this.sessionMentionTargets.get(sessionId)
|
|
1287
1405
|
: undefined;
|
|
1288
1406
|
if (atUserId)
|
|
1289
1407
|
this.sessionMentionTargets.delete(sessionId);
|
|
1290
|
-
await this.sendReply(chatId,
|
|
1408
|
+
await this.sendReply(chatId, outgoingText, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId));
|
|
1409
|
+
}
|
|
1410
|
+
projectBlockStreamChunk(text, sessionId) {
|
|
1411
|
+
let state = this.blockFileProjectors.get(sessionId);
|
|
1412
|
+
if (!state) {
|
|
1413
|
+
if (!this.blockProjectionArmed.has(sessionId)) {
|
|
1414
|
+
return { text: '', hasContent: false };
|
|
1415
|
+
}
|
|
1416
|
+
state = { projector: new OutboundFileProjector(), reportedMarkers: 0 };
|
|
1417
|
+
this.blockFileProjectors.set(sessionId, state);
|
|
1418
|
+
}
|
|
1419
|
+
const safe = state.projector.append(text);
|
|
1420
|
+
const hasContent = safe.trim().length > 0;
|
|
1421
|
+
const result = state.projector.result(safe);
|
|
1422
|
+
let outgoingText = safe;
|
|
1423
|
+
if (result.markerCount > state.reportedMarkers) {
|
|
1424
|
+
outgoingText = withFileUnavailableNotice(safe);
|
|
1425
|
+
state.reportedMarkers = result.markerCount;
|
|
1426
|
+
process.stderr.write(`[DingTalk:${this.name}] file markers redacted in block stream (session ${sessionId}, markers=${result.markerCount})\n`);
|
|
1427
|
+
}
|
|
1428
|
+
return { text: outgoingText, hasContent };
|
|
1291
1429
|
}
|
|
1292
|
-
async sendFallbackReply(chatId, text, sessionId) {
|
|
1430
|
+
async sendFallbackReply(chatId, text, sessionId, sourceLabel) {
|
|
1293
1431
|
// Mid-run fallbacks must not consume the prompt's mention target: the
|
|
1294
1432
|
// final answer of the same run still needs it.
|
|
1295
1433
|
const atUserId = this.atSender
|
|
1296
1434
|
? this.sessionMentionTargets.get(sessionId)
|
|
1297
1435
|
: undefined;
|
|
1298
|
-
await this.sendReply(chatId, text, atUserId);
|
|
1436
|
+
await this.sendReply(chatId, text, atUserId, sourceLabel);
|
|
1299
1437
|
}
|
|
1300
1438
|
async onResponseComplete(chatId, text, sessionId, segment) {
|
|
1439
|
+
const streamed = segment
|
|
1440
|
+
? this.fileProjectors.get(segment.runId)?.projector
|
|
1441
|
+
: undefined;
|
|
1442
|
+
if (segment)
|
|
1443
|
+
this.fileProjectors.delete(segment.runId);
|
|
1444
|
+
const outgoingText = await this.prepareOutgoingText(text, streamed);
|
|
1301
1445
|
if (segment && this.interactionPresenter) {
|
|
1302
|
-
const outgoingText = await this.prepareOutgoingText(text);
|
|
1303
1446
|
if (await this.interactionPresenter.closeOutput(segment.segmentId, outgoingText, 'completed', segment)) {
|
|
1304
1447
|
return;
|
|
1305
1448
|
}
|
|
1306
1449
|
}
|
|
1307
|
-
await this.sendResponseMessage(chatId,
|
|
1450
|
+
await this.sendResponseMessage(chatId, outgoingText, sessionId, segment?.sourceLabel);
|
|
1308
1451
|
}
|
|
1309
1452
|
onOutputSegmentEnd(_chatId, _sessionId, segment, reason) {
|
|
1453
|
+
if (reason === 'completed' ||
|
|
1454
|
+
reason === 'failed' ||
|
|
1455
|
+
reason === 'cancelled') {
|
|
1456
|
+
this.fileProjectors.delete(segment.runId);
|
|
1457
|
+
}
|
|
1310
1458
|
if (!this.interactionPresenter)
|
|
1311
1459
|
return;
|
|
1312
1460
|
return this.interactionPresenter
|
|
@@ -1314,8 +1462,22 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1314
1462
|
.then(() => undefined);
|
|
1315
1463
|
}
|
|
1316
1464
|
onResponseChunk(_chatId, chunk, _sessionId, segment) {
|
|
1317
|
-
if (segment)
|
|
1318
|
-
|
|
1465
|
+
if (!segment)
|
|
1466
|
+
return;
|
|
1467
|
+
let state = this.fileProjectors.get(segment.runId);
|
|
1468
|
+
if (!state) {
|
|
1469
|
+
state = {
|
|
1470
|
+
sessionId: segment.sessionId,
|
|
1471
|
+
projector: new OutboundFileProjector(),
|
|
1472
|
+
};
|
|
1473
|
+
this.fileProjectors.set(segment.runId, state);
|
|
1474
|
+
}
|
|
1475
|
+
const safe = state.projector.append(chunk);
|
|
1476
|
+
if (safe)
|
|
1477
|
+
this.interactionPresenter?.appendOutput(segment, safe);
|
|
1478
|
+
}
|
|
1479
|
+
deleteFileProjectorsForRun(runId) {
|
|
1480
|
+
this.fileProjectors.delete(runId);
|
|
1319
1481
|
}
|
|
1320
1482
|
async presentUserInputRequest(context) {
|
|
1321
1483
|
const run = this.cardRuns.get(context.runId);
|
|
@@ -1558,13 +1720,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1558
1720
|
const media = await downloadMedia(downloadCode, robotCode, token);
|
|
1559
1721
|
if (!media)
|
|
1560
1722
|
return;
|
|
1561
|
-
|
|
1562
|
-
// image attachment and silently drops every later one, so an image
|
|
1563
|
-
// arriving after the slot is taken (e.g. a quoted picture alongside the
|
|
1564
|
-
// message's own picture) falls through to the file-backed path — the
|
|
1565
|
-
// `saved to:` prompt line is what keeps it reachable for the agent.
|
|
1566
|
-
const inlineImageSlotFree = !(envelope.attachments || []).some((attachment) => attachment.type === 'image' && attachment.data);
|
|
1567
|
-
if (mediaType === 'image' && inlineImageSlotFree) {
|
|
1723
|
+
if (mediaType === 'image') {
|
|
1568
1724
|
const mimeType = media.mimeType.startsWith('image/')
|
|
1569
1725
|
? media.mimeType
|
|
1570
1726
|
: 'image/jpeg';
|
|
@@ -1725,20 +1881,27 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1725
1881
|
if (this.atSender && isGroup && senderStaffId) {
|
|
1726
1882
|
envelope[mentionTarget] = senderStaffId;
|
|
1727
1883
|
}
|
|
1728
|
-
const processMessage =
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1884
|
+
const processMessage = content.downloadCodes.length > 0 || quoted.media
|
|
1885
|
+
? this.prepareThenHandleInbound(envelope, async () => {
|
|
1886
|
+
// Download media in callback order.
|
|
1887
|
+
if (content.downloadCodes.length > 0 && content.mediaType) {
|
|
1888
|
+
for (const downloadCode of content.downloadCodes) {
|
|
1889
|
+
await this.attachMedia(envelope, downloadCode, content.mediaType, content.fileName, content.placeholder);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
if (quoted.media) {
|
|
1893
|
+
await this.attachMedia(envelope, quoted.media.downloadCode, quoted.media.mediaType, quoted.media.fileName);
|
|
1894
|
+
}
|
|
1895
|
+
})
|
|
1896
|
+
: this.handleInbound(envelope);
|
|
1897
|
+
processMessage.catch((err) => {
|
|
1898
|
+
// Don't await — stream callback should return quickly
|
|
1740
1899
|
process.stderr.write(`[DingTalk:${this.name}] Error handling message: ${err}\n`);
|
|
1741
|
-
|
|
1900
|
+
const sourceLabel = this.getInboundErrorSourceLabel(envelope);
|
|
1901
|
+
const delivery = sourceLabel
|
|
1902
|
+
? this.sendThreadMessage(chatId, envelope.threadId, 'Sorry, something went wrong processing your message.', sourceLabel)
|
|
1903
|
+
: this.sendMessage(chatId, 'Sorry, something went wrong processing your message.');
|
|
1904
|
+
delivery.catch(() => { });
|
|
1742
1905
|
});
|
|
1743
1906
|
}
|
|
1744
1907
|
catch (err) {
|