@jeik/dingtalk-connector 0.8.21-fix1

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 (154) hide show
  1. package/CHANGELOG.md +686 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +181 -0
  4. package/README.md +221 -0
  5. package/bin/dingtalk-connector.js +858 -0
  6. package/bin/wizard-config.mjs +110 -0
  7. package/dist/accounts-BAzdqkAV.mjs +268 -0
  8. package/dist/accounts-BQptOmgB.mjs +2 -0
  9. package/dist/chunk-upload-BBQgGtcZ.mjs +193 -0
  10. package/dist/chunk-upload-DaLXXZH3.mjs +2 -0
  11. package/dist/common-C8pYKU_y.mjs +2 -0
  12. package/dist/common-Dt9n6fQN.mjs +101 -0
  13. package/dist/connection-DHHFFNQJ.mjs +423 -0
  14. package/dist/entry-bundled.d.mts +16 -0
  15. package/dist/entry-bundled.mjs +31 -0
  16. package/dist/game-xiyou-CqHt-6Q1.mjs +4271 -0
  17. package/dist/gateway-methods-C4tcgI7P.mjs +771 -0
  18. package/dist/gateway-methods-Ci31A3vg.mjs +2 -0
  19. package/dist/http-client-CpnJHB89.mjs +2 -0
  20. package/dist/http-client-DFWZgO1n.mjs +33 -0
  21. package/dist/index.d.mts +193 -0
  22. package/dist/index.mjs +45 -0
  23. package/dist/logger-BmJkQkm1.mjs +2 -0
  24. package/dist/logger-mZ9OSbmD.mjs +58 -0
  25. package/dist/media-C_SVin7s.mjs +2 -0
  26. package/dist/media-cz72EVS3.mjs +509 -0
  27. package/dist/message-handler-DESzFFDc.mjs +1971 -0
  28. package/dist/messaging-B6l1sRvX.mjs +1044 -0
  29. package/dist/runtime-DUgpo5zC.mjs +1422 -0
  30. package/dist/session-DJ4jYqPv.mjs +114 -0
  31. package/dist/utils-Bjh4r_qS.mjs +4 -0
  32. package/dist/utils-CIfI_3Jh.mjs +63 -0
  33. package/dist/utils-legacy-CALCPP1t.mjs +230 -0
  34. package/dist/utils-legacy-CFYDBM4r.mjs +3 -0
  35. package/docs/DEAP_AGENT_GUIDE.en.md +115 -0
  36. package/docs/DEAP_AGENT_GUIDE.md +115 -0
  37. package/docs/DINGTALK_MANUAL_SETUP.md +50 -0
  38. package/docs/MULTI_AGENT_SETUP.md +306 -0
  39. package/docs/RELEASE_NOTES_V0.7.10.md +40 -0
  40. package/docs/RELEASE_NOTES_V0.7.2.md +143 -0
  41. package/docs/RELEASE_NOTES_V0.7.3.md +149 -0
  42. package/docs/RELEASE_NOTES_V0.7.4.md +206 -0
  43. package/docs/RELEASE_NOTES_V0.7.5.md +267 -0
  44. package/docs/RELEASE_NOTES_V0.7.6.md +219 -0
  45. package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
  46. package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
  47. package/docs/RELEASE_NOTES_V0.7.9.md +65 -0
  48. package/docs/RELEASE_NOTES_V0.8.0.md +53 -0
  49. package/docs/RELEASE_NOTES_V0.8.1.md +47 -0
  50. package/docs/RELEASE_NOTES_V0.8.10.md +49 -0
  51. package/docs/RELEASE_NOTES_V0.8.11.md +51 -0
  52. package/docs/RELEASE_NOTES_V0.8.12.md +63 -0
  53. package/docs/RELEASE_NOTES_V0.8.13-beta.0.md +69 -0
  54. package/docs/RELEASE_NOTES_V0.8.13.md +62 -0
  55. package/docs/RELEASE_NOTES_V0.8.14.md +86 -0
  56. package/docs/RELEASE_NOTES_V0.8.16.md +40 -0
  57. package/docs/RELEASE_NOTES_V0.8.17.md +87 -0
  58. package/docs/RELEASE_NOTES_V0.8.18.md +64 -0
  59. package/docs/RELEASE_NOTES_V0.8.19.md +62 -0
  60. package/docs/RELEASE_NOTES_V0.8.2.md +55 -0
  61. package/docs/RELEASE_NOTES_V0.8.20.md +49 -0
  62. package/docs/RELEASE_NOTES_V0.8.3.md +63 -0
  63. package/docs/RELEASE_NOTES_V0.8.4.md +45 -0
  64. package/docs/RELEASE_NOTES_V0.8.7.md +49 -0
  65. package/docs/RELEASE_NOTES_V0.8.8.md +63 -0
  66. package/docs/RELEASE_NOTES_V0.8.9.md +81 -0
  67. package/docs/RELEASE_NOTES_v0.7.0.md +142 -0
  68. package/docs/RELEASE_NOTES_v0.7.1.md +74 -0
  69. package/docs/TROUBLESHOOTING.md +122 -0
  70. package/index.ts +77 -0
  71. package/openclaw.plugin.json +551 -0
  72. package/package.json +147 -0
  73. package/skills/dingtalk-channel-rules/SKILL.md +91 -0
  74. package/skills/dingtalk-troubleshoot/SKILL.md +93 -0
  75. package/skills/dws-cli/SKILL.md +129 -0
  76. package/skills/dws-cli/references/error-codes.md +95 -0
  77. package/skills/dws-cli/references/field-rules.md +105 -0
  78. package/skills/dws-cli/references/global-reference.md +104 -0
  79. package/skills/dws-cli/references/intent-guide.md +114 -0
  80. package/skills/dws-cli/references/products/aitable.md +452 -0
  81. package/skills/dws-cli/references/products/attendance.md +93 -0
  82. package/skills/dws-cli/references/products/calendar.md +217 -0
  83. package/skills/dws-cli/references/products/chat.md +292 -0
  84. package/skills/dws-cli/references/products/contact.md +108 -0
  85. package/skills/dws-cli/references/products/ding.md +57 -0
  86. package/skills/dws-cli/references/products/report.md +162 -0
  87. package/skills/dws-cli/references/products/simple.md +128 -0
  88. package/skills/dws-cli/references/products/todo.md +138 -0
  89. package/skills/dws-cli/references/products/workbench.md +39 -0
  90. package/skills/dws-cli/references/recovery-guide.md +94 -0
  91. package/src/channel.ts +588 -0
  92. package/src/config/accounts.ts +242 -0
  93. package/src/config/schema.ts +180 -0
  94. package/src/core/connection.ts +741 -0
  95. package/src/core/message-handler.ts +1788 -0
  96. package/src/core/provider.ts +111 -0
  97. package/src/core/state.ts +54 -0
  98. package/src/device-auth-config.ts +14 -0
  99. package/src/device-auth.ts +197 -0
  100. package/src/directory.ts +95 -0
  101. package/src/docs.ts +293 -0
  102. package/src/game-xiyou/achievement-engine.ts +252 -0
  103. package/src/game-xiyou/bounty-system.ts +315 -0
  104. package/src/game-xiyou/commands.ts +223 -0
  105. package/src/game-xiyou/drop-engine.ts +241 -0
  106. package/src/game-xiyou/encounter-system.ts +135 -0
  107. package/src/game-xiyou/escape-engine.ts +164 -0
  108. package/src/game-xiyou/exp-calculator.ts +139 -0
  109. package/src/game-xiyou/index.ts +479 -0
  110. package/src/game-xiyou/level-system.ts +91 -0
  111. package/src/game-xiyou/monster-pool.ts +180 -0
  112. package/src/game-xiyou/pity-counter.ts +114 -0
  113. package/src/game-xiyou/random-event-engine.ts +648 -0
  114. package/src/game-xiyou/renderer.ts +679 -0
  115. package/src/game-xiyou/storage.ts +218 -0
  116. package/src/game-xiyou/treasure-system.ts +105 -0
  117. package/src/game-xiyou/types.ts +582 -0
  118. package/src/game-xiyou/uid-resolver.ts +49 -0
  119. package/src/gateway-methods.ts +740 -0
  120. package/src/onboarding.ts +553 -0
  121. package/src/policy.ts +32 -0
  122. package/src/probe.ts +210 -0
  123. package/src/reply-dispatcher.ts +874 -0
  124. package/src/runtime.ts +32 -0
  125. package/src/sdk/helpers.ts +322 -0
  126. package/src/sdk/types.ts +519 -0
  127. package/src/secret-input.ts +19 -0
  128. package/src/services/media/audio.ts +54 -0
  129. package/src/services/media/chunk-upload.ts +296 -0
  130. package/src/services/media/common.ts +155 -0
  131. package/src/services/media/file.ts +75 -0
  132. package/src/services/media/image.ts +81 -0
  133. package/src/services/media/index.ts +10 -0
  134. package/src/services/media/video.ts +162 -0
  135. package/src/services/media.ts +1143 -0
  136. package/src/services/messaging/card.ts +604 -0
  137. package/src/services/messaging/index.ts +18 -0
  138. package/src/services/messaging/mentions.ts +267 -0
  139. package/src/services/messaging/send.ts +141 -0
  140. package/src/services/messaging.ts +1191 -0
  141. package/src/services/reply-markers.ts +55 -0
  142. package/src/targets.ts +45 -0
  143. package/src/types/index.ts +59 -0
  144. package/src/types/pdf-parse.d.ts +3 -0
  145. package/src/utils/agent.ts +63 -0
  146. package/src/utils/async.ts +51 -0
  147. package/src/utils/constants.ts +27 -0
  148. package/src/utils/http-client.ts +38 -0
  149. package/src/utils/index.ts +8 -0
  150. package/src/utils/logger.ts +78 -0
  151. package/src/utils/session.ts +147 -0
  152. package/src/utils/token.ts +93 -0
  153. package/src/utils/utils-legacy.ts +454 -0
  154. package/tsconfig.json +20 -0
