@qwen-code/channel-dingtalk 0.23.3 → 0.23.4

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.
@@ -23,6 +23,9 @@ function nonEmptyString(value) {
23
23
  const trimmed = value.trim();
24
24
  return trimmed || undefined;
25
25
  }
26
+ function richTextPartType(part) {
27
+ return part.type || part.msgType || 'text';
28
+ }
26
29
  function parseJsonArray(value) {
27
30
  if (Array.isArray(value))
28
31
  return value;
@@ -410,15 +413,6 @@ function formatChatRecord(content, budget = MAX_CHAT_RECORD_CHARS) {
410
413
  }
411
414
  /** Track seen msgIds to deduplicate retried callbacks. */
412
415
  const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
413
- const BACKGROUND_RESPONSE_AGGREGATION_TIMEOUT_MS = 10 * 60 * 1000;
414
- /**
415
- * A failed aggregation delivery is retried rather than dropped: aggregating
416
- * concentrates a whole turn into one send, and the burst that makes a send
417
- * fail (several agents finishing at once against one chat quota) is exactly
418
- * when the whole result would be lost.
419
- */
420
- const BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS = 30 * 1000;
421
- const BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES = 3;
422
416
  const ACK_REACTION_NAME = '👀';
423
417
  const ACK_EMOTION_ID = '2659900';
424
418
  const ACK_EMOTION_BG_ID = 'im_bg_1';
@@ -631,24 +625,6 @@ async function withConnectLoggingSuppressed(connect) {
631
625
  }
632
626
  }
633
627
  }
