@soimy/dingtalk 3.6.7 → 3.6.8

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/index.js CHANGED
@@ -2681,7 +2681,7 @@ function normalizePendingState(parsed) {
2681
2681
  updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
2682
2682
  pendingCards: records2.filter(
2683
2683
  (entry) => Boolean(
2684
- entry && typeof entry.accountId === "string" && typeof entry.cardInstanceId === "string" && (entry.outTrackId === void 0 || typeof entry.outTrackId === "string") && typeof entry.conversationId === "string" && (entry.lastContent === void 0 || typeof entry.lastContent === "string") && (entry.lastBlockListJson === void 0 || typeof entry.lastBlockListJson === "string") && (entry.streamLifecycleOpened === void 0 || typeof entry.streamLifecycleOpened === "boolean")
2684
+ entry && typeof entry.accountId === "string" && typeof entry.cardInstanceId === "string" && (entry.outTrackId === void 0 || typeof entry.outTrackId === "string") && (entry.processQueryKey === void 0 || typeof entry.processQueryKey === "string") && typeof entry.conversationId === "string" && (entry.lastContent === void 0 || typeof entry.lastContent === "string") && (entry.lastBlockListJson === void 0 || typeof entry.lastBlockListJson === "string") && (entry.streamLifecycleOpened === void 0 || typeof entry.streamLifecycleOpened === "boolean") && (entry.recoveryAction === void 0 || entry.recoveryAction === "finalize" || entry.recoveryAction === "recall")
2685
2685
  )
2686
2686
  )
2687
2687
  };
@@ -2738,6 +2738,7 @@ function upsertPendingCard(card, storePath, log) {
2738
2738
  accountId: card.accountId,
2739
2739
  cardInstanceId: card.cardInstanceId,
2740
2740
  outTrackId: card.outTrackId,
2741
+ processQueryKey: card.processQueryKey,
2741
2742
  conversationId: card.conversationId,
2742
2743
  contextConversationId: card.contextConversationId,
2743
2744
  createdAt: card.createdAt,
@@ -2800,6 +2801,28 @@ function removePendingCardById(cardInstanceId, storePath, log) {
2800
2801
  state.updatedAt = Date.now();
2801
2802
  writePendingCardState(state, storePath, log);
2802
2803
  }
2804
+ function retainPendingCardForRecovery(card, recoveryAction, log) {
2805
+ if (!card.accountId || !card.storePath) {
2806
+ return;
2807
+ }
2808
+ const state = readPendingCardState(card.storePath, log);
2809
+ const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
2810
+ if (index < 0) {
2811
+ return;
2812
+ }
2813
+ const existing = state.pendingCards[index];
2814
+ state.pendingCards[index] = {
2815
+ ...existing,
2816
+ state: AICardStatus.FAILED,
2817
+ lastUpdated: Date.now(),
2818
+ lastContent: card.lastStreamedContent ?? existing.lastContent,
2819
+ lastBlockListJson: card.lastBlockListJson ?? existing.lastBlockListJson,
2820
+ streamLifecycleOpened: card.streamLifecycleOpened,
2821
+ recoveryAction
2822
+ };
2823
+ state.updatedAt = Date.now();
2824
+ writePendingCardState(state, card.storePath, log);
2825
+ }
2803
2826
  function listPendingCardsByAccount(accountId, storePath, log) {
2804
2827
  const state = readPendingCardState(storePath, log);
2805
2828
  return state.pendingCards.filter((item) => item.accountId === accountId);
@@ -2827,6 +2850,37 @@ async function ensureFreshToken(card, log) {
2827
2850
  }
2828
2851
  }
2829
2852
  }
