@qwen-code/channel-dingtalk 0.23.1-preview.1 â 0.23.1
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 +65 -3
- package/dist/DingtalkAdapter.d.ts.map +1 -1
- package/dist/DingtalkAdapter.js +1059 -58
- package/dist/DingtalkAdapter.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/interaction-presenter.d.ts +9 -0
- package/dist/interaction-presenter.d.ts.map +1 -1
- package/dist/interaction-presenter.js +25 -2
- package/dist/interaction-presenter.js.map +1 -1
- package/dist/presentation-phase.d.ts +7 -0
- package/dist/presentation-phase.d.ts.map +1 -0
- package/dist/presentation-phase.js +85 -0
- package/dist/presentation-phase.js.map +1 -0
- package/dist/status-card-controller.d.ts +4 -0
- package/dist/status-card-controller.d.ts.map +1 -1
- package/dist/status-card-controller.js +68 -7
- package/dist/status-card-controller.js.map +1 -1
- package/package.json +2 -2
package/dist/DingtalkAdapter.js
CHANGED
|
@@ -13,6 +13,7 @@ import { DingtalkConnectionManager, } from './DingtalkConnectionManager.js';
|
|
|
13
13
|
import { DingtalkCardRequestError, DingtalkInteractiveCardClient, } from './interactive-card-client.js';
|
|
14
14
|
import { parseDingtalkCardActorId, parseDingtalkCardCallback, parseDingtalkInteractiveCardConfig, } from './interactive-card-types.js';
|
|
15
15
|
import { StatusCardController } from './status-card-controller.js';
|
|
16
|
+
import { isChinesePresentationLanguage, lifecyclePresentationPhase, presentationPhaseLabel, } from './presentation-phase.js';
|
|
16
17
|
import { QuestionCardController } from './question-card-controller.js';
|
|
17
18
|
import { DingtalkInteractionPresenter } from './interaction-presenter.js';
|
|
18
19
|
function nonEmptyString(value) {
|
|
@@ -408,12 +409,27 @@ function formatChatRecord(content, budget = MAX_CHAT_RECORD_CHARS) {
|
|
|
408
409
|
}
|
|
409
410
|
/** Track seen msgIds to deduplicate retried callbacks. */
|
|
410
411
|
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
412
|
+
const BACKGROUND_RESPONSE_AGGREGATION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
413
|
+
/**
|
|
414
|
+
* A failed aggregation delivery is retried rather than dropped: aggregating
|
|
415
|
+
* concentrates a whole turn into one send, and the burst that makes a send
|
|
416
|
+
* fail (several agents finishing at once against one chat quota) is exactly
|
|
417
|
+
* when the whole result would be lost.
|
|
418
|
+
*/
|
|
419
|
+
const BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS = 30 * 1000;
|
|
420
|
+
const BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES = 3;
|
|
411
421
|
const ACK_REACTION_NAME = 'đ';
|
|
412
422
|
const ACK_EMOTION_ID = '2659900';
|
|
413
423
|
const ACK_EMOTION_BG_ID = 'im_bg_1';
|
|
424
|
+
const STATUS_EMOTION_ID = '34019';
|
|
425
|
+
const STATUS_EMOTION_BG_ID = 'im_bg_6';
|
|
426
|
+
const DONE_EMOTION_ID = '54054';
|
|
427
|
+
const DONE_EMOTION_BG_ID = 'im_bg_5';
|
|
414
428
|
const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion';
|
|
415
429
|
const EMOTION_MAX_ATTEMPTS = 3;
|
|
416
430
|
const EMOTION_RETRY_BASE_DELAY_MS = 250;
|
|
431
|
+
const EMOTION_FETCH_TIMEOUT_MS = 15_000;
|
|
432
|
+
const EMOTION_FINISH_MAX_ATTEMPTS = 3;
|
|
417
433
|
const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
|
|
418
434
|
const DIRECT_MSG_API = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
|
|
419
435
|
const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
|
|
@@ -553,6 +569,25 @@ const FILE_INSTRUCTIONS = [
|
|
|
553
569
|
'File paths containing ] are not supported.',
|
|
554
570
|
'Do not claim delivery succeeded; DingTalk shows successful files separately and reports failures in the final text.',
|
|
555
571
|
].join('\n');
|
|
572
|
+
function statusEmotionTag(name) {
|
|
573
|
+
return {
|
|
574
|
+
name,
|
|
575
|
+
emotionId: STATUS_EMOTION_ID,
|
|
576
|
+
backgroundId: STATUS_EMOTION_BG_ID,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const EYE_TAG = {
|
|
580
|
+
name: ACK_REACTION_NAME,
|
|
581
|
+
emotionId: ACK_EMOTION_ID,
|
|
582
|
+
backgroundId: ACK_EMOTION_BG_ID,
|
|
583
|
+
};
|
|
584
|
+
const DONE_TAG = {
|
|
585
|
+
name: 'â
Done',
|
|
586
|
+
emotionId: DONE_EMOTION_ID,
|
|
587
|
+
backgroundId: DONE_EMOTION_BG_ID,
|
|
588
|
+
};
|
|
589
|
+
const FAILED_TAG = statusEmotionTag('â Failed');
|
|
590
|
+
const STOPPED_TAG = statusEmotionTag('âšī¸ Stopped');
|
|
556
591
|
function collectNonBotMentionIds(data) {
|
|
557
592
|
if (!Array.isArray(data.atUsers) || typeof data.chatbotUserId !== 'string') {
|
|
558
593
|
return [];
|
|
@@ -595,9 +630,28 @@ async function withConnectLoggingSuppressed(connect) {
|
|
|
595
630
|
}
|
|
596
631
|
}
|
|
597
632
|
}
|
|
633
|
+
class ProactiveTextDeliveryError extends Error {
|
|
634
|
+
plan;
|
|
635
|
+
retryable;
|
|
636
|
+
constructor(plan, cause) {
|
|
637
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
638
|
+
this.plan = plan;
|
|
639
|
+
if (cause instanceof DingtalkCardRequestError) {
|
|
640
|
+
this.retryable = cause.retryable;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
class ReplyTextDeliveryError extends Error {
|
|
645
|
+
plan;
|
|
646
|
+
constructor(plan, cause) {
|
|
647
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
648
|
+
this.plan = plan;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
598
651
|
export class DingtalkChannel extends ChannelBase {
|
|
599
652
|
client;
|
|
600
653
|
atSender;
|
|
654
|
+
displayLanguage;
|
|
601
655
|
connectionManager;
|
|
602
656
|
seenMessages = new Map();
|
|
603
657
|
mentionTargets = new Map();
|
|
@@ -608,8 +662,11 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
608
662
|
/** Map conversationId â latest sessionWebhook URL for sending replies. */
|
|
609
663
|
webhooks = new Map();
|
|
610
664
|
activeReactionKeys = new Set();
|
|
665
|
+
reactionStates = new Map();
|
|
611
666
|
/** sessionId â reaction keys, so a dead session's reactions can be recalled. */
|
|
612
667
|
sessionReactionKeys = new Map();
|
|
668
|
+
/** Settles when the reaction cleanup queued by disconnect() has run. */
|
|
669
|
+
disconnectDrain;
|
|
613
670
|
/**
|
|
614
671
|
* Real inbound message ids (insertion-ordered, size-capped). Unlike the
|
|
615
672
|
* TTL-swept seenMessages dedup map, entries survive long queue waits, so a
|
|
@@ -622,6 +679,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
622
679
|
*/
|
|
623
680
|
proactiveToken;
|
|
624
681
|
interactiveCardConfig;
|
|
682
|
+
aggregateBackgroundAgentResponses;
|
|
625
683
|
interactiveCardClient;
|
|
626
684
|
statusCardController;
|
|
627
685
|
questionCardController;
|
|
@@ -641,10 +699,15 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
641
699
|
// must be dropped, because recreating state would post the tail of a
|
|
642
700
|
// force-split [FILE: ...] marker verbatim.
|
|
643
701
|
blockProjectionArmed = new Set();
|
|
702
|
+
backgroundResponseAggregations = new Map();
|
|
703
|
+
detachedBackgroundResponseAggregations = new Set();
|
|
704
|
+
pendingBackgroundResponseTerminals = new Map();
|
|
705
|
+
detachedPendingBackgroundResponseTerminals = new Set();
|
|
644
706
|
constructor(name, config, bridge, options) {
|
|
645
707
|
super(name, config, bridge, options);
|
|
646
708
|
this.atSender =
|
|
647
709
|
config['atSender'] === true;
|
|
710
|
+
this.displayLanguage = options?.displayLanguage;
|
|
648
711
|
if (!this.config.instructions) {
|
|
649
712
|
this.config.instructions = [
|
|
650
713
|
'## DingTalk Channel',
|
|
@@ -661,6 +724,13 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
661
724
|
this.config.instructions += FILE_INSTRUCTIONS;
|
|
662
725
|
}
|
|
663
726
|
this.interactiveCardConfig = parseDingtalkInteractiveCardConfig(config.interactiveCards);
|
|
727
|
+
const rawAggregateBackgroundAgentResponses = config.aggregateBackgroundAgentResponses;
|
|
728
|
+
if (rawAggregateBackgroundAgentResponses !== undefined &&
|
|
729
|
+
typeof rawAggregateBackgroundAgentResponses !== 'boolean') {
|
|
730
|
+
throw new Error(`Channel "${name}" aggregateBackgroundAgentResponses must be a boolean.`);
|
|
731
|
+
}
|
|
732
|
+
this.aggregateBackgroundAgentResponses =
|
|
733
|
+
rawAggregateBackgroundAgentResponses === true;
|
|
664
734
|
if (!config.clientId || !config.clientSecret) {
|
|
665
735
|
throw new Error(`Channel "${name}" requires clientId and clientSecret for DingTalk.`);
|
|
666
736
|
}
|
|
@@ -688,6 +758,9 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
688
758
|
client: this.interactiveCardClient,
|
|
689
759
|
cancelRun: (sessionId, runId) => this.requestPromptRunCancellation(sessionId, runId),
|
|
690
760
|
...(config.model ? { model: config.model } : {}),
|
|
761
|
+
...(options?.displayLanguage
|
|
762
|
+
? { language: options.displayLanguage }
|
|
763
|
+
: {}),
|
|
691
764
|
onError: (operation, error) => {
|
|
692
765
|
process.stderr.write(`[DingTalk:${this.name}] ${operation} failed: ${sanitizeLogText(String(error), 300)}\n`);
|
|
693
766
|
},
|
|
@@ -708,6 +781,9 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
708
781
|
this.interactionPresenter = new DingtalkInteractionPresenter({
|
|
709
782
|
statusCards: this.statusCardController,
|
|
710
783
|
questionCards: this.questionCardController,
|
|
784
|
+
...(options?.displayLanguage
|
|
785
|
+
? { language: options.displayLanguage }
|
|
786
|
+
: {}),
|
|
711
787
|
...(config.blockStreaming !== 'on'
|
|
712
788
|
? {
|
|
713
789
|
sendFallback: (chatId, text, sessionId, sourceLabel) => this.sendFallbackReply(chatId, text, sessionId, sourceLabel),
|
|
@@ -1078,11 +1154,14 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1078
1154
|
}
|
|
1079
1155
|
return replaceImageMarkers(text, markers, replacements);
|
|
1080
1156
|
}
|
|
1081
|
-
async sendReply(chatId, text, atUserId, sourceLabel, prepared = false) {
|
|
1157
|
+
async sendReply(chatId, text, atUserId, sourceLabel, prepared = false, failOnHttpError = false) {
|
|
1082
1158
|
// chatId is a conversationId â resolve to the latest sessionWebhook.
|
|
1083
1159
|
const webhook = this.webhooks.get(chatId);
|
|
1084
1160
|
if (!webhook) {
|
|
1085
1161
|
process.stderr.write(`[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`);
|
|
1162
|
+
if (failOnHttpError) {
|
|
1163
|
+
throw new Error('DingTalk session webhook unavailable');
|
|
1164
|
+
}
|
|
1086
1165
|
return;
|
|
1087
1166
|
}
|
|
1088
1167
|
const outgoingText = prepared
|
|
@@ -1090,6 +1169,15 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1090
1169
|
: await this.prepareReplyOutput(chatId, text);
|
|
1091
1170
|
if (!outgoingText.trim())
|
|
1092
1171
|
return;
|
|
1172
|
+
const plan = this.createReplyTextDelivery(outgoingText, atUserId, sourceLabel);
|
|
1173
|
+
try {
|
|
1174
|
+
await this.deliverReplyText(chatId, plan, failOnHttpError);
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
throw new ReplyTextDeliveryError(plan, error);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
createReplyTextDelivery(outgoingText, atUserId, sourceLabel) {
|
|
1093
1181
|
const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
|
|
1094
1182
|
const sourcePrefix = sourceLabel && outgoingText.trim().length > 0
|
|
1095
1183
|
? `${escapeDingTalkMarkdown(sourceLabel)}\n\n`
|
|
@@ -1099,17 +1187,32 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1099
1187
|
throw new Error('DingTalk source label exceeds the message limit.');
|
|
1100
1188
|
}
|
|
1101
1189
|
const chunks = normalizeDingTalkMarkdown(outgoingText, contentLimit).map((chunk, index) => `${index === 0 ? mentionPrefix : ''}${sourcePrefix}${chunk}`);
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1190
|
+
return {
|
|
1191
|
+
title: extractTitle(outgoingText),
|
|
1192
|
+
chunks,
|
|
1193
|
+
nextChunk: 0,
|
|
1194
|
+
...(atUserId ? { atUserId } : {}),
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
async deliverReplyText(chatId, plan, failOnHttpError = false) {
|
|
1198
|
+
const webhook = this.webhooks.get(chatId);
|
|
1199
|
+
if (!webhook) {
|
|
1200
|
+
if (failOnHttpError) {
|
|
1201
|
+
throw new Error('DingTalk session webhook unavailable');
|
|
1202
|
+
}
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
while (plan.nextChunk < plan.chunks.length) {
|
|
1206
|
+
const index = plan.nextChunk;
|
|
1207
|
+
const chunk = plan.chunks[index];
|
|
1208
|
+
const isMention = index === 0 && plan.atUserId !== undefined;
|
|
1106
1209
|
const body = {
|
|
1107
1210
|
msgtype: 'markdown',
|
|
1108
1211
|
markdown: {
|
|
1109
|
-
title:
|
|
1212
|
+
title: index === 0 ? plan.title : `${plan.title} (cont.)`,
|
|
1110
1213
|
text: chunk,
|
|
1111
1214
|
},
|
|
1112
|
-
...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
|
|
1215
|
+
...(isMention ? { at: { atUserIds: [plan.atUserId] } } : {}),
|
|
1113
1216
|
};
|
|
1114
1217
|
let resp;
|
|
1115
1218
|
try {
|
|
@@ -1141,7 +1244,26 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1141
1244
|
if (!resp.ok) {
|
|
1142
1245
|
const detail = await resp.text().catch(() => '');
|
|
1143
1246
|
process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`);
|
|
1247
|
+
if (failOnHttpError) {
|
|
1248
|
+
throw new Error(`DingTalk reply send failed: HTTP ${resp.status} ${detail}`);
|
|
1249
|
+
}
|
|
1144
1250
|
}
|
|
1251
|
+
else if (failOnHttpError) {
|
|
1252
|
+
const payload = (await resp
|
|
1253
|
+
.clone()
|
|
1254
|
+
.json()
|
|
1255
|
+
.catch(() => undefined));
|
|
1256
|
+
const response = payload && typeof payload === 'object'
|
|
1257
|
+
? payload
|
|
1258
|
+
: undefined;
|
|
1259
|
+
const value = response?.['errcode'] ?? response?.['code'];
|
|
1260
|
+
if (value !== undefined && String(value) !== '0') {
|
|
1261
|
+
const detail = sanitizeLogText(String(response?.['errmsg'] ?? response?.['message'] ?? value), 300);
|
|
1262
|
+
process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: ${detail}\n`);
|
|
1263
|
+
throw new Error(`DingTalk reply send failed: ${detail}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
plan.nextChunk++;
|
|
1145
1267
|
}
|
|
1146
1268
|
}
|
|
1147
1269
|
async sendMessage(chatId, text) {
|
|
@@ -1177,9 +1299,20 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1177
1299
|
async pushProactive(target, text, sourceLabel) {
|
|
1178
1300
|
if (!text.trim())
|
|
1179
1301
|
return;
|
|
1302
|
+
const plan = await this.createProactiveTextDelivery(target, text, sourceLabel);
|
|
1303
|
+
if (!plan)
|
|
1304
|
+
return;
|
|
1305
|
+
try {
|
|
1306
|
+
await this.deliverProactiveText(target, plan);
|
|
1307
|
+
}
|
|
1308
|
+
catch (error) {
|
|
1309
|
+
throw new ProactiveTextDeliveryError(plan, error);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
async createProactiveTextDelivery(target, text, sourceLabel) {
|
|
1180
1313
|
const outgoingText = await this.prepareFileOutput(text, (file, mediaId) => this.sendProactiveFile(target, file, mediaId));
|
|
1181
1314
|
if (!outgoingText.trim())
|
|
1182
|
-
return;
|
|
1315
|
+
return undefined;
|
|
1183
1316
|
const sourcePrefix = sourceLabel
|
|
1184
1317
|
? `${escapeDingTalkMarkdown(sourceLabel)}\n\n`
|
|
1185
1318
|
: '';
|
|
@@ -1188,9 +1321,13 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1188
1321
|
throw new Error('DingTalk source label exceeds the message limit.');
|
|
1189
1322
|
}
|
|
1190
1323
|
const chunks = normalizeDingTalkMarkdown(outgoingText, contentLimit).map((chunk) => `${sourcePrefix}${chunk}`);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1324
|
+
return { title: extractTitle(outgoingText), chunks, nextChunk: 0 };
|
|
1325
|
+
}
|
|
1326
|
+
async deliverProactiveText(target, plan) {
|
|
1327
|
+
while (plan.nextChunk < plan.chunks.length) {
|
|
1328
|
+
const index = plan.nextChunk;
|
|
1329
|
+
await this.sendProactiveChunk(target, index === 0 ? plan.title : `${plan.title} (cont.)`, plan.chunks[index], `chunk ${index + 1}/${plan.chunks.length}`);
|
|
1330
|
+
plan.nextChunk++;
|
|
1194
1331
|
}
|
|
1195
1332
|
}
|
|
1196
1333
|
async getProactiveToken() {
|
|
@@ -1339,16 +1476,16 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1339
1476
|
getAccessToken() {
|
|
1340
1477
|
return this.client.getConfig().access_token;
|
|
1341
1478
|
}
|
|
1342
|
-
async emotionApi(endpoint, msgId, conversationId) {
|
|
1479
|
+
async emotionApi(endpoint, msgId, conversationId, tag) {
|
|
1343
1480
|
const robotCode = this.config.clientId;
|
|
1344
1481
|
if (!robotCode || !msgId || !conversationId)
|
|
1345
|
-
return;
|
|
1482
|
+
return false;
|
|
1346
1483
|
try {
|
|
1347
1484
|
const token = this.config.clientSecret
|
|
1348
1485
|
? await this.getProactiveToken()
|
|
1349
1486
|
: this.getAccessToken();
|
|
1350
1487
|
if (!token)
|
|
1351
|
-
return;
|
|
1488
|
+
return false;
|
|
1352
1489
|
for (let attempt = 0; attempt < EMOTION_MAX_ATTEMPTS; attempt++) {
|
|
1353
1490
|
const resp = await fetch(`${EMOTION_API}/${endpoint}`, {
|
|
1354
1491
|
method: 'POST',
|
|
@@ -1361,17 +1498,18 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1361
1498
|
openMsgId: msgId,
|
|
1362
1499
|
openConversationId: conversationId,
|
|
1363
1500
|
emotionType: 2,
|
|
1364
|
-
emotionName:
|
|
1501
|
+
emotionName: tag.name,
|
|
1365
1502
|
textEmotion: {
|
|
1366
|
-
emotionId:
|
|
1367
|
-
emotionName:
|
|
1368
|
-
text:
|
|
1369
|
-
backgroundId:
|
|
1503
|
+
emotionId: tag.emotionId,
|
|
1504
|
+
emotionName: tag.name,
|
|
1505
|
+
text: tag.name,
|
|
1506
|
+
backgroundId: tag.backgroundId,
|
|
1370
1507
|
},
|
|
1371
1508
|
}),
|
|
1509
|
+
signal: AbortSignal.timeout(EMOTION_FETCH_TIMEOUT_MS),
|
|
1372
1510
|
});
|
|
1373
1511
|
if (resp.ok)
|
|
1374
|
-
return;
|
|
1512
|
+
return true;
|
|
1375
1513
|
const isTransient = resp.status === 429 || resp.status >= 500;
|
|
1376
1514
|
if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
|
|
1377
1515
|
await resp.body?.cancel();
|
|
@@ -1380,26 +1518,33 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1380
1518
|
}
|
|
1381
1519
|
const detail = sanitizeLogText(await resp.text().catch(() => ''), 500);
|
|
1382
1520
|
process.stderr.write(`[DingTalk:${this.name}] emotion/${endpoint} failed after ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} attempts: ${resp.status} ${detail}\n`);
|
|
1383
|
-
return;
|
|
1521
|
+
return false;
|
|
1384
1522
|
}
|
|
1385
1523
|
}
|
|
1386
1524
|
catch {
|
|
1387
1525
|
// best-effort, don't break message flow
|
|
1388
1526
|
}
|
|
1527
|
+
return false;
|
|
1389
1528
|
}
|
|
1390
|
-
async attachReaction(msgId, conversationId) {
|
|
1391
|
-
|
|
1529
|
+
async attachReaction(msgId, conversationId, tag = EYE_TAG) {
|
|
1530
|
+
return this.emotionApi('reply', msgId, conversationId, tag);
|
|
1392
1531
|
}
|
|
1393
|
-
async recallReaction(msgId, conversationId) {
|
|
1394
|
-
|
|
1532
|
+
async recallReaction(msgId, conversationId, tag = EYE_TAG) {
|
|
1533
|
+
return this.emotionApi('recall', msgId, conversationId, tag);
|
|
1395
1534
|
}
|
|
1396
1535
|
disconnect() {
|
|
1397
1536
|
if (this.dedupTimer) {
|
|
1398
1537
|
clearInterval(this.dedupTimer);
|
|
1399
1538
|
}
|
|
1539
|
+
const reactionStates = [...this.reactionStates.values()];
|
|
1540
|
+
for (const state of reactionStates) {
|
|
1541
|
+
this.finishReaction(state.chatId, state.messageId, state.sessionId);
|
|
1542
|
+
}
|
|
1400
1543
|
this.statusCardController?.dispose();
|
|
1544
|
+
this.drainBackgroundResponseAggregations();
|
|
1401
1545
|
this.activeReactionKeys.clear();
|
|
1402
1546
|
this.sessionReactionKeys.clear();
|
|
1547
|
+
this.reactionStates.clear();
|
|
1403
1548
|
if (this.connectionManager) {
|
|
1404
1549
|
this.connectionManager.stop();
|
|
1405
1550
|
}
|
|
@@ -1407,6 +1552,10 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1407
1552
|
this.client.disconnect();
|
|
1408
1553
|
}
|
|
1409
1554
|
process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`);
|
|
1555
|
+
this.disconnectDrain = Promise.allSettled(reactionStates.map((state) => state.tail)).then(() => undefined);
|
|
1556
|
+
}
|
|
1557
|
+
waitForDisconnect() {
|
|
1558
|
+
return this.disconnectDrain ?? Promise.resolve();
|
|
1410
1559
|
}
|
|
1411
1560
|
/** Stable API targets are conversation or user IDs, never webhook URLs. */
|
|
1412
1561
|
isStableTargetId(chatId) {
|
|
@@ -1415,6 +1564,133 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1415
1564
|
reactionKey(messageId, conversationId) {
|
|
1416
1565
|
return `${conversationId}:${messageId}`;
|
|
1417
1566
|
}
|
|
1567
|
+
forgetReactionState(state) {
|
|
1568
|
+
this.activeReactionKeys.delete(state.key);
|
|
1569
|
+
if (this.reactionStates.get(state.key) === state) {
|
|
1570
|
+
this.reactionStates.delete(state.key);
|
|
1571
|
+
}
|
|
1572
|
+
if (!state.sessionId)
|
|
1573
|
+
return;
|
|
1574
|
+
const keys = this.sessionReactionKeys.get(state.sessionId);
|
|
1575
|
+
keys?.delete(state.key);
|
|
1576
|
+
if (keys?.size === 0)
|
|
1577
|
+
this.sessionReactionKeys.delete(state.sessionId);
|
|
1578
|
+
}
|
|
1579
|
+
enqueueReaction(state, operation) {
|
|
1580
|
+
state.tail = state.tail.then(operation).catch((err) => {
|
|
1581
|
+
this.logReactionFailure('lifecycle tag update', err);
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
scheduleReactionDrain(state) {
|
|
1585
|
+
if (state.drainScheduled)
|
|
1586
|
+
return;
|
|
1587
|
+
state.drainScheduled = true;
|
|
1588
|
+
this.enqueueReaction(state, async () => {
|
|
1589
|
+
try {
|
|
1590
|
+
await this.drainReactionState(state);
|
|
1591
|
+
}
|
|
1592
|
+
finally {
|
|
1593
|
+
state.drainScheduled = false;
|
|
1594
|
+
if (this.reactionStates.get(state.key) === state &&
|
|
1595
|
+
(state.finishing
|
|
1596
|
+
? !state.finishBlocked
|
|
1597
|
+
: this.activeReactionKeys.has(state.key) &&
|
|
1598
|
+
state.desiredStatusTag &&
|
|
1599
|
+
state.desiredStatusTag.name !== state.statusTag?.name)) {
|
|
1600
|
+
this.scheduleReactionDrain(state);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
async drainReactionState(state) {
|
|
1606
|
+
while (true) {
|
|
1607
|
+
if (state.finishing) {
|
|
1608
|
+
await this.finishReactionState(state);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (this.reactionStates.get(state.key) !== state ||
|
|
1612
|
+
!this.activeReactionKeys.has(state.key)) {
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const desired = state.desiredStatusTag;
|
|
1616
|
+
if (!desired || desired.name === state.statusTag?.name)
|
|
1617
|
+
return;
|
|
1618
|
+
const latched = state.failedTransition;
|
|
1619
|
+
if (state.statusTag) {
|
|
1620
|
+
const from = state.statusTag.name;
|
|
1621
|
+
if (latched && latched.from === from && latched.to === desired.name) {
|
|
1622
|
+
// This exact replacement already failed; re-issuing it once per
|
|
1623
|
+
// lifecycle event hammers a failing emotion API on every streamed
|
|
1624
|
+
// chunk. A different desired tag retries the recall.
|
|
1625
|
+
state.desiredStatusTag = state.statusTag;
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const revision = state.revision;
|
|
1629
|
+
if ((await this.recallReaction(state.messageId, state.chatId, state.statusTag)) === false) {
|
|
1630
|
+
state.failedTransition = { from, to: desired.name };
|
|
1631
|
+
if (state.revision !== revision)
|
|
1632
|
+
continue;
|
|
1633
|
+
state.desiredStatusTag = state.statusTag;
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
state.failedTransition = undefined;
|
|
1637
|
+
state.statusTag = undefined;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (latched &&
|
|
1641
|
+
latched.from === undefined &&
|
|
1642
|
+
latched.to === desired.name) {
|
|
1643
|
+
state.desiredStatusTag = undefined;
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const revision = state.revision;
|
|
1647
|
+
if ((await this.attachReaction(state.messageId, state.chatId, desired)) ===
|
|
1648
|
+
false) {
|
|
1649
|
+
state.failedTransition = { from: undefined, to: desired.name };
|
|
1650
|
+
if (state.revision !== revision)
|
|
1651
|
+
continue;
|
|
1652
|
+
state.desiredStatusTag = undefined;
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
state.failedTransition = undefined;
|
|
1656
|
+
state.statusTag = desired;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async finishReactionState(state) {
|
|
1660
|
+
let statusCleared = true;
|
|
1661
|
+
if (state.statusTag) {
|
|
1662
|
+
statusCleared =
|
|
1663
|
+
(await this.recallReaction(state.messageId, state.chatId, state.statusTag)) !== false;
|
|
1664
|
+
if (statusCleared)
|
|
1665
|
+
state.statusTag = undefined;
|
|
1666
|
+
}
|
|
1667
|
+
const eyeCleared = !state.eyeAttached ||
|
|
1668
|
+
(await this.recallReaction(state.messageId, state.chatId, EYE_TAG)) !==
|
|
1669
|
+
false;
|
|
1670
|
+
if (eyeCleared)
|
|
1671
|
+
state.eyeAttached = false;
|
|
1672
|
+
let terminalAttached = true;
|
|
1673
|
+
if (state.terminalTag && statusCleared && eyeCleared) {
|
|
1674
|
+
terminalAttached =
|
|
1675
|
+
(await this.attachReaction(state.messageId, state.chatId, state.terminalTag)) !== false;
|
|
1676
|
+
}
|
|
1677
|
+
if (!statusCleared || !eyeCleared || !terminalAttached) {
|
|
1678
|
+
// Keep the state registered: forgetting it here would leave a stale
|
|
1679
|
+
// phase tag nothing can ever recall. finishReaction re-arms the drain
|
|
1680
|
+
// (disconnect, session death) so the cleanup gets a second chance.
|
|
1681
|
+
state.finishAttempts = (state.finishAttempts ?? 0) + 1;
|
|
1682
|
+
if (state.finishAttempts >= EMOTION_FINISH_MAX_ATTEMPTS) {
|
|
1683
|
+
// A permanently failing emotion API (robot removed, expired token)
|
|
1684
|
+
// would otherwise hold the entry for the process lifetime.
|
|
1685
|
+
this.logReactionFailure('reaction cleanup', `abandoned after ${state.finishAttempts} attempts`);
|
|
1686
|
+
this.forgetReactionState(state);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
state.finishBlocked = true;
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
this.forgetReactionState(state);
|
|
1693
|
+
}
|
|
1418
1694
|
rememberInboundMessageId(msgId) {
|
|
1419
1695
|
this.inboundMessageIds.delete(msgId);
|
|
1420
1696
|
this.inboundMessageIds.add(msgId);
|
|
@@ -1439,6 +1715,19 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1439
1715
|
if (this.activeReactionKeys.has(key))
|
|
1440
1716
|
return;
|
|
1441
1717
|
this.activeReactionKeys.add(key);
|
|
1718
|
+
const state = {
|
|
1719
|
+
key,
|
|
1720
|
+
messageId,
|
|
1721
|
+
chatId,
|
|
1722
|
+
...(sessionId ? { sessionId } : {}),
|
|
1723
|
+
desiredStatusTag: this.phaseReactionTag('thinking'),
|
|
1724
|
+
finishing: false,
|
|
1725
|
+
drainScheduled: false,
|
|
1726
|
+
eyeAttached: false,
|
|
1727
|
+
revision: 0,
|
|
1728
|
+
tail: Promise.resolve(),
|
|
1729
|
+
};
|
|
1730
|
+
this.reactionStates.set(key, state);
|
|
1442
1731
|
if (sessionId) {
|
|
1443
1732
|
let keys = this.sessionReactionKeys.get(sessionId);
|
|
1444
1733
|
if (!keys) {
|
|
@@ -1447,36 +1736,75 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1447
1736
|
}
|
|
1448
1737
|
keys.set(key, { messageId, chatId });
|
|
1449
1738
|
}
|
|
1450
|
-
this.
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
}
|
|
1739
|
+
this.enqueueReaction(state, async () => {
|
|
1740
|
+
try {
|
|
1741
|
+
if ((await this.attachReaction(messageId, chatId, EYE_TAG)) === false) {
|
|
1742
|
+
this.forgetReactionState(state);
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
state.eyeAttached = true;
|
|
1746
|
+
this.scheduleReactionDrain(state);
|
|
1747
|
+
}
|
|
1748
|
+
catch (err) {
|
|
1749
|
+
this.forgetReactionState(state);
|
|
1750
|
+
this.logReactionFailure('reaction attach', err);
|
|
1456
1751
|
}
|
|
1457
|
-
})
|
|
1458
|
-
.catch((err) => {
|
|
1459
|
-
this.activeReactionKeys.delete(key);
|
|
1460
|
-
this.logReactionFailure('reaction attach', err);
|
|
1461
1752
|
});
|
|
1462
1753
|
}
|
|
1463
|
-
|
|
1754
|
+
replaceStatusReaction(chatId, messageId, tag) {
|
|
1755
|
+
if (!messageId)
|
|
1756
|
+
return;
|
|
1757
|
+
const state = this.reactionStates.get(this.reactionKey(messageId, chatId));
|
|
1758
|
+
if (!state || !this.activeReactionKeys.has(state.key))
|
|
1759
|
+
return;
|
|
1760
|
+
state.desiredStatusTag = tag;
|
|
1761
|
+
state.revision++;
|
|
1762
|
+
this.scheduleReactionDrain(state);
|
|
1763
|
+
}
|
|
1764
|
+
phaseReactionTag(phase) {
|
|
1765
|
+
return statusEmotionTag(presentationPhaseLabel(phase, this.displayLanguage));
|
|
1766
|
+
}
|
|
1767
|
+
terminalReactionTag(type) {
|
|
1768
|
+
if (!isChinesePresentationLanguage(this.displayLanguage)) {
|
|
1769
|
+
return type === 'completed'
|
|
1770
|
+
? DONE_TAG
|
|
1771
|
+
: type === 'failed'
|
|
1772
|
+
? FAILED_TAG
|
|
1773
|
+
: STOPPED_TAG;
|
|
1774
|
+
}
|
|
1775
|
+
if (type === 'completed') {
|
|
1776
|
+
return { ...DONE_TAG, name: 'â
åˇ˛åŽæ' };
|
|
1777
|
+
}
|
|
1778
|
+
return statusEmotionTag(type === 'failed' ? 'â å¤ąč´Ĩ' : 'âšī¸ 厞åæĸ');
|
|
1779
|
+
}
|
|
1780
|
+
finishReaction(chatId, messageId, sessionId, terminalTag) {
|
|
1464
1781
|
if (!messageId || !this.isStableTargetId(chatId))
|
|
1465
1782
|
return;
|
|
1466
1783
|
const key = this.reactionKey(messageId, chatId);
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1784
|
+
const state = this.reactionStates.get(key);
|
|
1785
|
+
if (!state)
|
|
1786
|
+
return;
|
|
1787
|
+
if (state.finishing) {
|
|
1788
|
+
// A finish is either draining or blocked on a failed recall; only the
|
|
1789
|
+
// blocked case needs this trigger to retry the cleanup.
|
|
1790
|
+
if (!state.finishBlocked)
|
|
1791
|
+
return;
|
|
1792
|
+
state.finishBlocked = false;
|
|
1793
|
+
if (terminalTag)
|
|
1794
|
+
state.terminalTag = terminalTag;
|
|
1795
|
+
this.scheduleReactionDrain(state);
|
|
1796
|
+
return;
|
|
1474
1797
|
}
|
|
1475
1798
|
if (!this.activeReactionKeys.delete(key))
|
|
1476
1799
|
return;
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1800
|
+
state.finishing = true;
|
|
1801
|
+
state.desiredStatusTag = undefined;
|
|
1802
|
+
state.terminalTag = terminalTag;
|
|
1803
|
+
state.revision++;
|
|
1804
|
+
this.scheduleReactionDrain(state);
|
|
1805
|
+
}
|
|
1806
|
+
stopReaction(chatId, messageId, sessionId) {
|
|
1807
|
+
this.finishReaction(chatId, messageId, sessionId);
|
|
1480
1808
|
}
|
|
1481
1809
|
/** Recall reactions left behind when a session dies without terminal lifecycle events. */
|
|
1482
1810
|
onSessionDied(sessionId) {
|
|
@@ -1495,6 +1823,11 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1495
1823
|
}
|
|
1496
1824
|
}
|
|
1497
1825
|
this.sessionMentionTargets.delete(sessionId);
|
|
1826
|
+
// A session dying after segments arrived but before the terminal signal is
|
|
1827
|
+
// precisely the case the partial-card fallback exists for; dropping the
|
|
1828
|
+
// buffer here would lose text the agent already produced, which the
|
|
1829
|
+
// pre-aggregation code always delivered on arrival.
|
|
1830
|
+
this.drainBackgroundResponseAggregations(sessionId);
|
|
1498
1831
|
const cardRunId = this.cardRunBySession.get(sessionId);
|
|
1499
1832
|
if (cardRunId) {
|
|
1500
1833
|
this.cardRunBySession.delete(sessionId);
|
|
@@ -1504,16 +1837,35 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1504
1837
|
const keys = this.sessionReactionKeys.get(sessionId);
|
|
1505
1838
|
if (keys) {
|
|
1506
1839
|
this.sessionReactionKeys.delete(sessionId);
|
|
1507
|
-
for (const
|
|
1508
|
-
|
|
1509
|
-
void this.recallReaction(messageId, chatId).catch((err) => {
|
|
1510
|
-
this.logReactionFailure('session-death reaction recall', err);
|
|
1511
|
-
});
|
|
1512
|
-
}
|
|
1840
|
+
for (const { messageId, chatId } of keys.values()) {
|
|
1841
|
+
this.finishReaction(chatId, messageId, sessionId);
|
|
1513
1842
|
}
|
|
1514
1843
|
}
|
|
1515
1844
|
super.onSessionDied(sessionId);
|
|
1516
1845
|
}
|
|
1846
|
+
/**
|
|
1847
|
+
* A crashed standalone bridge never settles its in-flight turns, so their
|
|
1848
|
+
* transient tags and ticking status cards would otherwise assert a live
|
|
1849
|
+
* turn forever. Finish the tags without a terminal result and terminalize
|
|
1850
|
+
* the cards as interrupted. Session state stays: crash recovery restores
|
|
1851
|
+
* the sessions on a fresh bridge.
|
|
1852
|
+
*/
|
|
1853
|
+
onBridgeDisconnected() {
|
|
1854
|
+
for (const [sessionId, keys] of this.sessionReactionKeys) {
|
|
1855
|
+
this.sessionReactionKeys.delete(sessionId);
|
|
1856
|
+
for (const { messageId, chatId } of keys.values()) {
|
|
1857
|
+
this.finishReaction(chatId, messageId, sessionId);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
for (const [sessionId, runId] of this.cardRunBySession) {
|
|
1861
|
+
this.cardRunBySession.delete(sessionId);
|
|
1862
|
+
this.interactionPresenter?.terminalizeRun(runId, 'cancelled');
|
|
1863
|
+
this.cardRuns.delete(runId);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
onSessionRetiring(sessionId) {
|
|
1867
|
+
this.drainBackgroundResponseAggregations(sessionId);
|
|
1868
|
+
}
|
|
1517
1869
|
onTaskLifecycle(event) {
|
|
1518
1870
|
if (event.type === 'started') {
|
|
1519
1871
|
this.startReaction(event.chatId, event.messageId, event.sessionId);
|
|
@@ -1533,10 +1885,18 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1533
1885
|
}
|
|
1534
1886
|
return;
|
|
1535
1887
|
}
|
|
1888
|
+
const presentationPhase = lifecyclePresentationPhase(event);
|
|
1889
|
+
if (event.runId && presentationPhase) {
|
|
1890
|
+
this.interactionPresenter?.updateStatusCardPhase(event.runId, presentationPhase);
|
|
1891
|
+
}
|
|
1892
|
+
if (presentationPhase) {
|
|
1893
|
+
this.replaceStatusReaction(event.chatId, event.messageId, this.phaseReactionTag(presentationPhase));
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1536
1896
|
if (isTerminalTaskLifecycleType(event.type)) {
|
|
1537
1897
|
if (event.messageId)
|
|
1538
1898
|
this.mentionTargets.delete(event.messageId);
|
|
1539
|
-
this.
|
|
1899
|
+
this.finishReaction(event.chatId, event.messageId, event.sessionId, this.terminalReactionTag(event.type));
|
|
1540
1900
|
if (event.runId) {
|
|
1541
1901
|
this.deleteFileProjectorsForRun(event.runId);
|
|
1542
1902
|
if (event.type === 'failed') {
|
|
@@ -1681,18 +2041,659 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1681
2041
|
process.stderr.write(`[DingTalk:${this.name}] projector tail delivery failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1682
2042
|
});
|
|
1683
2043
|
}
|
|
2044
|
+
/** Deliver every Agent segment immediately unless aggregation is enabled. */
|
|
2045
|
+
async dispatchBackgroundResponse(sessionId, text, context) {
|
|
2046
|
+
const target = this.router.getTarget(sessionId);
|
|
2047
|
+
if (!target ||
|
|
2048
|
+
target.channelName !== this.name ||
|
|
2049
|
+
(context !== undefined && context.kind !== 'agent')) {
|
|
2050
|
+
return super.dispatchBackgroundResponse(sessionId, text, context);
|
|
2051
|
+
}
|
|
2052
|
+
const canAggregate = this.aggregateBackgroundAgentResponses &&
|
|
2053
|
+
context?.kind === 'agent' &&
|
|
2054
|
+
typeof context.turnComplete === 'boolean';
|
|
2055
|
+
if (!canAggregate) {
|
|
2056
|
+
if (text.trim().length === 0) {
|
|
2057
|
+
return super.dispatchBackgroundResponse(sessionId, text, context);
|
|
2058
|
+
}
|
|
2059
|
+
return super.dispatchBackgroundResponse(sessionId, this.formatBackgroundAgentResponse(text, context?.label), context);
|
|
2060
|
+
}
|
|
2061
|
+
const key = JSON.stringify([sessionId, context.taskId, context.turnId]);
|
|
2062
|
+
let current = this.backgroundResponseAggregations.get(key);
|
|
2063
|
+
let parked = this.pendingBackgroundResponseTerminals.get(key);
|
|
2064
|
+
if (current?.turnComplete === true) {
|
|
2065
|
+
this.detachedBackgroundResponseAggregations.add(current);
|
|
2066
|
+
this.backgroundResponseAggregations.delete(key);
|
|
2067
|
+
current = undefined;
|
|
2068
|
+
}
|
|
2069
|
+
if (!current &&
|
|
2070
|
+
(parked?.turnEnded === true ||
|
|
2071
|
+
(parked?.turnComplete === true &&
|
|
2072
|
+
(parked.retryTimer || parked.retryInFlight || parked.resolvers > 0)))) {
|
|
2073
|
+
if (!parked.turnEnded) {
|
|
2074
|
+
this.detachedPendingBackgroundResponseTerminals.add(parked);
|
|
2075
|
+
}
|
|
2076
|
+
parked = { sessionId, target, resolvers: 0, held: [] };
|
|
2077
|
+
this.pendingBackgroundResponseTerminals.set(key, parked);
|
|
2078
|
+
}
|
|
2079
|
+
if (!current && text.trim().length === 0) {
|
|
2080
|
+
// The first segment's target resolution can suspend (named-session owner
|
|
2081
|
+
// lock), so a turn's terminal marker may arrive before the aggregation
|
|
2082
|
+
// exists. Park it instead of routing it to the empty-text early return,
|
|
2083
|
+
// or the completed turn only surfaces via the bounded wait, mislabeled.
|
|
2084
|
+
if (!parked ||
|
|
2085
|
+
(parked.resolvers === 0 &&
|
|
2086
|
+
!parked.retryTimer &&
|
|
2087
|
+
!parked.retryInFlight &&
|
|
2088
|
+
!parked.resolutionDropped &&
|
|
2089
|
+
!parked.turnComplete)) {
|
|
2090
|
+
if (parked)
|
|
2091
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2092
|
+
return super.dispatchBackgroundResponse(sessionId, text, context);
|
|
2093
|
+
}
|
|
2094
|
+
if (context.turnComplete) {
|
|
2095
|
+
parked.turnComplete = true;
|
|
2096
|
+
parked.status = context.status;
|
|
2097
|
+
parked.label = context.label ?? parked.label;
|
|
2098
|
+
parked.completionPartial = context.partial === true;
|
|
2099
|
+
if (parked.resolvers === 0 &&
|
|
2100
|
+
!parked.retryTimer &&
|
|
2101
|
+
!parked.retryInFlight &&
|
|
2102
|
+
(parked.retryAttempts ?? 0) >=
|
|
2103
|
+
BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES) {
|
|
2104
|
+
parked.turnEnded = true;
|
|
2105
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (!current) {
|
|
2111
|
+
parked ??= { sessionId, target, resolvers: 0, held: [] };
|
|
2112
|
+
this.pendingBackgroundResponseTerminals.set(key, parked);
|
|
2113
|
+
this.holdPendingBackgroundResponse(parked, text, context);
|
|
2114
|
+
parked.resolvers++;
|
|
2115
|
+
try {
|
|
2116
|
+
let delivery;
|
|
2117
|
+
try {
|
|
2118
|
+
delivery = await this.resolveBackgroundResponseDelivery(sessionId);
|
|
2119
|
+
}
|
|
2120
|
+
catch (error) {
|
|
2121
|
+
if (parked.resolvers === 1 &&
|
|
2122
|
+
!this.backgroundResponseAggregations.has(key)) {
|
|
2123
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, parked);
|
|
2124
|
+
}
|
|
2125
|
+
else {
|
|
2126
|
+
const existing = this.backgroundResponseAggregations.get(key);
|
|
2127
|
+
if (existing) {
|
|
2128
|
+
if (parked.held.length > 0) {
|
|
2129
|
+
this.applyHeldBackgroundResponses(existing, parked);
|
|
2130
|
+
}
|
|
2131
|
+
this.applyPendingBackgroundResponseTerminal(existing, parked);
|
|
2132
|
+
if (parked.resolvers === 1) {
|
|
2133
|
+
if (existing.turnComplete) {
|
|
2134
|
+
await this.completeBackgroundResponseAggregation(key, existing);
|
|
2135
|
+
}
|
|
2136
|
+
else {
|
|
2137
|
+
this.scheduleBackgroundResponseAggregationFlush(key, existing);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
throw error;
|
|
2143
|
+
}
|
|
2144
|
+
if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
|
|
2145
|
+
if (parked.resolvers === 1 &&
|
|
2146
|
+
!this.backgroundResponseAggregations.has(key)) {
|
|
2147
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, parked);
|
|
2148
|
+
}
|
|
2149
|
+
else {
|
|
2150
|
+
const existing = this.backgroundResponseAggregations.get(key);
|
|
2151
|
+
if (existing) {
|
|
2152
|
+
if (parked.held.length > 0) {
|
|
2153
|
+
this.applyHeldBackgroundResponses(existing, parked);
|
|
2154
|
+
}
|
|
2155
|
+
this.applyPendingBackgroundResponseTerminal(existing, parked);
|
|
2156
|
+
if (parked.resolvers === 1) {
|
|
2157
|
+
if (existing.turnComplete) {
|
|
2158
|
+
await this.completeBackgroundResponseAggregation(key, existing);
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
this.scheduleBackgroundResponseAggregationFlush(key, existing);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
if (parked.retiring ||
|
|
2169
|
+
this.pendingBackgroundResponseTerminals.get(key) !== parked) {
|
|
2170
|
+
await this.flushDetachedBackgroundResponse(key, sessionId, parked, delivery);
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (parked.retryTimer) {
|
|
2174
|
+
clearTimeout(parked.retryTimer);
|
|
2175
|
+
parked.retryTimer = undefined;
|
|
2176
|
+
}
|
|
2177
|
+
current =
|
|
2178
|
+
this.backgroundResponseAggregations.get(key) ??
|
|
2179
|
+
this.createBackgroundResponseAggregation(key, sessionId, parked.held[0]?.context ?? context, delivery.target, delivery.sourceLabel);
|
|
2180
|
+
this.applyHeldBackgroundResponses(current, parked);
|
|
2181
|
+
this.applyPendingBackgroundResponseTerminal(current, parked);
|
|
2182
|
+
if (parked.resolutionDropped) {
|
|
2183
|
+
current.resolutionDropped = true;
|
|
2184
|
+
parked.resolutionDropped = undefined;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
finally {
|
|
2188
|
+
parked.resolvers--;
|
|
2189
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === parked &&
|
|
2190
|
+
parked.resolvers === 0 &&
|
|
2191
|
+
!parked.retryTimer &&
|
|
2192
|
+
parked.held.length === 0 &&
|
|
2193
|
+
(!parked.resolutionDropped || parked.turnEnded)) {
|
|
2194
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
if (!current)
|
|
2198
|
+
return;
|
|
2199
|
+
if (current.turnComplete && parked.resolvers > 0)
|
|
2200
|
+
return;
|
|
2201
|
+
if (!current.turnComplete) {
|
|
2202
|
+
this.scheduleBackgroundResponseAggregationFlush(key, current);
|
|
2203
|
+
}
|
|
2204
|
+
else {
|
|
2205
|
+
await this.completeBackgroundResponseAggregation(key, current);
|
|
2206
|
+
}
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
current.status = context.status;
|
|
2210
|
+
current.label = context.label ?? current.label;
|
|
2211
|
+
if (text.trim().length > 0)
|
|
2212
|
+
current.parts.push(text);
|
|
2213
|
+
if (context.turnComplete && parked && parked.resolvers > 0) {
|
|
2214
|
+
parked.turnComplete = true;
|
|
2215
|
+
parked.status = context.status;
|
|
2216
|
+
parked.label = context.label ?? parked.label;
|
|
2217
|
+
parked.completionPartial = context.partial === true;
|
|
2218
|
+
}
|
|
2219
|
+
else if (context.turnComplete) {
|
|
2220
|
+
current.turnComplete = true;
|
|
2221
|
+
current.completionPartial = context.partial === true;
|
|
2222
|
+
}
|
|
2223
|
+
else if (parked?.turnComplete && parked.resolvers === 0) {
|
|
2224
|
+
current.turnComplete = true;
|
|
2225
|
+
current.status = parked.status ?? current.status;
|
|
2226
|
+
current.label = parked.label ?? current.label;
|
|
2227
|
+
current.completionPartial = parked.completionPartial === true;
|
|
2228
|
+
}
|
|
2229
|
+
current.resolutionDropped ||= parked?.resolutionDropped;
|
|
2230
|
+
if (!current.turnComplete) {
|
|
2231
|
+
this.scheduleBackgroundResponseAggregationFlush(key, current);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
await this.completeBackgroundResponseAggregation(key, current);
|
|
2235
|
+
}
|
|
2236
|
+
async flushBackgroundResponseAggregation(key, aggregation) {
|
|
2237
|
+
if ((this.backgroundResponseAggregations.get(key) !== aggregation &&
|
|
2238
|
+
!this.detachedBackgroundResponseAggregations.has(aggregation)) ||
|
|
2239
|
+
aggregation.flushing) {
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
this.refreshBackgroundResponseDelivery(aggregation);
|
|
2243
|
+
if (aggregation.retryTimer)
|
|
2244
|
+
clearTimeout(aggregation.retryTimer);
|
|
2245
|
+
aggregation.retryTimer = undefined;
|
|
2246
|
+
let delivery = aggregation.delivery;
|
|
2247
|
+
if (!delivery) {
|
|
2248
|
+
if (aggregation.parts.length === 0) {
|
|
2249
|
+
// A turn whose text was already drained by the bounded wait still owes
|
|
2250
|
+
// the user its completion: the last card it saw reads `īŧé¨åīŧ`.
|
|
2251
|
+
if (!this.owesTerminalBackgroundResponseCard(aggregation)) {
|
|
2252
|
+
if (aggregation.retiring || aggregation.turnComplete) {
|
|
2253
|
+
this.removeBackgroundResponseAggregation(key, aggregation);
|
|
2254
|
+
}
|
|
2255
|
+
else {
|
|
2256
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2257
|
+
}
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
if (aggregation.timeoutTimer)
|
|
2262
|
+
clearTimeout(aggregation.timeoutTimer);
|
|
2263
|
+
aggregation.timeoutTimer = undefined;
|
|
2264
|
+
const parts = aggregation.parts.splice(0);
|
|
2265
|
+
delivery = {
|
|
2266
|
+
status: aggregation.status,
|
|
2267
|
+
label: aggregation.label,
|
|
2268
|
+
parts,
|
|
2269
|
+
partial: this.isPartialBackgroundResponseDelivery(aggregation, parts.length),
|
|
2270
|
+
attempts: 0,
|
|
2271
|
+
composedTurnComplete: aggregation.turnComplete === true,
|
|
2272
|
+
};
|
|
2273
|
+
aggregation.delivery = delivery;
|
|
2274
|
+
}
|
|
2275
|
+
const body = this.formatBackgroundResponseAggregation(delivery);
|
|
2276
|
+
aggregation.flushing = true;
|
|
2277
|
+
let error;
|
|
2278
|
+
try {
|
|
2279
|
+
if (delivery.proactivePlan) {
|
|
2280
|
+
await this.deliverProactiveText(aggregation.target, delivery.proactivePlan);
|
|
2281
|
+
}
|
|
2282
|
+
else if (delivery.replyPlan) {
|
|
2283
|
+
await this.deliverReplyText(aggregation.target.chatId, delivery.replyPlan, true);
|
|
2284
|
+
}
|
|
2285
|
+
else if (this.supportsProactiveSend() &&
|
|
2286
|
+
this.supportsProactiveTarget(aggregation.target)) {
|
|
2287
|
+
await this.deliverBackgroundResponseToTarget(aggregation.sessionId, body, {
|
|
2288
|
+
target: aggregation.target,
|
|
2289
|
+
sourceLabel: aggregation.sourceLabel,
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
else {
|
|
2293
|
+
delivery.preparedReplyBody ??= await this.prepareReplyOutput(aggregation.target.chatId, body);
|
|
2294
|
+
await this.deliverBackgroundReply(aggregation.target.chatId, delivery.preparedReplyBody, aggregation.sessionId, aggregation.sourceLabel, true, true);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch (caught) {
|
|
2298
|
+
error = caught;
|
|
2299
|
+
if (caught instanceof ProactiveTextDeliveryError) {
|
|
2300
|
+
delivery.proactivePlan = caught.plan;
|
|
2301
|
+
}
|
|
2302
|
+
else if (caught instanceof ReplyTextDeliveryError) {
|
|
2303
|
+
delivery.replyPlan = caught.plan;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
finally {
|
|
2307
|
+
aggregation.flushing = false;
|
|
2308
|
+
}
|
|
2309
|
+
if (error === undefined) {
|
|
2310
|
+
aggregation.delivery = undefined;
|
|
2311
|
+
aggregation.delivered = true;
|
|
2312
|
+
if (delivery.composedTurnComplete === true &&
|
|
2313
|
+
aggregation.turnComplete &&
|
|
2314
|
+
delivery.partial !== true &&
|
|
2315
|
+
!aggregation.retiring) {
|
|
2316
|
+
aggregation.completionDelivered = true;
|
|
2317
|
+
}
|
|
2318
|
+
if (aggregation.retiring || aggregation.turnComplete) {
|
|
2319
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2320
|
+
}
|
|
2321
|
+
else {
|
|
2322
|
+
// The turn is still open: keep the entry so its later segments re-join
|
|
2323
|
+
// this one (and stay labelled `īŧé¨åīŧ`), and re-arm the bounded wait
|
|
2324
|
+
// so a silent turn is still reaped.
|
|
2325
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2326
|
+
}
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
delivery.attempts++;
|
|
2330
|
+
process.stderr.write(`[DingTalk:${this.name}] background response delivery failed (attempt ${delivery.attempts}): ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
|
|
2331
|
+
if (delivery.attempts >= BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES ||
|
|
2332
|
+
// A permanently rejected send (an invalid appkey, a missing app) cannot
|
|
2333
|
+
// succeed later; retrying it only spends the chat's send quota.
|
|
2334
|
+
(error instanceof ProactiveTextDeliveryError && error.retryable === false)) {
|
|
2335
|
+
aggregation.delivery = undefined;
|
|
2336
|
+
aggregation.dropped = true;
|
|
2337
|
+
if (aggregation.parts.length === 0) {
|
|
2338
|
+
if (aggregation.retiring || aggregation.turnComplete) {
|
|
2339
|
+
this.removeBackgroundResponseAggregation(key, aggregation);
|
|
2340
|
+
}
|
|
2341
|
+
else {
|
|
2342
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
else if (aggregation.retiring || aggregation.turnComplete) {
|
|
2346
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2347
|
+
}
|
|
2348
|
+
else {
|
|
2349
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2350
|
+
}
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
aggregation.retryTimer = setTimeout(() => {
|
|
2354
|
+
aggregation.retryTimer = undefined;
|
|
2355
|
+
void this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2356
|
+
}, BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS);
|
|
2357
|
+
aggregation.retryTimer.unref?.();
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* A card carries `īŧé¨åīŧ` whenever it is not the turn's whole output: the
|
|
2361
|
+
* turn is still open, earlier text already went out (or was given up on),
|
|
2362
|
+
* or the turn itself ended early.
|
|
2363
|
+
*/
|
|
2364
|
+
isPartialBackgroundResponseDelivery(aggregation, partCount) {
|
|
2365
|
+
return (partCount > 0 &&
|
|
2366
|
+
(aggregation.delivered === true ||
|
|
2367
|
+
aggregation.dropped === true ||
|
|
2368
|
+
aggregation.resolutionDropped === true ||
|
|
2369
|
+
aggregation.retiring === true ||
|
|
2370
|
+
aggregation.completionPartial === true ||
|
|
2371
|
+
aggregation.turnComplete !== true));
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* A turn that outran the bounded wait had its text delivered under the
|
|
2375
|
+
* `īŧé¨åīŧ` label. When it then completes normally with nothing buffered,
|
|
2376
|
+
* a header-only card is the only way the chat ever learns it finished.
|
|
2377
|
+
*/
|
|
2378
|
+
owesTerminalBackgroundResponseCard(aggregation) {
|
|
2379
|
+
return (aggregation.turnComplete === true &&
|
|
2380
|
+
aggregation.delivered === true &&
|
|
2381
|
+
aggregation.completionDelivered !== true &&
|
|
2382
|
+
aggregation.retiring !== true &&
|
|
2383
|
+
aggregation.completionPartial !== true &&
|
|
2384
|
+
aggregation.dropped !== true &&
|
|
2385
|
+
aggregation.resolutionDropped !== true);
|
|
2386
|
+
}
|
|
2387
|
+
refreshBackgroundResponseDelivery(aggregation) {
|
|
2388
|
+
const delivery = aggregation.delivery;
|
|
2389
|
+
if (!delivery || aggregation.flushing)
|
|
2390
|
+
return;
|
|
2391
|
+
const plan = delivery.proactivePlan ?? delivery.replyPlan;
|
|
2392
|
+
if (plan && plan.nextChunk > 0)
|
|
2393
|
+
return;
|
|
2394
|
+
const body = this.formatBackgroundResponseAggregation(delivery);
|
|
2395
|
+
const header = body.split('\n', 1)[0];
|
|
2396
|
+
const replaceHeader = (text) => text.replace(/## (?:â
|â|âšī¸) Agent ¡ [^\n]+/, () => header);
|
|
2397
|
+
if (plan) {
|
|
2398
|
+
plan.title = extractTitle(body);
|
|
2399
|
+
plan.chunks[0] = replaceHeader(plan.chunks[0]);
|
|
2400
|
+
}
|
|
2401
|
+
if (delivery.preparedReplyBody) {
|
|
2402
|
+
delivery.preparedReplyBody = replaceHeader(delivery.preparedReplyBody);
|
|
2403
|
+
}
|
|
2404
|
+
delivery.composedTurnComplete = aggregation.turnComplete === true;
|
|
2405
|
+
}
|
|
2406
|
+
removeBackgroundResponseAggregation(key, aggregation) {
|
|
2407
|
+
if (this.backgroundResponseAggregations.get(key) === aggregation) {
|
|
2408
|
+
this.backgroundResponseAggregations.delete(key);
|
|
2409
|
+
}
|
|
2410
|
+
this.detachedBackgroundResponseAggregations.delete(aggregation);
|
|
2411
|
+
}
|
|
2412
|
+
drainBackgroundResponseAggregations(sessionId) {
|
|
2413
|
+
for (const [key, pending] of this.pendingBackgroundResponseTerminals) {
|
|
2414
|
+
if (sessionId !== undefined && pending.sessionId !== sessionId)
|
|
2415
|
+
continue;
|
|
2416
|
+
if (pending.retryTimer)
|
|
2417
|
+
clearTimeout(pending.retryTimer);
|
|
2418
|
+
pending.retryTimer = undefined;
|
|
2419
|
+
pending.retiring = true;
|
|
2420
|
+
pending.turnComplete = true;
|
|
2421
|
+
pending.completionPartial = true;
|
|
2422
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2423
|
+
this.detachedPendingBackgroundResponseTerminals.add(pending);
|
|
2424
|
+
}
|
|
2425
|
+
for (const pending of this.detachedPendingBackgroundResponseTerminals) {
|
|
2426
|
+
if (sessionId !== undefined && pending.sessionId !== sessionId)
|
|
2427
|
+
continue;
|
|
2428
|
+
if (pending.retryTimer)
|
|
2429
|
+
clearTimeout(pending.retryTimer);
|
|
2430
|
+
pending.retryTimer = undefined;
|
|
2431
|
+
pending.retiring = true;
|
|
2432
|
+
pending.turnComplete = true;
|
|
2433
|
+
pending.completionPartial = true;
|
|
2434
|
+
if (pending.held.length > 0 &&
|
|
2435
|
+
this.router.getTarget(pending.sessionId) === pending.target) {
|
|
2436
|
+
void this.flushDetachedBackgroundResponse('', pending.sessionId, pending, { target: pending.target }).catch((error) => {
|
|
2437
|
+
process.stderr.write(`[DingTalk:${this.name}] background response delivery failed during drain: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
else if (pending.held.length > 0) {
|
|
2441
|
+
process.stderr.write(`[DingTalk:${this.name}] background response target unavailable during drain; ${pending.held.length} buffered segment(s) discarded\n`);
|
|
2442
|
+
pending.held.length = 0;
|
|
2443
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2444
|
+
}
|
|
2445
|
+
else if (pending.held.length === 0) {
|
|
2446
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
const aggregations = new Set([
|
|
2450
|
+
...this.backgroundResponseAggregations.values(),
|
|
2451
|
+
...this.detachedBackgroundResponseAggregations,
|
|
2452
|
+
]);
|
|
2453
|
+
for (const aggregation of aggregations) {
|
|
2454
|
+
if (sessionId !== undefined && aggregation.sessionId !== sessionId) {
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
if (aggregation.timeoutTimer)
|
|
2458
|
+
clearTimeout(aggregation.timeoutTimer);
|
|
2459
|
+
if (aggregation.retryTimer)
|
|
2460
|
+
clearTimeout(aggregation.retryTimer);
|
|
2461
|
+
aggregation.timeoutTimer = undefined;
|
|
2462
|
+
aggregation.retryTimer = undefined;
|
|
2463
|
+
aggregation.retiring = true;
|
|
2464
|
+
aggregation.turnComplete = true;
|
|
2465
|
+
aggregation.completionPartial = true;
|
|
2466
|
+
if (!aggregation.flushing) {
|
|
2467
|
+
void this.flushBackgroundResponseAggregation(aggregation.key, aggregation);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
createBackgroundResponseAggregation(key, sessionId, context, target, sourceLabel) {
|
|
2472
|
+
const aggregation = {
|
|
2473
|
+
key,
|
|
2474
|
+
sessionId,
|
|
2475
|
+
target,
|
|
2476
|
+
sourceLabel,
|
|
2477
|
+
status: context.status,
|
|
2478
|
+
label: context.label,
|
|
2479
|
+
parts: [],
|
|
2480
|
+
};
|
|
2481
|
+
this.backgroundResponseAggregations.set(key, aggregation);
|
|
2482
|
+
return aggregation;
|
|
2483
|
+
}
|
|
2484
|
+
scheduleBackgroundResponseResolutionRetry(key, sessionId, pending) {
|
|
2485
|
+
if (pending.retiring)
|
|
2486
|
+
return;
|
|
2487
|
+
if (pending.retryTimer)
|
|
2488
|
+
return;
|
|
2489
|
+
pending.retryAttempts = (pending.retryAttempts ?? 0) + 1;
|
|
2490
|
+
if (pending.retryAttempts >= BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES) {
|
|
2491
|
+
pending.resolutionDropped = true;
|
|
2492
|
+
pending.turnEnded = pending.turnComplete === true;
|
|
2493
|
+
pending.held.length = 0;
|
|
2494
|
+
pending.turnComplete = undefined;
|
|
2495
|
+
pending.status = undefined;
|
|
2496
|
+
pending.label = undefined;
|
|
2497
|
+
pending.completionPartial = undefined;
|
|
2498
|
+
if (pending.turnEnded) {
|
|
2499
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2500
|
+
}
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
pending.retryTimer = setTimeout(() => {
|
|
2504
|
+
pending.retryTimer = undefined;
|
|
2505
|
+
pending.retryInFlight = true;
|
|
2506
|
+
void this.retryBackgroundResponseResolution(key, sessionId, pending)
|
|
2507
|
+
.catch((error) => {
|
|
2508
|
+
process.stderr.write(`[DingTalk:${this.name}] background response target resolution failed: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
|
|
2509
|
+
})
|
|
2510
|
+
.finally(() => {
|
|
2511
|
+
pending.retryInFlight = false;
|
|
2512
|
+
if (pending.retiring ||
|
|
2513
|
+
(!pending.retryTimer && !pending.resolutionDropped)) {
|
|
2514
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2515
|
+
}
|
|
2516
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === pending &&
|
|
2517
|
+
pending.resolvers === 0 &&
|
|
2518
|
+
!pending.retryTimer &&
|
|
2519
|
+
pending.held.length === 0 &&
|
|
2520
|
+
(!pending.resolutionDropped || pending.turnEnded)) {
|
|
2521
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2522
|
+
}
|
|
2523
|
+
});
|
|
2524
|
+
}, BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS);
|
|
2525
|
+
pending.retryTimer.unref?.();
|
|
2526
|
+
const active = this.pendingBackgroundResponseTerminals.get(key);
|
|
2527
|
+
if (!active || active === pending) {
|
|
2528
|
+
this.pendingBackgroundResponseTerminals.set(key, pending);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
async retryBackgroundResponseResolution(key, sessionId, pending) {
|
|
2532
|
+
let delivery;
|
|
2533
|
+
try {
|
|
2534
|
+
delivery = await this.resolveBackgroundResponseDelivery(sessionId);
|
|
2535
|
+
}
|
|
2536
|
+
catch (error) {
|
|
2537
|
+
if ((this.pendingBackgroundResponseTerminals.get(key) === pending &&
|
|
2538
|
+
!this.backgroundResponseAggregations.has(key)) ||
|
|
2539
|
+
this.detachedPendingBackgroundResponseTerminals.has(pending)) {
|
|
2540
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, pending);
|
|
2541
|
+
}
|
|
2542
|
+
throw error;
|
|
2543
|
+
}
|
|
2544
|
+
if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
|
|
2545
|
+
if (pending.retiring) {
|
|
2546
|
+
if (pending.held.length > 0) {
|
|
2547
|
+
process.stderr.write(`[DingTalk:${this.name}] background response target unavailable during drain; ${pending.held.length} buffered segment(s) discarded\n`);
|
|
2548
|
+
}
|
|
2549
|
+
pending.held.length = 0;
|
|
2550
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
if ((this.pendingBackgroundResponseTerminals.get(key) === pending &&
|
|
2554
|
+
!this.backgroundResponseAggregations.has(key)) ||
|
|
2555
|
+
this.detachedPendingBackgroundResponseTerminals.has(pending)) {
|
|
2556
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, pending);
|
|
2557
|
+
}
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
if (pending.retiring ||
|
|
2561
|
+
this.pendingBackgroundResponseTerminals.get(key) !== pending ||
|
|
2562
|
+
pending.turnComplete) {
|
|
2563
|
+
await this.flushDetachedBackgroundResponse(key, sessionId, pending, delivery);
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
const first = pending.held[0];
|
|
2567
|
+
if (!first)
|
|
2568
|
+
return;
|
|
2569
|
+
const aggregation = this.createBackgroundResponseAggregation(key, sessionId, first.context, delivery.target, delivery.sourceLabel);
|
|
2570
|
+
this.applyHeldBackgroundResponses(aggregation, pending);
|
|
2571
|
+
if (pending.resolutionDropped) {
|
|
2572
|
+
aggregation.resolutionDropped = true;
|
|
2573
|
+
pending.resolutionDropped = undefined;
|
|
2574
|
+
}
|
|
2575
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === pending) {
|
|
2576
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2577
|
+
}
|
|
2578
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2579
|
+
}
|
|
2580
|
+
holdPendingBackgroundResponse(pending, text, context) {
|
|
2581
|
+
if (text.trim().length > 0)
|
|
2582
|
+
pending.held.push({ text, context });
|
|
2583
|
+
if (context.turnComplete) {
|
|
2584
|
+
pending.turnComplete = true;
|
|
2585
|
+
pending.status = context.status;
|
|
2586
|
+
pending.label = context.label ?? pending.label;
|
|
2587
|
+
pending.completionPartial = context.partial === true;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
applyHeldBackgroundResponses(aggregation, pending) {
|
|
2591
|
+
for (const { text, context } of pending.held.splice(0)) {
|
|
2592
|
+
aggregation.status = context.status;
|
|
2593
|
+
aggregation.label = context.label ?? aggregation.label;
|
|
2594
|
+
aggregation.parts.push(text);
|
|
2595
|
+
if (context.turnComplete) {
|
|
2596
|
+
aggregation.turnComplete = true;
|
|
2597
|
+
aggregation.completionPartial = context.partial === true;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
applyPendingBackgroundResponseTerminal(aggregation, pending) {
|
|
2602
|
+
if (!pending.turnComplete)
|
|
2603
|
+
return;
|
|
2604
|
+
aggregation.turnComplete = true;
|
|
2605
|
+
aggregation.status = pending.status ?? aggregation.status;
|
|
2606
|
+
aggregation.label = pending.label ?? aggregation.label;
|
|
2607
|
+
aggregation.completionPartial = pending.completionPartial === true;
|
|
2608
|
+
pending.turnComplete = undefined;
|
|
2609
|
+
pending.status = undefined;
|
|
2610
|
+
pending.label = undefined;
|
|
2611
|
+
pending.completionPartial = undefined;
|
|
2612
|
+
}
|
|
2613
|
+
async completeBackgroundResponseAggregation(key, aggregation) {
|
|
2614
|
+
if (aggregation.delivery) {
|
|
2615
|
+
aggregation.delivery.status = aggregation.status;
|
|
2616
|
+
aggregation.delivery.label =
|
|
2617
|
+
aggregation.label ?? aggregation.delivery.label;
|
|
2618
|
+
aggregation.delivery.partial =
|
|
2619
|
+
this.isPartialBackgroundResponseDelivery(aggregation, aggregation.delivery.parts.length) || aggregation.parts.length > 0;
|
|
2620
|
+
}
|
|
2621
|
+
if (aggregation.timeoutTimer)
|
|
2622
|
+
clearTimeout(aggregation.timeoutTimer);
|
|
2623
|
+
aggregation.timeoutTimer = undefined;
|
|
2624
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2625
|
+
}
|
|
2626
|
+
async flushDetachedBackgroundResponse(key, sessionId, pending, delivery) {
|
|
2627
|
+
const first = pending.held[0];
|
|
2628
|
+
if (!first) {
|
|
2629
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
const aggregation = {
|
|
2633
|
+
key,
|
|
2634
|
+
sessionId,
|
|
2635
|
+
target: delivery.target,
|
|
2636
|
+
sourceLabel: delivery.sourceLabel,
|
|
2637
|
+
status: first.context.status,
|
|
2638
|
+
label: first.context.label,
|
|
2639
|
+
parts: [],
|
|
2640
|
+
turnComplete: pending.turnComplete,
|
|
2641
|
+
completionPartial: pending.completionPartial,
|
|
2642
|
+
resolutionDropped: pending.resolutionDropped,
|
|
2643
|
+
};
|
|
2644
|
+
this.applyHeldBackgroundResponses(aggregation, pending);
|
|
2645
|
+
aggregation.status = pending.status ?? aggregation.status;
|
|
2646
|
+
aggregation.label = pending.label ?? aggregation.label;
|
|
2647
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === pending) {
|
|
2648
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2649
|
+
}
|
|
2650
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2651
|
+
this.detachedBackgroundResponseAggregations.add(aggregation);
|
|
2652
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2653
|
+
}
|
|
2654
|
+
scheduleBackgroundResponseAggregationFlush(key, aggregation) {
|
|
2655
|
+
if (aggregation.timeoutTimer || aggregation.delivery)
|
|
2656
|
+
return;
|
|
2657
|
+
aggregation.timeoutTimer = setTimeout(() => {
|
|
2658
|
+
aggregation.timeoutTimer = undefined;
|
|
2659
|
+
void this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2660
|
+
}, BACKGROUND_RESPONSE_AGGREGATION_TIMEOUT_MS);
|
|
2661
|
+
aggregation.timeoutTimer.unref?.();
|
|
2662
|
+
}
|
|
2663
|
+
formatBackgroundResponseAggregation(delivery) {
|
|
2664
|
+
const icon = delivery.status === 'completed'
|
|
2665
|
+
? 'â
'
|
|
2666
|
+
: delivery.status === 'failed'
|
|
2667
|
+
? 'â'
|
|
2668
|
+
: 'âšī¸';
|
|
2669
|
+
const label = this.formatBackgroundAgentLabel(delivery.label);
|
|
2670
|
+
const header = `## ${icon} Agent ¡ ${label}${delivery.partial ? 'īŧé¨åīŧ' : ''}`;
|
|
2671
|
+
if (delivery.parts.length === 0)
|
|
2672
|
+
return header;
|
|
2673
|
+
return `${header}\n\n${delivery.parts.join('\n\n')}`;
|
|
2674
|
+
}
|
|
2675
|
+
formatBackgroundAgentResponse(text, label) {
|
|
2676
|
+
return `## đ¤ Agent ¡ ${this.formatBackgroundAgentLabel(label)}\n\n${text}`;
|
|
2677
|
+
}
|
|
2678
|
+
formatBackgroundAgentLabel(label) {
|
|
2679
|
+
const normalized = label
|
|
2680
|
+
?.replace(/\p{Cc}+/gu, ' ')
|
|
2681
|
+
.replace(/\s+/g, ' ')
|
|
2682
|
+
.trim();
|
|
2683
|
+
return escapeDingTalkMarkdown(normalized || 'åå°äģģåĄ');
|
|
2684
|
+
}
|
|
1684
2685
|
/**
|
|
1685
2686
|
* Out-of-turn one-shot sends (background responses) must not flow through
|
|
1686
2687
|
* the session's block-streaming projector: a second sender interleaving with
|
|
1687
2688
|
* mid-projection state can swallow the send or split a held marker.
|
|
1688
2689
|
*/
|
|
1689
|
-
async deliverBackgroundReply(chatId, text, sessionId, sourceLabel) {
|
|
2690
|
+
async deliverBackgroundReply(chatId, text, sessionId, sourceLabel, prepared = false, failOnHttpError = false) {
|
|
1690
2691
|
if (this.config.blockStreaming !== 'on') {
|
|
1691
|
-
return
|
|
2692
|
+
return this.sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared, failOnHttpError);
|
|
1692
2693
|
}
|
|
1693
|
-
await this.sendReply(chatId, text, undefined, sourceLabel);
|
|
2694
|
+
await this.sendReply(chatId, text, undefined, sourceLabel, prepared, failOnHttpError);
|
|
1694
2695
|
}
|
|
1695
|
-
async sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared = false) {
|
|
2696
|
+
async sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared = false, failOnHttpError = false) {
|
|
1696
2697
|
let outgoingText = text;
|
|
1697
2698
|
let consumesMention = true;
|
|
1698
2699
|
if (this.config.blockStreaming === 'on') {
|
|
@@ -1709,7 +2710,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1709
2710
|
: undefined;
|
|
1710
2711
|
if (atUserId)
|
|
1711
2712
|
this.sessionMentionTargets.delete(sessionId);
|
|
1712
|
-
await this.sendReply(chatId, outgoingText, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId), prepared);
|
|
2713
|
+
await this.sendReply(chatId, outgoingText, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId), prepared, failOnHttpError);
|
|
1713
2714
|
}
|
|
1714
2715
|
projectBlockStreamChunk(text, sessionId) {
|
|
1715
2716
|
let state = this.blockFileProjectors.get(sessionId);
|