@jeik/dingtalk-connector 0.8.27 → 0.8.28

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.en.md +28 -35
  3. package/README.md +28 -43
  4. package/dist/background-work-Cxb4b-91.mjs +42 -0
  5. package/dist/card-content-cache-c4Ls96eJ.mjs +2 -0
  6. package/dist/chunk-upload-PwhogwfE.mjs +2 -0
  7. package/dist/{common-DE0gd0Jg.mjs → common-BZiVgd3A.mjs} +2 -2
  8. package/dist/{common-B_A78n8u.mjs → common-DgL28gCZ.mjs} +1 -1
  9. package/dist/{connection-fc6B4z4G.mjs → connection-CZ7ZoN5_.mjs} +313 -86
  10. package/dist/entry-bundled.mjs +1 -1
  11. package/dist/gateway-methods-BkI1gKaC.mjs +2 -0
  12. package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-kNX3qvqw.mjs} +1 -1
  13. package/dist/index.mjs +2 -2
  14. package/dist/{message-handler-DQZiKx3i.mjs → message-handler-B1usz1wb.mjs} +238 -122
  15. package/dist/{messaging-WleKf54h.mjs → messaging-DVS3UKOp.mjs} +6 -2
  16. package/dist/{runtime-DzZo6Gvk.mjs → runtime-A6G7WAWn.mjs} +3 -3
  17. package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
  18. package/package.json +2 -2
  19. package/src/core/connection.ts +465 -158
  20. package/src/core/message-handler.ts +173 -86
  21. package/src/reply-dispatcher.ts +234 -77
  22. package/src/services/messaging/card.ts +2 -0
  23. package/src/utils/background-work.ts +63 -0
  24. package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
  25. package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
  26. package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
  27. /package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-bDQFz2u8.mjs} +0 -0
  28. /package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-BE71drPg.mjs} +0 -0
  29. /package/dist/{game-xiyou-CAT0ilSj.mjs → game-xiyou-uwsv23AC.mjs} +0 -0
@@ -56,7 +56,7 @@ import {
56
56
  uploadAndReplaceFileMarkers
57
57
  } from "../services/media/index.ts";
58
58
  import { sendProactive, type AICardTarget } from "../services/messaging/index.ts";
59
- import { createAICardForTarget, streamAICard, registerActiveCard, type AICardInstance } from "../services/messaging/card.ts";
59
+ import { createAICardForTarget, streamAICard, finishAICard, registerActiveCard, type AICardInstance } from "../services/messaging/card.ts";
60
60
  import {
61
61
  collectCardLookupIds,
62
62
  lookupCardContent,
@@ -67,10 +67,18 @@ import { normalizeSlashCommand } from "../utils/session.ts";
67
67
  import { getDingtalkRuntime } from "../runtime.ts";
68
68
  import { dingtalkHttp } from '../utils/http-client.ts';
69
69
  import { createLoggerFromConfig } from '../utils/index.ts';
70
+ import { trackBackgroundWork } from "../utils/background-work.ts";
70
71
  import * as fs from 'fs';
71
72
  import * as path from 'path';
72
73
  import * as os from 'os';
73
74
 
75
+ /**
76
+ * 单条会话队列任务最长执行时间。
77
+ * 503 重试 + 工具循环偶发挂死时,若不超时,后续同会话消息会永远堵在 previousTask 上,
78
+ * 表现:连发多条「网关没反应 / 不贴表情」(表情在排队 ACK 或 dequeued 后才贴)。
79
+ */
80
+ const SESSION_QUEUE_TASK_TIMEOUT_MS = 3 * 60 * 1000; // 3 分钟
81
+
74
82
  // ============ 常量 ============
75
83
 
76
84
  const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
@@ -1645,6 +1653,10 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1645
1653
  }
1646
1654
 
1647
1655
  // ===== 使用 SDK 的 dispatchReplyFromConfig =====