2853
+ async function sendTemplateMismatchNotification(card, text, log) {
2854
+ const config = card.config;
2855
+ if (!config) {
2856
+ return;
2857
+ }
2858
+ try {
2859
+ const token = await getAccessToken(config, log);
2860
+ const { targetId, isExplicitUser } = stripTargetPrefix(card.conversationId);
2861
+ const resolvedTarget = resolveOriginalPeerId(targetId);
2862
+ const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
2863
+ const url = isGroup ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send" : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
2864
+ const payload = {
2865
+ robotCode: resolveRobotCode(config),
2866
+ msgKey: "sampleMarkdown",
2867
+ msgParam: JSON.stringify({ title: "OpenClaw \u63D0\u9192", text })
2868
+ };
2869
+ if (isGroup) {
2870
+ payload.openConversationId = resolvedTarget;
2871
+ } else {
2872
+ payload.userIds = [resolvedTarget];
2873
+ }
2874
+ await http_client_default({
2875
+ url,
2876
+ method: "POST",
2877
+ data: payload,
2878
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" }
2879
+ });
2880
+ } catch (sendErr) {
2881
+ log?.warn?.(`[DingTalk][AICard] Failed to send error notification to user: ${sendErr.message}`);
2882
+ }
2883
+ }
2830
2884
  async function sendProactiveCardText(config, conversationId, content, log) {
2831
2885
  try {
2832
2886
  const card = await createAICard(config, conversationId, log, { persistPending: false });
@@ -2867,7 +2921,7 @@ async function finalizePendingCardsByAccount(config, accountId, reason, storePat
2867
2921
  return 0;
2868
2922
  }
2869
2923
  const pendingCards = listPendingCardsByAccount(accountId, storePath, log).filter(
2870
- (item) => !isCardInTerminalState(item.state)
2924
+ (item) => item.recoveryAction === "recall" && mode === "recover" || item.recoveryAction !== "recall" && (!isCardInTerminalState(item.state) || item.recoveryAction === "finalize")
2871
2925
  );
2872
2926
  if (pendingCards.length === 0) {
2873
2927
  return 0;
@@ -2892,6 +2946,7 @@ async function finalizePendingCardsByAccount(config, accountId, reason, storePat
2892
2946
  accountId: entry.accountId,
2893
2947
  storePath,
2894
2948
  outTrackId: entry.outTrackId,
2949
+ processQueryKey: entry.processQueryKey,
2895
2950
  createdAt: entry.createdAt || Date.now(),
2896
2951
  lastUpdated: entry.lastUpdated || Date.now(),
2897
2952
  state: normalizeRecoveredState(entry.state),
@@ -2901,6 +2956,12 @@ async function finalizePendingCardsByAccount(config, accountId, reason, storePat
2901
2956
  streamLifecycleOpened: entry.streamLifecycleOpened
2902
2957
  };
2903
2958
  try {
2959
+ if (entry.recoveryAction === "recall") {
2960
+ if (await recallAICardMessage(card, log)) {
2961
+ finalizedCount += 1;
2962
+ }
2963
+ continue;
2964
+ }
2904
2965
  await finalizeStoppedAICard(card, {
2905
2966
  reason,
2906
2967
  previousContent: entry.lastContent,
@@ -3177,6 +3238,9 @@ async function commitAICardBlocks(card, options, log) {
3177
3238
  } catch (err) {
3178
3239
  const message = err instanceof Error ? err.message : String(err);
3179
3240
  log?.error?.(`[DingTalk][AICard] Finalize via instances API failed: ${message}`);
3241
+ card.state = AICardStatus.FAILED;
3242
+ card.lastUpdated = Date.now();
3243
+ retainPendingCardForRecovery(card, "finalize", log);
3180
3244
  throw err;
3181
3245
  }
3182
3246
  if (card.conversationId && options.content.trim() && card.accountId && card.processQueryKey) {
@@ -3196,6 +3260,35 @@ async function commitAICardBlocks(card, options, log) {
3196
3260
  removePendingCard(card, log);
3197
3261
  log?.info?.(`[DingTalk][AICard] Card finalized: outTrackId=${card.outTrackId || card.cardInstanceId} state=FINISHED`);
3198
3262
  }
3263
+ async function streamAICard(card, content, finished = false, log, options = {}) {
3264
+ if (isCardInTerminalState(card.state)) {
3265
+ log?.debug?.(
3266
+ `[DingTalk][AICard] Skip stream update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`
3267
+ );
3268
+ return;
3269
+ }
3270
+ const template = DINGTALK_CARD_TEMPLATE;
3271
+ try {
3272
+ await putAICardStreamingField(card, template.contentKey, content, finished, log);
3273
+ card.lastStreamedContent = content;
3274
+ if (finished) {
3275
+ card.state = AICardStatus.FINISHED;
3276
+ removePendingCard(card, log);
3277
+ } else if (card.state === AICardStatus.PROCESSING) {
3278
+ card.state = AICardStatus.INPUTING;
3279
+ upsertPendingCard(card, card.storePath, log);
3280
+ }
3281
+ } catch (err) {
3282
+ card.state = AICardStatus.FAILED;
3283
+ card.lastUpdated = Date.now();
3284
+ retainPendingCardForRecovery(card, options.recoveryAction ?? "finalize", log);
3285
+ if (err.response?.status === 500 && err.response?.data?.code === "unknownError") {
3286
+ const errorMsg = "\u26A0\uFE0F **[DingTalk] AI Card \u4E32\u6D41\u66F4\u65B0\u5931\u8D25 (500 unknownError)**\n\n\u8FD9\u901A\u5E38\u8868\u793A\u5F53\u524D\u5185\u7F6E\u6A21\u677F\u5951\u7EA6\u4E0E\u9489\u9489\u4FA7\u6A21\u677F\u5B57\u6BB5\u4E0D\u4E00\u81F4\uFF0C\u5F53\u524D\u53CA\u540E\u7EED\u6D88\u606F\u5C06\u81EA\u52A8\u56DE\u9000\u4E3A Markdown \u53D1\u9001\u3002";
3287
+ await sendTemplateMismatchNotification(card, errorMsg, log);
3288
+ }
3289
+ throw err;
3290
+ }
3291
+ }
3199
3292
  function getCardRecallTarget(card) {
3200
3293
  const { targetId, isExplicitUser } = stripTargetPrefix(card.conversationId);
3201
3294
  const resolvedTarget = resolveOriginalPeerId(targetId);
@@ -4944,6 +5037,130 @@ async function handleInboundCommandDispatch(params) {
4944
5037
  return false;
4945
5038
  }
4946
5039
 
5040
+ // src/gateway/inbound-session-queue.ts
5041
+ var SESSION_QUEUE_TTL_MS = 5 * 60 * 1e3;
5042
+ var SESSION_QUEUE_CLEANUP_INTERVAL_MS = 60 * 1e3;
5043
+ var MAX_INBOUND_SESSION_QUEUE_DEPTH = 8;
5044
+ var MAX_INBOUND_SESSION_QUEUE_WAIT_MS = 15 * 60 * 1e3;
5045
+ var sessionQueues = /* @__PURE__ */ new Map();
5046
+ var sessionLastActivity = /* @__PURE__ */ new Map();
5047
+ var sessionQueueDepths = /* @__PURE__ */ new Map();
5048
+ var cleanupTimer = null;
5049
+ var InboundSessionQueueWaitTimeoutError = class extends Error {
5050
+ constructor(queueKey) {
5051
+ super(`Inbound session queue wait timed out for ${queueKey}`);
5052
+ this.name = "InboundSessionQueueWaitTimeoutError";
5053
+ }
5054
+ };
5055
+ function ensureCleanupTimer() {
5056
+ if (cleanupTimer) {
5057
+ return;
5058
+ }
5059
+ cleanupTimer = setInterval(() => {
5060
+ const now2 = Date.now();
5061
+ for (const [key, lastSeen] of sessionLastActivity) {
5062
+ if (now2 - lastSeen > SESSION_QUEUE_TTL_MS && !sessionQueues.has(key)) {
5063
+ sessionLastActivity.delete(key);
5064
+ }
5065
+ }
5066
+ }, SESSION_QUEUE_CLEANUP_INTERVAL_MS);
5067
+ if (typeof cleanupTimer?.unref === "function") {
5068
+ cleanupTimer.unref();
5069
+ }
5070
+ }
5071
+ function isInboundSessionQueueBusy(queueKey) {
5072
+ return sessionQueues.has(queueKey);
5073
+ }
5074
+ function getInboundSessionQueueDepth(queueKey) {
5075
+ return sessionQueueDepths.get(queueKey) ?? 0;
5076
+ }
5077
+ function chainInboundSessionTask(queueKey, task, options = {}) {
5078
+ const hadPriorTask = sessionQueues.has(queueKey);
5079
+ const previousTail = sessionQueues.get(queueKey) ?? Promise.resolve();
5080
+ sessionLastActivity.set(queueKey, Date.now());
5081
+ sessionQueueDepths.set(queueKey, getInboundSessionQueueDepth(queueKey) + 1);
5082
+ ensureCleanupTimer();
5083
+ let timedOut = false;
5084
+ let timeout;
5085
+ let resolveWaitingCaller;
5086
+ let rejectWaitingCaller;
5087
+ const maxQueueWaitMs = Math.max(0, options.maxQueueWaitMs ?? 0);
5088
+ const caller = maxQueueWaitMs > 0 && hadPriorTask ? new Promise((resolve3, reject) => {
5089
+ resolveWaitingCaller = resolve3;
5090
+ rejectWaitingCaller = reject;
5091
+ timeout = setTimeout(() => {
5092
+ timedOut = true;
5093
+ reject(new InboundSessionQueueWaitTimeoutError(queueKey));
5094
+ }, maxQueueWaitMs);
5095
+ if (typeof timeout.unref === "function") {
5096
+ timeout.unref();
5097
+ }
5098
+ }) : void 0;
5099
+ if (caller) {
5100
+ void caller.catch(() => void 0);
5101
+ }
5102
+ const current = previousTail.then(() => {
5103
+ if (timeout) {
5104
+ clearTimeout(timeout);
5105
+ timeout = void 0;
5106
+ }
5107
+ if (timedOut) {
5108
+ throw new InboundSessionQueueWaitTimeoutError(queueKey);
5109
+ }
5110
+ return task();
5111
+ });
5112
+ const tail = current.then(
5113
+ () => void 0,
5114
+ () => {
5115
+ }
5116
+ );
5117
+ sessionQueues.set(queueKey, tail);
5118
+ const cleanup = () => {
5119
+ const nextDepth = Math.max(0, getInboundSessionQueueDepth(queueKey) - 1);
5120
+ if (nextDepth) {
5121
+ sessionQueueDepths.set(queueKey, nextDepth);
5122
+ } else {
5123
+ sessionQueueDepths.delete(queueKey);
5124
+ }
5125
+ if (sessionQueues.get(queueKey) === tail) {
5126
+ sessionQueues.delete(queueKey);
5127
+ sessionLastActivity.delete(queueKey);
5128
+ }
5129
+ };
5130
+ void current.then(cleanup, cleanup);
5131
+ if (!caller) {
5132
+ return current;
5133
+ }
5134
+ void current.then(
5135
+ (value) => {
5136
+ if (timeout) {
5137
+ clearTimeout(timeout);
5138
+ }
5139
+ resolveWaitingCaller?.(value);
5140
+ },
5141
+ (error) => {
5142
+ if (timeout) {
5143
+ clearTimeout(timeout);
5144
+ }
5145
+ rejectWaitingCaller?.(error);
5146
+ }
5147
+ );
5148
+ return caller;
5149
+ }
5150
+ var QUEUE_BUSY_ACK_PHRASES = [
5151
+ "\u4E0A\u4E00\u6761\u8FD8\u6CA1\u7ED3\u675F\uFF0C\u8FD9\u6761\u6211\u5DF2\u7ECF\u8BB0\u4E0B\uFF0C\u7A0D\u540E\u6309\u987A\u5E8F\u7EE7\u7EED\u5904\u7406\u3002",
5152
+ "\u5F53\u524D\u8FD8\u5728\u5FD9\uFF0C\u4F60\u7684\u65B0\u6D88\u606F\u5DF2\u7ECF\u6392\u961F\uFF0C\u4E0A\u4E00\u6761\u5B8C\u6210\u540E\u6211\u9A6C\u4E0A\u7EE7\u7EED\u3002",
5153
+ "\u6211\u8FD9\u8FB9\u8FD8\u5728\u5904\u7406\u4E0A\u4E00\u6761\uFF0C\u8FD9\u6761\u5DF2\u52A0\u5165\u961F\u5217\uFF0C\u5B8C\u6210\u540E\u7EE7\u7EED\u5904\u7406\u3002"
5154
+ ];
5155
+ function pickQueueBusyAckPhrase(seed) {
5156
+ const list = QUEUE_BUSY_ACK_PHRASES;
5157
+ const index = seed === void 0 ? Math.floor(Math.random() * list.length) : seed % list.length;
5158
+ return list[index];
5159
+ }
5160
+
5161
+ // src/send-service.ts
5162
+ import * as path6 from "node:path";
5163
+
4947
5164
  // src/media-utils.ts
4948
5165
  import { randomUUID as randomUUID3 } from "node:crypto";
4949
5166
  import * as os4 from "node:os";
@@ -6217,96 +6434,6 @@ function extractMessageContent(data) {
6217
6434
  };
6218
6435
  }
6219
6436
 
6220
- // src/messaging/attachment-text-extractor.ts
6221
- import fs4 from "node:fs/promises";
6222
- import path6 from "node:path";
6223
- var MAX_EXTRACTED_TEXT_CHARS = 6e3;
6224
- var MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
6225
- async function isFileTooLarge(filePath) {
6226
- const stat = await fs4.stat(filePath);
6227
- return stat.size > MAX_ATTACHMENT_EXTRACT_BYTES;
6228
- }
6229
- function isTextLikeMimeType(mimeType) {
6230
- if (!mimeType) {
6231
- return false;
6232
- }
6233
- return mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/xml" || mimeType === "application/javascript";
6234
- }
6235
- function normalizeWhitespace(text) {
6236
- return text.replace(/\r\n/g, "\n").split("\0").join("").replace(/[ \t]{2,}/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
6237
- }
6238
- function limitExtractedText(text) {
6239
- const normalized = normalizeWhitespace(text);
6240
- if (!normalized) {
6241
- return null;
6242
- }
6243
- const truncated = normalized.length > MAX_EXTRACTED_TEXT_CHARS;
6244
- const limited = truncated ? `${normalized.slice(0, MAX_EXTRACTED_TEXT_CHARS)}
6245
-
6246
- [\u5185\u5BB9\u5DF2\u622A\u65AD]` : normalized;
6247
- return {
6248
- text: limited,
6249
- truncated,
6250
- sourceType: "text"
6251
- };
6252
- }
6253
- function stripHtml(html) {
6254
- return html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&amp;/gi, "&").replace(/&#39;/gi, "'").replace(/&quot;/gi, '"');
6255
- }
6256
- async function extractTextLikeFile(filePath) {
6257
- const raw = await fs4.readFile(filePath, "utf8");
6258
- return limitExtractedText(raw);
6259
- }
6260
- async function extractPdf(filePath) {
6261
- const pdfParseModule = await import("pdf-parse");
6262
- const fileBuffer = await fs4.readFile(filePath);
6263
- const parser = new pdfParseModule.PDFParse({ data: new Uint8Array(fileBuffer) });
6264
- try {
6265
- const result = await parser.getText();
6266
- const limited = limitExtractedText(result.text || "");
6267
- return limited ? { ...limited, sourceType: "pdf" } : null;
6268
- } finally {
6269
- await parser.destroy();
6270
- }
6271
- }
6272
- async function extractDocx(filePath) {
6273
- const mammothModule = await import("mammoth");
6274
- const result = await mammothModule.extractRawText({ path: filePath });
6275
- const limited = limitExtractedText(result.value || "");
6276
- return limited ? { ...limited, sourceType: "docx" } : null;
6277
- }
6278
- async function extractHtml(filePath) {
6279
- const raw = await fs4.readFile(filePath, "utf8");
6280
- const limited = limitExtractedText(stripHtml(raw));
6281
- return limited ? { ...limited, sourceType: "html" } : null;
6282
- }
6283
- async function extractAttachmentText(input) {
6284
- const mimeType = input.mimeType?.toLowerCase();
6285
- const fileName = (input.fileName || path6.basename(input.path)).toLowerCase();
6286
- if (mimeType?.startsWith("image/") || mimeType?.startsWith("audio/") || mimeType?.startsWith("video/")) {
6287
- return null;
6288
- }
6289
- if (await isFileTooLarge(input.path)) {
6290
- return null;
6291
- }
6292
- if (mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || fileName.endsWith(".docx")) {
6293
- return extractDocx(input.path);
6294
- }
6295
- if (mimeType === "application/pdf" || fileName.endsWith(".pdf")) {
6296
- return extractPdf(input.path);
6297
- }
6298
- if (mimeType === "text/html" || mimeType === "application/xhtml+xml" || fileName.endsWith(".html") || fileName.endsWith(".htm")) {
6299
- return extractHtml(input.path);
6300
- }
6301
- if (isTextLikeMimeType(mimeType) || fileName.endsWith(".txt") || fileName.endsWith(".md") || fileName.endsWith(".markdown") || fileName.endsWith(".csv") || fileName.endsWith(".json") || fileName.endsWith(".xml") || fileName.endsWith(".log")) {
6302
- return extractTextLikeFile(input.path);
6303
- }
6304
- return null;
6305
- }
6306
-
6307
- // src/send-service.ts
6308
- import * as path7 from "node:path";
6309
-
6310
6437
  // src/proactive-risk-registry.ts
6311
6438
  var DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
6312
6439
  var store = /* @__PURE__ */ new Map();
@@ -6694,9 +6821,9 @@ async function sendProactiveMedia(config, target, mediaPath, mediaType, options
6694
6821
  const durationMs = uploadedDurationMs ?? await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
6695
6822
  msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
6696
6823
  } else {
6697
- const filename = path7.basename(mediaPath);
6824
+ const filename = path6.basename(mediaPath);
6698
6825
  const defaultExt = mediaType === "video" ? "mp4" : "file";
6699
- const ext = path7.extname(mediaPath).slice(1) || defaultExt;
6826
+ const ext = path6.extname(mediaPath).slice(1) || defaultExt;
6700
6827
  msgKey = "sampleFile";
6701
6828
  msgParam = JSON.stringify({ mediaId, fileName: filename, fileType: ext });
6702
6829
  }
@@ -6799,7 +6926,7 @@ async function sendMedia(config, target, mediaInput, options = {}) {
6799
6926
  let preparedMedia;
6800
6927
  try {
6801
6928
  preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
6802
- const mediaPath = preparedMedia.cleanup ? preparedMedia.path : path7.resolve(process.cwd(), preparedMedia.path);
6929
+ const mediaPath = preparedMedia.cleanup ? preparedMedia.path : path6.resolve(process.cwd(), preparedMedia.path);
6803
6930
  const mediaType = resolveOutboundMediaType({
6804
6931
  mediaType: options.mediaType,
6805
6932
  mediaPath,
@@ -6855,7 +6982,7 @@ async function sendBySession(config, sessionWebhook, text, options = {}) {
6855
6982
  mediaLocalRoots: options.mediaLocalRoots
6856
6983
  });
6857
6984
  if (uploadResult) {
6858
- const imageMarkdown = `![${path7.basename(options.mediaPath)}](${uploadResult.mediaId})`;
6985
+ const imageMarkdown = `![${path6.basename(options.mediaPath)}](${uploadResult.mediaId})`;
6859
6986
  text = text ? `${text}
6860
6987
 
6861
6988
  ${imageMarkdown}` : imageMarkdown;
@@ -7038,6 +7165,284 @@ async function sendMessage(config, conversationId, text, options = {}) {
7038
7165
  }
7039
7166
  }
7040
7167
 
7168
+ // src/gateway/inbound-session-queue-dispatcher.ts
7169
+ var QUEUE_FULL_ACK = "\u5F53\u524D\u6D88\u606F\u8F83\u591A\uFF0C\u5DF2\u8FBE\u5230\u672C\u4F1A\u8BDD\u6392\u961F\u4E0A\u9650\uFF1B\u8BF7\u7B49\u5F85\u4E0A\u4E00\u8F6E\u5B8C\u6210\u540E\u518D\u53D1\u9001\u3002";
7170
+ var QUEUE_WAIT_TIMEOUT_ACK = "\u4E0A\u4E00\u8F6E\u5904\u7406\u65F6\u95F4\u8F83\u957F\uFF0C\u8FD9\u6761\u6D88\u606F\u672A\u6267\u884C\uFF1B\u8BF7\u7A0D\u540E\u91CD\u65B0\u53D1\u9001\u3002";
7171
+ var QUEUE_HANDLER_FAILURE_ACK = "\u672C\u6B21\u5904\u7406\u5F02\u5E38\uFF0C\u672A\u80FD\u5B8C\u6210\uFF1B\u8BF7\u7A0D\u540E\u91CD\u65B0\u53D1\u9001\u3002";
7172
+ var MIN_QUEUE_ACK_CARD_VISIBLE_MS = 750;
7173
+ var queuedAckVisibleAt = /* @__PURE__ */ new WeakMap();
7174
+ function shouldPrepareQueueAckCard(input) {
7175
+ return input.dingtalkConfig.messageType === "card";
7176
+ }
7177
+ async function keepQueueAckCardVisible(card) {
7178
+ const visibleAt = queuedAckVisibleAt.get(card);
7179
+ if (!visibleAt) {
7180
+ return;
7181
+ }
7182
+ const remainingMs = MIN_QUEUE_ACK_CARD_VISIBLE_MS - (Date.now() - visibleAt);
7183
+ if (remainingMs > 0) {
7184
+ await new Promise((resolve3) => setTimeout(resolve3, remainingMs));
7185
+ }
7186
+ }
7187
+ async function settleUnusedQueueAckCard(input, card) {
7188
+ if (isCardInTerminalState(card.state)) {
7189
+ return;
7190
+ }
7191
+ try {
7192
+ if (await recallAICardMessage(card, input.log)) {
7193
+ return;
7194
+ }
7195
+ } catch (err) {
7196
+ input.log?.warn?.(
7197
+ `[DingTalk] Failed to recall unused queue acknowledgement card: ${err instanceof Error ? err.message : String(err)}`
7198
+ );
7199
+ }
7200
+ await sendQueueTerminalAck(input, "\u5DF2\u7ED3\u675F\u6392\u961F\u786E\u8BA4\uFF0C\u8BF7\u4EE5\u672C\u6B21\u5B9E\u9645\u56DE\u590D\u4E3A\u51C6\u3002", card);
7201
+ }
7202
+ async function settleFailedQueueAckCard(input, card) {
7203
+ if (isCardInTerminalState(card.state)) {
7204
+ return;
7205
+ }
7206
+ await sendQueueTerminalAck(input, QUEUE_HANDLER_FAILURE_ACK, card);
7207
+ }
7208
+ async function dispatchInboundViaSessionQueue(input, handler) {
7209
+ const queueKey = input.sessionKey;
7210
+ if (!queueKey) {
7211
+ return handler(void 0);
7212
+ }
7213
+ const wasBusy = isInboundSessionQueueBusy(queueKey);
7214
+ if (getInboundSessionQueueDepth(queueKey) >= MAX_INBOUND_SESSION_QUEUE_DEPTH) {
7215
+ await sendQueueTerminalAck(input, QUEUE_FULL_ACK);
7216
+ return void 0;
7217
+ }
7218
+ let queuedAckState = "queued";
7219
+ const preCreatedCardPromise = wasBusy && shouldPrepareQueueAckCard(input) ? tryPrepareQueueAckCard(
7220
+ input,
7221
+ () => queuedAckState === "timed-out" ? { content: QUEUE_WAIT_TIMEOUT_ACK, finished: true } : { content: pickQueueBusyAckPhrase(), finished: false }
7222
+ ) : void 0;
7223
+ try {
7224
+ return await chainInboundSessionTask(
7225
+ queueKey,
7226
+ async () => {
7227
+ const preCreatedCard = preCreatedCardPromise ? await preCreatedCardPromise : void 0;
7228
+ if (!preCreatedCard) {
7229
+ return handler(void 0);
7230
+ }
7231
+ await keepQueueAckCardVisible(preCreatedCard);
7232
+ let handlerFailed = false;
7233
+ try {
7234
+ return await handler(preCreatedCard);
7235
+ } catch (err) {
7236
+ handlerFailed = true;
7237
+ await settleFailedQueueAckCard(input, preCreatedCard);
7238
+ throw err;
7239
+ } finally {
7240
+ if (!handlerFailed && !isCardInTerminalState(preCreatedCard.state)) {
7241
+ await settleUnusedQueueAckCard(input, preCreatedCard);
7242
+ }
7243
+ }
7244
+ },
7245
+ {
7246
+ maxQueueWaitMs: wasBusy ? MAX_INBOUND_SESSION_QUEUE_WAIT_MS : void 0
7247
+ }
7248
+ );
7249
+ } catch (err) {
7250
+ if (err instanceof InboundSessionQueueWaitTimeoutError) {
7251
+ queuedAckState = "timed-out";
7252
+ await sendQueueTerminalAck(
7253
+ input,
7254
+ QUEUE_WAIT_TIMEOUT_ACK,
7255
+ preCreatedCardPromise ? await preCreatedCardPromise : void 0
7256
+ );
7257
+ return void 0;
7258
+ }
7259
+ throw err;
7260
+ }
7261
+ }
7262
+ async function tryPrepareQueueAckCard(input, ack) {
7263
+ const { dingtalkConfig, data, log, to, storePath, quoteContent } = input;
7264
+ if (!data) {
7265
+ return void 0;
7266
+ }
7267
+ if (!to) {
7268
+ return void 0;
7269
+ }
7270
+ let card = null;
7271
+ let ackFinished = false;
7272
+ try {
7273
+ card = await createAICard(dingtalkConfig, to, log, {
7274
+ accountId: input.accountId,
7275
+ storePath,
7276
+ quoteContent
7277
+ });
7278
+ if (!card) {
7279
+ return void 0;
7280
+ }
7281
+ const { content, finished } = ack();
7282
+ ackFinished = finished;
7283
+ await streamAICard(card, content, finished, log, {
7284
+ recoveryAction: finished ? "finalize" : "recall"
7285
+ });
7286
+ if (!finished) {
7287
+ queuedAckVisibleAt.set(card, Date.now());
7288
+ }
7289
+ if (finished) {
7290
+ return card;
7291
+ }
7292
+ void attachNativeAckReaction(
7293
+ dingtalkConfig,
7294
+ { msgId: data.msgId, conversationId: data.conversationId },
7295
+ log
7296
+ ).catch((err) => {
7297
+ log?.debug?.(
7298
+ `[DingTalk] Queue-busy ack reaction attach failed: ${err instanceof Error ? err.message : String(err)}`
7299
+ );
7300
+ });
7301
+ log?.info?.(
7302
+ `[DingTalk] Inbound message queued behind active run for session=${input.sessionKey}; pre-created ACK card outTrackId=${card.cardInstanceId}.`
7303
+ );
7304
+ return card;
7305
+ } catch (err) {
7306
+ if (card && !ackFinished) {
7307
+ try {
7308
+ await recallAICardMessage(card, log);
7309
+ } catch (recallErr) {
7310
+ log?.warn?.(
7311
+ `[DingTalk] Failed to recall queue ACK card after prepare failure: ${recallErr instanceof Error ? recallErr.message : String(recallErr)}`
7312
+ );
7313
+ }
7314
+ }
7315
+ log?.warn?.(
7316
+ `[DingTalk] Queue-busy ACK card prepare failed: ${err instanceof Error ? err.message : String(err)}`
7317
+ );
7318
+ return void 0;
7319
+ }
7320
+ }
7321
+ async function sendQueueTerminalAck(input, content, preCreatedCard) {
7322
+ const { dingtalkConfig, data, log, to, storePath } = input;
7323
+ try {
7324
+ if (preCreatedCard) {
7325
+ try {
7326
+ await streamAICard(preCreatedCard, content, true, log);
7327
+ return;
7328
+ } catch (err) {
7329
+ log?.warn?.(
7330
+ `[DingTalk] Queue acknowledgement card finalization failed; falling back to text: ${err instanceof Error ? err.message : String(err)}`
7331
+ );
7332
+ }
7333
+ } else {
7334
+ const card = await tryPrepareQueueAckCard(input, () => ({ content, finished: true }));
7335
+ if (card) {
7336
+ return;
7337
+ }
7338
+ }
7339
+ if (!to) {
7340
+ return;
7341
+ }
7342
+ const result = await sendMessage(dingtalkConfig, to, content, {
7343
+ sessionWebhook: data.sessionWebhook,
7344
+ log,
7345
+ accountId: input.accountId,
7346
+ storePath,
7347
+ conversationId: data.conversationId
7348
+ });
7349
+ if (!result.ok) {
7350
+ log?.warn?.(`[DingTalk] Queue terminal acknowledgement failed: ${result.error || "unknown"}`);
7351
+ }
7352
+ } catch (err) {
7353
+ log?.warn?.(
7354
+ `[DingTalk] Queue terminal acknowledgement delivery failed: ${err instanceof Error ? err.message : String(err)}`
7355
+ );
7356
+ }
7357
+ }
7358
+
7359
+ // src/messaging/attachment-text-extractor.ts
7360
+ import fs4 from "node:fs/promises";
7361
+ import path7 from "node:path";
7362
+ var MAX_EXTRACTED_TEXT_CHARS = 6e3;
7363
+ var MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
7364
+ async function isFileTooLarge(filePath) {
7365
+ const stat = await fs4.stat(filePath);
7366
+ return stat.size > MAX_ATTACHMENT_EXTRACT_BYTES;
7367
+ }
7368
+ function isTextLikeMimeType(mimeType) {
7369
+ if (!mimeType) {
7370
+ return false;
7371
+ }
7372
+ return mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/xml" || mimeType === "application/javascript";
7373
+ }
7374
+ function normalizeWhitespace(text) {
7375
+ return text.replace(/\r\n/g, "\n").split("\0").join("").replace(/[ \t]{2,}/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
7376
+ }
7377
+ function limitExtractedText(text) {
7378
+ const normalized = normalizeWhitespace(text);
7379
+ if (!normalized) {
7380
+ return null;
7381
+ }
7382
+ const truncated = normalized.length > MAX_EXTRACTED_TEXT_CHARS;
7383
+ const limited = truncated ? `${normalized.slice(0, MAX_EXTRACTED_TEXT_CHARS)}
7384
+
7385
+ [\u5185\u5BB9\u5DF2\u622A\u65AD]` : normalized;
7386
+ return {
7387
+ text: limited,
7388
+ truncated,
7389
+ sourceType: "text"
7390
+ };
7391
+ }
7392
+ function stripHtml(html) {
7393
+ return html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&amp;/gi, "&").replace(/&#39;/gi, "'").replace(/&quot;/gi, '"');
7394
+ }
7395
+ async function extractTextLikeFile(filePath) {
7396
+ const raw = await fs4.readFile(filePath, "utf8");
7397
+ return limitExtractedText(raw);
7398
+ }
7399
+ async function extractPdf(filePath) {
7400
+ const pdfParseModule = await import("pdf-parse");
7401
+ const fileBuffer = await fs4.readFile(filePath);
7402
+ const parser = new pdfParseModule.PDFParse({ data: new Uint8Array(fileBuffer) });
7403
+ try {
7404
+ const result = await parser.getText();
7405
+ const limited = limitExtractedText(result.text || "");
7406
+ return limited ? { ...limited, sourceType: "pdf" } : null;
7407
+ } finally {
7408
+ await parser.destroy();
7409
+ }
7410
+ }
7411
+ async function extractDocx(filePath) {
7412
+ const mammothModule = await import("mammoth");
7413
+ const result = await mammothModule.extractRawText({ path: filePath });
7414
+ const limited = limitExtractedText(result.value || "");
7415
+ return limited ? { ...limited, sourceType: "docx" } : null;
7416
+ }
7417
+ async function extractHtml(filePath) {
7418
+ const raw = await fs4.readFile(filePath, "utf8");
7419
+ const limited = limitExtractedText(stripHtml(raw));
7420
+ return limited ? { ...limited, sourceType: "html" } : null;
7421
+ }
7422
+ async function extractAttachmentText(input) {
7423
+ const mimeType = input.mimeType?.toLowerCase();
7424
+ const fileName = (input.fileName || path7.basename(input.path)).toLowerCase();
7425
+ if (mimeType?.startsWith("image/") || mimeType?.startsWith("audio/") || mimeType?.startsWith("video/")) {
7426
+ return null;
7427
+ }
7428
+ if (await isFileTooLarge(input.path)) {
7429
+ return null;
7430
+ }
7431
+ if (mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || fileName.endsWith(".docx")) {
7432
+ return extractDocx(input.path);
7433
+ }
7434
+ if (mimeType === "application/pdf" || fileName.endsWith(".pdf")) {
7435
+ return extractPdf(input.path);
7436
+ }
7437
+ if (mimeType === "text/html" || mimeType === "application/xhtml+xml" || fileName.endsWith(".html") || fileName.endsWith(".htm")) {
7438
+ return extractHtml(input.path);
7439
+ }
7440
+ if (isTextLikeMimeType(mimeType) || fileName.endsWith(".txt") || fileName.endsWith(".md") || fileName.endsWith(".markdown") || fileName.endsWith(".csv") || fileName.endsWith(".json") || fileName.endsWith(".xml") || fileName.endsWith(".log")) {
7441
+ return extractTextLikeFile(input.path);
7442
+ }
7443
+ return null;
7444
+ }
7445
+
7041
7446
  // src/messaging/btw-deliver.ts
7042
7447
  var MAX_QUESTION_LENGTH = 80;
7043
7448
  var LEADING_MENTIONS_RE = /^(?:@\S+\s+)*/u;
@@ -7622,6 +8027,47 @@ async function resolveQuotedFile(config, params, log) {
7622
8027
  }
7623
8028
  }
7624
8029
 
8030
+ // src/gateway/reply-session-conflict.ts
8031
+ var REPLY_SESSION_CONFLICT_PATTERN = /reply session initialization conflicted/i;
8032
+ function readErrorMessage(error) {
8033
+ if (error instanceof Error) {
8034
+ return error.message;
8035
+ }
8036
+ if (typeof error === "string") {
8037
+ return error;
8038
+ }
8039
+ if (error && typeof error === "object" && "message" in error) {
8040
+ const msg = error.message;
8041
+ return typeof msg === "string" ? msg : "";
8042
+ }
8043
+ return "";
8044
+ }
8045
+ function isReplySessionConflictError(error) {
8046
+ return REPLY_SESSION_CONFLICT_PATTERN.test(readErrorMessage(error));
8047
+ }
8048
+ var sleep = (ms) => new Promise((resolve3) => {
8049
+ setTimeout(resolve3, ms);
8050
+ });
8051
+ async function withReplySessionConflictRetry(fn, options = {}) {
8052
+ const maxRetries = options.maxRetries ?? 3;
8053
+ const baseDelayMs = options.baseDelayMs ?? 1500;
8054
+ const sessionLabel = options.sessionKey ?? "?";
8055
+ for (let attempt = 0; ; attempt += 1) {
8056
+ try {
8057
+ return await fn();
8058
+ } catch (error) {
8059
+ if (!isReplySessionConflictError(error) || attempt >= maxRetries) {
8060
+ throw error;
8061
+ }
8062
+ const delay = baseDelayMs * (attempt + 1);
8063
+ options.log?.warn?.(
8064
+ `[DingTalk] Reply session initialization conflicted for session=${sessionLabel}; active run still draining. Retry ${attempt + 1}/${maxRetries} after ${delay}ms.`
8065
+ );
8066
+ await sleep(delay);
8067
+ }
8068
+ }
8069
+ }
8070
+
7625
8071
  // src/card/reasoning-answer-split.ts
7626
8072
  var THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi;
7627
8073
  function isWrappedReasoningLine(line) {
@@ -9793,7 +10239,8 @@ async function dispatchSubAgents(params) {
9793
10239
  onRoutesResolved,
9794
10240
  handleMessage,
9795
10241
  downloadMedia: download,
9796
- log
10242
+ log,
10243
+ inboundQueueEligible
9797
10244
  } = params;
9798
10245
  let helperMissingWarningSent = false;
9799
10246
  const sendHelperMissingWarning = async () => {
@@ -9885,7 +10332,14 @@ async function dispatchSubAgents(params) {
9885
10332
  matchedName: agentMatch.matchedName,
9886
10333
  commandText
9887
10334
  },
9888
- preDownloadedMedia
10335
+ preDownloadedMedia,
10336
+ // Propagate queue eligibility so each recursive sub-agent handler
10337
+ // enters the handler-owned queue on its own `target.route.sessionKey`
10338
+ // instead of bypassing it (which previously left @子Agent messages
10339
+ // exposed to reply-session conflicts when the sub-agent session was
10340
+ // already busy). Synthetic callers without this flag keep the legacy
10341
+ // direct-dispatch behavior.
10342
+ inboundQueueEligible
9889
10343
  });
9890
10344
  } catch (error) {
9891
10345
  const message = getErrorMessage(error);
@@ -10840,7 +11294,8 @@ async function handleDingTalkMessageInner(params) {
10840
11294
  onRoutesResolved: (targets) => invalidateQuestionRoutes(targets.map((target) => target.route)),
10841
11295
  handleMessage: handleDingTalkMessage,
10842
11296
  downloadMedia,
10843
- log
11297
+ log,
11298
+ inboundQueueEligible: params.inboundQueueEligible
10844
11299
  });
10845
11300
  return;
10846
11301
  }
@@ -10870,7 +11325,8 @@ async function handleDingTalkMessageInner(params) {
10870
11325
  onRoutesResolved: (targets) => invalidateQuestionRoutes(targets.map((target) => target.route)),
10871
11326
  handleMessage: handleDingTalkMessage,
10872
11327
  downloadMedia,
10873
- log
11328
+ log,
11329
+ inboundQueueEligible: params.inboundQueueEligible
10874
11330
  });
10875
11331
  return;
10876
11332
  }
@@ -10948,7 +11404,29 @@ async function handleDingTalkMessageInner(params) {
10948
11404
  const quotedRef = buildInboundQuotedRef(data, extractedContent);
10949
11405
  const replyQuotedRef = createReplyQuotedRef(data.msgId);
10950
11406
  const content = extractedContent;
10951
- const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
11407
+ const controlText = stripLeadingMentions(content.text).trim();
11408
+ const isBtwBypass = isBtwRequestText(controlText);
11409
+ const isAbortBypass = isAbortRequestText(controlText);
11410
+ if (params.inboundQueueEligible && !params.inboundQueueHandled && inboundOrigin !== "ask-user" && !isBtwBypass && !isAbortBypass) {
11411
+ await dispatchInboundViaSessionQueue(
11412
+ {
11413
+ accountId,
11414
+ data,
11415
+ dingtalkConfig,
11416
+ sessionKey: route.sessionKey,
11417
+ to,
11418
+ storePath: accountStorePath,
11419
+ quoteContent: rawInboundText.slice(0, 200),
11420
+ log
11421
+ },
11422
+ (preCreatedCard) => handleDingTalkMessage({
11423
+ ...params,
11424
+ preCreatedCard,
11425
+ inboundQueueHandled: true
11426
+ })
11427
+ );
11428
+ return;
11429
+ }
10952
11430
  const taskInfoConversationId = groupId || to;
10953
11431
  const agentDisplayName = getAgentDisplayName({
10954
11432
  subAgentOptions,
@@ -11029,7 +11507,7 @@ async function handleDingTalkMessageInner(params) {
11029
11507
  return true;
11030
11508
  };
11031
11509
  }
11032
- if (useCardMode && !isBtwBypass) {
11510
+ if (useCardMode && !isBtwBypass && !params.preCreatedCard) {
11033
11511
  const key = `${accountId}:${to}`;
11034
11512
  if (cardCreationInFlight.has(key)) {
11035
11513
  useCardMode = false;
@@ -11047,7 +11525,7 @@ async function handleDingTalkMessageInner(params) {
11047
11525
  `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`
11048
11526
  );
11049
11527
  const inboundQuoteText = rawInboundText.slice(0, 200);
11050
- const aiCard = await createAICard(dingtalkConfig, to, log, {
11528
+ const aiCard = params.preCreatedCard ?? await createAICard(dingtalkConfig, to, log, {
11051
11529
  accountId,
11052
11530
  storePath: accountStorePath,
11053
11531
  contextConversationId: groupId,
@@ -11711,8 +12189,7 @@ ${attachmentExtractedText}` : inboundBody;
11711
12189
  }
11712
12190
  });
11713
12191
  log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
11714
- const textForAbortCheck = stripLeadingMentions(inboundText).trim();
11715
- if (isAbortRequestText(textForAbortCheck)) {
12192
+ if (isAbortBypass) {
11716
12193
  log?.info?.(
11717
12194
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`
11718
12195
  );
@@ -11973,53 +12450,57 @@ ${attachmentExtractedText}` : inboundBody;
11973
12450
  inboundText: rawInboundText,
11974
12451
  taskMeta
11975
12452
  });
11976
- try {
11977
- let deliveredFinalCount = 0;
11978
- const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
11979
- ctx,
11980
- cfg,
11981
- dispatcherOptions: {
11982
- responsePrefix: subAgentOptions?.responsePrefix || "",
11983
- deliver: async (payload, info) => {
11984
- if (isCurrentCardStopRequested()) {
11985
- log?.debug?.(
11986
- "[DingTalk][CardStop] Ignoring reply delivery because stop was already requested"
12453
+ let deliveredFinalCount = 0;
12454
+ const runDispatch = () => rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
12455
+ ctx,
12456
+ cfg,
12457
+ dispatcherOptions: {
12458
+ responsePrefix: subAgentOptions?.responsePrefix || "",
12459
+ deliver: async (payload, info) => {
12460
+ if (isCurrentCardStopRequested()) {
12461
+ log?.debug?.(
12462
+ "[DingTalk][CardStop] Ignoring reply delivery because stop was already requested"
12463
+ );
12464
+ return;
12465
+ }
12466
+ try {
12467
+ if (info?.kind === "final") {
12468
+ deliveredFinalCount += 1;
12469
+ }
12470
+ const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
12471
+ const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
12472
+ const richPayload = payload;
12473
+ const replyKind = info?.kind || "block";
12474
+ if (questionCardTookOver) {
12475
+ log?.info?.(
12476
+ `[DingTalk][AskUser] Suppressed ${replyKind} reply after question card took over`
11987
12477
  );
11988
12478
  return;
11989
12479
  }
11990
- try {
11991
- if (info?.kind === "final") {
11992
- deliveredFinalCount += 1;
11993
- }
11994
- const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
11995
- const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
11996
- const richPayload = payload;
11997
- const replyKind = info?.kind || "block";
11998
- if (questionCardTookOver) {
11999
- log?.info?.(
12000
- `[DingTalk][AskUser] Suppressed ${replyKind} reply after question card took over`
12001
- );
12002
- return;
12003
- }
12004
- await strategy.deliver({
12005
- text: inlineReplyPayload.text,
12006
- mediaUrls,
12007
- audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
12008
- kind: replyKind,
12009
- isError: payload.isError === true,
12010
- isReasoning: richPayload.isReasoning === true
12011
- });
12012
- } catch (err) {
12013
- log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
12014
- const responseData = getErrorResponseData(err);
12015
- if (responseData !== void 0) {
12016
- log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
12017
- }
12018
- throw err;
12480
+ await strategy.deliver({
12481
+ text: inlineReplyPayload.text,
12482
+ mediaUrls,
12483
+ audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
12484
+ kind: replyKind,
12485
+ isError: payload.isError === true,
12486
+ isReasoning: richPayload.isReasoning === true
12487
+ });
12488
+ } catch (err) {
12489
+ log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
12490
+ const responseData = getErrorResponseData(err);
12491
+ if (responseData !== void 0) {
12492
+ log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
12019
12493
  }
12494
+ throw err;
12020
12495
  }
12021
- },
12022
- replyOptions: strategy.getReplyOptions()
12496
+ }
12497
+ },
12498
+ replyOptions: strategy.getReplyOptions()
12499
+ });
12500
+ try {
12501
+ const dispatchResult = await withReplySessionConflictRetry(runDispatch, {
12502
+ log,
12503
+ sessionKey: route.sessionKey
12023
12504
  });
12024
12505
  const bufferedFinal = dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult ? dispatchResult.queuedFinal : void 0;
12025
12506
  const finalCount = dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult ? dispatchResult.counts?.final : void 0;
@@ -12049,6 +12530,51 @@ ${attachmentExtractedText}` : inboundBody;
12049
12530
  }
12050
12531
  } catch (dispatchErr) {
12051
12532
  const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
12533
+ if (isReplySessionConflictError(error)) {
12534
+ log?.warn?.(
12535
+ `[DingTalk] Reply session still conflicted after retries for session=${route.sessionKey}; sending "processing" acknowledgement instead of dropping the message.`
12536
+ );
12537
+ const ackText = "\u6536\u5230\uFF0C\u4E0A\u4E00\u8F6E\u8FD8\u5728\u5904\u7406\u4E2D\uFF0C\u8BF7\u7A0D\u5019\u518D\u8BD5\u3002";
12538
+ if (replyMode === "card") {
12539
+ try {
12540
+ await strategy.deliver({
12541
+ text: ackText,
12542
+ mediaUrls: [],
12543
+ kind: "final",
12544
+ isReasoning: false
12545
+ });
12546
+ await strategy.finalize();
12547
+ return;
12548
+ } catch (cardAckErr) {
12549
+ log?.warn?.(
12550
+ `[DingTalk] Processing acknowledgement card finalize failed: ${getErrorMessage(cardAckErr)}`
12551
+ );
12552
+ return;
12553
+ }
12554
+ }
12555
+ try {
12556
+ if (sessionWebhook) {
12557
+ await sendBySession(dingtalkConfig, sessionWebhook, ackText, {
12558
+ log,
12559
+ accountId,
12560
+ storePath: accountStorePath
12561
+ });
12562
+ } else {
12563
+ await sendMessage(dingtalkConfig, to, ackText, {
12564
+ log,
12565
+ accountId,
12566
+ storePath: accountStorePath,
12567
+ conversationId: groupId
12568
+ });
12569
+ }
12570
+ } catch (ackErr) {
12571
+ log?.warn?.(
12572
+ `[DingTalk] Processing acknowledgement delivery failed: ${getErrorMessage(ackErr)}`
12573
+ );
12574
+ }
12575
+ await strategy.abort(error);
12576
+ return;
12577
+ }
12052
12578
  await strategy.abort(error);
12053
12579
  throw dispatchErr;
12054
12580
  }
@@ -13130,13 +13656,12 @@ function registerDingTalkAskUserQuestionTool(api) {
13130
13656
  api.logger?.warn?.(`${TOOL_NAME}: registerTool unavailable, skipping tool registration`);
13131
13657
  return;
13132
13658
  }
13133
- registerTool.call(api, {
13659
+ const createTool = (context) => ({
13134
13660
  name: TOOL_NAME,
13135
13661
  label: "Ask User Question",
13136
13662
  description: "Ask the user a blocking question or collect structured input via an interactive DingTalk form card when the current task cannot continue without the user's answer. Returns immediately after sending the card. The user's answer will arrive as a new message in the conversation. Do NOT poll or re-call this tool \u2014 just wait for the response message. Use questions only for simple confirmation, single-select, multi-select, or simple free-text prompts. For simple selection questions, provide options; for simple free-text input, set options to an empty array. When collecting multiple missing values, when the user asks for a form, or when you would otherwise list required parameters for the user to fill, call this tool with top-level fields instead of replying with a markdown checklist. Do not call this tool for normal explanations, why/how questions, capability introductions, or cases where you can answer directly.",
13137
13663
  parameters: AskUserQuestionSchema,
13138
13664
  async execute(_toolCallId, params) {
13139
- const context = getDingTalkQuestionContext();
13140
13665
  if (!context) {
13141
13666
  return jsonToolResult({
13142
13667
  status: "failed",
@@ -13285,6 +13810,18 @@ function registerDingTalkAskUserQuestionTool(api) {
13285
13810
  });
13286
13811
  }
13287
13812
  });
13813
+ registerTool.call(
13814
+ api,
13815
+ (toolContext) => {
13816
+ const context = getDingTalkQuestionContext();
13817
+ const runtimeSessionKey = toolContext.sessionKey?.trim();
13818
+ const contextSessionKey = context?.resolvedRoute?.sessionKey.trim();
13819
+ return createTool(
13820
+ context && (!runtimeSessionKey || contextSessionKey === runtimeSessionKey) ? context : void 0
13821
+ );
13822
+ },
13823
+ { name: TOOL_NAME }
13824
+ );
13288
13825
  api.logger?.debug?.(`${TOOL_NAME}: registered tool`);
13289
13826
  }
13290
13827
 
@@ -14346,7 +14883,6 @@ function instrumentConnectionStages(client) {
14346
14883
  }
14347
14884
  };
14348
14885
  }
14349
- var INFLIGHT_TTL_MS = 5 * 60 * 1e3;
14350
14886
  var processingDedupKeys = /* @__PURE__ */ new Map();
14351
14887
  var inboundCountersByAccount = /* @__PURE__ */ new Map();
14352
14888
  var INBOUND_COUNTER_LOG_EVERY = 10;
@@ -14490,7 +15026,9 @@ function createDingTalkGateway() {
14490
15026
  data,
14491
15027
  sessionWebhook: data.sessionWebhook,
14492
15028
  log: pluginLog,
14493
- dingtalkConfig: config
15029
+ dingtalkConfig: config,
15030
+ inboundOrigin: "stream",
15031
+ inboundQueueEligible: true
14494
15032
  });
14495
15033
  stats.processed += 1;
14496
15034
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
@@ -14505,22 +15043,14 @@ function createDingTalkGateway() {
14505
15043
  logInboundCounters(pluginLog, account.accountId, "dedup-skipped");
14506
15044
  return;
14507
15045
  }
14508
- const inflightSince = processingDedupKeys.get(dedupKey);
14509
- if (inflightSince !== void 0) {
14510
- if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
14511
- pluginLog?.warn?.(
14512
- `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`
14513
- );
14514
- processingDedupKeys.delete(dedupKey);
14515
- } else {
14516
- pluginLog?.debug?.(
14517
- `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`
14518
- );
14519
- stats.inflightSkipped += 1;
14520
- acknowledge();
14521
- logInboundCounters(pluginLog, account.accountId, "inflight-skipped");
14522
- return;
14523
- }
15046
+ if (processingDedupKeys.has(dedupKey)) {
15047
+ pluginLog?.debug?.(
15048
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`
15049
+ );
15050
+ stats.inflightSkipped += 1;
15051
+ acknowledge();
15052
+ logInboundCounters(pluginLog, account.accountId, "inflight-skipped");
15053
+ return;
14524
15054
  }
14525
15055
  acknowledge();
14526
15056
  processingDedupKeys.set(dedupKey, Date.now());
@@ -14531,7 +15061,9 @@ function createDingTalkGateway() {
14531
15061
  data,
14532
15062
  sessionWebhook: data.sessionWebhook,
14533
15063
  log: pluginLog,
14534
- dingtalkConfig: config
15064
+ dingtalkConfig: config,
15065
+ inboundOrigin: "stream",
15066
+ inboundQueueEligible: true
14535
15067
  });
14536
15068
  stats.processed += 1;
14537
15069
  markMessageProcessed(dedupKey);
@@ -14755,19 +15287,6 @@ function createDingTalkGateway() {
14755
15287
  lastError: null
14756
15288
  });
14757
15289
  } else if (state === "FAILED" /* FAILED */ || state === "DISCONNECTED" /* DISCONNECTED */) {
14758
- const robotKey = resolveRobotCode(config) || account.accountId;
14759
- let cleared = 0;
14760
- for (const key of processingDedupKeys.keys()) {
14761
- if (key.startsWith(`${robotKey}:`)) {
14762
- processingDedupKeys.delete(key);
14763
- cleared++;
14764
- }
14765
- }
14766
- if (cleared > 0) {
14767
- pluginLog?.info?.(
14768
- `[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`
14769
- );
14770
- }
14771
15290
  applyStatusPatch({
14772
15291
  running: false,
14773
15292
  connected: false,
@@ -14908,13 +15427,13 @@ async function beginDeviceRegistration() {
14908
15427
  }) : null;
14909
15428
  abortPromise?.catch(() => {
14910
15429
  });
14911
- const sleep = () => new Promise((resolve3) => setTimeout(resolve3, interval * 1e3));
15430
+ const sleep2 = () => new Promise((resolve3) => setTimeout(resolve3, interval * 1e3));
14912
15431
  try {
14913
15432
  while (Date.now() < deadline) {
14914
15433
  if (signal?.aborted) {
14915
15434
  throw new RegistrationError("registration cancelled");
14916
15435
  }
14917
- await (abortPromise ? Promise.race([sleep(), abortPromise]) : sleep());
15436
+ await (abortPromise ? Promise.race([sleep2(), abortPromise]) : sleep2());
14918
15437
  if (signal?.aborted) {
14919
15438
  throw new RegistrationError("registration cancelled");
14920
15439
  }