@@ -0,0 +1,1971 @@
1
+ import { u as uploadMediaToDingTalk } from "./media-cz72EVS3.mjs";
2
+ import { a as resolveDingtalkAccount } from "./accounts-BAzdqkAV.mjs";
3
+ import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-DUgpo5zC.mjs";
4
+ import { n as createLoggerFromConfig } from "./logger-mZ9OSbmD.mjs";
5
+ import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
6
+ import { i as getOapiAccessToken } from "./utils-CIfI_3Jh.mjs";
7
+ import { a as sendTextMessage, f as isQpsLimitError, h as unregisterActiveCard, i as sendProactive, l as createAICardForTarget, m as streamAICard, p as registerActiveCard, r as sendMessage, t as sendMarkdownMessage, u as finishAICard } from "./messaging-B6l1sRvX.mjs";
8
+ import { a as QUEUE_BUSY_ACK_PHRASES, n as normalizeSlashCommand, t as buildSessionContext } from "./session-DJ4jYqPv.mjs";
9
+ import { d as recallEmotionReply, o as getAccessToken, r as addEmotionReply, s as getOapiAccessToken$1, t as DINGTALK_API } from "./utils-legacy-CALCPP1t.mjs";
10
+ import "./chunk-upload-BBQgGtcZ.mjs";
11
+ import { a as VIDEO_MARKER_PATTERN, i as LOCAL_IMAGE_RE, o as toLocalPath, r as FILE_MARKER_PATTERN, s as uploadMediaToDingTalk$1, t as AUDIO_MARKER_PATTERN } from "./common-Dt9n6fQN.mjs";
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import * as os from "node:os";
15
+ import * as path$1 from "node:path";
16
+ import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
17
+ //#region src/utils/agent.ts
18
+ /**
19
+ * Agent 相关工具函数
20
+ *
21
+ * 提供 Agent 配置解析、工作空间路径解析等功能
22
+ */
23
+ /**
24
+ * 解析 Agent 工作空间路径
25
+ *
26
+ * 参考 OpenClaw SDK 的 resolveAgentWorkspaceDir 实现逻辑:
27
+ * 1. 优先从 agents.list 中查找用户配置的 workspace
28
+ * 2. 如果没有配置,使用默认路径规则:
29
+ * - 默认 Agent (main): ~/.openclaw/workspace
30
+ * - 其他 Agent: ~/.openclaw/workspace-{agentId}
31
+ *
32
+ * @param cfg - OpenClaw 配置对象
33
+ * @param agentId - Agent ID
34
+ * @returns Agent 工作空间的绝对路径
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * // 用户自定义工作空间
39
+ * const cfg = {
40
+ * agents: {
41
+ * list: [{ id: 'bot1', workspace: '~/my-workspace' }]
42
+ * }
43
+ * };
44
+ * resolveAgentWorkspaceDir(cfg, 'bot1'); // => '/Users/xxx/my-workspace'
45
+ *
46
+ * // 默认 Agent
47
+ * resolveAgentWorkspaceDir(cfg, 'main'); // => '/Users/xxx/.openclaw/workspace'
48
+ *
49
+ * // 其他 Agent
50
+ * resolveAgentWorkspaceDir(cfg, 'bot2'); // => '/Users/xxx/.openclaw/workspace-bot2'
51
+ * ```
52
+ */
53
+ function resolveAgentWorkspaceDir(cfg, agentId) {
54
+ const agentConfig = cfg.agents?.list?.find((a) => a.id === agentId);
55
+ if (agentConfig?.workspace) return agentConfig.workspace.startsWith("~") ? path$1.join(os.homedir(), agentConfig.workspace.slice(1)) : agentConfig.workspace;
56
+ if (agentId === "main" || agentId === cfg.defaultAgent) return path$1.join(os.homedir(), ".openclaw", "workspace");
57
+ return path$1.join(os.homedir(), ".openclaw", `workspace-${agentId}`);
58
+ }
59
+ //#endregion
60
+ //#region src/services/media/image.ts
61
+ /**
62
+ * 扫描内容中的本地图片路径,上传到钉钉并替换为标准 Markdown 图片语法
63
+ *
64
+ * 上传本地文件到钉钉媒体服务,获取 mediaId 后,
65
+ * 使用 ![文案](mediaId) 格式替换原始本地路径。
66
+ */
67
+ async function processLocalImages(content, oapiToken, log) {
68
+ if (!oapiToken) {
69
+ log?.warn?.(`[DingTalk][Media] 无 oapiToken,跳过图片后处理`);
70
+ return content;
71
+ }
72
+ let result = content;
73
+ const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)];
74
+ if (mdMatches.length > 0) {
75
+ log?.info?.(`[DingTalk][Media] 检测到 ${mdMatches.length} 个 markdown 图片,开始上传...`);
76
+ for (const match of mdMatches) {
77
+ const [fullMatch, alt, rawPath] = match;
78
+ const { mediaId } = await uploadMediaToDingTalk(rawPath.replace(/\\ /g, " "), "image", oapiToken, 20 * 1024 * 1024, log);
79
+ if (mediaId) {
80
+ const replacement = `![${alt}](${mediaId})`;
81
+ result = result.replace(fullMatch, replacement);
82
+ log?.info?.(`[DingTalk][Media] 图片已替换为 Markdown 格式: ${replacement}`);
83
+ }
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ //#endregion
89
+ //#region src/services/media/video.ts
90
+ /**
91
+ * 提取视频标记并发送视频消息
92
+ */
93
+ async function processVideoMarkers(content, sessionWebhook, config, oapiToken, log, useProactiveApi = false, target) {
94
+ const logPrefix = useProactiveApi ? "[DingTalk][Video][Proactive]" : "[DingTalk][Video]";
95
+ if (!oapiToken) {
96
+ log?.warn?.(`${logPrefix} 无 oapiToken,跳过视频处理`);
97
+ return content;
98
+ }
99
+ const matches = [...content.matchAll(VIDEO_MARKER_PATTERN)];
100
+ if (matches.length === 0) {
101
+ log?.info?.(`${logPrefix} 未检测到视频标记,跳过处理`);
102
+ return content;
103
+ }
104
+ const videoInfos = [];
105
+ const invalidVideos = [];
106
+ for (const match of matches) try {
107
+ const rawPath = JSON.parse(match[1]).path;
108
+ const absPath = toLocalPath(rawPath);
109
+ videoInfos.push({ path: absPath });
110
+ } catch (err) {
111
+ log?.warn?.(`${logPrefix} 解析视频标记失败:${match[1]}`);
112
+ invalidVideos.push(match[1]);
113
+ }
114
+ if (videoInfos.length === 0) {
115
+ if (invalidVideos.length > 0) {
116
+ log?.warn?.(`${logPrefix} 检测到无效视频标记,已忽略并移除`);
117
+ return content.replaceAll(VIDEO_MARKER_PATTERN, "").trim();
118
+ }
119
+ return content;
120
+ }
121
+ log?.info?.(`${logPrefix} 检测到 ${videoInfos.length} 个视频,开始上传...`);
122
+ let result = content;
123
+ for (const match of matches) {
124
+ const full = match[0];
125
+ try {
126
+ const absPath = toLocalPath(JSON.parse(match[1]).path);
127
+ if (!fs.existsSync(absPath)) {
128
+ log?.warn?.(`${logPrefix} 视频文件不存在:${absPath}`);
129
+ result = result.replace(full, "⚠️ 视频文件不存在");
130
+ continue;
131
+ }
132
+ const mediaId = await uploadMediaToDingTalk$1(absPath, "video", oapiToken, 20 * 1024 * 1024, log);
133
+ result = result.replace(full, mediaId ? `[视频已上传:${mediaId}]` : "⚠️ 视频上传失败");
134
+ } catch {
135
+ log?.warn?.(`${logPrefix} 解析视频标记失败:${match[1]}`);
136
+ result = result.replace(full, "");
137
+ }
138
+ }
139
+ return result;
140
+ }
141
+ //#endregion
142
+ //#region src/services/media/audio.ts
143
+ /**
144
+ * 提取音频标记并发送音频消息
145
+ */
146
+ async function processAudioMarkers(content, sessionWebhook, config, oapiToken, log, useProactiveApi = false, target) {
147
+ const logPrefix = useProactiveApi ? "[DingTalk][Audio][Proactive]" : "[DingTalk][Audio]";
148
+ if (!oapiToken) {
149
+ log?.warn?.(`${logPrefix} 无 oapiToken,跳过音频处理`);
150
+ return content;
151
+ }
152
+ const matches = [...content.matchAll(AUDIO_MARKER_PATTERN)];
153
+ if (matches.length === 0) return content;
154
+ log?.info?.(`${logPrefix} 检测到 ${matches.length} 个音频,开始上传...`);
155
+ let result = content;
156
+ for (const match of matches) {
157
+ const full = match[0];
158
+ try {
159
+ const absPath = toLocalPath(JSON.parse(match[1]).path);
160
+ if (!fs.existsSync(absPath)) {
161
+ log?.warn?.(`${logPrefix} 音频文件不存在:${absPath}`);
162
+ result = result.replace(full, "⚠️ 音频文件不存在");
163
+ continue;
164
+ }
165
+ const uploadResult = await uploadMediaToDingTalk$1(absPath, "voice", oapiToken, 20 * 1024 * 1024, log);
166
+ result = result.replace(full, uploadResult ? `[音频已上传:${uploadResult}]` : "⚠️ 音频上传失败");
167
+ } catch {
168
+ log?.warn?.(`${logPrefix} 解析音频标记失败:${match[1]}`);
169
+ result = result.replace(full, "");
170
+ }
171
+ }
172
+ return result.trim();
173
+ }
174
+ //#endregion
175
+ //#region src/services/media/file.ts
176
+ /**
177
+ * 提取文件标记,上传文件到钉钉,并用文本替换标记。
178
+ *
179
+ * 注意:此函数只做「上传 + 文本替换」,不会发送独立的文件消息。
180
+ * 如果需要上传后再发送独立文件消息,请使用 media.ts 中的 processFileMarkers。
181
+ *
182
+ * 调用方:reply-dispatcher.ts、message-handler.ts(通过 media/index.ts 导入)
183
+ */
184
+ async function uploadAndReplaceFileMarkers(content, sessionWebhook, config, oapiToken, log, useProactiveApi = false, target) {
185
+ const logPrefix = useProactiveApi ? "[DingTalk][File][Proactive]" : "[DingTalk][File]";
186
+ if (!oapiToken) {
187
+ log?.warn?.(`${logPrefix} 无 oapiToken,跳过文件处理`);
188
+ return content;
189
+ }
190
+ const matches = [...content.matchAll(FILE_MARKER_PATTERN)];
191
+ if (matches.length === 0) return content;
192
+ log?.info?.(`${logPrefix} 检测到 ${matches.length} 个文件,开始上传...`);
193
+ let result = content;
194
+ for (const match of matches) {
195
+ const full = match[0];
196
+ try {
197
+ const uploadResult = await uploadMediaToDingTalk$1(toLocalPath(JSON.parse(match[1]).path), "file", oapiToken, 20 * 1024 * 1024, log);
198
+ result = result.replace(full, uploadResult ? `[文件已上传:${uploadResult}]` : "⚠️ 文件上传失败");
199
+ } catch {
200
+ log?.warn?.(`${logPrefix} 解析文件标记失败:${match[1]}`);
201
+ result = result.replace(full, "");
202
+ }
203
+ }
204
+ return result;
205
+ }
206
+ //#endregion
207
+ //#region src/services/reply-markers.ts
208
+ const PROCESS_TAG = "[-process-]";
209
+ const FINAL_TAG = "[-final-]";
210
+ const EDGE_WS = /^[ \t\r\n]+|[ \t\r\n]+$/g;
211
+ /** 取最终答案:去掉那一个 [-final-](开头/中间/结尾都行),其余原样,首尾空白清掉。
212
+ * 没有 [-final-] 返回 null。多处出现取最后一个当信号。 */
213
+ function extractFinal(text) {
214
+ if (!text) return null;
215
+ const i = text.lastIndexOf(FINAL_TAG);
216
+ if (i < 0) return null;
217
+ return (text.slice(0, i) + text.slice(i + 9)).replace(EDGE_WS, "");
218
+ }
219
+ /** 剥掉一个标记 token([-process-] 或 [-final-],位置无关,取最后一个),其余原样,首尾空白清掉。 */
220
+ function stripOneMarker(text) {
221
+ if (!text) return "";
222
+ const pi = text.lastIndexOf(PROCESS_TAG);
223
+ const fi = text.lastIndexOf(FINAL_TAG);
224
+ if (pi < 0 && fi < 0) return text.replace(EDGE_WS, "");
225
+ const [i, len] = fi >= pi ? [fi, 9] : [pi, 11];
226
+ return (text.slice(0, i) + text.slice(i + len)).replace(EDGE_WS, "");
227
+ }
228
+ /** 去掉流式途中末尾未闭合的半截标记(如 "[-fin",缺右 "]")。完整标记交给上面两个函数。 */
229
+ function stripPartialTail(text) {
230
+ if (!text) return "";
231
+ return text.replace(/\[-[a-z]*-?$/i, "");
232
+ }
233
+ /** 定稿用:有 [-final-] 去掉它;否则剥一个标记。正文不动。 */
234
+ function finalClean(text) {
235
+ return extractFinal(text) ?? stripOneMarker(text);
236
+ }
237
+ /** 流式展示用:有 [-final-] 直接显示最终答案;否则剥一个标记 + 半截标记,正文不动。 */
238
+ function displayClean(text) {
239
+ const fin = extractFinal(text);
240
+ return fin !== null ? fin : stripPartialTail(stripOneMarker(text));
241
+ }
242
+ //#endregion
243
+ //#region src/reply-dispatcher.ts
244
+ const { createReplyPrefixOptions, createTypingCallbacks, logTypingFailure } = await import("openclaw/plugin-sdk/channel-runtime");
245
+ function createDingtalkReplyDispatcher(params) {
246
+ const core = getDingtalkRuntime();
247
+ const { cfg, agentId, conversationId, senderId, isDirect, accountId, sessionWebhook, asyncMode = false, preCreatedCard } = params;
248
+ const account = resolveDingtalkAccount({
249
+ cfg,
250
+ accountId
251
+ });
252
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
253
+ cfg,
254
+ agentId,
255
+ channel: CHANNEL_ID,
256
+ accountId
257
+ });
258
+ const log = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
259
+ let currentCardTarget = null;
260
+ let accumulatedText = "";
261
+ const deliveredFinalTexts = /* @__PURE__ */ new Set();
262
+ let sessionClosed = false;
263
+ let asyncModeFullResponse = "";
264
+ let finalMarkedText = null;
265
+ let lastAnswerText = "";
266
+ let processMarkerLogged = false;
267
+ let finalMarkerLogged = false;
268
+ const observeReply = (raw, isReasoning) => {
269
+ const text = raw ?? "";
270
+ if (!text) return;
271
+ if (!processMarkerLogged && text.includes("[-process-]")) {
272
+ processMarkerLogged = true;
273
+ log.info(`[DingTalk][marker] 检测到过程标记 ${PROCESS_TAG}(已剥离,不展示给用户)`);
274
+ }
275
+ const fin = extractFinal(text);
276
+ if (fin !== null) {
277
+ if (!finalMarkerLogged) {
278
+ finalMarkerLogged = true;
279
+ log.info(`[DingTalk][marker] 检测到最终标记 ${FINAL_TAG}(已剥离,以其后内容为最终答案)`);
280
+ }
281
+ finalMarkedText = fin;
282
+ }
283
+ if (!isReasoning && text.trim()) lastAnswerText = text;
284
+ };
285
+ const pickFinalText = () => finalMarkedText ?? (lastAnswerText || accumulatedText);
286
+ const applyReplyTemplate = async (text) => {
287
+ try {
288
+ const runner = getGlobalHookRunner?.();
289
+ if (!runner?.hasHooks?.("reply_payload_sending")) return text;
290
+ const res = await runner.runReplyPayloadSending({
291
+ payload: { text },
292
+ kind: "final",
293
+ channel: CHANNEL_ID
294
+ }, {
295
+ channelId: CHANNEL_ID,
296
+ accountId,
297
+ conversationId,
298
+ senderId
299
+ });
300
+ if (res?.cancel) return text;
301
+ const out = res?.payload?.text;
302
+ return typeof out === "string" && out ? out : text;
303
+ } catch (e) {
304
+ log.warn(`[DingTalk] 套用回复模板失败(忽略):${e?.message || String(e)}`);
305
+ return text;
306
+ }
307
+ };
308
+ const detectedDwsProducts = /* @__PURE__ */ new Set();
309
+ const DWS_PRODUCT_PATTERN = /\bdws\s+(aitable|calendar|chat|contact|todo|approval|attendance|report|ding|workbench|devdoc)\b/;
310
+ let lastUpdateTime = 0;
311
+ const updateInterval = 800;
312
+ const deliveredErrorTypes = /* @__PURE__ */ new Set();
313
+ let lastErrorTime = 0;
314
+ const ERROR_COOLDOWN = 6e4;
315
+ /**
316
+ * 发送兜底错误消息,确保用户始终能收到反馈
317
+ */
318
+ const sendFallbackErrorMessage = async (errorType, originalError, forceSend = false) => {
319
+ const now = Date.now();
320
+ const errorKey = `${errorType}:${conversationId}:${senderId}`;
321
+ if (!forceSend && deliveredErrorTypes.has(errorKey)) {
322
+ log.debug(`[DingTalk][Fallback] 跳过重复错误消息:${errorType}`);
323
+ return;
324
+ }
325
+ if (!forceSend && now - lastErrorTime < ERROR_COOLDOWN) {
326
+ log.debug(`[DingTalk][Fallback] 冷却时间内,跳过错误消息`);
327
+ return;
328
+ }
329
+ const errorMessage = {
330
+ mediaProcess: "⚠️ 媒体文件处理失败,已发送文字回复",
331
+ sendMessage: "⚠️ 消息发送失败,请稍后重试",
332
+ unknown: "⚠️ 抱歉,处理您的请求时出错,请稍后重试"
333
+ }[errorType];
334
+ log.warn(`[DingTalk][Fallback] ${errorMessage}, error: ${originalError}`);
335
+ try {
336
+ await sendMessage(account.config, sessionWebhook, errorMessage, {
337
+ useMarkdown: false,
338
+ log: params.runtime.log
339
+ });
340
+ deliveredErrorTypes.add(errorKey);
341
+ lastErrorTime = now;
342
+ log.info(`[DingTalk][Fallback] ✅ 错误消息发送成功`);
343
+ } catch (fallbackErr) {
344
+ log.error(`[DingTalk][Fallback] ❌ 错误消息发送失败:${fallbackErr.message}`);
345
+ }
346
+ };
347
+ const typingCallbacks = createTypingCallbacks({
348
+ start: async () => {},
349
+ stop: async () => {},
350
+ onStartError: (err) => logTypingFailure({
351
+ log: (message) => params.runtime.log?.(message),
352
+ channel: CHANNEL_ID,
353
+ action: "start",
354
+ error: err
355
+ }),
356
+ onStopError: (err) => logTypingFailure({
357
+ log: (message) => params.runtime.log?.(message),
358
+ channel: CHANNEL_ID,
359
+ action: "stop",
360
+ error: err
361
+ })
362
+ });
363
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, CHANNEL_ID, accountId, { fallbackLimit: 4e3 });
364
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, CHANNEL_ID);
365
+ const groupReplyMode = account.config?.groupReplyMode || "aicard";
366
+ const isTextMode = !isDirect && (groupReplyMode === "text" || groupReplyMode === "markdown");
367
+ if (isTextMode) log.info(`[DingTalk] 群聊回复模式: ${groupReplyMode},禁用 AI Card,使用 ${groupReplyMode} 发送`);
368
+ const streamingEnabled = !isTextMode && account.config?.streaming !== false;
369
+ let cardCreationPromise = null;
370
+ const startStreaming = () => {
371
+ if (cardCreationPromise) return cardCreationPromise;
372
+ if (currentCardTarget) return Promise.resolve();
373
+ cardCreationPromise = (async () => {
374
+ if (asyncMode) {
375
+ log.info(`[DingTalk][startStreaming] 异步模式,跳过 AI Card 创建`);
376
+ return;
377
+ }
378
+ if (!streamingEnabled) {
379
+ log.info(`[DingTalk][startStreaming] 流式功能被禁用,跳过 AI Card 创建`);
380
+ return;
381
+ }
382
+ if (sessionClosed) {
383
+ log.info(`[DingTalk][startStreaming] 会话已关闭,跳过 AI Card 创建`);
384
+ return;
385
+ }
386
+ if (preCreatedCard) {
387
+ log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
388
+ currentCardTarget = preCreatedCard;
389
+ accumulatedText = "";
390
+ if (!isDirect) registerActiveCard(conversationId, preCreatedCard);
391
+ return;
392
+ }
393
+ log.info(`[DingTalk][startStreaming] 开始创建 AI Card...`);
394
+ try {
395
+ const target = isDirect ? {
396
+ type: "user",
397
+ userId: senderId
398
+ } : {
399
+ type: "group",
400
+ openConversationId: conversationId
401
+ };
402
+ log.info(`[DingTalk][startStreaming] 目标:${JSON.stringify(target)}`);
403
+ const card = await createAICardForTarget(account.config, target, log);
404
+ currentCardTarget = card;
405
+ accumulatedText = "";
406
+ if (card) {
407
+ if (!isDirect) registerActiveCard(conversationId, card);
408
+ log.info(`[DingTalk][startStreaming] ✅ AI Card 创建成功`);
409
+ } else log.warn(`[DingTalk][startStreaming] AI Card 创建返回 null,静默降级到普通消息模式`);
410
+ } catch (error) {
411
+ log.error(`[DingTalk][startStreaming] ❌ AI Card 创建失败:${error?.message || String(error)},静默降级到普通消息模式`);
412
+ currentCardTarget = null;
413
+ } finally {
414
+ cardCreationPromise = null;
415
+ }
416
+ })();
417
+ return cardCreationPromise;
418
+ };
419
+ const closeStreaming = async () => {
420
+ const cardSnapshot = currentCardTarget;
421
+ if (!cardSnapshot) {
422
+ log.info(`[DingTalk][closeStreaming] 无 AI Card,跳过关闭`);
423
+ return;
424
+ }
425
+ currentCardTarget = null;
426
+ sessionClosed = true;
427
+ if (!isDirect) unregisterActiveCard(conversationId);
428
+ log.info(`[DingTalk][closeStreaming] 开始关闭 AI Card...`);
429
+ try {
430
+ let finalText = finalClean(pickFinalText());
431
+ log.info(`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : lastAnswerText ? "非reasoning答案" : "accumulatedText兜底"},长度=${finalText.length}`);
432
+ if (!finalText.trim()) {
433
+ finalText = "✅ 任务执行完成(无文本输出)";
434
+ log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案`);
435
+ }
436
+ const oapiToken = await getOapiAccessToken(account.config);
437
+ const target = isDirect ? {
438
+ type: "user",
439
+ userId: senderId
440
+ } : {
441
+ type: "group",
442
+ openConversationId: conversationId
443
+ };
444
+ log.info(`[DingTalk][closeStreaming] 开始处理媒体文件,target=${JSON.stringify(target)}`);
445
+ if (oapiToken) {
446
+ finalText = await processLocalImages(finalText, oapiToken, log);
447
+ finalText = await processVideoMarkers(finalText, "", account.config, oapiToken, log, true, target);
448
+ finalText = await processAudioMarkers(finalText, "", account.config, oapiToken, log, true, target);
449
+ finalText = await uploadAndReplaceFileMarkers(finalText, "", account.config, oapiToken, log, true, target);
450
+ log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
451
+ const { processRawMediaPaths } = await import("./media-C_SVin7s.mjs");
452
+ finalText = await processRawMediaPaths(finalText, account.config, oapiToken, log, target);
453
+ log.info(`[DingTalk][closeStreaming] processRawMediaPaths 处理完成`);
454
+ } else log.warn(`[DingTalk][closeStreaming] oapiToken 为空,跳过媒体处理`);
455
+ try {
456
+ const productsToProcess = new Set(detectedDwsProducts);
457
+ if (productsToProcess.size === 0) {
458
+ const dwsProductMatch = finalText.match(/(?:^|\n)\s*(?:>?\s*)?(?:`\s*)?dws\s+(aitable|calendar|chat|contact|todo|approval|attendance|report|ding|workbench|devdoc)\b/m);
459
+ if (dwsProductMatch && !finalText.includes("command not found: dws") && !finalText.includes("请先执行 dws login")) {
460
+ productsToProcess.add(dwsProductMatch[1]);
461
+ log.info(`[DingTalk][closeStreaming] 养成系统:正则兜底匹配到产品=${dwsProductMatch[1]}`);
462
+ }
463
+ } else log.info(`[DingTalk][closeStreaming] 养成系统:onCommandOutput 监听到 ${productsToProcess.size} 个 dws 产品: ${[...productsToProcess].join(", ")}`);
464
+ if (productsToProcess.size > 0) {
465
+ const { GamificationEngine } = await import("./game-xiyou-CqHt-6Q1.mjs");
466
+ const engine = GamificationEngine.getInstanceForUser(senderId);
467
+ if (engine.isEnabled()) {
468
+ const primaryProduct = [...productsToProcess][0];
469
+ const allProducts = [...productsToProcess].join("+");
470
+ const gamificationBlock = engine.onDwsCommandResult(primaryProduct, true, `dws ${allProducts}`);
471
+ if (gamificationBlock) {
472
+ finalText += "\n" + gamificationBlock;
473
+ log.info(`[DingTalk][closeStreaming] ✅ 养成系统渲染已追加,主产品=${primaryProduct},涉及产品=${allProducts}`);
474
+ }
475
+ }
476
+ }
477
+ detectedDwsProducts.clear();
478
+ } catch (gamErr) {
479
+ log.warn(`[DingTalk][closeStreaming] 养成系统处理失败(不影响主流程): ${gamErr?.message || gamErr}`);
480
+ }
481
+ finalText = await applyReplyTemplate(finalText);
482
+ log.info(`[DingTalk][closeStreaming] 准备调用 finishAICard,文本长度=${finalText.length}`);
483
+ log.debug(`[DingTalk][closeStreaming] 最终发送内容长度=${finalText.length}`);
484
+ await finishAICard(cardSnapshot, finalText, account.config, log);
485
+ log.info(`[DingTalk][closeStreaming] ✅ AI Card 关闭成功`);
486
+ } catch (error) {
487
+ log.error(`[DingTalk][closeStreaming] ❌ AI Card 关闭失败:${error?.message || String(error)}`);
488
+ await sendFallbackErrorMessage("mediaProcess", error?.message || String(error));
489
+ const fallbackText = finalClean(finalMarkedText ?? accumulatedText);
490
+ if (fallbackText.trim()) try {
491
+ log.info(`[DingTalk][closeStreaming] 降级发送普通消息`);
492
+ await sendMessage(account.config, sessionWebhook, fallbackText, {
493
+ useMarkdown: true,
494
+ log: params.runtime.log
495
+ });
496
+ log.info(`[DingTalk][closeStreaming] ✅ 降级发送成功`);
497
+ } catch (sendErr) {
498
+ log.error(`[DingTalk][closeStreaming] ❌ 降级发送失败:${sendErr.message}`);
499
+ }
500
+ } finally {
501
+ accumulatedText = "";
502
+ }
503
+ };
504
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
505
+ ...prefixOptions,
506
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
507
+ onReplyStart: () => {
508
+ log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
509
+ deliveredFinalTexts.clear();
510
+ finalMarkedText = null;
511
+ lastAnswerText = "";
512
+ processMarkerLogged = false;
513
+ finalMarkerLogged = false;
514
+ if (streamingEnabled) startStreaming();
515
+ typingCallbacks.onActive?.();
516
+ },
517
+ deliver: async (payload, info) => {
518
+ let text = payload.text ?? "";
519
+ log.info(`[DingTalk][deliver] 被调用:kind=${info?.kind}, textLength=${text.length}, hasText=${Boolean(text.trim())}`);
520
+ log.debug(`[DingTalk][deliver] payload keys=${Object.keys(payload).join(",")}, info.kind=${info?.kind}`);
521
+ observeReply(payload.text, payload.isReasoning);
522
+ if (info?.kind === "final" && text.trim()) {
523
+ const target = isDirect ? {
524
+ type: "user",
525
+ userId: senderId
526
+ } : {
527
+ type: "group",
528
+ openConversationId: conversationId
529
+ };
530
+ try {
531
+ const oapiToken = await getOapiAccessToken(account.config);
532
+ if (oapiToken) {
533
+ log.info(`[DingTalk][deliver] 检测到 final 响应,准备处理裸露文件路径`);
534
+ const { processRawMediaPaths } = await import("./media-C_SVin7s.mjs");
535
+ text = await processRawMediaPaths(text, account.config, oapiToken, log, target);
536
+ log.info(`[DingTalk][deliver] 裸露文件路径处理完成`);
537
+ }
538
+ } catch (err) {
539
+ log.error(`[DingTalk][deliver] 处理裸露文件路径失败:${err.message}`);
540
+ }
541
+ }
542
+ const hasText = Boolean(text.trim());
543
+ const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
544
+ if (info?.kind === "final" && !hasText) {
545
+ text = "✅ 任务执行完成(无文本输出)";
546
+ log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案`);
547
+ }
548
+ if (!(Boolean(text.trim()) && !skipTextForDuplicateFinal)) {
549
+ log.info(`[DingTalk][deliver] 跳过发送:hasText=${hasText}, skipTextForDuplicateFinal=${skipTextForDuplicateFinal}`);
550
+ return;
551
+ }
552
+ if (asyncMode) {
553
+ log.info(`[DingTalk][deliver] 异步模式,累积响应`);
554
+ asyncModeFullResponse = finalClean(finalMarkedText ?? text);
555
+ return;
556
+ }
557
+ if (info?.kind === "block") {
558
+ if (!streamingEnabled) {
559
+ log.info(`[DingTalk][deliver] block 消息,流式未启用,丢弃`);
560
+ return;
561
+ }
562
+ log.info(`[DingTalk][deliver] block 消息,追加到流式 AI Card,文本长度=${text.length}`);
563
+ await startStreaming();
564
+ if (currentCardTarget) {
565
+ if (accumulatedText) {
566
+ log.info(`[DingTalk][deliver] block 消息:最终回复已在流式中(${accumulatedText.length}字),跳过以防覆盖流式内容`);
567
+ return;
568
+ }
569
+ const now = Date.now();
570
+ if (now - lastUpdateTime >= updateInterval) {
571
+ lastUpdateTime = now;
572
+ try {
573
+ await streamAICard(currentCardTarget, displayClean(text), false, account.config, log);
574
+ log.info(`[DingTalk][deliver] ✅ block 更新到 AI Card 成功`);
575
+ } catch (streamErr) {
576
+ log.error(`[DingTalk][deliver] ❌ block 更新 AI Card 失败:${streamErr.message}`);
577
+ }
578
+ }
579
+ } else log.warn(`[DingTalk][deliver] block 消息:AI Card 创建失败,丢弃该 block`);
580
+ return;
581
+ }
582
+ if (info?.kind === "final" && streamingEnabled) {
583
+ log.info(`[DingTalk][deliver] final 响应,流式模式`);
584
+ await startStreaming();
585
+ if (currentCardTarget) {
586
+ accumulatedText = text;
587
+ log.info(`[DingTalk][deliver] 多轮 Agent 模式:仅更新 accumulatedText(len=${text.length}),不触发卡片更新`);
588
+ deliveredFinalTexts.add(text);
589
+ return;
590
+ } else log.warn(`[DingTalk][deliver] ⚠️ AI Card 创建失败,降级到非流式发送`);
591
+ }
592
+ if (info?.kind === "final") {
593
+ text = await applyReplyTemplate(finalClean(finalMarkedText ?? text));
594
+ log.info(`[DingTalk][deliver] 降级到非流式发送,文本长度=${text.length}, isTextMode=${isTextMode}, groupReplyMode=${groupReplyMode}`);
595
+ try {
596
+ for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) if (isTextMode) if (groupReplyMode === "markdown") await sendMarkdownMessage(account.config, sessionWebhook, chunk.split("\n")[0]?.replace(/^[#*\s\->]+/, "").slice(0, 20) || "Message", chunk, {
597
+ cfg,
598
+ detectBareAliases: true
599
+ });
600
+ else await sendTextMessage(account.config, sessionWebhook, chunk, {
601
+ cfg,
602
+ detectBareAliases: true
603
+ });
604
+ else await sendMessage(account.config, sessionWebhook, chunk, {
605
+ useMarkdown: true,
606
+ log: params.runtime.log,
607
+ cfg,
608
+ detectBareAliases: true
609
+ });
610
+ log.info(`[DingTalk][deliver] ✅ 非流式发送成功`);
611
+ deliveredFinalTexts.add(text);
612
+ } catch (error) {
613
+ log.error(`[DingTalk][deliver] ❌ 非流式发送失败:${error.message}`);
614
+ params.runtime.error?.(`dingtalk[${account.accountId}]: non-streaming delivery failed: ${String(error)}`);
615
+ await sendFallbackErrorMessage("sendMessage", error.message);
616
+ }
617
+ return;
618
+ }
619
+ },
620
+ onError: async (error, info) => {
621
+ log.error(`[DingTalk][onError] ${info.kind} reply failed: ${String(error)}`);
622
+ params.runtime.error?.(`dingtalk[${account.accountId}] ${info.kind} reply failed: ${String(error)}`);
623
+ await closeStreaming();
624
+ typingCallbacks.onIdle?.();
625
+ },
626
+ onIdle: async () => {
627
+ log.info(`[DingTalk][onIdle] 回复空闲,关闭 AI Card`);
628
+ typingCallbacks.onIdle?.();
629
+ await closeStreaming();
630
+ },
631
+ onCleanup: () => {
632
+ log.info(`[DingTalk][onCleanup] 清理回调`);
633
+ typingCallbacks.onCleanup?.();
634
+ }
635
+ });
636
+ return {
637
+ dispatcher,
638
+ replyOptions: {
639
+ ...replyOptions,
640
+ onModelSelected,
641
+ ...streamingEnabled && { onPartialReply: async (payload) => {
642
+ log.info(`[DingTalk][onPartialReply] 被调用,payload.text=${payload.text ? payload.text.length : "null"}`);
643
+ log.debug(`[DingTalk][onPartialReply] textLength=${payload.text?.length ?? 0}`);
644
+ if (!payload.text) {
645
+ log.debug(`[DingTalk][onPartialReply] 空文本,跳过`);
646
+ return;
647
+ }
648
+ log.debug(`[DingTalk][onPartialReply] 收到部分响应,文本长度=${payload.text.length}`);
649
+ observeReply(payload.text, payload.isReasoning);
650
+ if (asyncMode) {
651
+ log.debug(`[DingTalk][onPartialReply] 异步模式,累积响应`);
652
+ asyncModeFullResponse = finalClean(finalMarkedText ?? payload.text);
653
+ return;
654
+ }
655
+ await startStreaming();
656
+ if (currentCardTarget) {
657
+ accumulatedText = payload.text;
658
+ const now = Date.now();
659
+ if (now - lastUpdateTime >= updateInterval) {
660
+ const { FILE_MARKER_PATTERN, VIDEO_MARKER_PATTERN, AUDIO_MARKER_PATTERN } = await import("./common-C8pYKU_y.mjs");
661
+ const displayContent = displayClean(finalMarkedText ?? accumulatedText).replace(FILE_MARKER_PATTERN, "").replace(VIDEO_MARKER_PATTERN, "").replace(AUDIO_MARKER_PATTERN, "").trim();
662
+ log.debug(`[DingTalk][onPartialReply] 更新 AI Card,显示文本长度=${displayContent.length}`);
663
+ lastUpdateTime = now;
664
+ try {
665
+ await streamAICard(currentCardTarget, displayContent, false, account.config, log, account.config?.cardContentVar || "msgContent");
666
+ log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
667
+ } catch (err) {
668
+ if (isQpsLimitError(err)) log.warn(`[DingTalk][onPartialReply] AI Card 流式更新遇到 QPS 限流,已在内部退避重试;本次跳过,等待下一次 partial 更新补齐内容`);
669
+ else {
670
+ log.error(`[DingTalk][onPartialReply] ❌ AI Card 更新失败:${err.message}`);
671
+ await sendFallbackErrorMessage("sendMessage", err.message);
672
+ }
673
+ }
674
+ } else log.debug(`[DingTalk][onPartialReply] 节流控制,跳过本次更新(距离上次更新 ${now - lastUpdateTime}ms)`);
675
+ } else log.warn(`[DingTalk][onPartialReply] ⚠️ AI Card 不存在,跳过更新`);
676
+ } },
677
+ onCommandOutput: (payload) => {
678
+ const dwsMatch = (payload.title || payload.name || "").match(DWS_PRODUCT_PATTERN) || payload.output?.match(DWS_PRODUCT_PATTERN);
679
+ if (dwsMatch) {
680
+ const product = dwsMatch[1];
681
+ if (!(payload.phase === "end" && payload.exitCode !== null && payload.exitCode !== 0)) {
682
+ detectedDwsProducts.add(product);
683
+ log.info(`[DingTalk][onCommandOutput] 检测到 dws 产品: ${product},phase=${payload.phase}, exitCode=${payload.exitCode}`);
684
+ } else log.info(`[DingTalk][onCommandOutput] dws 命令执行失败,跳过: ${product},exitCode=${payload.exitCode}`);
685
+ }
686
+ const toolVar = account.config?.cardToolVar;
687
+ if (toolVar && payload.output && currentCardTarget) {
688
+ payload.output;
689
+ const now = Date.now();
690
+ if (now - lastUpdateTime >= updateInterval) {
691
+ lastUpdateTime = now;
692
+ streamAICard(currentCardTarget, payload.output, false, account.config, log, toolVar).then(() => {
693
+ log.debug(`[DingTalk][onCommandOutput] ✅ 工具输出写入 AI Card(${toolVar})`);
694
+ }).catch((err) => {
695
+ log.error(`[DingTalk][onCommandOutput] ❌ 工具输出写入失败:${err.message}`);
696
+ });
697
+ }
698
+ }
699
+ }
700
+ },
701
+ markDispatchIdle,
702
+ getAsyncModeResponse: () => asyncModeFullResponse
703
+ };
704
+ }
705
+ //#endregion
706
+ //#region src/core/message-handler.ts
707
+ /**
708
+ * 会话消息队列管理
709
+ * 用于确保同一会话+agent的消息按顺序处理,避免并发冲突导致AI返回空响应
710
+ * 队列键格式:{sessionId}:{agentId}
711
+ * 这样不同 agent 可以并发处理,同一 agent 的同一会话串行处理
712
+ */
713
+ const sessionQueues = /* @__PURE__ */ new Map();
714
+ /**
715
+ * 清理过期的会话队列(超过5分钟没有新消息的会话+agent)
716
+ */
717
+ const sessionLastActivity = /* @__PURE__ */ new Map();
718
+ const SESSION_QUEUE_TTL = 300 * 1e3;
719
+ function cleanupExpiredSessionQueues() {
720
+ const now = Date.now();
721
+ for (const [queueKey, lastActivity] of sessionLastActivity.entries()) if (now - lastActivity > SESSION_QUEUE_TTL) {
722
+ sessionQueues.delete(queueKey);
723
+ sessionLastActivity.delete(queueKey);
724
+ }
725
+ }
726
+ setInterval(cleanupExpiredSessionQueues, 6e4);
727
+ /**
728
+ * 解析 data.content 字段:可能是对象,也可能是 JSON 字符串(钉钉部分 API 版本会将 content 序列化为字符串)。
729
+ * 返回解析后的对象,或 null(字段不存在 / 无法解析)。
730
+ */
731
+ function resolveContent(data) {
732
+ const raw = data?.content;
733
+ if (raw == null) return null;
734
+ if (typeof raw === "object") return raw;
735
+ if (typeof raw === "string") try {
736
+ const parsed = JSON.parse(raw);
737
+ if (parsed && typeof parsed === "object") return parsed;
738
+ } catch {}
739
+ return null;
740
+ }
741
+ /**
742
+ * 从消息的内容容器(data.text 或 data.content)中提取引用消息文本,最多递归 maxDepth 层。
743
+ * 对齐 Rust chatbot.rs 的 extract_quoted_msg_text 逻辑。
744
+ *
745
+ * 钉钉引用消息结构:
746
+ * { isReplyMsg: true, repliedMsg: { msgType, content, msgId, senderId } }
747
+ */
748
+ function extractQuotedMsgText(container, maxDepth) {
749
+ if (maxDepth <= 0 || !container) return null;
750
+ if (!container.isReplyMsg) return null;
751
+ const repliedMsg = container.repliedMsg;
752
+ if (!repliedMsg) return null;
753
+ const msgType = repliedMsg.msgType || "text";
754
+ let contentObj = null;
755
+ const rawContent = repliedMsg.content;
756
+ if (rawContent && typeof rawContent === "object") contentObj = rawContent;
757
+ else if (typeof rawContent === "string") try {
758
+ const parsed = JSON.parse(rawContent);
759
+ if (parsed && typeof parsed === "object") contentObj = parsed;
760
+ } catch {}
761
+ let bodyText = "";
762
+ switch (msgType) {
763
+ case "text":
764
+ bodyText = contentObj?.text?.trim() || repliedMsg.text?.trim() || "";
765
+ if (contentObj?.isReplyMsg) {
766
+ const nested = extractQuotedMsgText(contentObj, maxDepth - 1);
767
+ if (nested) bodyText = bodyText ? `${bodyText}\n${nested}` : nested;
768
+ }
769
+ break;
770
+ case "richText":
771
+ bodyText = (contentObj?.richText || []).filter((item) => item.text && item.msgType !== "skill" && !item.skillData).map((item) => item.text).join("");
772
+ break;
773
+ case "picture":
774
+ bodyText = "[图片]";
775
+ break;
776
+ case "video":
777
+ bodyText = "[视频]";
778
+ break;
779
+ case "audio":
780
+ bodyText = contentObj?.recognition || "[语音消息]";
781
+ break;
782
+ case "file":
783
+ bodyText = `[文件: ${contentObj?.fileName || "unknown"}]`;
784
+ break;
785
+ case "markdown":
786
+ bodyText = contentObj?.text?.trim() || "[markdown消息]";
787
+ break;
788
+ case "interactiveCard": {
789
+ const cardUrl = contentObj?.biz_custom_action_url || repliedMsg.biz_custom_action_url || "";
790
+ bodyText = cardUrl ? `收到交互式卡片链接:${cardUrl}` : "[interactiveCard消息]";
791
+ break;
792
+ }
793
+ default: bodyText = `[${msgType}消息]`;
794
+ }
795
+ if (!bodyText) return null;
796
+ return `[引用] ${bodyText}`;
797
+ }
798
+ /**
799
+ * 从 richText 列表中提取媒体附件(图片 downloadCode)。
800
+ * 兼容新结构(content.richText)和旧结构(richText.richTextList)。
801
+ */
802
+ function extractRichTextMediaAttachments(data, content) {
803
+ const imageUrls = [];
804
+ const downloadCodes = [];
805
+ const fileNames = [];
806
+ const richList = content?.richText || data?.richText?.richTextList || [];
807
+ for (const item of richList) {
808
+ if (item.pictureUrl) imageUrls.push(item.pictureUrl);
809
+ if (item.downloadCode) {
810
+ const itemType = item.type || "";
811
+ if (itemType === "picture" || !itemType) imageUrls.push(`downloadCode:${item.downloadCode}`);
812
+ else if (itemType === "video") {
813
+ downloadCodes.push(item.downloadCode);
814
+ fileNames.push(item.fileName || "video.mp4");
815
+ } else if (itemType === "audio") {
816
+ downloadCodes.push(item.downloadCode);
817
+ fileNames.push(item.fileName || "audio.amr");
818
+ } else if (itemType === "file") {
819
+ downloadCodes.push(item.downloadCode);
820
+ fileNames.push(item.fileName || "文件");
821
+ }
822
+ }
823
+ }
824
+ return {
825
+ imageUrls,
826
+ downloadCodes,
827
+ fileNames
828
+ };
829
+ }
830
+ /**
831
+ * 从 repliedMsg 中提取媒体附件(用于 reply 类型消息)。
832
+ */
833
+ function extractRepliedMsgMediaAttachments(repliedMsg) {
834
+ const imageUrls = [];
835
+ const downloadCodes = [];
836
+ const fileNames = [];
837
+ if (!repliedMsg) return {
838
+ imageUrls,
839
+ downloadCodes,
840
+ fileNames
841
+ };
842
+ const msgType = repliedMsg.msgType || "text";
843
+ let contentObj = null;
844
+ const rawContent = repliedMsg.content;
845
+ if (rawContent && typeof rawContent === "object") contentObj = rawContent;
846
+ else if (typeof rawContent === "string") try {
847
+ const parsed = JSON.parse(rawContent);
848
+ if (parsed && typeof parsed === "object") contentObj = parsed;
849
+ } catch {}
850
+ switch (msgType) {
851
+ case "picture":
852
+ case "video":
853
+ case "audio": {
854
+ const code = contentObj?.downloadCode;
855
+ if (code) if (msgType === "picture") imageUrls.push(`downloadCode:${code}`);
856
+ else {
857
+ downloadCodes.push(code);
858
+ fileNames.push(contentObj?.fileName || (msgType === "video" ? "video.mp4" : "audio.amr"));
859
+ }
860
+ break;
861
+ }
862
+ case "file": {
863
+ const code = contentObj?.downloadCode;
864
+ if (code) {
865
+ downloadCodes.push(code);
866
+ fileNames.push(contentObj?.fileName || "文件");
867
+ }
868
+ break;
869
+ }
870
+ case "richText": {
871
+ const richList = contentObj?.richText || [];
872
+ for (const item of richList) if (item.downloadCode) imageUrls.push(`downloadCode:${item.downloadCode}`);
873
+ break;
874
+ }
875
+ default: break;
876
+ }
877
+ return {
878
+ imageUrls,
879
+ downloadCodes,
880
+ fileNames
881
+ };
882
+ }
883
+ function extractMessageContent(data) {
884
+ const msgtype = data.msgtype || "text";
885
+ switch (msgtype) {
886
+ case "text": {
887
+ const atDingtalkIds = data.text?.at?.atDingtalkIds || [];
888
+ const atMobiles = data.text?.at?.atMobiles || [];
889
+ const bodyText = data.text?.content?.trim() || "";
890
+ const hasReply = !!data.text?.isReplyMsg;
891
+ const quotedText = extractQuotedMsgText(data.text, 3);
892
+ const text = quotedText ? `${bodyText}\n${quotedText}` : bodyText;
893
+ const repliedMsgInText = data.text?.repliedMsg;
894
+ const { imageUrls, downloadCodes, fileNames } = extractRepliedMsgMediaAttachments(repliedMsgInText);
895
+ let interactiveCardUrl;
896
+ if (hasReply && repliedMsgInText) {
897
+ const extractedUrl = extractFirstUrlFromText((typeof repliedMsgInText.content === "object" ? repliedMsgInText.content : (() => {
898
+ try {
899
+ return JSON.parse(repliedMsgInText.content);
900
+ } catch {
901
+ return null;
902
+ }
903
+ })())?.text || repliedMsgInText.text || "");
904
+ if (extractedUrl) interactiveCardUrl = extractedUrl;
905
+ }
906
+ return {
907
+ text,
908
+ messageType: hasReply ? "reply" : "text",
909
+ imageUrls,
910
+ downloadCodes,
911
+ fileNames,
912
+ atDingtalkIds,
913
+ atMobiles,
914
+ interactiveCardUrl
915
+ };
916
+ }
917
+ case "richText": {
918
+ const content = resolveContent(data);
919
+ const textParts = [];
920
+ const richList = content?.richText || data?.richText?.richTextList || [];
921
+ for (const item of richList) {
922
+ const isSkillItem = item.type === "skill" || !!item.skillData;
923
+ if (item.text && !isSkillItem) textParts.push(item.text);
924
+ if (isSkillItem && item.skillData) {
925
+ const skillId = item.skillData.skillId || "";
926
+ const displayName = item.skillData.displayName || "";
927
+ const iconUrl = item.skillData.iconUrl || "";
928
+ const skillTag = iconUrl ? `<skill data-id="${skillId}" data-name="${displayName}" icon="${iconUrl}">` : `<skill data-id="${skillId}" data-name="${displayName}">`;
929
+ textParts.push(skillTag);
930
+ }
931
+ if (item.pictureUrl) {}
932
+ }
933
+ const hasReply = !!content?.isReplyMsg;
934
+ const quotedText = extractQuotedMsgText(content, 3);
935
+ if (quotedText) textParts.push(quotedText);
936
+ const richTextMedia = extractRichTextMediaAttachments(data, content);
937
+ const repliedMsgInRichText = content?.repliedMsg;
938
+ const repliedMedia = extractRepliedMsgMediaAttachments(repliedMsgInRichText);
939
+ const imageUrls = [...richTextMedia.imageUrls, ...repliedMedia.imageUrls];
940
+ const downloadCodes = [...richTextMedia.downloadCodes, ...repliedMedia.downloadCodes];
941
+ const fileNames = [...richTextMedia.fileNames, ...repliedMedia.fileNames];
942
+ return {
943
+ text: textParts.join("") || (imageUrls.length > 0 ? "[图片]" : downloadCodes.length > 0 ? "[媒体文件]" : "[富文本消息]"),
944
+ messageType: hasReply ? "reply" : "richText",
945
+ imageUrls,
946
+ downloadCodes,
947
+ fileNames,
948
+ atDingtalkIds: [],
949
+ atMobiles: []
950
+ };
951
+ }
952
+ case "picture": {
953
+ const content = resolveContent(data);
954
+ const downloadCode = content?.downloadCode || "";
955
+ const pictureUrl = content?.pictureUrl || "";
956
+ const imageUrls = [];
957
+ const downloadCodes = [];
958
+ if (pictureUrl) imageUrls.push(pictureUrl);
959
+ if (downloadCode) downloadCodes.push(downloadCode);
960
+ return {
961
+ text: "[图片]",
962
+ messageType: "picture",
963
+ imageUrls,
964
+ downloadCodes,
965
+ fileNames: [],
966
+ atDingtalkIds: [],
967
+ atMobiles: []
968
+ };
969
+ }
970
+ case "audio": {
971
+ const content = resolveContent(data);
972
+ const recognition = content?.recognition || data?.audio?.recognition || "[语音消息]";
973
+ const audioDownloadCode = content?.downloadCode || "";
974
+ const audioFileName = content?.fileName || "audio.amr";
975
+ const downloadCodes = [];
976
+ const fileNames = [];
977
+ if (audioDownloadCode) {
978
+ downloadCodes.push(audioDownloadCode);
979
+ fileNames.push(audioFileName);
980
+ }
981
+ return {
982
+ text: recognition,
983
+ messageType: "audio",
984
+ imageUrls: [],
985
+ downloadCodes,
986
+ fileNames,
987
+ atDingtalkIds: [],
988
+ atMobiles: []
989
+ };
990
+ }
991
+ case "video": {
992
+ const content = resolveContent(data);
993
+ const videoDownloadCode = content?.downloadCode || "";
994
+ const videoFileName = content?.fileName || "video.mp4";
995
+ const downloadCodes = [];
996
+ const fileNames = [];
997
+ if (videoDownloadCode) {
998
+ downloadCodes.push(videoDownloadCode);
999
+ fileNames.push(videoFileName);
1000
+ }
1001
+ return {
1002
+ text: "[视频]",
1003
+ messageType: "video",
1004
+ imageUrls: [],
1005
+ downloadCodes,
1006
+ fileNames,
1007
+ atDingtalkIds: [],
1008
+ atMobiles: []
1009
+ };
1010
+ }
1011
+ case "file": {
1012
+ const content = resolveContent(data);
1013
+ const fileName = content?.fileName || data?.file?.fileName || "文件";
1014
+ const downloadCode = content?.downloadCode || "";
1015
+ const downloadCodes = [];
1016
+ const fileNames = [];
1017
+ if (downloadCode) {
1018
+ downloadCodes.push(downloadCode);
1019
+ fileNames.push(fileName);
1020
+ }
1021
+ return {
1022
+ text: `[文件: ${fileName}]`,
1023
+ messageType: "file",
1024
+ imageUrls: [],
1025
+ downloadCodes,
1026
+ fileNames,
1027
+ atDingtalkIds: [],
1028
+ atMobiles: []
1029
+ };
1030
+ }
1031
+ case "markdown": return {
1032
+ text: data.text?.content?.trim() || resolveContent(data)?.text?.trim() || "[markdown消息]",
1033
+ messageType: "markdown",
1034
+ imageUrls: [],
1035
+ downloadCodes: [],
1036
+ fileNames: [],
1037
+ atDingtalkIds: [],
1038
+ atMobiles: []
1039
+ };
1040
+ case "actionCard": {
1041
+ const content = resolveContent(data);
1042
+ const title = content?.title?.trim() || "";
1043
+ const body = content?.text?.trim() || "";
1044
+ const actionUrls = (content?.actionUrlItemList || []).map((item) => item.actionUrl?.trim()).filter((url) => !!url);
1045
+ const sections = [];
1046
+ if (title) sections.push(title);
1047
+ if (body) sections.push(body);
1048
+ if (actionUrls.length > 0) {
1049
+ const linkSection = actionUrls.length === 1 ? `操作链接:${actionUrls[0]}` : `操作链接:\n- ${actionUrls.join("\n- ")}`;
1050
+ sections.push(linkSection);
1051
+ }
1052
+ return {
1053
+ text: sections.length > 0 ? sections.join("\n\n") : "[actionCard消息]",
1054
+ messageType: "actionCard",
1055
+ imageUrls: [],
1056
+ downloadCodes: [],
1057
+ fileNames: [],
1058
+ atDingtalkIds: [],
1059
+ atMobiles: [],
1060
+ actionCardUrl: actionUrls.length === 1 ? actionUrls[0] : void 0
1061
+ };
1062
+ }
1063
+ case "interactiveCard": {
1064
+ const interactiveCardUrl = (resolveContent(data)?.biz_custom_action_url || data?.biz_custom_action_url || "").trim() || void 0;
1065
+ if (interactiveCardUrl) return {
1066
+ text: `收到交互式卡片链接:${interactiveCardUrl}`,
1067
+ messageType: "interactiveCard",
1068
+ imageUrls: [],
1069
+ downloadCodes: [],
1070
+ fileNames: [],
1071
+ atDingtalkIds: [],
1072
+ atMobiles: [],
1073
+ interactiveCardUrl
1074
+ };
1075
+ return {
1076
+ text: "[interactiveCard消息]",
1077
+ messageType: "interactiveCard",
1078
+ imageUrls: [],
1079
+ downloadCodes: [],
1080
+ fileNames: [],
1081
+ atDingtalkIds: [],
1082
+ atMobiles: []
1083
+ };
1084
+ }
1085
+ case "reply": {
1086
+ const replyContainer = data.text || resolveContent(data);
1087
+ const bodyText = data.text?.content?.trim() || "";
1088
+ const quotedText = extractQuotedMsgText(replyContainer, 3);
1089
+ const text = quotedText ? `${bodyText}\n${quotedText}` : bodyText || "[引用消息]";
1090
+ const { imageUrls, downloadCodes, fileNames } = extractRepliedMsgMediaAttachments(data.text?.repliedMsg || resolveContent(data)?.repliedMsg);
1091
+ return {
1092
+ text,
1093
+ messageType: "reply",
1094
+ imageUrls,
1095
+ downloadCodes,
1096
+ fileNames,
1097
+ atDingtalkIds: [],
1098
+ atMobiles: []
1099
+ };
1100
+ }
1101
+ default: return {
1102
+ text: data.text?.content?.trim() || `[${msgtype}消息]`,
1103
+ messageType: msgtype,
1104
+ imageUrls: [],
1105
+ downloadCodes: [],
1106
+ fileNames: [],
1107
+ atDingtalkIds: [],
1108
+ atMobiles: []
1109
+ };
1110
+ }
1111
+ }
1112
+ /**
1113
+ * 从文本内容中提取第一个 HTTP/HTTPS URL。
1114
+ * 用于处理引用消息文本里直接粘贴链接的场景(如引用一条含 alidocs 链接的文本消息)。
1115
+ */
1116
+ function extractFirstUrlFromText(text) {
1117
+ const urlMatch = text.match(/https?:\/\/[^\s\u3000\u3001\uff0c\u3002\uff01\uff1f"'<>]+/);
1118
+ return urlMatch ? urlMatch[0].trim() : null;
1119
+ }
1120
+ /**
1121
+ * 根据消息中的 interactiveCardUrl / actionCardUrl 构建链接路由 system prompt。
1122
+ * 对齐 Rust agent_support.rs 的 build_link_routing_prompt 逻辑:
1123
+ * - alidocs.dingtalk.com → 使用 dws skill 的 doc 能力读取
1124
+ * - 其他 URL → 使用 read_url 读取
1125
+ * 返回 null 表示无需注入额外 prompt。
1126
+ */
1127
+ function buildLinkRoutingPrompt(content) {
1128
+ const interactiveCardUrl = content.interactiveCardUrl?.trim();
1129
+ const actionCardUrl = content.actionCardUrl?.trim();
1130
+ const linkUrl = interactiveCardUrl || actionCardUrl;
1131
+ if (!linkUrl) return null;
1132
+ const cardKind = interactiveCardUrl ? "interactive card" : "action card";
1133
+ let host = null;
1134
+ try {
1135
+ host = new URL(linkUrl).hostname;
1136
+ } catch {}
1137
+ if (host === "alidocs.dingtalk.com") return [
1138
+ `The inbound DingTalk message is an ${cardKind} with a document link.`,
1139
+ `Linked URL: ${linkUrl}`,
1140
+ `This URL is hosted on \`alidocs.dingtalk.com\`.`,
1141
+ `You MUST inspect and summarize it via the \`dws\` skill using its \`doc\` product capability.`,
1142
+ `If \`dws\` is not already visible in the skill snapshot, call \`search_skills\` to locate it, then call \`use_skill\` with the exact id.`,
1143
+ `Never switch to browser-based reading for this link. Browser incompatibility or markdown export limitations are not final answers.`,
1144
+ `Do not use \`read_url\` for this link.`,
1145
+ `Reply to the DingTalk user with a concise summary of the linked document content.`
1146
+ ].join("\n");
1147
+ return [
1148
+ `The inbound DingTalk message is an ${cardKind} with a link.`,
1149
+ `Linked URL: ${linkUrl}`,
1150
+ `For this URL, you MUST use \`read_url\` to inspect the linked content before answering.`,
1151
+ `Do not use the \`dws\` skill for this link.`,
1152
+ `Reply to the DingTalk user with a concise summary of the linked content.`
1153
+ ].join("\n");
1154
+ }
1155
+ async function downloadImageToFile(downloadUrl, agentWorkspaceDir, log) {
1156
+ try {
1157
+ log?.info?.(`开始下载图片: ${downloadUrl.slice(0, 100)}...`);
1158
+ const resp = await dingtalkHttp.get(downloadUrl, {
1159
+ proxy: false,
1160
+ headers: { "Content-Type": void 0 },
1161
+ responseType: "arraybuffer",
1162
+ timeout: 3e4
1163
+ });
1164
+ const buffer = Buffer.from(resp.data);
1165
+ const contentType = resp.headers["content-type"] || "image/jpeg";
1166
+ const ext = contentType.includes("png") ? ".png" : contentType.includes("gif") ? ".gif" : contentType.includes("webp") ? ".webp" : ".jpg";
1167
+ const mediaDir = path.join(agentWorkspaceDir, "media", "inbound");
1168
+ fs.mkdirSync(mediaDir, { recursive: true });
1169
+ const tmpFile = path.join(mediaDir, `openclaw-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
1170
+ fs.writeFileSync(tmpFile, buffer);
1171
+ log?.info?.(`图片下载成功: size=${buffer.length} bytes, type=${contentType}, path=${tmpFile}`);
1172
+ return tmpFile;
1173
+ } catch (err) {
1174
+ log?.error?.(`图片下载失败: ${err.message}`);
1175
+ return null;
1176
+ }
1177
+ }
1178
+ async function downloadMediaByCode(downloadCode, config, agentWorkspaceDir, log) {
1179
+ try {
1180
+ const token = await getAccessToken(config);
1181
+ log?.info?.(`通过 downloadCode 下载媒体: ${downloadCode.slice(0, 30)}...`);
1182
+ const resp = await dingtalkHttp.post(`${DINGTALK_API}/v1.0/robot/messageFiles/download`, {
1183
+ downloadCode,
1184
+ robotCode: String(config.clientId)
1185
+ }, {
1186
+ headers: {
1187
+ "x-acs-dingtalk-access-token": token,
1188
+ "Content-Type": "application/json"
1189
+ },
1190
+ timeout: 3e4
1191
+ });
1192
+ const downloadUrl = resp.data?.downloadUrl;
1193
+ if (!downloadUrl) {
1194
+ log?.warn?.(`downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`);
1195
+ return null;
1196
+ }
1197
+ return downloadImageToFile(downloadUrl, agentWorkspaceDir, log);
1198
+ } catch (err) {
1199
+ log?.error?.(`downloadCode 下载失败: ${err.message}`);
1200
+ return null;
1201
+ }
1202
+ }
1203
+ async function getFileDownloadUrl(downloadCode, fileName, config, log) {
1204
+ try {
1205
+ const token = await getAccessToken(config);
1206
+ log?.info?.(`获取文件下载链接: ${fileName}`);
1207
+ const resp = await dingtalkHttp.post(`${DINGTALK_API}/v1.0/robot/messageFiles/download`, {
1208
+ downloadCode,
1209
+ robotCode: String(config.clientId)
1210
+ }, {
1211
+ headers: {
1212
+ "x-acs-dingtalk-access-token": token,
1213
+ "Content-Type": "application/json"
1214
+ },
1215
+ timeout: 3e4
1216
+ });
1217
+ const downloadUrl = resp.data?.downloadUrl;
1218
+ if (!downloadUrl) {
1219
+ log?.warn?.(`downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`);
1220
+ return null;
1221
+ }
1222
+ log?.info?.(`获取下载链接成功: ${fileName}`);
1223
+ return downloadUrl;
1224
+ } catch (err) {
1225
+ log?.error?.(`获取下载链接失败: ${err.message}`);
1226
+ return null;
1227
+ }
1228
+ }
1229
+ /**
1230
+ * 下载文件到本地
1231
+ */
1232
+ async function downloadFileToLocal(downloadUrl, fileName, agentWorkspaceDir, log) {
1233
+ try {
1234
+ log?.info?.(`开始下载文件: ${fileName}`);
1235
+ const resp = await dingtalkHttp.get(downloadUrl, {
1236
+ proxy: false,
1237
+ headers: { "Content-Type": void 0 },
1238
+ responseType: "arraybuffer",
1239
+ timeout: 6e4
1240
+ });
1241
+ const buffer = Buffer.from(resp.data);
1242
+ const mediaDir = path.join(agentWorkspaceDir, "media", "inbound");
1243
+ fs.mkdirSync(mediaDir, { recursive: true });
1244
+ const sanitizeFileName = (name) => {
1245
+ let safe = name.replace(/[/\\]/g, "_");
1246
+ safe = safe.replace(/[<>:"|?*\x00-\x1f]/g, "_");
1247
+ safe = safe.replace(/^\.+/, "");
1248
+ if (safe.length > 200) {
1249
+ const ext = path.extname(safe);
1250
+ safe = path.basename(safe, ext).substring(0, 200 - ext.length) + ext;
1251
+ }
1252
+ if (!safe) safe = "unnamed_file";
1253
+ return safe;
1254
+ };
1255
+ const ext = path.extname(fileName);
1256
+ const baseName = path.basename(fileName, ext);
1257
+ const timestamp = Date.now();
1258
+ const safeFileName = `${sanitizeFileName(baseName)}-${timestamp}${ext}`;
1259
+ const localPath = path.join(mediaDir, safeFileName);
1260
+ fs.writeFileSync(localPath, buffer);
1261
+ log?.info?.(`文件下载成功: ${fileName}, size=${buffer.length} bytes, path=${localPath}`);
1262
+ return localPath;
1263
+ } catch (err) {
1264
+ log?.error?.(`downloadFileToLocal 异常: ${err.message}\n${err.stack}`);
1265
+ return null;
1266
+ }
1267
+ }
1268
+ /**
1269
+ * 解析 Word 文档 (.docx)
1270
+ */
1271
+ async function parseDocxFile(filePath, log) {
1272
+ try {
1273
+ log?.info?.(`开始解析 Word 文档: ${filePath}`);
1274
+ let mammoth;
1275
+ try {
1276
+ mammoth = (await import("mammoth")).default;
1277
+ } catch {
1278
+ log?.warn?.("mammoth 库未安装,无法解析 .docx 文件。请运行: npm install mammoth");
1279
+ return null;
1280
+ }
1281
+ const buffer = fs.readFileSync(filePath);
1282
+ const text = (await mammoth.extractRawText({ buffer })).value.trim();
1283
+ if (text) {
1284
+ log?.info?.(`Word 文档解析成功: ${filePath}, 文本长度=${text.length}`);
1285
+ return text;
1286
+ } else {
1287
+ log?.warn?.(`Word 文档解析结果为空: ${filePath}`);
1288
+ return null;
1289
+ }
1290
+ } catch (err) {
1291
+ log?.error?.(`Word 文档解析失败: ${filePath}, error=${err.message}`);
1292
+ return null;
1293
+ }
1294
+ }
1295
+ /**
1296
+ * 解析 PDF 文档
1297
+ */
1298
+ async function parsePdfFile(filePath, log) {
1299
+ try {
1300
+ log?.info?.(`开始解析 PDF 文档: ${filePath}`);
1301
+ let pdfParseV1;
1302
+ let pdfParseV2;
1303
+ try {
1304
+ const mod = await import("pdf-parse");
1305
+ if (mod.PDFParse) pdfParseV2 = mod.PDFParse;
1306
+ else if (mod.default) pdfParseV1 = mod.default;
1307
+ else throw new Error("pdf-parse module format not recognized");
1308
+ } catch {
1309
+ log?.warn?.("pdf-parse 库未安装,无法解析 .pdf 文件。请运行: npm install pdf-parse");
1310
+ return null;
1311
+ }
1312
+ const buffer = fs.readFileSync(filePath);
1313
+ let text;
1314
+ let numPages;
1315
+ if (pdfParseV2) {
1316
+ const parser = new pdfParseV2({ data: buffer });
1317
+ const result = await parser.getText();
1318
+ text = (result.text ?? "").trim();
1319
+ numPages = result.total;
1320
+ parser.destroy?.();
1321
+ } else {
1322
+ const data = await pdfParseV1(buffer);
1323
+ text = (data.text ?? "").trim();
1324
+ numPages = data.numpages;
1325
+ }
1326
+ if (text) {
1327
+ log?.info?.(`PDF 文档解析成功: ${filePath}, 文本长度=${text.length}, 页数=${numPages}`);
1328
+ return text;
1329
+ } else {
1330
+ log?.warn?.(`PDF 文档解析结果为空: ${filePath}`);
1331
+ return null;
1332
+ }
1333
+ } catch (err) {
1334
+ log?.error?.(`PDF 文档解析失败: ${filePath}, error=${err.message}`);
1335
+ return null;
1336
+ }
1337
+ }
1338
+ /**
1339
+ * 读取纯文本文件
1340
+ */
1341
+ async function readTextFile(filePath, log) {
1342
+ try {
1343
+ log?.info?.(`开始读取文本文件: ${filePath}`);
1344
+ const text = fs.readFileSync(filePath, "utf-8").trim();
1345
+ if (text) {
1346
+ log?.info?.(`文本文件读取成功: ${filePath}, 文本长度=${text.length}`);
1347
+ return text;
1348
+ } else {
1349
+ log?.warn?.(`文本文件内容为空: ${filePath}`);
1350
+ return null;
1351
+ }
1352
+ } catch (err) {
1353
+ log?.error?.(`文本文件读取失败: ${filePath}, error=${err.message}`);
1354
+ return null;
1355
+ }
1356
+ }
1357
+ /**
1358
+ * 根据文件类型解析文件内容
1359
+ */
1360
+ async function parseFileContent(filePath, fileName, log) {
1361
+ const ext = path.extname(fileName).toLowerCase();
1362
+ if ([".docx", ".doc"].includes(ext)) return {
1363
+ content: await parseDocxFile(filePath, log),
1364
+ type: "text"
1365
+ };
1366
+ if (ext === ".pdf") return {
1367
+ content: await parsePdfFile(filePath, log),
1368
+ type: "text"
1369
+ };
1370
+ if ([
1371
+ ".txt",
1372
+ ".md",
1373
+ ".json",
1374
+ ".xml",
1375
+ ".yaml",
1376
+ ".yml",
1377
+ ".csv",
1378
+ ".log",
1379
+ ".js",
1380
+ ".ts",
1381
+ ".py",
1382
+ ".java",
1383
+ ".c",
1384
+ ".cpp",
1385
+ ".h",
1386
+ ".sh",
1387
+ ".bat"
1388
+ ].includes(ext)) return {
1389
+ content: await readTextFile(filePath, log),
1390
+ type: "text"
1391
+ };
1392
+ return {
1393
+ content: null,
1394
+ type: "binary"
1395
+ };
1396
+ }
1397
+ /**
1398
+ * 内部消息处理函数(实际执行消息处理逻辑)
1399
+ */
1400
+ async function handleDingTalkMessageInternal(params) {
1401
+ const { accountId, config, data, sessionWebhook, runtime, cfg } = params;
1402
+ const log = createLoggerFromConfig(config, `DingTalk:${accountId}`);
1403
+ const content = extractMessageContent(data);
1404
+ if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return;
1405
+ const isDirect = data.conversationType === "1";
1406
+ const senderId = data.senderStaffId || data.senderId;
1407
+ const senderName = data.senderNick || "Unknown";
1408
+ if (isDirect) {
1409
+ const dmPolicy = config.dmPolicy || "open";
1410
+ const allowFrom = config.allowFrom || [];
1411
+ if (dmPolicy === "pairing") log?.warn?.(`dmPolicy="pairing" 暂不支持,将按 "open" 策略处理`);
1412
+ if (dmPolicy === "allowlist") {
1413
+ if (!senderId) {
1414
+ log?.warn?.(`DM 被拦截: senderId 为空`);
1415
+ return;
1416
+ }
1417
+ const normalizedSenderId = String(senderId);
1418
+ const normalizedAllowFrom = allowFrom.map((id) => String(id));
1419
+ if (normalizedAllowFrom.length === 0) {
1420
+ log?.warn?.(`[DingTalk] DM 被拦截: allowFrom 白名单为空,拒绝所有请求`);
1421
+ try {
1422
+ await sendProactive(config, { userId: senderId }, "抱歉,此机器人的访问白名单配置有误。请联系管理员检查配置。", {
1423
+ msgType: "text",
1424
+ useAICard: false,
1425
+ fallbackToNormal: true,
1426
+ log
1427
+ });
1428
+ } catch (err) {
1429
+ log?.error?.(`[DingTalk] 发送 DM 配置错误提示失败: ${err.message}`);
1430
+ }
1431
+ return;
1432
+ }
1433
+ if (!normalizedAllowFrom.includes(normalizedSenderId)) {
1434
+ log?.warn?.(`DM 被拦截: senderId=${senderId} (${senderName}) 不在白名单中`);
1435
+ try {
1436
+ await sendProactive(config, { userId: senderId }, "抱歉,您暂无权限使用此机器人。如需开通权限,请联系管理员。", {
1437
+ msgType: "text",
1438
+ useAICard: false,
1439
+ fallbackToNormal: true,
1440
+ log
1441
+ });
1442
+ } catch (err) {
1443
+ log?.error?.(`发送 DM 拦截提示失败: ${err.message}`);
1444
+ }
1445
+ return;
1446
+ }
1447
+ }
1448
+ }
1449
+ if (!isDirect) {
1450
+ const groupPolicy = config.groupPolicy || "open";
1451
+ const conversationId = data.conversationId;
1452
+ const groupAllowFrom = config.groupAllowFrom || [];
1453
+ if (groupPolicy === "disabled") {
1454
+ log?.warn?.(`群聊被拦截: groupPolicy=disabled`);
1455
+ try {
1456
+ await sendProactive(config, { openConversationId: conversationId }, "抱歉,此机器人暂不支持群聊功能。", {
1457
+ msgType: "text",
1458
+ useAICard: false,
1459
+ fallbackToNormal: true,
1460
+ log
1461
+ });
1462
+ } catch (err) {
1463
+ log?.error?.(`发送群聊 disabled 提示失败: ${err.message}`);
1464
+ }
1465
+ return;
1466
+ }
1467
+ if (groupPolicy === "allowlist") {
1468
+ if (!conversationId) {
1469
+ log?.warn?.(`群聊被拦截: conversationId 为空`);
1470
+ return;
1471
+ }
1472
+ const normalizedConversationId = String(conversationId);
1473
+ const normalizedGroupAllowFrom = groupAllowFrom.map((id) => String(id));
1474
+ if (normalizedGroupAllowFrom.length === 0) {
1475
+ log?.warn?.(`群聊被拦截: groupAllowFrom 白名单为空,拒绝所有请求`);
1476
+ try {
1477
+ await sendProactive(config, { openConversationId: conversationId }, "抱歉,此机器人的群组访问白名单配置有误。请联系管理员检查配置。", {
1478
+ msgType: "text",
1479
+ useAICard: false,
1480
+ fallbackToNormal: true,
1481
+ log
1482
+ });
1483
+ } catch (err) {
1484
+ log?.error?.(`发送群聊配置错误提示失败: ${err.message}`);
1485
+ }
1486
+ return;
1487
+ }
1488
+ if (!normalizedGroupAllowFrom.includes(normalizedConversationId)) {
1489
+ log?.warn?.(`群聊被拦截: conversationId=${conversationId} 不在 groupAllowFrom 白名单中`);
1490
+ try {
1491
+ await sendProactive(config, { openConversationId: conversationId }, "抱歉,此群组暂无权限使用此机器人。如需开通权限,请联系管理员。", {
1492
+ msgType: "text",
1493
+ useAICard: false,
1494
+ fallbackToNormal: true,
1495
+ log
1496
+ });
1497
+ } catch (err) {
1498
+ log?.error?.(`发送群聊 allowlist 提示失败: ${err.message}`);
1499
+ }
1500
+ return;
1501
+ }
1502
+ }
1503
+ }
1504
+ const sessionContext = buildSessionContext({
1505
+ accountId,
1506
+ senderId,
1507
+ senderName,
1508
+ conversationType: data.conversationType,
1509
+ conversationId: data.conversationId,
1510
+ groupSubject: data.conversationTitle,
1511
+ separateSessionByConversation: config.separateSessionByConversation,
1512
+ groupSessionScope: config.groupSessionScope,
1513
+ sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations
1514
+ });
1515
+ let matchedAgentId = null;
1516
+ if (cfg.bindings && cfg.bindings.length > 0) for (const binding of cfg.bindings) {
1517
+ const match = binding.match;
1518
+ if (match.channel && match.channel !== "dingtalk-connector") continue;
1519
+ if (match.accountId && match.accountId !== accountId) continue;
1520
+ if (match.peer) {
1521
+ if (match.peer.kind && match.peer.kind !== sessionContext.chatType) continue;
1522
+ if (match.peer.id && match.peer.id !== "*" && match.peer.id !== sessionContext.peerId) continue;
1523
+ }
1524
+ matchedAgentId = binding.agentId;
1525
+ break;
1526
+ }
1527
+ if (!matchedAgentId) matchedAgentId = cfg.defaultAgent || "main";
1528
+ const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, matchedAgentId);
1529
+ log?.info?.(`Agent 工作空间路径: ${agentWorkspaceDir}`);
1530
+ const rawText = content.text || "";
1531
+ let userContent = normalizeSlashCommand(rawText) || (content.imageUrls.length > 0 ? "请描述这张图片" : "");
1532
+ try {
1533
+ const { GamificationEngine, isGamificationCommand } = await import("./game-xiyou-CqHt-6Q1.mjs");
1534
+ if (isGamificationCommand(rawText)) {
1535
+ const engine = GamificationEngine.getInstanceForUser(senderId);
1536
+ if (rawText.trim().startsWith("/西游") || engine.isEnabled()) {
1537
+ const response = engine.handleCommand(rawText);
1538
+ if (response) {
1539
+ log?.info?.(`[DingTalk][Gamification] 处理养成系统命令: ${rawText.slice(0, 20)}`);
1540
+ await sendProactive(config, isDirect ? { userId: senderId } : { openConversationId: data.conversationId }, response, {
1541
+ useAICard: true,
1542
+ fallbackToNormal: true,
1543
+ log
1544
+ });
1545
+ return;
1546
+ }
1547
+ }
1548
+ }
1549
+ } catch (gamErr) {
1550
+ log?.warn?.(`[DingTalk][Gamification] 命令处理失败: ${gamErr?.message || gamErr}`);
1551
+ }
1552
+ const imageLocalPaths = [];
1553
+ log?.info?.(`处理消息: accountId=${accountId}, data= ${JSON.stringify(data, null, 2)}, sender=${senderName}, text=${content.text.slice(0, 50)}...`);
1554
+ for (let i = 0; i < content.imageUrls.length; i++) {
1555
+ const url = content.imageUrls[i];
1556
+ try {
1557
+ log?.info?.(`处理图片 ${i + 1}/${content.imageUrls.length}: ${url.slice(0, 50)}...`);
1558
+ if (url.startsWith("downloadCode:")) {
1559
+ const localPath = await downloadMediaByCode(url.slice(13), config, agentWorkspaceDir, log);
1560
+ if (localPath) {
1561
+ imageLocalPaths.push(localPath);
1562
+ log?.info?.(`图片下载成功 ${i + 1}/${content.imageUrls.length}`);
1563
+ } else log?.warn?.(`图片下载失败 ${i + 1}/${content.imageUrls.length}`);
1564
+ } else {
1565
+ const localPath = await downloadImageToFile(url, agentWorkspaceDir, log);
1566
+ if (localPath) {
1567
+ imageLocalPaths.push(localPath);
1568
+ log?.info?.(`图片下载成功 ${i + 1}/${content.imageUrls.length}`);
1569
+ } else log?.warn?.(`图片下载失败 ${i + 1}/${content.imageUrls.length}`);
1570
+ }
1571
+ } catch (err) {
1572
+ log?.error?.(`图片下载异常 ${i + 1}/${content.imageUrls.length}: ${err.message}`);
1573
+ }
1574
+ }
1575
+ for (let i = 0; i < content.downloadCodes.length; i++) {
1576
+ const code = content.downloadCodes[i];
1577
+ if (!content.fileNames[i]) try {
1578
+ log?.info?.(`处理 downloadCode 图片 ${i + 1}/${content.downloadCodes.length}`);
1579
+ const localPath = await downloadMediaByCode(code, config, agentWorkspaceDir, log);
1580
+ if (localPath) {
1581
+ imageLocalPaths.push(localPath);
1582
+ log?.info?.(`downloadCode 图片下载成功 ${i + 1}/${content.downloadCodes.length}`);
1583
+ } else log?.warn?.(`downloadCode 图片下载失败 ${i + 1}/${content.downloadCodes.length}`);
1584
+ } catch (err) {
1585
+ log?.error?.(`downloadCode 图片下载异常 ${i + 1}/${content.downloadCodes.length}: ${err.message}`);
1586
+ }
1587
+ }
1588
+ log?.info?.(`图片下载完成: 成功=${imageLocalPaths.length}, 总数=${content.imageUrls.length + content.downloadCodes.filter((_, i) => !content.fileNames[i]).length}`);
1589
+ const fileContentParts = [];
1590
+ for (let i = 0; i < content.downloadCodes.length; i++) {
1591
+ const code = content.downloadCodes[i];
1592
+ const fileName = content.fileNames[i];
1593
+ if (!fileName) continue;
1594
+ try {
1595
+ log?.info?.(`处理文件附件 ${i + 1}/${content.downloadCodes.length}: ${fileName}`);
1596
+ const downloadUrl = await getFileDownloadUrl(code, fileName, config, log);
1597
+ if (!downloadUrl) {
1598
+ fileContentParts.push(`⚠️ 文件获取失败: ${fileName}`);
1599
+ continue;
1600
+ }
1601
+ const localPath = await downloadFileToLocal(downloadUrl, fileName, agentWorkspaceDir, log);
1602
+ if (!localPath) {
1603
+ fileContentParts.push(`⚠️ 文件下载失败: ${fileName}\n🔗 [点击下载](${downloadUrl})`);
1604
+ continue;
1605
+ }
1606
+ const ext = path.extname(fileName).toLowerCase();
1607
+ let fileType = "文件";
1608
+ if ([
1609
+ ".mp4",
1610
+ ".avi",
1611
+ ".mov",
1612
+ ".mkv",
1613
+ ".flv",
1614
+ ".wmv",
1615
+ ".webm"
1616
+ ].includes(ext)) fileType = "视频";
1617
+ else if ([
1618
+ ".mp3",
1619
+ ".wav",
1620
+ ".aac",
1621
+ ".ogg",
1622
+ ".m4a",
1623
+ ".flac",
1624
+ ".wma"
1625
+ ].includes(ext)) fileType = "音频";
1626
+ else if ([
1627
+ ".jpg",
1628
+ ".jpeg",
1629
+ ".png",
1630
+ ".gif",
1631
+ ".bmp",
1632
+ ".webp"
1633
+ ].includes(ext)) fileType = "图片";
1634
+ else if ([
1635
+ ".txt",
1636
+ ".md",
1637
+ ".json",
1638
+ ".xml",
1639
+ ".yaml",
1640
+ ".yml",
1641
+ ".csv",
1642
+ ".log",
1643
+ ".js",
1644
+ ".ts",
1645
+ ".py",
1646
+ ".java",
1647
+ ".c",
1648
+ ".cpp",
1649
+ ".h",
1650
+ ".sh",
1651
+ ".bat"
1652
+ ].includes(ext)) fileType = "文本文件";
1653
+ else if ([".docx", ".doc"].includes(ext)) fileType = "Word 文档";
1654
+ else if (ext === ".pdf") fileType = "PDF 文档";
1655
+ else if ([".xlsx", ".xls"].includes(ext)) fileType = "Excel 表格";
1656
+ else if ([".pptx", ".ppt"].includes(ext)) fileType = "PPT 演示文稿";
1657
+ else if ([
1658
+ ".zip",
1659
+ ".rar",
1660
+ ".7z",
1661
+ ".tar",
1662
+ ".gz"
1663
+ ].includes(ext)) fileType = "压缩包";
1664
+ const parseResult = await parseFileContent(localPath, fileName, log);
1665
+ if (parseResult.type === "text" && parseResult.content) {
1666
+ const contentPreview = parseResult.content.length > 200 ? parseResult.content.slice(0, 200) + "..." : parseResult.content;
1667
+ fileContentParts.push(`📄 **${fileType}**: ${fileName}\n✅ 已解析文件内容(${parseResult.content.length} 字符)\n💾 已保存到本地: ${localPath}\n📝 内容预览:\n\`\`\`\n${contentPreview}\n\`\`\`\n\n📋 完整内容:\n${parseResult.content}`);
1668
+ log?.info?.(`文件解析成功: ${fileName}, 内容长度=${parseResult.content.length}`);
1669
+ } else if (parseResult.type === "text" && !parseResult.content) {
1670
+ fileContentParts.push(`📄 **${fileType}**: ${fileName}\n⚠️ 文件解析失败,已保存到本地\n💾 本地路径: ${localPath}\n🔗 [点击下载](${downloadUrl})`);
1671
+ log?.warn?.(`文件解析失败: ${fileName}`);
1672
+ } else {
1673
+ if (fileType === "音频" && content.text && content.text !== "[语音消息]") fileContentParts.push(`🎤 **${fileType}**: ${fileName}\n📝 语音识别: ${content.text}\n💾 已保存到本地: ${localPath}\n🔗 [点击下载](${downloadUrl})`);
1674
+ else fileContentParts.push(`📎 **${fileType}**: ${fileName}\n💾 已保存到本地: ${localPath}\n🔗 [点击下载](${downloadUrl})`);
1675
+ log?.info?.(`二进制文件已保存: ${fileName}, path=${localPath}`);
1676
+ }
1677
+ } catch (err) {
1678
+ log?.error?.(`文件处理异常: ${fileName}, error=${err.message}`);
1679
+ fileContentParts.push(`⚠️ 文件处理失败: ${fileName}`);
1680
+ }
1681
+ }
1682
+ if (fileContentParts.length > 0) {
1683
+ const fileText = fileContentParts.join("\n\n");
1684
+ userContent = userContent ? `${userContent}\n\n${fileText}` : fileText;
1685
+ }
1686
+ if (!userContent && imageLocalPaths.length === 0) return;
1687
+ if (!params.emotionAlreadyAdded) addEmotionReply(config, data, log).catch((err) => {
1688
+ log?.warn?.(`贴表情失败: ${err.message}`);
1689
+ });
1690
+ const asyncMode = config.asyncMode === true;
1691
+ log?.info?.(`asyncMode 检测: config.asyncMode=${config.asyncMode}, asyncMode=${asyncMode}`);
1692
+ const proactiveTarget = isDirect ? { userId: senderId } : { openConversationId: data.conversationId };
1693
+ if (asyncMode) {
1694
+ log?.info?.(`进入异步模式分支`);
1695
+ const ackText = config.ackText || "🫡 任务已接收,处理中...";
1696
+ try {
1697
+ await sendProactive(config, proactiveTarget, ackText, {
1698
+ msgType: "text",
1699
+ useAICard: false,
1700
+ fallbackToNormal: true,
1701
+ log
1702
+ });
1703
+ } catch (ackErr) {
1704
+ log?.warn?.(`Failed to send acknowledgment: ${ackErr?.message || ackErr}`);
1705
+ }
1706
+ }
1707
+ try {
1708
+ const core = getDingtalkRuntime();
1709
+ let finalContent = userContent;
1710
+ if (imageLocalPaths.length > 0) {
1711
+ const imageMarkdown = imageLocalPaths.map((p) => `![image](file://${p})`).join("\n");
1712
+ finalContent = finalContent ? `${finalContent}\n\n${imageMarkdown}` : imageMarkdown;
1713
+ }
1714
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1715
+ const envelopeFrom = isDirect ? senderId : `${data.conversationId}:${senderId}`;
1716
+ const body = core.channel.reply.formatAgentEnvelope({
1717
+ channel: "DingTalk",
1718
+ from: envelopeFrom,
1719
+ timestamp: /* @__PURE__ */ new Date(),
1720
+ envelope: envelopeOptions,
1721
+ body: finalContent
1722
+ });
1723
+ const matchedBy = matchedAgentId !== (cfg.defaultAgent || "main") ? "binding" : "default";
1724
+ const dmScope = cfg.session?.dmScope || "per-channel-peer";
1725
+ log?.info?.(`🔍 构建 sessionKey 前的参数: agentId=${matchedAgentId}, channel=dingtalk-connector, accountId=${accountId}, chatType=${sessionContext.chatType}, sessionPeerId=${sessionContext.sessionPeerId}, dmScope=${dmScope}`);
1726
+ const sessionKey = core.channel.routing.buildAgentSessionKey({
1727
+ agentId: matchedAgentId,
1728
+ channel: "dingtalk-connector",
1729
+ accountId,
1730
+ peer: {
1731
+ kind: sessionContext.chatType,
1732
+ id: sessionContext.sessionPeerId
1733
+ },
1734
+ dmScope
1735
+ });
1736
+ log?.info?.(`路由解析完成: agentId=${matchedAgentId}, sessionKey=${sessionKey}, matchedBy=${matchedBy}`);
1737
+ log?.info?.(`开始构建 inbound context...`);
1738
+ const toField = isDirect ? senderId : data.conversationId;
1739
+ log?.info?.(`构建 inbound context: isDirect=${isDirect}, senderId=${senderId}, conversationId=${data.conversationId}, To=${toField}`);
1740
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
1741
+ Body: body,
1742
+ BodyForAgent: finalContent,
1743
+ RawBody: userContent,
1744
+ CommandBody: userContent,
1745
+ From: senderId,
1746
+ To: toField,
1747
+ SessionKey: sessionKey,
1748
+ AccountId: accountId,
1749
+ ChatType: sessionContext.chatType,
1750
+ GroupSubject: isDirect ? void 0 : data.conversationTitle,
1751
+ SenderName: senderName,
1752
+ SenderId: senderId,
1753
+ Provider: "dingtalk-connector",
1754
+ Surface: "dingtalk-connector",
1755
+ MessageSid: data.msgId,
1756
+ Timestamp: Date.now(),
1757
+ CommandAuthorized: true,
1758
+ OriginatingChannel: "dingtalk-connector",
1759
+ OriginatingTo: toField,
1760
+ BotChatbotUserId: data.chatbotUserId,
1761
+ BotChatbotCorpId: data.chatbotCorpId
1762
+ });
1763
+ const { dispatcher, replyOptions, markDispatchIdle, getAsyncModeResponse } = createDingtalkReplyDispatcher({
1764
+ cfg,
1765
+ agentId: matchedAgentId,
1766
+ runtime,
1767
+ conversationId: data.conversationId,
1768
+ senderId,
1769
+ isDirect,
1770
+ accountId,
1771
+ messageCreateTimeMs: Date.now(),
1772
+ sessionWebhook: data.sessionWebhook,
1773
+ asyncMode,
1774
+ preCreatedCard: params.preCreatedCard
1775
+ });
1776
+ if (config.clientId) {
1777
+ const botIdentityHint = `[DingTalk Bot Context] Current bot clientId: ${String(config.clientId)}. When executing \`dws chat message send-by-bot\`, always pass \`--client-id ${String(config.clientId)}\` to ensure messages are sent from the correct bot.`;
1778
+ finalContent = finalContent ? `${finalContent}\n\n${botIdentityHint}` : botIdentityHint;
1779
+ }
1780
+ const linkRoutingPrompt = buildLinkRoutingPrompt(content);
1781
+ if (linkRoutingPrompt) {
1782
+ finalContent = finalContent ? `${finalContent}\n\n${linkRoutingPrompt}` : linkRoutingPrompt;
1783
+ log?.info?.(`注入卡片链接路由指令: ${linkRoutingPrompt.slice(0, 100)}...`);
1784
+ }
1785
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1786
+ dispatcher,
1787
+ onSettled: () => {
1788
+ markDispatchIdle();
1789
+ },
1790
+ run: async () => {
1791
+ return await core.channel.reply.dispatchReplyFromConfig({
1792
+ ctx: ctxPayload,
1793
+ cfg,
1794
+ dispatcher,
1795
+ replyOptions: {
1796
+ ...replyOptions,
1797
+ sourceReplyDeliveryMode: "automatic"
1798
+ }
1799
+ });
1800
+ }
1801
+ });
1802
+ log.info?.(`[DingTalk][dispatch] dispatchReplyFromConfig 完成: queuedFinal=${queuedFinal}, counts=${JSON.stringify(counts)}`);
1803
+ if (asyncMode) try {
1804
+ const fullResponse = getAsyncModeResponse();
1805
+ const oapiToken = await getOapiAccessToken$1(config);
1806
+ let finalText = fullResponse;
1807
+ if (oapiToken) {
1808
+ finalText = await processLocalImages(finalText, oapiToken, log);
1809
+ const mediaTarget = isDirect ? {
1810
+ type: "user",
1811
+ userId: senderId
1812
+ } : {
1813
+ type: "group",
1814
+ openConversationId: data.conversationId
1815
+ };
1816
+ finalText = await processVideoMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
1817
+ finalText = await processAudioMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
1818
+ finalText = await uploadAndReplaceFileMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
1819
+ const { processRawMediaPaths } = await import("./media-C_SVin7s.mjs");
1820
+ finalText = await processRawMediaPaths(finalText, config, oapiToken, log, mediaTarget);
1821
+ }
1822
+ const textToSend = finalText.trim() || "✅ 任务执行完成(无文本输出)";
1823
+ await sendProactive(config, proactiveTarget, textToSend, {
1824
+ msgType: "markdown",
1825
+ title: textToSend.split("\n")[0]?.replace(/^[#*\s\->]+/, "").trim() || "消息",
1826
+ useAICard: false,
1827
+ fallbackToNormal: true,
1828
+ log
1829
+ });
1830
+ } catch (asyncErr) {
1831
+ const errMsg = `⚠️ 任务执行失败: ${asyncErr?.message || asyncErr}`;
1832
+ try {
1833
+ await sendProactive(config, proactiveTarget, errMsg, {
1834
+ msgType: "text",
1835
+ useAICard: false,
1836
+ fallbackToNormal: true,
1837
+ log
1838
+ });
1839
+ } catch (sendErr) {
1840
+ log?.error?.(`错误通知发送失败: ${sendErr?.message || sendErr}`);
1841
+ }
1842
+ }
1843
+ } catch (err) {
1844
+ log?.error?.(`SDK dispatch 失败: ${err.message}`);
1845
+ try {
1846
+ const token = await getAccessToken(config);
1847
+ const body = {
1848
+ msgtype: "text",
1849
+ text: { content: `抱歉,处理请求时出错: ${err.message}` }
1850
+ };
1851
+ if (!isDirect) body.at = {
1852
+ atUserIds: [senderId],
1853
+ isAtAll: false
1854
+ };
1855
+ await dingtalkHttp.post(sessionWebhook, body, { headers: {
1856
+ "x-acs-dingtalk-access-token": token,
1857
+ "Content-Type": "application/json"
1858
+ } });
1859
+ } catch (fallbackErr) {
1860
+ log?.error?.(`错误消息发送也失败: ${fallbackErr.message}`);
1861
+ }
1862
+ }
1863
+ try {
1864
+ await recallEmotionReply(config, data, log);
1865
+ } catch (err) {
1866
+ log?.warn?.(`撤回表情异常: ${err.message}`);
1867
+ }
1868
+ }
1869
+ /**
1870
+ * 消息处理入口函数(带队列管理)
1871
+ * 确保同一会话+agent的消息按顺序处理,避免并发冲突
1872
+ */
1873
+ async function handleDingTalkMessage(params) {
1874
+ const { accountId, config, data, log, cfg } = params;
1875
+ const isDirect = data.conversationType === "1";
1876
+ const senderId = data.senderStaffId || data.senderId;
1877
+ const conversationId = data.conversationId;
1878
+ const queueSessionContext = buildSessionContext({
1879
+ accountId,
1880
+ senderId,
1881
+ conversationType: data.conversationType,
1882
+ conversationId,
1883
+ separateSessionByConversation: config.separateSessionByConversation,
1884
+ groupSessionScope: config.groupSessionScope,
1885
+ sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations
1886
+ });
1887
+ const baseSessionId = queueSessionContext.sessionPeerId;
1888
+ if (!baseSessionId) {
1889
+ log?.warn?.("无法构建会话标识,跳过队列管理");
1890
+ return handleDingTalkMessageInternal(params);
1891
+ }
1892
+ let matchedAgentId = null;
1893
+ if (cfg.bindings && cfg.bindings.length > 0) for (const binding of cfg.bindings) {
1894
+ const match = binding.match;
1895
+ if (match.channel && match.channel !== "dingtalk-connector") continue;
1896
+ if (match.accountId && match.accountId !== accountId) continue;
1897
+ if (match.peer) {
1898
+ if (match.peer.kind && match.peer.kind !== queueSessionContext.chatType) continue;
1899
+ if (match.peer.id && match.peer.id !== "*" && match.peer.id !== queueSessionContext.peerId) continue;
1900
+ }
1901
+ matchedAgentId = binding.agentId;
1902
+ break;
1903
+ }
1904
+ if (!matchedAgentId) matchedAgentId = cfg.defaultAgent || "main";
1905
+ const queueKey = `${baseSessionId}:${matchedAgentId}`;
1906
+ try {
1907
+ sessionLastActivity.set(queueKey, Date.now());
1908
+ const isQueueBusy = sessionQueues.has(queueKey);
1909
+ const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
1910
+ let preCreatedCard;
1911
+ if (isQueueBusy) {
1912
+ const ackPhrases = QUEUE_BUSY_ACK_PHRASES;
1913
+ const ackText = ackPhrases[Math.floor(Math.random() * ackPhrases.length)];
1914
+ const groupReplyMode = config.groupReplyMode || "aicard";
1915
+ if (!isDirect && (groupReplyMode === "text" || groupReplyMode === "markdown")) try {
1916
+ await sendProactive(config, { openConversationId: data.conversationId }, ackText, {
1917
+ msgType: "text",
1918
+ useAICard: false,
1919
+ fallbackToNormal: true
1920
+ });
1921
+ log?.info?.(`[队列] 队列繁忙,已发送普通文本 ACK(groupReplyMode=${groupReplyMode})`);
1922
+ } catch (ackErr) {
1923
+ log?.warn?.(`[队列] 发送普通 ACK 失败: ${ackErr?.message || ackErr}`);
1924
+ }
1925
+ else {
1926
+ const cardTarget = isDirect ? {
1927
+ type: "user",
1928
+ userId: senderId
1929
+ } : {
1930
+ type: "group",
1931
+ openConversationId: data.conversationId
1932
+ };
1933
+ try {
1934
+ const card = await createAICardForTarget(config, cardTarget, log);
1935
+ if (card) {
1936
+ await streamAICard(card, ackText, false, config, log);
1937
+ preCreatedCard = card;
1938
+ log?.info?.(`[队列] 队列繁忙,已创建排队 ACK Card,cardInstanceId=${card.cardInstanceId}`);
1939
+ } else log?.warn?.(`[队列] 创建排队 ACK Card 失败(返回 null),跳过 ACK`);
1940
+ addEmotionReply(config, data, log).catch((err) => {
1941
+ log?.warn?.(`[队列] 贴排队表情失败: ${err.message}`);
1942
+ });
1943
+ } catch (ackErr) {
1944
+ log?.warn?.(`[队列] 创建排队 ACK Card 异常: ${ackErr?.message || ackErr}`);
1945
+ }
1946
+ }
1947
+ }
1948
+ const currentTask = previousTask.then(async () => {
1949
+ log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
1950
+ await handleDingTalkMessageInternal({
1951
+ ...params,
1952
+ preCreatedCard,
1953
+ emotionAlreadyAdded: isQueueBusy
1954
+ });
1955
+ log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
1956
+ }).catch((err) => {
1957
+ log?.error?.(`[队列] 消息处理异常,queueKey=${queueKey}, error=${err.message}`);
1958
+ }).finally(() => {
1959
+ if (sessionQueues.get(queueKey) === currentTask) {
1960
+ sessionQueues.delete(queueKey);
1961
+ log?.info?.(`[队列] 队列已清空,queueKey=${queueKey}`);
1962
+ }
1963
+ });
1964
+ sessionQueues.set(queueKey, currentTask);
1965
+ } catch (err) {
1966
+ log?.error?.(`[队列] 队列管理异常,直接处理: ${err.message}`);
1967
+ handleDingTalkMessageInternal(params);
1968
+ }
1969
+ }
1970
+ //#endregion
1971
+ export { downloadFileToLocal, downloadImageToFile, downloadMediaByCode, extractMessageContent, getFileDownloadUrl, handleDingTalkMessage, handleDingTalkMessageInternal };