1656
+ // 提升到 try 外:catch 需要 finish 即时建卡;replyOptions.onError 不存在(OpenClaw 不导出)
1657
+ let earlyCard: AICardInstance | undefined = params.preCreatedCard;
1658
+ let finalizeDispatchError: ((error: unknown, kind?: string) => Promise<void>) | undefined;
1659
+
1648
1660
  try {
1649
1661
  const core = getDingtalkRuntime();
1650
1662
 
@@ -1736,7 +1748,6 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1736
1748
  const skipAICardForCard = !isDirect && (groupReplyModeForCard === 'text' || groupReplyModeForCard === 'markdown');
1737
1749
  const streamingEnabledForCard = !skipAICardForCard && (config as any).streaming !== false;
1738
1750
 
1739
- let earlyCard: AICardInstance | undefined = params.preCreatedCard;
1740
1751
  if (!earlyCard && streamingEnabledForCard && !asyncMode) {
1741
1752
  try {
1742
1753
  const earlyTarget: AICardTarget = isDirect
@@ -1757,7 +1768,13 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1757
1768
  }
1758
1769
 
1759
1770
  // 创建 reply dispatcher,使用解析后的 agentId
1760
- const { dispatcher, replyOptions, markDispatchIdle, getAsyncModeResponse } = createDingtalkReplyDispatcher({
1771
+ const {
1772
+ dispatcher,
1773
+ replyOptions,
1774
+ markDispatchIdle,
1775
+ getAsyncModeResponse,
1776
+ finalizeDispatchError: finalizeErr,
1777
+ } = createDingtalkReplyDispatcher({
1761
1778
  cfg,
1762
1779
  agentId: matchedAgentId, // ✅ 使用手动匹配的 agentId
1763
1780
  runtime: runtime as RuntimeEnv,
@@ -1770,6 +1787,7 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1770
1787
  asyncMode,
1771
1788
  preCreatedCard: earlyCard,
1772
1789
  });
1790
+ finalizeDispatchError = finalizeErr;
1773
1791
 
1774
1792
  // ===== 注入当前 bot 的 clientId(用于 dws CLI --client-id 参数) =====
1775
1793
  // 多 bot 场景下,AI 需要知道当前对话属于哪个 bot,以便在调用
@@ -1905,32 +1923,59 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1905
1923
 
1906
1924
  } catch (err: any) {
1907
1925
  log?.error?.(`SDK dispatch 失败: ${err.message}`);
1908
-
1909
- // 解析上游错误文本(例如 503 No available channel),匹配中文提示文案
1910
- const errorText = matchModelErrorText(err.message, { includeCatchAll: true }) ?? `⚠️ 抱歉,处理请求时出错: ${err.message}`;
1911
1926
 
1912
- // 如果已经建了 AI Card,则更新报错状态到 Card,避免停留在"正在召唤大模型"
1913
- if (earlyCard) {
1927
+ // 统一错误定稿:优先走 dispatcher 的 finalizeDispatchError(finish AI Card + 中文提示)。
1928
+ // 注意:OpenClaw createReplyDispatcherWithTyping 返回的 replyOptions 不含 onError,
1929
+ // 绝不能依赖 replyOptions.onError(会永远 undefined / 甚至 catch 作用域 ReferenceError)。
1930
+ if (finalizeDispatchError) {
1914
1931
  try {
1915
- await streamAICard(earlyCard, errorText, true, config, log);
1916
- } catch (cardErr: any) {
1917
- log?.error?.(`更新错误卡片失败: ${cardErr.message}`);
1932
+ log?.info?.(`[DingTalk] 统一错误定稿:调用 finalizeDispatchError 处理同步异常`);
1933
+ await finalizeDispatchError(err, "invoke");
1934
+ } catch (finalizeErr: any) {
1935
+ log?.error?.(`[DingTalk] finalizeDispatchError 失败: ${finalizeErr?.message || finalizeErr}`);
1918
1936
  }
1919
1937
  } else {
1920
- // 降级:发送错误文本消息
1921
- try {
1922
- const token = await getAccessToken(config);
1923
- const body: any = {
1924
- msgtype: 'text',
1925
- text: { content: errorText }
1926
- };
1927
- if (!isDirect) body.at = { atUserIds: [senderId], isAtAll: false };
1928
-
1929
- await dingtalkHttp.post(sessionWebhook, body, {
1930
- headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
1931
- });
1932
- } catch (fallbackErr: any) {
1933
- log?.error?.(`错误消息发送也失败: ${fallbackErr.message}`);
1938
+ // dispatcher 尚未创建就失败:直接 finish 即时建卡,再不行发纯文本
1939
+ const errorText =
1940
+ matchModelErrorText(err.message, { includeCatchAll: true }) ??
1941
+ `⚠️ 抱歉,处理请求时出错: ${err.message}`;
1942
+ let cardSuccess = false;
1943
+ if (earlyCard) {
1944
+ try {
1945
+ await finishAICard(
1946
+ earlyCard,
1947
+ errorText,
1948
+ config,
1949
+ log,
1950
+ undefined,
1951
+ undefined,
1952
+ data.conversationId,
1953
+ );
1954
+ cardSuccess = true;
1955
+ log?.info?.(`[DingTalk] 极端兜底:finishAICard 错误卡成功`);
1956
+ } catch (cardErr: any) {
1957
+ log?.error?.(`更新错误卡片失败: ${cardErr.message}`);
1958
+ }
1959
+ }
1960
+ if (!cardSuccess) {
1961
+ log?.warn?.(`[DingTalk] 无可用错误定稿路径,走纯文本发送`);
1962
+ try {
1963
+ const token = await getAccessToken(config);
1964
+ const body: any = {
1965
+ msgtype: "text",
1966
+ text: { content: errorText },
1967
+ };
1968
+ if (!isDirect) body.at = { atUserIds: [senderId], isAtAll: false };
1969
+
1970
+ await dingtalkHttp.post(sessionWebhook, body, {
1971
+ headers: {
1972
+ "x-acs-dingtalk-access-token": token,
1973
+ "Content-Type": "application/json",
1974
+ },
1975
+ });
1976
+ } catch (fallbackErr: any) {
1977
+ log?.error?.(`错误消息发送也失败: ${fallbackErr.message}`);
1978
+ }
1934
1979
  }
1935
1980
  }
1936
1981
  }
@@ -2007,89 +2052,131 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
2007
2052
  // 更新会话活跃时间
2008
2053
  sessionLastActivity.set(queueKey, Date.now());
2009
2054
 
2010
- // 检测队列是否繁忙(入队前检查,此时 previousTask 尚未被当前消息覆盖)
2011
- const isQueueBusy = sessionQueues.has(queueKey);
2012
-
2013
- // 获取该会话+agent的上一个处理任务
2055
+ // ═══════════════════════════════════════════════════════════
2056
+ // ⚠️ 关键:下面「读 previous → 建 currentTask → set」之间禁止任何 await。
2057
+ // JS 虽是单线程,但 await 会让出执行权;若在 set 之前 await 繁忙 ACK(最多 3s),
2058
+ // 并发第 2/3 条会看到「队列仍只有第 1 条」甚至「空闲」,表现为:
2059
+ // - 任务进行中连发却不弹排队卡(要发两三次才命中 isQueueBusy)
2060
+ // - 多条消息错误地 chain 到同一 previous,乱序/并行
2061
+ // ═══════════════════════════════════════════════════════════
2014
2062
  const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
2063
+ const isQueueBusy = sessionQueues.has(queueKey);
2015
2064
 
2016
- // 队列繁忙时:根据 groupReplyMode 决定是创建 AI Card 还是发送普通文本 ACK
2017
- let preCreatedCard: AICardInstance | undefined;
2018
- if (isQueueBusy) {
2019
- const ackPhrases = QUEUE_BUSY_ACK_PHRASES;
2020
- const ackText = ackPhrases[Math.floor(Math.random() * ackPhrases.length)];
2065
+ // 异步 ACK 写回的卡片:主任务 dequeue 时再读,避免 await ACK 再入队
2066
+ const cardHolder: { card?: AICardInstance; ackDone: boolean } = {
2067
+ card: undefined,
2068
+ ackDone: !isQueueBusy,
2069
+ };
2021
2070
 
2022
- // ✅ 检查 groupReplyMode:text/markdown 模式下不创建 AI Card,改用普通消息发送 ACK
2023
- const groupReplyMode = config.groupReplyMode || 'aicard';
2024
- const skipAICard = !isDirect && (groupReplyMode === 'text' || groupReplyMode === 'markdown');
2071
+ const currentTask = previousTask
2072
+ .then(async () => {
2073
+ log?.info?.(
2074
+ `[队列] 开始处理消息,queueKey=${queueKey} wasBusy=${isQueueBusy} timeoutMs=${SESSION_QUEUE_TASK_TIMEOUT_MS}`,
2075
+ );
2025
2076
 
2026
- if (skipAICard) {
2027
- // text/markdown 模式:使用普通消息发送 ACK,不创建 AI Card
2028
- try {
2029
- await sendProactive(config, { openConversationId: data.conversationId }, ackText, {
2030
- msgType: 'text',
2031
- useAICard: false,
2032
- fallbackToNormal: true,
2033
- });
2034
- log?.info?.(`[队列] 队列繁忙,已发送普通文本 ACK(groupReplyMode=${groupReplyMode})`);
2035
- } catch (ackErr: any) {
2036
- log?.warn?.(`[队列] 发送普通 ACK 失败: ${ackErr?.message || ackErr}`);
2077
+ // 若排队 ACK 仍在建卡,最多再等 2s 拿 preCreatedCard,避免丢复用
2078
+ if (isQueueBusy && !cardHolder.ackDone) {
2079
+ const waitStart = Date.now();
2080
+ while (!cardHolder.ackDone && Date.now() - waitStart < 2_000) {
2081
+ await new Promise((r) => setTimeout(r, 50));
2082
+ }
2037
2083
  }
2038
- // text/markdown 模式不贴🤔表情(表情是 AI Card 场景的配套功能)
2039
- } else {
2040
- // aicard 模式:创建 AI Card 显示排队 ACK
2041
- const cardTarget: AICardTarget = isDirect
2042
- ? { type: 'user', userId: senderId }
2043
- : { type: 'group', openConversationId: data.conversationId };
2044
2084
 
2085
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
2045
2086
  try {
2046
- const card = await createAICardForTarget(config, cardTarget, log);
2047
- if (card) {
2048
- // 用 streamAICard 把 ACK 文案写入 Card(INPUTING 状态,表示正在处理中)
2049
- await streamAICard(card, ackText, false, config, log);
2050
- preCreatedCard = card;
2051
- log?.info?.(`[队列] 队列繁忙,已创建排队 ACK Card,cardInstanceId=${card.cardInstanceId}`);
2052
- } else {
2053
- log?.warn?.(`[队列] 创建排队 ACK Card 失败(返回 null),跳过 ACK`);
2054
- }
2055
- // 在发送 ACK 的同时立即贴上思考中表情,让用户知道消息已被接收
2056
- addEmotionReply(config, data, log).catch(err => {
2057
- log?.warn?.(`[队列] 贴排队表情失败: ${err.message}`);
2058
- });
2059
- } catch (ackErr: any) {
2060
- log?.warn?.(`[队列] 创建排队 ACK Card 异常: ${ackErr?.message || ackErr}`);
2087
+ await Promise.race([
2088
+ handleDingTalkMessageInternal({
2089
+ ...params,
2090
+ preCreatedCard: cardHolder.card,
2091
+ emotionAlreadyAdded: true,
2092
+ }),
2093
+ new Promise<never>((_, reject) => {
2094
+ timeoutId = setTimeout(() => {
2095
+ reject(
2096
+ new Error(
2097
+ `会话队列任务超时(${SESSION_QUEUE_TASK_TIMEOUT_MS}ms),释放队列以免堵死后续消息`,
2098
+ ),
2099
+ );
2100
+ }, SESSION_QUEUE_TASK_TIMEOUT_MS);
2101
+ }),
2102
+ ]);
2103
+ log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
2104
+ } finally {
2105
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
2061
2106
  }
2062
- }
2063
- }
2064
-
2065
- // 创建当前消息的处理任务
2066
- const currentTask = previousTask
2067
- .then(async () => {
2068
- log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
2069
- await handleDingTalkMessageInternal({ ...params, preCreatedCard, emotionAlreadyAdded: isQueueBusy });
2070
- log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
2071
2107
  })
2072
2108
  .catch((err: any) => {
2073
2109
  log?.error?.(`[队列] 消息处理异常,queueKey=${queueKey}, error=${err.message}`);
2074
- // 不抛出错误,避免阻塞后续消息
2075
2110
  })
2076
2111
  .finally(() => {
2077
- // 如果当前任务是队列中的最后一个任务,清理队列
2078
2112
  if (sessionQueues.get(queueKey) === currentTask) {
2079
2113
  sessionQueues.delete(queueKey);
2080
2114
  log?.info?.(`[队列] 队列已清空,queueKey=${queueKey}`);
2081
2115
  }
2082
2116
  });
2083
-
2084
- // 更新队列
2117
+
2118
+ // ★ 立刻占槽:必须在任何 await 之前,保证并发入站能看到 isQueueBusy
2085
2119
  sessionQueues.set(queueKey, currentTask);
2120
+ trackBackgroundWork(currentTask, queueKey);
2121
+ log?.info?.(
2122
+ `[队列] 已登记 queueKey=${queueKey} busy=${isQueueBusy} depth≈pending`,
2123
+ );
2086
2124
 
2087
- // 不等待任务完成,立即返回,不阻塞 WebSocket 消息接收
2088
- // 消息处理在后台异步执行,队列保证同一会话+agent的消息串行处理
2125
+ // ===== 登记之后再做用户反馈(可异步,不再阻塞占槽)=====
2126
+ addEmotionReply(config, data, log).catch((err: any) => {
2127
+ log?.warn?.(`[队列] 即时贴表情失败: ${err?.message || err}`);
2128
+ });
2129
+
2130
+ if (isQueueBusy) {
2131
+ const ackPhrases = QUEUE_BUSY_ACK_PHRASES;
2132
+ const ackText = ackPhrases[Math.floor(Math.random() * ackPhrases.length)];
2133
+ const groupReplyMode = config.groupReplyMode || "aicard";
2134
+ const skipAICard =
2135
+ !isDirect && (groupReplyMode === "text" || groupReplyMode === "markdown");
2136
+
2137
+ // 火并忘记:不 await,避免拖慢本回调、也不挡住下一条消息的占槽
2138
+ void (async () => {
2139
+ try {
2140
+ if (skipAICard) {
2141
+ await sendProactive(
2142
+ config,
2143
+ { openConversationId: data.conversationId },
2144
+ ackText,
2145
+ { msgType: "text", useAICard: false, fallbackToNormal: true },
2146
+ );
2147
+ log?.info?.(
2148
+ `[队列] 队列繁忙,已发送普通文本 ACK(groupReplyMode=${groupReplyMode})`,
2149
+ );
2150
+ } else {
2151
+ const cardTarget: AICardTarget = isDirect
2152
+ ? { type: "user", userId: senderId }
2153
+ : { type: "group", openConversationId: data.conversationId };
2154
+ const card = await createAICardForTarget(config, cardTarget, log);
2155
+ if (card) {
2156
+ await streamAICard(card, ackText, false, config, log);
2157
+ cardHolder.card = card;
2158
+ log?.info?.(
2159
+ `[队列] 队列繁忙,已创建排队 ACK Card,cardInstanceId=${card.cardInstanceId}`,
2160
+ );
2161
+ } else {
2162
+ log?.warn?.(`[队列] 创建排队 ACK Card 失败(返回 null)`);
2163
+ }
2164
+ }
2165
+ } catch (ackErr: any) {
2166
+ log?.warn?.(`[队列] 繁忙 ACK 异常: ${ackErr?.message || ackErr}`);
2167
+ } finally {
2168
+ cardHolder.ackDone = true;
2169
+ }
2170
+ })();
2171
+ }
2172
+
2173
+ // 立即返回,不阻塞 WebSocket 收下一条
2089
2174
  } catch (err: any) {
2090
2175
  log?.error?.(`[队列] 队列管理异常,直接处理: ${err.message}`);
2091
- // 如果队列管理失败,直接调用内部处理函数(不阻塞)
2092
- void handleDingTalkMessageInternal(params);
2176
+ const fallback = handleDingTalkMessageInternal(params).catch((e: any) => {
2177
+ log?.error?.(`[队列] 直接处理也失败: ${e.message}`);
2178
+ });
2179
+ trackBackgroundWork(fallback, "queue-fallback");
2093
2180
  }
2094
2181
  }
2095
2182