634
- class ProactiveTextDeliveryError extends Error {
635
- plan;
636
- retryable;
637
- constructor(plan, cause) {
638
- super(cause instanceof Error ? cause.message : String(cause), { cause });
639
- this.plan = plan;
640
- if (cause instanceof DingtalkCardRequestError) {
641
- this.retryable = cause.retryable;
642
- }
643
- }
644
- }
645
- class ReplyTextDeliveryError extends Error {
646
- plan;
647
- constructor(plan, cause) {
648
- super(cause instanceof Error ? cause.message : String(cause), { cause });
649
- this.plan = plan;
650
- }
651
- }
652
628
  export class DingtalkChannel extends ChannelBase {
653
629
  client;
654
630
  atSender;
@@ -680,7 +656,6 @@ export class DingtalkChannel extends ChannelBase {
680
656
  */
681
657
  proactiveToken;
682
658
  interactiveCardConfig;
683
- aggregateBackgroundAgentResponses;
684
659
  interactiveCardClient;
685
660
  statusCardController;
686
661
  questionCardController;
@@ -693,10 +668,6 @@ export class DingtalkChannel extends ChannelBase {
693
668
  // boundary, input requested) mints a fresh segment UUID but the projection
694
669
  // state must survive it, or a marker split across the reset leaks.
695
670
  fileProjectors = new Map();
696
- backgroundResponseAggregations = new Map();
697
- detachedBackgroundResponseAggregations = new Set();
698
- pendingBackgroundResponseTerminals = new Map();
699
- detachedPendingBackgroundResponseTerminals = new Set();
700
671
  constructor(name, config, bridge, options) {
701
672
  super(name, config, bridge, options);
702
673
  this.atSender =
@@ -717,13 +688,6 @@ export class DingtalkChannel extends ChannelBase {
717
688
  this.config.instructions += FILE_INSTRUCTIONS;
718
689
  }
719
690
  this.interactiveCardConfig = parseDingtalkInteractiveCardConfig(config.interactiveCards);
720
- const rawAggregateBackgroundAgentResponses = config.aggregateBackgroundAgentResponses;
721
- if (rawAggregateBackgroundAgentResponses !== undefined &&
722
- typeof rawAggregateBackgroundAgentResponses !== 'boolean') {
723
- throw new Error(`Channel "${name}" aggregateBackgroundAgentResponses must be a boolean.`);
724
- }
725
- this.aggregateBackgroundAgentResponses =
726
- rawAggregateBackgroundAgentResponses === true;
727
691
  if (!config.clientId || !config.clientSecret) {
728
692
  throw new Error(`Channel "${name}" requires clientId and clientSecret for DingTalk.`);
729
693
  }
@@ -1157,14 +1121,11 @@ export class DingtalkChannel extends ChannelBase {
1157
1121
  }
1158
1122
  return replaceImageMarkers(text, markers, replacements);
1159
1123
  }
1160
- async sendReply(chatId, text, atUserId, sourceLabel, prepared = false, failOnHttpError = false) {
1124
+ async sendReply(chatId, text, atUserId, sourceLabel, prepared = false) {
1161
1125
  // chatId is a conversationId — resolve to the latest sessionWebhook.
1162
1126
  const webhook = this.webhooks.get(chatId);
1163
1127
  if (!webhook) {
1164
1128
  process.stderr.write(`[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`);
1165
- if (failOnHttpError) {
1166
- throw new Error('DingTalk session webhook unavailable');
1167
- }
1168
1129
  return;
1169
1130
  }
1170
1131
  const outgoingText = prepared
@@ -1173,12 +1134,7 @@ export class DingtalkChannel extends ChannelBase {
1173
1134
  if (!outgoingText.trim())
1174
1135
  return;
1175
1136
  const plan = this.createReplyTextDelivery(outgoingText, atUserId, sourceLabel);
1176
- try {
1177
- await this.deliverReplyText(chatId, plan, failOnHttpError);
1178
- }
1179
- catch (error) {
1180
- throw new ReplyTextDeliveryError(plan, error);
1181
- }
1137
+ await this.deliverReplyText(chatId, plan);
1182
1138
  }
1183
1139
  createReplyTextDelivery(outgoingText, atUserId, sourceLabel) {
1184
1140
  const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
@@ -1197,12 +1153,9 @@ export class DingtalkChannel extends ChannelBase {
1197
1153
  ...(atUserId ? { atUserId } : {}),
1198
1154
  };
1199
1155
  }
1200
- async deliverReplyText(chatId, plan, failOnHttpError = false) {
1156
+ async deliverReplyText(chatId, plan) {
1201
1157
  const webhook = this.webhooks.get(chatId);
1202
1158
  if (!webhook) {
1203
- if (failOnHttpError) {
1204
- throw new Error('DingTalk session webhook unavailable');
1205
- }
1206
1159
  return;
1207
1160
  }
1208
1161
  while (plan.nextChunk < plan.chunks.length) {
@@ -1247,24 +1200,6 @@ export class DingtalkChannel extends ChannelBase {
1247
1200
  if (!resp.ok) {
1248
1201
  const detail = await resp.text().catch(() => '');
1249
1202
  process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`);
1250
- if (failOnHttpError) {
1251
- throw new Error(`DingTalk reply send failed: HTTP ${resp.status} ${detail}`);
1252
- }
1253
- }
1254
- else if (failOnHttpError) {
1255
- const payload = (await resp
1256
- .clone()
1257
- .json()
1258
- .catch(() => undefined));
1259
- const response = payload && typeof payload === 'object'
1260
- ? payload
1261
- : undefined;
1262
- const value = response?.['errcode'] ?? response?.['code'];
1263
- if (value !== undefined && String(value) !== '0') {
1264
- const detail = sanitizeLogText(String(response?.['errmsg'] ?? response?.['message'] ?? value), 300);
1265
- process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: ${detail}\n`);
1266
- throw new Error(`DingTalk reply send failed: ${detail}`);
1267
- }
1268
1203
  }
1269
1204
  plan.nextChunk++;
1270
1205
  }
@@ -1305,12 +1240,7 @@ export class DingtalkChannel extends ChannelBase {
1305
1240
  const plan = await this.createProactiveTextDelivery(target, text, sourceLabel);
1306
1241
  if (!plan)
1307
1242
  return;
1308
- try {
1309
- await this.deliverProactiveText(target, plan);
1310
- }
1311
- catch (error) {
1312
- throw new ProactiveTextDeliveryError(plan, error);
1313
- }
1243
+ await this.deliverProactiveText(target, plan);
1314
1244
  }
1315
1245
  async createProactiveTextDelivery(target, text, sourceLabel) {
1316
1246
  const outgoingText = await this.prepareFileOutput(text, (file, mediaId) => this.sendProactiveFile(target, file, mediaId));
@@ -1555,7 +1485,6 @@ export class DingtalkChannel extends ChannelBase {
1555
1485
  this.finishReaction(state.chatId, state.messageId, state.sessionId);
1556
1486
  }
1557
1487
  this.statusCardController?.dispose();
1558
- this.drainBackgroundResponseAggregations();
1559
1488
  this.activeReactionKeys.clear();
1560
1489
  this.sessionReactionKeys.clear();
1561
1490
  this.reactionStates.clear();
@@ -1835,11 +1764,6 @@ export class DingtalkChannel extends ChannelBase {
1835
1764
  }
1836
1765
  }
1837
1766
  this.sessionMentionTargets.delete(sessionId);
1838
- // A session dying after segments arrived but before the terminal signal is
1839
- // precisely the case the partial-card fallback exists for; dropping the
1840
- // buffer here would lose text the agent already produced, which the
1841
- // pre-aggregation code always delivered on arrival.
1842
- this.drainBackgroundResponseAggregations(sessionId);
1843
1767
  const cardRunId = this.cardRunBySession.get(sessionId);
1844
1768
  if (cardRunId) {
1845
1769
  this.cardRunBySession.delete(sessionId);
@@ -1875,9 +1799,6 @@ export class DingtalkChannel extends ChannelBase {
1875
1799
  this.cardRuns.delete(runId);
1876
1800
  }
1877
1801
  }
1878
- onSessionRetiring(sessionId) {
1879
- this.drainBackgroundResponseAggregations(sessionId);
1880
- }
1881
1802
  onTaskLifecycle(event) {
1882
1803
  if (event.type === 'started') {
1883
1804
  this.startReaction(event.chatId, event.messageId, event.sessionId);
@@ -2023,665 +1944,13 @@ export class DingtalkChannel extends ChannelBase {
2023
1944
  this.sessionMentionTargets.delete(sessionId);
2024
1945
  this.stopReaction(chatId, messageId, sessionId);
2025
1946
  }
2026
- /** Deliver every Agent segment immediately unless aggregation is enabled. */
2027
- async dispatchBackgroundResponse(sessionId, text, context) {
2028
- const target = this.router.getTarget(sessionId);
2029
- if (!target ||
2030
- target.channelName !== this.name ||
2031
- (context !== undefined && context.kind !== 'agent')) {
2032
- return super.dispatchBackgroundResponse(sessionId, text, context);
2033
- }
2034
- const canAggregate = this.aggregateBackgroundAgentResponses &&
2035
- context?.kind === 'agent' &&
2036
- typeof context.turnComplete === 'boolean';
2037
- if (!canAggregate) {
2038
- if (text.trim().length === 0) {
2039
- return super.dispatchBackgroundResponse(sessionId, text, context);
2040
- }
2041
- return super.dispatchBackgroundResponse(sessionId, this.formatBackgroundAgentResponse(text, context?.label), context);
2042
- }
2043
- const key = JSON.stringify([sessionId, context.taskId, context.turnId]);
2044
- let current = this.backgroundResponseAggregations.get(key);
2045
- let parked = this.pendingBackgroundResponseTerminals.get(key);
2046
- if (current?.turnComplete === true) {
2047
- this.detachedBackgroundResponseAggregations.add(current);
2048
- this.backgroundResponseAggregations.delete(key);
2049
- current = undefined;
2050
- }
2051
- if (!current &&
2052
- (parked?.turnEnded === true ||
2053
- (parked?.turnComplete === true &&
2054
- (parked.retryTimer || parked.retryInFlight || parked.resolvers > 0)))) {
2055
- if (!parked.turnEnded) {
2056
- this.detachedPendingBackgroundResponseTerminals.add(parked);
2057
- }
2058
- parked = { sessionId, target, resolvers: 0, held: [] };
2059
- this.pendingBackgroundResponseTerminals.set(key, parked);
2060
- }
2061
- if (!current && text.trim().length === 0) {
2062
- // The first segment's target resolution can suspend (named-session owner
2063
- // lock), so a turn's terminal marker may arrive before the aggregation
2064
- // exists. Park it instead of routing it to the empty-text early return,
2065
- // or the completed turn only surfaces via the bounded wait, mislabeled.
2066
- if (!parked ||
2067
- (parked.resolvers === 0 &&
2068
- !parked.retryTimer &&
2069
- !parked.retryInFlight &&
2070
- !parked.resolutionDropped &&
2071
- !parked.turnComplete)) {
2072
- if (parked)
2073
- this.pendingBackgroundResponseTerminals.delete(key);
2074
- return super.dispatchBackgroundResponse(sessionId, text, context);
2075
- }
2076
- if (context.turnComplete) {
2077
- parked.turnComplete = true;
2078
- parked.status = context.status;
2079
- parked.label = context.label ?? parked.label;
2080
- parked.completionPartial = context.partial === true;
2081
- if (parked.resolvers === 0 &&
2082
- !parked.retryTimer &&
2083
- !parked.retryInFlight &&
2084
- (parked.retryAttempts ?? 0) >=
2085
- BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES) {
2086
- parked.turnEnded = true;
2087
- this.pendingBackgroundResponseTerminals.delete(key);
2088
- }
2089
- }
2090
- return;
2091
- }
2092
- if (!current) {
2093
- parked ??= { sessionId, target, resolvers: 0, held: [] };
2094
- this.pendingBackgroundResponseTerminals.set(key, parked);
2095
- this.holdPendingBackgroundResponse(parked, text, context);
2096
- parked.resolvers++;
2097
- try {
2098
- let delivery;
2099
- try {
2100
- delivery = await this.resolveBackgroundResponseDelivery(sessionId);
2101
- }
2102
- catch (error) {
2103
- if (parked.resolvers === 1 &&
2104
- !this.backgroundResponseAggregations.has(key)) {
2105
- this.scheduleBackgroundResponseResolutionRetry(key, sessionId, parked);
2106
- }
2107
- else {
2108
- const existing = this.backgroundResponseAggregations.get(key);
2109
- if (existing) {
2110
- if (parked.held.length > 0) {
2111
- this.applyHeldBackgroundResponses(existing, parked);
2112
- }
2113
- this.applyPendingBackgroundResponseTerminal(existing, parked);
2114
- if (parked.resolvers === 1) {
2115
- if (existing.turnComplete) {
2116
- await this.completeBackgroundResponseAggregation(key, existing);
2117
- }
2118
- else {
2119
- this.scheduleBackgroundResponseAggregationFlush(key, existing);
2120
- }
2121
- }
2122
- }
2123
- }
2124
- throw error;
2125
- }
2126
- if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
2127
- if (parked.resolvers === 1 &&
2128
- !this.backgroundResponseAggregations.has(key)) {
2129
- this.scheduleBackgroundResponseResolutionRetry(key, sessionId, parked);
2130
- }
2131
- else {
2132
- const existing = this.backgroundResponseAggregations.get(key);
2133
- if (existing) {
2134
- if (parked.held.length > 0) {
2135
- this.applyHeldBackgroundResponses(existing, parked);
2136
- }
2137
- this.applyPendingBackgroundResponseTerminal(existing, parked);
2138
- if (parked.resolvers === 1) {
2139
- if (existing.turnComplete) {
2140
- await this.completeBackgroundResponseAggregation(key, existing);
2141
- }
2142
- else {
2143
- this.scheduleBackgroundResponseAggregationFlush(key, existing);
2144
- }
2145
- }
2146
- }
2147
- }
2148
- return;
2149
- }
2150
- if (parked.retiring ||
2151
- this.pendingBackgroundResponseTerminals.get(key) !== parked) {
2152
- await this.flushDetachedBackgroundResponse(key, sessionId, parked, delivery);
2153
- return;
2154
- }
2155
- if (parked.retryTimer) {
2156
- clearTimeout(parked.retryTimer);
2157
- parked.retryTimer = undefined;
2158
- }
2159
- current =
2160
- this.backgroundResponseAggregations.get(key) ??
2161
- this.createBackgroundResponseAggregation(key, sessionId, parked.held[0]?.context ?? context, delivery.target, delivery.sourceLabel);
2162
- this.applyHeldBackgroundResponses(current, parked);
2163
- this.applyPendingBackgroundResponseTerminal(current, parked);
2164
- if (parked.resolutionDropped) {
2165
- current.resolutionDropped = true;
2166
- parked.resolutionDropped = undefined;
2167
- }
2168
- }
2169
- finally {
2170
- parked.resolvers--;
2171
- if (this.pendingBackgroundResponseTerminals.get(key) === parked &&
2172
- parked.resolvers === 0 &&
2173
- !parked.retryTimer &&
2174
- parked.held.length === 0 &&
2175
- (!parked.resolutionDropped || parked.turnEnded)) {
2176
- this.pendingBackgroundResponseTerminals.delete(key);
2177
- }
2178
- }
2179
- if (!current)
2180
- return;
2181
- if (current.turnComplete && parked.resolvers > 0)
2182
- return;
2183
- if (!current.turnComplete) {
2184
- this.scheduleBackgroundResponseAggregationFlush(key, current);
2185
- }
2186
- else {
2187
- await this.completeBackgroundResponseAggregation(key, current);
2188
- }
2189
- return;
2190
- }
2191
- current.status = context.status;
2192
- current.label = context.label ?? current.label;
2193
- if (text.trim().length > 0)
2194
- current.parts.push(text);
2195
- if (context.turnComplete && parked && parked.resolvers > 0) {
2196
- parked.turnComplete = true;
2197
- parked.status = context.status;
2198
- parked.label = context.label ?? parked.label;
2199
- parked.completionPartial = context.partial === true;
2200
- }
2201
- else if (context.turnComplete) {
2202
- current.turnComplete = true;
2203
- current.completionPartial = context.partial === true;
2204
- }
2205
- else if (parked?.turnComplete && parked.resolvers === 0) {
2206
- current.turnComplete = true;
2207
- current.status = parked.status ?? current.status;
2208
- current.label = parked.label ?? current.label;
2209
- current.completionPartial = parked.completionPartial === true;
2210
- }
2211
- current.resolutionDropped ||= parked?.resolutionDropped;
2212
- if (!current.turnComplete) {
2213
- this.scheduleBackgroundResponseAggregationFlush(key, current);
2214
- return;
2215
- }
2216
- await this.completeBackgroundResponseAggregation(key, current);
2217
- }
2218
- async flushBackgroundResponseAggregation(key, aggregation) {
2219
- if ((this.backgroundResponseAggregations.get(key) !== aggregation &&
2220
- !this.detachedBackgroundResponseAggregations.has(aggregation)) ||
2221
- aggregation.flushing) {
2222
- return;
2223
- }
2224
- this.refreshBackgroundResponseDelivery(aggregation);
2225
- if (aggregation.retryTimer)
2226
- clearTimeout(aggregation.retryTimer);
2227
- aggregation.retryTimer = undefined;
2228
- let delivery = aggregation.delivery;
2229
- if (!delivery) {
2230
- if (aggregation.parts.length === 0) {
2231
- // A turn whose text was already drained by the bounded wait still owes
2232
- // the user its completion: the last card it saw reads `(部分)`.
2233
- if (!this.owesTerminalBackgroundResponseCard(aggregation)) {
2234
- if (aggregation.retiring || aggregation.turnComplete) {
2235
- this.removeBackgroundResponseAggregation(key, aggregation);
2236
- }
2237
- else {
2238
- this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
2239
- }
2240
- return;
2241
- }
2242
- }
2243
- if (aggregation.timeoutTimer)
2244
- clearTimeout(aggregation.timeoutTimer);
2245
- aggregation.timeoutTimer = undefined;
2246
- const parts = aggregation.parts.splice(0);
2247
- delivery = {
2248
- status: aggregation.status,
2249
- label: aggregation.label,
2250
- parts,
2251
- partial: this.isPartialBackgroundResponseDelivery(aggregation, parts.length),
2252
- attempts: 0,
2253
- composedTurnComplete: aggregation.turnComplete === true,
2254
- };
2255
- aggregation.delivery = delivery;
2256
- }
2257
- const body = this.formatBackgroundResponseAggregation(delivery);
2258
- aggregation.flushing = true;
2259
- let error;
2260
- try {
2261
- if (delivery.proactivePlan) {
2262
- await this.deliverProactiveText(aggregation.target, delivery.proactivePlan);
2263
- }
2264
- else if (delivery.replyPlan) {
2265
- await this.deliverReplyText(aggregation.target.chatId, delivery.replyPlan, true);
2266
- }
2267
- else if (this.supportsProactiveSend() &&
2268
- this.supportsProactiveTarget(aggregation.target)) {
2269
- await this.deliverBackgroundResponseToTarget(aggregation.sessionId, body, {
2270
- target: aggregation.target,
2271
- sourceLabel: aggregation.sourceLabel,
2272
- });
2273
- }
2274
- else {
2275
- delivery.preparedReplyBody ??= await this.prepareReplyOutput(aggregation.target.chatId, body);
2276
- await this.deliverBackgroundReply(aggregation.target.chatId, delivery.preparedReplyBody, aggregation.sessionId, aggregation.sourceLabel, true, true);
2277
- }
2278
- }
2279
- catch (caught) {
2280
- error = caught;
2281
- if (caught instanceof ProactiveTextDeliveryError) {
2282
- delivery.proactivePlan = caught.plan;
2283
- }
2284
- else if (caught instanceof ReplyTextDeliveryError) {
2285
- delivery.replyPlan = caught.plan;
2286
- }
2287
- }
2288
- finally {
2289
- aggregation.flushing = false;
2290
- }
2291
- if (error === undefined) {
2292
- aggregation.delivery = undefined;
2293
- aggregation.delivered = true;
2294
- if (delivery.composedTurnComplete === true &&
2295
- aggregation.turnComplete &&
2296
- delivery.partial !== true &&
2297
- !aggregation.retiring) {
2298
- aggregation.completionDelivered = true;
2299
- }
2300
- if (aggregation.retiring || aggregation.turnComplete) {
2301
- await this.flushBackgroundResponseAggregation(key, aggregation);
2302
- }
2303
- else {
2304
- // The turn is still open: keep the entry so its later segments re-join
2305
- // this one (and stay labelled `(部分)`), and re-arm the bounded wait
2306
- // so a silent turn is still reaped.
2307
- this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
2308
- }
2309
- return;
2310
- }
2311
- delivery.attempts++;
2312
- process.stderr.write(`[DingTalk:${this.name}] background response delivery failed (attempt ${delivery.attempts}): ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
2313
- if (delivery.attempts >= BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES ||
2314
- // A permanently rejected send (an invalid appkey, a missing app) cannot
2315
- // succeed later; retrying it only spends the chat's send quota.
2316
- (error instanceof ProactiveTextDeliveryError && error.retryable === false)) {
2317
- aggregation.delivery = undefined;
2318
- aggregation.dropped = true;
2319
- if (aggregation.parts.length === 0) {
2320
- if (aggregation.retiring || aggregation.turnComplete) {
2321
- this.removeBackgroundResponseAggregation(key, aggregation);
2322
- }
2323
- else {
2324
- this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
2325
- }
2326
- }
2327
- else if (aggregation.retiring || aggregation.turnComplete) {
2328
- await this.flushBackgroundResponseAggregation(key, aggregation);
2329
- }
2330
- else {
2331
- this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
2332
- }
2333
- return;
2334
- }
2335
- aggregation.retryTimer = setTimeout(() => {
2336
- aggregation.retryTimer = undefined;
2337
- void this.flushBackgroundResponseAggregation(key, aggregation);
2338
- }, BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS);
2339
- aggregation.retryTimer.unref?.();
2340
- }
2341
- /**
2342
- * A card carries `(部分)` whenever it is not the turn's whole output: the
2343
- * turn is still open, earlier text already went out (or was given up on),
2344
- * or the turn itself ended early.
2345
- */
2346
- isPartialBackgroundResponseDelivery(aggregation, partCount) {
2347
- return (partCount > 0 &&
2348
- (aggregation.delivered === true ||
2349
- aggregation.dropped === true ||
2350
- aggregation.resolutionDropped === true ||
2351
- aggregation.retiring === true ||
2352
- aggregation.completionPartial === true ||
2353
- aggregation.turnComplete !== true));
2354
- }
2355
- /**
2356
- * A turn that outran the bounded wait had its text delivered under the
2357
- * `(部分)` label. When it then completes normally with nothing buffered,
2358
- * a header-only card is the only way the chat ever learns it finished.
2359
- */
2360
- owesTerminalBackgroundResponseCard(aggregation) {
2361
- return (aggregation.turnComplete === true &&
2362
- aggregation.delivered === true &&
2363
- aggregation.completionDelivered !== true &&
2364
- aggregation.retiring !== true &&
2365
- aggregation.completionPartial !== true &&
2366
- aggregation.dropped !== true &&
2367
- aggregation.resolutionDropped !== true);
2368
- }
2369
- refreshBackgroundResponseDelivery(aggregation) {
2370
- const delivery = aggregation.delivery;
2371
- if (!delivery || aggregation.flushing)
2372
- return;
2373
- const plan = delivery.proactivePlan ?? delivery.replyPlan;
2374
- if (plan && plan.nextChunk > 0)
2375
- return;
2376
- const body = this.formatBackgroundResponseAggregation(delivery);
2377
- const header = body.split('\n', 1)[0];
2378
- const replaceHeader = (text) => text.replace(/## (?:✅|❌|⏹️) Agent · [^\n]+/, () => header);
2379
- if (plan) {
2380
- plan.title = extractTitle(body);
2381
- plan.chunks[0] = replaceHeader(plan.chunks[0]);
2382
- }
2383
- if (delivery.preparedReplyBody) {
2384
- delivery.preparedReplyBody = replaceHeader(delivery.preparedReplyBody);
2385
- }
2386
- delivery.composedTurnComplete = aggregation.turnComplete === true;
2387
- }
2388
- removeBackgroundResponseAggregation(key, aggregation) {
2389
- if (this.backgroundResponseAggregations.get(key) === aggregation) {
2390
- this.backgroundResponseAggregations.delete(key);
2391
- }
2392
- this.detachedBackgroundResponseAggregations.delete(aggregation);
2393
- }
2394
- drainBackgroundResponseAggregations(sessionId) {
2395
- for (const [key, pending] of this.pendingBackgroundResponseTerminals) {
2396
- if (sessionId !== undefined && pending.sessionId !== sessionId)
2397
- continue;
2398
- if (pending.retryTimer)
2399
- clearTimeout(pending.retryTimer);
2400
- pending.retryTimer = undefined;
2401
- pending.retiring = true;
2402
- pending.turnComplete = true;
2403
- pending.completionPartial = true;
2404
- this.pendingBackgroundResponseTerminals.delete(key);
2405
- this.detachedPendingBackgroundResponseTerminals.add(pending);
2406
- }
2407
- for (const pending of this.detachedPendingBackgroundResponseTerminals) {
2408
- if (sessionId !== undefined && pending.sessionId !== sessionId)
2409
- continue;
2410
- if (pending.retryTimer)
2411
- clearTimeout(pending.retryTimer);
2412
- pending.retryTimer = undefined;
2413
- pending.retiring = true;
2414
- pending.turnComplete = true;
2415
- pending.completionPartial = true;
2416
- if (pending.held.length > 0 &&
2417
- this.router.getTarget(pending.sessionId) === pending.target) {
2418
- void this.flushDetachedBackgroundResponse('', pending.sessionId, pending, { target: pending.target }).catch((error) => {
2419
- process.stderr.write(`[DingTalk:${this.name}] background response delivery failed during drain: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
2420
- });
2421
- }
2422
- else if (pending.held.length > 0) {
2423
- process.stderr.write(`[DingTalk:${this.name}] background response target unavailable during drain; ${pending.held.length} buffered segment(s) discarded\n`);
2424
- pending.held.length = 0;
2425
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2426
- }
2427
- else if (pending.held.length === 0) {
2428
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2429
- }
2430
- }
2431
- const aggregations = new Set([
2432
- ...this.backgroundResponseAggregations.values(),
2433
- ...this.detachedBackgroundResponseAggregations,
2434
- ]);
2435
- for (const aggregation of aggregations) {
2436
- if (sessionId !== undefined && aggregation.sessionId !== sessionId) {
2437
- continue;
2438
- }
2439
- if (aggregation.timeoutTimer)
2440
- clearTimeout(aggregation.timeoutTimer);
2441
- if (aggregation.retryTimer)
2442
- clearTimeout(aggregation.retryTimer);
2443
- aggregation.timeoutTimer = undefined;
2444
- aggregation.retryTimer = undefined;
2445
- aggregation.retiring = true;
2446
- aggregation.turnComplete = true;
2447
- aggregation.completionPartial = true;
2448
- if (!aggregation.flushing) {
2449
- void this.flushBackgroundResponseAggregation(aggregation.key, aggregation);
2450
- }
2451
- }
2452
- }
2453
- createBackgroundResponseAggregation(key, sessionId, context, target, sourceLabel) {
2454
- const aggregation = {
2455
- key,
2456
- sessionId,
2457
- target,
2458
- sourceLabel,
2459
- status: context.status,
2460
- label: context.label,
2461
- parts: [],
2462
- };
2463
- this.backgroundResponseAggregations.set(key, aggregation);
2464
- return aggregation;
2465
- }
2466
- scheduleBackgroundResponseResolutionRetry(key, sessionId, pending) {
2467
- if (pending.retiring)
2468
- return;
2469
- if (pending.retryTimer)
2470
- return;
2471
- pending.retryAttempts = (pending.retryAttempts ?? 0) + 1;
2472
- if (pending.retryAttempts >= BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES) {
2473
- pending.resolutionDropped = true;
2474
- pending.turnEnded = pending.turnComplete === true;
2475
- pending.held.length = 0;
2476
- pending.turnComplete = undefined;
2477
- pending.status = undefined;
2478
- pending.label = undefined;
2479
- pending.completionPartial = undefined;
2480
- if (pending.turnEnded) {
2481
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2482
- }
2483
- return;
2484
- }
2485
- pending.retryTimer = setTimeout(() => {
2486
- pending.retryTimer = undefined;
2487
- pending.retryInFlight = true;
2488
- void this.retryBackgroundResponseResolution(key, sessionId, pending)
2489
- .catch((error) => {
2490
- process.stderr.write(`[DingTalk:${this.name}] background response target resolution failed: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
2491
- })
2492
- .finally(() => {
2493
- pending.retryInFlight = false;
2494
- if (pending.retiring ||
2495
- (!pending.retryTimer && !pending.resolutionDropped)) {
2496
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2497
- }
2498
- if (this.pendingBackgroundResponseTerminals.get(key) === pending &&
2499
- pending.resolvers === 0 &&
2500
- !pending.retryTimer &&
2501
- pending.held.length === 0 &&
2502
- (!pending.resolutionDropped || pending.turnEnded)) {
2503
- this.pendingBackgroundResponseTerminals.delete(key);
2504
- }
2505
- });
2506
- }, BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS);
2507
- pending.retryTimer.unref?.();
2508
- const active = this.pendingBackgroundResponseTerminals.get(key);
2509
- if (!active || active === pending) {
2510
- this.pendingBackgroundResponseTerminals.set(key, pending);
2511
- }
2512
- }
2513
- async retryBackgroundResponseResolution(key, sessionId, pending) {
2514
- let delivery;
2515
- try {
2516
- delivery = await this.resolveBackgroundResponseDelivery(sessionId);
2517
- }
2518
- catch (error) {
2519
- if ((this.pendingBackgroundResponseTerminals.get(key) === pending &&
2520
- !this.backgroundResponseAggregations.has(key)) ||
2521
- this.detachedPendingBackgroundResponseTerminals.has(pending)) {
2522
- this.scheduleBackgroundResponseResolutionRetry(key, sessionId, pending);
2523
- }
2524
- throw error;
2525
- }
2526
- if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
2527
- if (pending.retiring) {
2528
- if (pending.held.length > 0) {
2529
- process.stderr.write(`[DingTalk:${this.name}] background response target unavailable during drain; ${pending.held.length} buffered segment(s) discarded\n`);
2530
- }
2531
- pending.held.length = 0;
2532
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2533
- return;
2534
- }
2535
- if ((this.pendingBackgroundResponseTerminals.get(key) === pending &&
2536
- !this.backgroundResponseAggregations.has(key)) ||
2537
- this.detachedPendingBackgroundResponseTerminals.has(pending)) {
2538
- this.scheduleBackgroundResponseResolutionRetry(key, sessionId, pending);
2539
- }
2540
- return;
2541
- }
2542
- if (pending.retiring ||
2543
- this.pendingBackgroundResponseTerminals.get(key) !== pending ||
2544
- pending.turnComplete) {
2545
- await this.flushDetachedBackgroundResponse(key, sessionId, pending, delivery);
2546
- return;
2547
- }
2548
- const first = pending.held[0];
2549
- if (!first)
2550
- return;
2551
- const aggregation = this.createBackgroundResponseAggregation(key, sessionId, first.context, delivery.target, delivery.sourceLabel);
2552
- this.applyHeldBackgroundResponses(aggregation, pending);
2553
- if (pending.resolutionDropped) {
2554
- aggregation.resolutionDropped = true;
2555
- pending.resolutionDropped = undefined;
2556
- }
2557
- if (this.pendingBackgroundResponseTerminals.get(key) === pending) {
2558
- this.pendingBackgroundResponseTerminals.delete(key);
2559
- }
2560
- this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
2561
- }
2562
- holdPendingBackgroundResponse(pending, text, context) {
2563
- if (text.trim().length > 0)
2564
- pending.held.push({ text, context });
2565
- if (context.turnComplete) {
2566
- pending.turnComplete = true;
2567
- pending.status = context.status;
2568
- pending.label = context.label ?? pending.label;
2569
- pending.completionPartial = context.partial === true;
2570
- }
2571
- }
2572
- applyHeldBackgroundResponses(aggregation, pending) {
2573
- for (const { text, context } of pending.held.splice(0)) {
2574
- aggregation.status = context.status;
2575
- aggregation.label = context.label ?? aggregation.label;
2576
- aggregation.parts.push(text);
2577
- if (context.turnComplete) {
2578
- aggregation.turnComplete = true;
2579
- aggregation.completionPartial = context.partial === true;
2580
- }
2581
- }
2582
- }
2583
- applyPendingBackgroundResponseTerminal(aggregation, pending) {
2584
- if (!pending.turnComplete)
2585
- return;
2586
- aggregation.turnComplete = true;
2587
- aggregation.status = pending.status ?? aggregation.status;
2588
- aggregation.label = pending.label ?? aggregation.label;
2589
- aggregation.completionPartial = pending.completionPartial === true;
2590
- pending.turnComplete = undefined;
2591
- pending.status = undefined;
2592
- pending.label = undefined;
2593
- pending.completionPartial = undefined;
2594
- }
2595
- async completeBackgroundResponseAggregation(key, aggregation) {
2596
- if (aggregation.delivery) {
2597
- aggregation.delivery.status = aggregation.status;
2598
- aggregation.delivery.label =
2599
- aggregation.label ?? aggregation.delivery.label;
2600
- aggregation.delivery.partial =
2601
- this.isPartialBackgroundResponseDelivery(aggregation, aggregation.delivery.parts.length) || aggregation.parts.length > 0;
2602
- }
2603
- if (aggregation.timeoutTimer)
2604
- clearTimeout(aggregation.timeoutTimer);
2605
- aggregation.timeoutTimer = undefined;
2606
- await this.flushBackgroundResponseAggregation(key, aggregation);
2607
- }
2608
- async flushDetachedBackgroundResponse(key, sessionId, pending, delivery) {
2609
- const first = pending.held[0];
2610
- if (!first) {
2611
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2612
- return;
2613
- }
2614
- const aggregation = {
2615
- key,
2616
- sessionId,
2617
- target: delivery.target,
2618
- sourceLabel: delivery.sourceLabel,
2619
- status: first.context.status,
2620
- label: first.context.label,
2621
- parts: [],
2622
- turnComplete: pending.turnComplete,
2623
- completionPartial: pending.completionPartial,
2624
- resolutionDropped: pending.resolutionDropped,
2625
- };
2626
- this.applyHeldBackgroundResponses(aggregation, pending);
2627
- aggregation.status = pending.status ?? aggregation.status;
2628
- aggregation.label = pending.label ?? aggregation.label;
2629
- if (this.pendingBackgroundResponseTerminals.get(key) === pending) {
2630
- this.pendingBackgroundResponseTerminals.delete(key);
2631
- }
2632
- this.detachedPendingBackgroundResponseTerminals.delete(pending);
2633
- this.detachedBackgroundResponseAggregations.add(aggregation);
2634
- await this.flushBackgroundResponseAggregation(key, aggregation);
2635
- }
2636
- scheduleBackgroundResponseAggregationFlush(key, aggregation) {
2637
- if (aggregation.timeoutTimer || aggregation.delivery)
2638
- return;
2639
- aggregation.timeoutTimer = setTimeout(() => {
2640
- aggregation.timeoutTimer = undefined;
2641
- void this.flushBackgroundResponseAggregation(key, aggregation);
2642
- }, BACKGROUND_RESPONSE_AGGREGATION_TIMEOUT_MS);
2643
- aggregation.timeoutTimer.unref?.();
2644
- }
2645
- formatBackgroundResponseAggregation(delivery) {
2646
- const icon = delivery.status === 'completed'
2647
- ? '✅'
2648
- : delivery.status === 'failed'
2649
- ? '❌'
2650
- : '⏹️';
2651
- const label = this.formatBackgroundAgentLabel(delivery.label);
2652
- const header = `## ${icon} Agent · ${label}${delivery.partial ? '(部分)' : ''}`;
2653
- if (delivery.parts.length === 0)
2654
- return header;
2655
- return `${header}\n\n${delivery.parts.join('\n\n')}`;
2656
- }
2657
- formatBackgroundAgentResponse(text, label) {
2658
- return `## 🤖 Agent · ${this.formatBackgroundAgentLabel(label)}\n\n${text}`;
2659
- }
2660
- formatBackgroundAgentLabel(label) {
2661
- const normalized = label
2662
- ?.replace(/\p{Cc}+/gu, ' ')
2663
- .replace(/\s+/g, ' ')
2664
- .trim();
2665
- return escapeDingTalkMarkdown(normalized || '后台任务');
2666
- }
2667
- /**
2668
- * Body-identical to the base implementation — this override exists only to
2669
- * widen the signature, which the base seam does not declare. The background
2670
- * aggregation flush passes `prepared` so the body it already projected isn't
2671
- * projected a second time (that would re-upload its files), and
2672
- * `failOnHttpError` so a failed send throws instead of being swallowed,
2673
- * letting the flush capture the delivery plan for the next retry.
2674
- */
2675
- async deliverBackgroundReply(chatId, text, sessionId, sourceLabel, prepared = false, failOnHttpError = false) {
2676
- await this.sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared, failOnHttpError);
2677
- }
2678
- async sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared = false, failOnHttpError = false) {
1947
+ async sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared = false) {
2679
1948
  const atUserId = this.atSender
2680
1949
  ? this.sessionMentionTargets.get(sessionId)
2681
1950
  : undefined;
2682
1951
  if (atUserId)
2683
1952
  this.sessionMentionTargets.delete(sessionId);
2684
- await this.sendReply(chatId, text, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId), prepared, failOnHttpError);
1953
+ await this.sendReply(chatId, text, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId), prepared);
2685
1954
  }
2686
1955
  async sendFallbackReply(chatId, text, sessionId, sourceLabel) {
2687
1956
  // Mid-run fallbacks must not consume the prompt's mention target: the
@@ -2767,20 +2036,31 @@ export class DingtalkChannel extends ChannelBase {
2767
2036
  // Note: DingTalk doesn't include content for interactiveCard replies
2768
2037
  // (bot responses sent via webhook). Only user message quotes have text.
2769
2038
  const text = this.summarizeRepliedContent(replied);
2770
- const downloadCode = replied.content?.downloadCode;
2771
- const mediaType = this.mediaTypeFromMsgType(replied.msgType);
2039
+ const media = [];
2040
+ const richText = replied.content?.richText;
2041
+ if (Array.isArray(richText)) {
2042
+ for (const part of richText) {
2043
+ const mediaType = this.mediaTypeFromMsgType(richTextPartType(part));
2044
+ if (part.downloadCode && mediaType) {
2045
+ media.push({ downloadCode: part.downloadCode, mediaType });
2046
+ }
2047
+ }
2048
+ }
2049
+ else {
2050
+ const downloadCode = replied.content?.downloadCode;
2051
+ const mediaType = this.mediaTypeFromMsgType(replied.msgType);
2052
+ if (downloadCode && mediaType) {
2053
+ media.push({
2054
+ downloadCode,
2055
+ mediaType,
2056
+ fileName: replied.content?.fileName,
2057
+ });
2058
+ }
2059
+ }
2772
2060
  return {
2773
2061
  referencedText: text || undefined,
2774
2062
  isReplyToBot,
2775
- ...(downloadCode && mediaType
2776
- ? {
2777
- media: {
2778
- downloadCode,
2779
- mediaType,
2780
- fileName: replied.content?.fileName,
2781
- },
2782
- }
2783
- : {}),
2063
+ media,
2784
2064
  };
2785
2065
  }
2786
2066
  // Legacy format: quoteMessage
@@ -2788,9 +2068,9 @@ export class DingtalkChannel extends ChannelBase {
2788
2068
  const quote = data.quoteMessage;
2789
2069
  const isReplyToBot = !!data.chatbotUserId && quote.senderId === data.chatbotUserId;
2790
2070
  const text = quote.text?.content?.trim();
2791
- return { referencedText: text || undefined, isReplyToBot };
2071
+ return { referencedText: text || undefined, isReplyToBot, media: [] };
2792
2072
  }
2793
- return { isReplyToBot: false };
2073
+ return { isReplyToBot: false, media: [] };
2794
2074
  }
2795
2075
  /**
2796
2076
  * Warn once when a chat-record payload yields nothing to show the model.
@@ -2831,9 +2111,10 @@ export class DingtalkChannel extends ChannelBase {
2831
2111
  if (content?.richText && Array.isArray(content.richText)) {
2832
2112
  const parts = [];
2833
2113
  for (const part of content.richText) {
2834
- const partType = part.type || 'text';
2835
- if (partType === 'text' && part.text) {
2836
- parts.push(part.text);
2114
+ const partType = richTextPartType(part);
2115
+ const partText = typeof part.text === 'string' ? part.text : part.content;
2116
+ if (partType === 'text' && partText) {
2117
+ parts.push(partText);
2837
2118
  }
2838
2119
  else if (partType === 'picture') {
2839
2120
  parts.push('[image]');
@@ -2980,8 +2261,7 @@ export class DingtalkChannel extends ChannelBase {
2980
2261
  * this message's own media — `(audio)`, `(video)`, `(file: name)`. Only the
2981
2262
  * direct-media call site has one, and only that call may erase it: on the
2982
2263
  * quoted-media path `envelope.text` is the user's own reply, and a reply
2983
- * that happens to read exactly like a placeholder must survive (a group
2984
- * `@Bot (audio)` reaches here as exactly `(audio)` after mention removal).
2264
+ * that happens to read exactly like a placeholder must survive.
2985
2265
  */
2986
2266
  async attachMedia(envelope, downloadCode, mediaType, fileName, cleanPlaceholderText) {
2987
2267
  let token;
@@ -3120,23 +2400,12 @@ export class DingtalkChannel extends ChannelBase {
3120
2400
  process.stderr.write(`[DingTalk:${this.name}] message msgId=${sanitizeLogText(msgId || 'unknown', 80)} conversationId=${sanitizeLogText(conversationId || '', 120)} isGroup=${isGroup} isMentioned=${isMentioned} senderNick=${sanitizeLogText(senderNick || '', 80)} senderStaffId=${sanitizeLogText(senderStaffId || '', 80)} senderId=${sanitizeLogText(senderIdValue || '', 80)}\n`);
3121
2401
  // Extract text and media info from message
3122
2402
  const content = this.extractContent(data);
3123
- let cleanText = content.text;
3124
- // Strip first @mention (the bot) from text, keep other @mentions intact.
3125
- // Anchor to start-of-string so @ symbols inside URLs or emails
3126
- // (e.g. git@host:path) are not accidentally stripped (#7402).
3127
- if (isMentioned) {
3128
- cleanText = cleanText.replace(/^\s*@[^\s\p{Cf}]+/u, '').trim();
3129
- }
3130
2403
  // Extract quoted message context
3131
2404
  const quoted = this.extractQuotedContext(data);
3132
2405
  const chatId = conversationId || sessionWebhook;
3133
- // After stripping the bot @mention, cleanText may legitimately be empty
3134
- // (user pinged the bot with no other text). Don't fall back to the
3135
- // original text in that case — it would re-introduce the @mention.
3136
- const messageText = isMentioned ? cleanText : cleanText || content.text;
3137
2406
  // Carry mention targets as a structured envelope field (like
3138
2407
  // referencedText) so ChannelBase renders the marker after prompt
3139
- // sanitization and slash-command parsing sees the body alone.
2408
+ // sanitization.
3140
2409
  const mentionedMemberIds = isGroup ? collectNonBotMentionIds(data) : [];
3141
2410
  const senderId = senderStaffId || senderIdValue || '';
3142
2411
  const senderName = senderNick || senderId || 'Unknown';
@@ -3148,7 +2417,7 @@ export class DingtalkChannel extends ChannelBase {
3148
2417
  ...(isGroup && conversationTitle
3149
2418
  ? { chatName: conversationTitle }
3150
2419
  : {}),
3151
- text: messageText,
2420
+ text: content.text,
3152
2421
  ...(content.syntheticText ? { syntheticText: true } : {}),
3153
2422
  ...(mentionedMemberIds.length > 0 ? { mentionedMemberIds } : {}),
3154
2423
  isGroup,
@@ -3162,7 +2431,7 @@ export class DingtalkChannel extends ChannelBase {
3162
2431
  if (this.atSender && isGroup && senderStaffId) {
3163
2432
  envelope[mentionTarget] = senderStaffId;
3164
2433
  }
3165
- const processMessage = content.downloadCodes.length > 0 || quoted.media
2434
+ const processMessage = content.downloadCodes.length > 0 || quoted.media.length > 0
3166
2435
  ? this.prepareThenHandleInbound(envelope, async () => {
3167
2436
  // Download media in callback order.
3168
2437
  if (content.downloadCodes.length > 0 && content.mediaType) {
@@ -3170,8 +2439,8 @@ export class DingtalkChannel extends ChannelBase {
3170
2439
  await this.attachMedia(envelope, downloadCode, content.mediaType, content.fileName, content.placeholder);
3171
2440
  }
3172
2441
  }
3173
- if (quoted.media) {
3174
- await this.attachMedia(envelope, quoted.media.downloadCode, quoted.media.mediaType, quoted.media.fileName);
2442
+ for (const media of quoted.media) {
2443
+ await this.attachMedia(envelope, media.downloadCode, media.mediaType, media.fileName);
3175
2444
  }
3176
2445
  })
3177
2446
  : this.handleInbound(envelope);