@wecom/wecom-openclaw-plugin 2026.4.2 → 2026.4.3-beta.0

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 (215) hide show
  1. package/README.md +395 -10
  2. package/dist/index.d.ts +2 -4
  3. package/dist/index.js +58 -0
  4. package/dist/src/accounts.d.ts +57 -0
  5. package/dist/src/accounts.js +222 -0
  6. package/dist/src/agent/api-client.d.ts +94 -0
  7. package/dist/src/agent/api-client.js +292 -0
  8. package/dist/src/agent/handler.d.ts +63 -0
  9. package/dist/src/agent/handler.js +551 -0
  10. package/dist/src/agent/index.d.ts +5 -0
  11. package/dist/src/agent/index.js +5 -0
  12. package/dist/src/agent/webhook.d.ts +24 -0
  13. package/dist/src/agent/webhook.js +228 -0
  14. package/dist/src/agent/xml.d.ts +21 -0
  15. package/dist/src/agent/xml.js +41 -0
  16. package/dist/src/channel.js +579 -0
  17. package/dist/src/chat-queue.d.ts +31 -0
  18. package/dist/src/chat-queue.js +45 -0
  19. package/dist/{esm/types/src → src}/const.d.ts +46 -0
  20. package/dist/{esm/src → src}/const.js +77 -30
  21. package/dist/{esm/src → src}/dm-policy.js +6 -9
  22. package/dist/src/dynamic-agent.d.ts +37 -0
  23. package/dist/src/dynamic-agent.js +60 -0
  24. package/dist/src/dynamic-routing.d.ts +65 -0
  25. package/dist/src/dynamic-routing.js +58 -0
  26. package/dist/{esm/src → src}/group-policy.js +8 -22
  27. package/dist/src/http.d.ts +26 -0
  28. package/dist/src/http.js +93 -0
  29. package/dist/{esm/types/src → src}/interface.d.ts +0 -2
  30. package/dist/src/interface.js +4 -0
  31. package/dist/src/mcp/index.js +6 -0
  32. package/dist/{esm/src → src}/mcp/interceptors/biz-error.js +2 -6
  33. package/dist/{esm/src → src}/mcp/interceptors/index.js +6 -10
  34. package/dist/{esm/src → src}/mcp/interceptors/msg-media.js +6 -10
  35. package/dist/{esm/src → src}/mcp/interceptors/smartpage-create.js +2 -6
  36. package/dist/{esm/src → src}/mcp/interceptors/smartpage-export.js +2 -6
  37. package/dist/src/mcp/interceptors/types.js +7 -0
  38. package/dist/{esm/src → src}/mcp/schema.js +1 -4
  39. package/dist/{esm/src → src}/mcp/tool.js +4 -8
  40. package/dist/{esm/src → src}/mcp/transport.js +15 -16
  41. package/dist/{esm/src → src}/media-handler.js +6 -10
  42. package/dist/{esm/src → src}/media-uploader.js +64 -10
  43. package/dist/{esm/src → src}/message-parser.js +1 -4
  44. package/dist/{esm/types/src → src}/message-sender.d.ts +18 -0
  45. package/dist/{esm/src → src}/message-sender.js +45 -11
  46. package/dist/{esm/src → src}/monitor.js +145 -292
  47. package/dist/{esm/src → src}/onboarding.js +22 -26
  48. package/dist/{esm/types/src → src}/openclaw-compat.d.ts +49 -0
  49. package/dist/{esm/src → src}/openclaw-compat.js +133 -27
  50. package/dist/{esm/src → src}/reqid-store.js +4 -7
  51. package/dist/{esm/src → src}/runtime.js +2 -5
  52. package/dist/src/shared/command-auth.d.ts +23 -0
  53. package/dist/src/shared/command-auth.js +73 -0
  54. package/dist/src/shared/xml-parser.d.ts +46 -0
  55. package/dist/src/shared/xml-parser.js +210 -0
  56. package/dist/{esm/src → src}/state-dir-resolve.js +3 -7
  57. package/dist/{esm/src → src}/state-manager.js +84 -16
  58. package/dist/src/target.d.ts +35 -0
  59. package/dist/src/target.js +67 -0
  60. package/dist/src/template-card-manager.d.ts +55 -0
  61. package/dist/src/template-card-manager.js +205 -0
  62. package/dist/{esm/src → src}/template-card-parser.js +17 -8
  63. package/dist/{esm/src → src}/timeout.js +2 -5
  64. package/dist/src/types/account.d.ts +29 -0
  65. package/dist/src/types/account.js +4 -0
  66. package/dist/src/types/config.d.ts +91 -0
  67. package/dist/src/types/config.js +7 -0
  68. package/dist/src/types/constants.d.ts +42 -0
  69. package/dist/src/types/constants.js +42 -0
  70. package/dist/src/types/index.d.ts +7 -0
  71. package/dist/src/types/index.js +5 -0
  72. package/dist/src/types/message.d.ts +238 -0
  73. package/dist/src/types/message.js +5 -0
  74. package/dist/{esm/types/src → src}/utils.d.ts +41 -0
  75. package/dist/{esm/src → src}/utils.js +24 -10
  76. package/dist/src/version.d.ts +2 -0
  77. package/dist/src/version.js +24 -0
  78. package/dist/src/webhook/command-auth.d.ts +47 -0
  79. package/dist/src/webhook/command-auth.js +96 -0
  80. package/dist/src/webhook/gateway.d.ts +36 -0
  81. package/dist/src/webhook/gateway.js +235 -0
  82. package/dist/src/webhook/handler.d.ts +18 -0
  83. package/dist/src/webhook/handler.js +420 -0
  84. package/dist/src/webhook/helpers.d.ts +156 -0
  85. package/dist/src/webhook/helpers.js +833 -0
  86. package/dist/src/webhook/http.d.ts +26 -0
  87. package/dist/src/webhook/http.js +94 -0
  88. package/dist/src/webhook/index.d.ts +11 -0
  89. package/dist/src/webhook/index.js +14 -0
  90. package/dist/src/webhook/media.d.ts +29 -0
  91. package/dist/src/webhook/media.js +88 -0
  92. package/dist/src/webhook/monitor.d.ts +59 -0
  93. package/dist/src/webhook/monitor.js +1161 -0
  94. package/dist/src/webhook/state.d.ts +220 -0
  95. package/dist/src/webhook/state.js +491 -0
  96. package/dist/src/webhook/target.d.ts +41 -0
  97. package/dist/src/webhook/target.js +132 -0
  98. package/dist/src/webhook/types.d.ts +347 -0
  99. package/dist/src/webhook/types.js +33 -0
  100. package/dist/src/webhook/video-frame.d.ts +13 -0
  101. package/dist/src/webhook/video-frame.js +30 -0
  102. package/package.json +15 -7
  103. package/dist/cjs/index.js +0 -45
  104. package/dist/cjs/index.js.map +0 -1
  105. package/dist/cjs/src/channel.js +0 -339
  106. package/dist/cjs/src/channel.js.map +0 -1
  107. package/dist/cjs/src/const.js +0 -137
  108. package/dist/cjs/src/const.js.map +0 -1
  109. package/dist/cjs/src/dm-policy.js +0 -89
  110. package/dist/cjs/src/dm-policy.js.map +0 -1
  111. package/dist/cjs/src/group-policy.js +0 -133
  112. package/dist/cjs/src/group-policy.js.map +0 -1
  113. package/dist/cjs/src/mcp/interceptors/biz-error.js +0 -70
  114. package/dist/cjs/src/mcp/interceptors/biz-error.js.map +0 -1
  115. package/dist/cjs/src/mcp/interceptors/index.js +0 -73
  116. package/dist/cjs/src/mcp/interceptors/index.js.map +0 -1
  117. package/dist/cjs/src/mcp/interceptors/msg-media.js +0 -159
  118. package/dist/cjs/src/mcp/interceptors/msg-media.js.map +0 -1
  119. package/dist/cjs/src/mcp/interceptors/smartpage-create.js +0 -157
  120. package/dist/cjs/src/mcp/interceptors/smartpage-create.js.map +0 -1
  121. package/dist/cjs/src/mcp/interceptors/smartpage-export.js +0 -93
  122. package/dist/cjs/src/mcp/interceptors/smartpage-export.js.map +0 -1
  123. package/dist/cjs/src/mcp/schema.js +0 -104
  124. package/dist/cjs/src/mcp/schema.js.map +0 -1
  125. package/dist/cjs/src/mcp/tool.js +0 -208
  126. package/dist/cjs/src/mcp/tool.js.map +0 -1
  127. package/dist/cjs/src/mcp/transport.js +0 -417
  128. package/dist/cjs/src/mcp/transport.js.map +0 -1
  129. package/dist/cjs/src/media-handler.js +0 -129
  130. package/dist/cjs/src/media-handler.js.map +0 -1
  131. package/dist/cjs/src/media-uploader.js +0 -284
  132. package/dist/cjs/src/media-uploader.js.map +0 -1
  133. package/dist/cjs/src/message-parser.js +0 -134
  134. package/dist/cjs/src/message-parser.js.map +0 -1
  135. package/dist/cjs/src/message-sender.js +0 -89
  136. package/dist/cjs/src/message-sender.js.map +0 -1
  137. package/dist/cjs/src/monitor.js +0 -991
  138. package/dist/cjs/src/monitor.js.map +0 -1
  139. package/dist/cjs/src/onboarding.js +0 -182
  140. package/dist/cjs/src/onboarding.js.map +0 -1
  141. package/dist/cjs/src/openclaw-compat.js +0 -400
  142. package/dist/cjs/src/openclaw-compat.js.map +0 -1
  143. package/dist/cjs/src/reqid-store.js +0 -88
  144. package/dist/cjs/src/reqid-store.js.map +0 -1
  145. package/dist/cjs/src/runtime.js +0 -16
  146. package/dist/cjs/src/runtime.js.map +0 -1
  147. package/dist/cjs/src/state-dir-resolve.js +0 -35
  148. package/dist/cjs/src/state-dir-resolve.js.map +0 -1
  149. package/dist/cjs/src/state-manager.js +0 -158
  150. package/dist/cjs/src/state-manager.js.map +0 -1
  151. package/dist/cjs/src/template-card-parser.js +0 -641
  152. package/dist/cjs/src/template-card-parser.js.map +0 -1
  153. package/dist/cjs/src/timeout.js +0 -42
  154. package/dist/cjs/src/timeout.js.map +0 -1
  155. package/dist/cjs/src/utils.js +0 -60
  156. package/dist/cjs/src/utils.js.map +0 -1
  157. package/dist/cjs/src/version.js +0 -26
  158. package/dist/cjs/src/version.js.map +0 -1
  159. package/dist/esm/index.js +0 -41
  160. package/dist/esm/index.js.map +0 -1
  161. package/dist/esm/src/channel.js +0 -337
  162. package/dist/esm/src/channel.js.map +0 -1
  163. package/dist/esm/src/const.js.map +0 -1
  164. package/dist/esm/src/dm-policy.js.map +0 -1
  165. package/dist/esm/src/group-policy.js.map +0 -1
  166. package/dist/esm/src/mcp/interceptors/biz-error.js.map +0 -1
  167. package/dist/esm/src/mcp/interceptors/index.js.map +0 -1
  168. package/dist/esm/src/mcp/interceptors/msg-media.js.map +0 -1
  169. package/dist/esm/src/mcp/interceptors/smartpage-create.js.map +0 -1
  170. package/dist/esm/src/mcp/interceptors/smartpage-export.js.map +0 -1
  171. package/dist/esm/src/mcp/schema.js.map +0 -1
  172. package/dist/esm/src/mcp/tool.js.map +0 -1
  173. package/dist/esm/src/mcp/transport.js.map +0 -1
  174. package/dist/esm/src/media-handler.js.map +0 -1
  175. package/dist/esm/src/media-uploader.js.map +0 -1
  176. package/dist/esm/src/message-parser.js.map +0 -1
  177. package/dist/esm/src/message-sender.js.map +0 -1
  178. package/dist/esm/src/monitor.js.map +0 -1
  179. package/dist/esm/src/onboarding.js.map +0 -1
  180. package/dist/esm/src/openclaw-compat.js.map +0 -1
  181. package/dist/esm/src/reqid-store.js.map +0 -1
  182. package/dist/esm/src/runtime.js.map +0 -1
  183. package/dist/esm/src/state-dir-resolve.js.map +0 -1
  184. package/dist/esm/src/state-manager.js.map +0 -1
  185. package/dist/esm/src/template-card-parser.js.map +0 -1
  186. package/dist/esm/src/timeout.js.map +0 -1
  187. package/dist/esm/src/utils.js.map +0 -1
  188. package/dist/esm/src/version.js +0 -23
  189. package/dist/esm/src/version.js.map +0 -1
  190. package/dist/esm/types/index.d.ts +0 -9
  191. package/dist/esm/types/src/version.d.ts +0 -2
  192. /package/dist/{esm/types/src → src}/channel.d.ts +0 -0
  193. /package/dist/{esm/types/src → src}/dm-policy.d.ts +0 -0
  194. /package/dist/{esm/types/src → src}/group-policy.d.ts +0 -0
  195. /package/dist/{esm/types/src → src}/mcp/index.d.ts +0 -0
  196. /package/dist/{esm/types/src → src}/mcp/interceptors/biz-error.d.ts +0 -0
  197. /package/dist/{esm/types/src → src}/mcp/interceptors/index.d.ts +0 -0
  198. /package/dist/{esm/types/src → src}/mcp/interceptors/msg-media.d.ts +0 -0
  199. /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-create.d.ts +0 -0
  200. /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-export.d.ts +0 -0
  201. /package/dist/{esm/types/src → src}/mcp/interceptors/types.d.ts +0 -0
  202. /package/dist/{esm/types/src → src}/mcp/schema.d.ts +0 -0
  203. /package/dist/{esm/types/src → src}/mcp/tool.d.ts +0 -0
  204. /package/dist/{esm/types/src → src}/mcp/transport.d.ts +0 -0
  205. /package/dist/{esm/types/src → src}/media-handler.d.ts +0 -0
  206. /package/dist/{esm/types/src → src}/media-uploader.d.ts +0 -0
  207. /package/dist/{esm/types/src → src}/message-parser.d.ts +0 -0
  208. /package/dist/{esm/types/src → src}/monitor.d.ts +0 -0
  209. /package/dist/{esm/types/src → src}/onboarding.d.ts +0 -0
  210. /package/dist/{esm/types/src → src}/reqid-store.d.ts +0 -0
  211. /package/dist/{esm/types/src → src}/runtime.d.ts +0 -0
  212. /package/dist/{esm/types/src → src}/state-dir-resolve.d.ts +0 -0
  213. /package/dist/{esm/types/src → src}/state-manager.d.ts +0 -0
  214. /package/dist/{esm/types/src → src}/template-card-parser.d.ts +0 -0
  215. /package/dist/{esm/types/src → src}/timeout.d.ts +0 -0
@@ -0,0 +1,1161 @@
1
+ /**
2
+ * Webhook 核心消息处理
3
+ *
4
+ * 从 @mocrane/wecom monitor.ts 部分迁移 + 重构。
5
+ * 负责:入站消息解析、防抖聚合、Agent 调度、流式输出、超时兜底。
6
+ */
7
+ import { pathToFileURL } from "node:url";
8
+ import os from "node:os";
9
+ import { resolveWecomCommandAuthorization, buildWecomUnauthorizedCommandPrompt, } from "./command-auth.js";
10
+ import { STREAM_MAX_BYTES, BOT_WINDOW_MS, BOT_SWITCH_MARGIN_MS, REQUEST_TIMEOUT_MS, } from "./types.js";
11
+ import { getMonitorState } from "./gateway.js";
12
+ import { sendText as sendAgentText, uploadMedia, sendMedia as sendAgentMedia } from "../agent/api-client.js";
13
+ import { wecomFetch } from "./http.js";
14
+ import { truncateUtf8Bytes, appendDmContent, buildFallbackPrompt, extractLocalFilePathsFromText, extractLocalImagePathsFromText, looksLikeSendLocalFileIntent, computeTaskKey, isAgentConfigured, guessContentTypeFromPath, buildStreamReplyFromState, computeMd5, resolveWecomMediaMaxBytes, buildCfgForDispatch, processInboundMessage, MIME_BY_EXT, resolveWecomSenderUserId, buildInboundBody, hasMedia, buildStreamResponse, buildStreamPlaceholderReply, buildStreamTextPlaceholderReply, } from "./helpers.js";
15
+ import { processDynamicRouting } from "../dynamic-routing.js";
16
+ // ============================================================================
17
+ // 入站消息处理
18
+ // ============================================================================
19
+ /**
20
+ * 处理入站消息
21
+ *
22
+ * 解析消息类型和内容,创建/获取 stream,加入防抖队列,返回占位符响应。
23
+ */
24
+ export async function handleInboundMessage(target, message, timestamp, nonce, proxyUrl, msgFilterData) {
25
+ const state = getMonitorState();
26
+ const { streamStore, activeReplyStore } = state;
27
+ const msgid = message.msgid;
28
+ // msgid 去重检查
29
+ if (msgid) {
30
+ const existingStreamId = streamStore.getStreamByMsgId(msgid);
31
+ if (existingStreamId) {
32
+ const existingStream = streamStore.getStream(existingStreamId);
33
+ if (existingStream) {
34
+ target.runtime.log?.(`[webhook] 消息去重: msgid=${msgid} 已关联 streamId=${existingStreamId}`);
35
+ return buildStreamResponse(existingStream);
36
+ }
37
+ }
38
+ }
39
+ // 解析消息内容(对齐原版 buildInboundBody)
40
+ const msgContent = buildInboundBody(message);
41
+ if (!msgContent && !hasMedia(message)) {
42
+ target.runtime.log?.(`[webhook] 空消息内容 (type=${message.msgtype}, msgid=${msgid})`);
43
+ return null;
44
+ }
45
+ const userid = msgFilterData?.senderUserId ?? "";
46
+ const chatType = String(message.chattype ?? "").trim().toLowerCase();
47
+ const chatId = msgFilterData?.chatId ?? message.chatid ?? "";
48
+ // 原版 conversationKey 格式:wecom:{accountId}:{userid}:{chatId}
49
+ // 单聊时 chatId 等于 userid
50
+ const resolvedChatId = chatId || userid;
51
+ const conversationKey = `wecom:${target.account.accountId}:${userid}:${resolvedChatId}`;
52
+ // 加入防抖队列
53
+ const result = streamStore.addPendingMessage({
54
+ conversationKey,
55
+ target,
56
+ msg: message,
57
+ msgContent: msgContent ?? "",
58
+ nonce,
59
+ timestamp,
60
+ debounceMs: target.account.config?.debounceMs
61
+ });
62
+ const { streamId, status } = result;
63
+ target.runtime.log?.(`[webhook] 消息入队 (status=${status}, streamId=${streamId}, convKey=${conversationKey})`);
64
+ // 存储 response_url(对齐原版:同时保存 proxyUrl 用于后续出站请求的代理)
65
+ if (message.response_url) {
66
+ activeReplyStore.store(streamId, message.response_url, proxyUrl);
67
+ }
68
+ // 更新 stream 的元数据
69
+ streamStore.updateStream(streamId, (s) => {
70
+ s.userId = userid;
71
+ s.chatType = chatType === "group" ? "group" : "direct";
72
+ s.chatId = resolvedChatId;
73
+ s.aibotid = target.account.botId;
74
+ });
75
+ // 根据 status 返回不同的占位符响应(对齐原版 status 分支处理)
76
+ const defaultPlaceholder = target.account.config?.streamPlaceholderContent;
77
+ const queuedPlaceholder = "已收到,已排队处理中...";
78
+ const mergedQueuedPlaceholder = "已收到,已合并排队处理中...";
79
+ if (status === "active_new") {
80
+ // 第一条消息,返回默认占位符
81
+ return buildStreamPlaceholderReply(streamId, defaultPlaceholder);
82
+ }
83
+ if (status === "queued_new") {
84
+ // 进入排队批次,返回排队提示
85
+ target.runtime.log?.(`[webhook] queue: 已进入下一批次 streamId=${streamId} msgid=${String(message.msgid ?? "")}`);
86
+ return buildStreamPlaceholderReply(streamId, queuedPlaceholder);
87
+ }
88
+ // active_merged / queued_merged:合并进某个批次
89
+ // 为本条 msgid 创建一个"回执 stream",先显示"已合并排队",并在批次结束时自动更新为"已合并处理完成"
90
+ const ackStreamId = streamStore.createStream({ msgid: message.msgid ? String(message.msgid) : undefined });
91
+ streamStore.updateStream(ackStreamId, (s) => {
92
+ s.finished = false;
93
+ s.started = true;
94
+ s.content = mergedQueuedPlaceholder;
95
+ });
96
+ if (message.msgid) {
97
+ streamStore.setStreamIdForMsgId(String(message.msgid), ackStreamId);
98
+ }
99
+ streamStore.addAckStreamForBatch({ batchStreamId: streamId, ackStreamId });
100
+ target.runtime.log?.(`[webhook] queue: 已合并排队(回执流)ackStreamId=${ackStreamId} mergedIntoStreamId=${streamId} msgid=${String(message.msgid ?? "")}`);
101
+ return buildStreamTextPlaceholderReply(ackStreamId, mergedQueuedPlaceholder);
102
+ }
103
+ // ============================================================================
104
+ // stream_refresh 处理
105
+ // ============================================================================
106
+ /**
107
+ * 处理 stream_refresh 请求
108
+ *
109
+ * 返回 StreamState 中的当前累积内容、图片附件和 finish 标记。
110
+ */
111
+ export async function handleStreamRefresh(target, message) {
112
+ const state = getMonitorState();
113
+ const streamId = String(message.stream?.id ?? "").trim();
114
+ if (!streamId) {
115
+ target.runtime.log?.("[webhook] stream_refresh 缺少 stream_id");
116
+ return null;
117
+ }
118
+ const stream = state.streamStore.getStream(streamId);
119
+ if (!stream) {
120
+ target.runtime.log?.(`[webhook] stream_refresh: stream ${streamId} 不存在`);
121
+ // 返回 finish=true 以通知客户端停止轮询
122
+ return {
123
+ msgtype: "stream",
124
+ stream: { id: streamId, finish: true, content: "" },
125
+ };
126
+ }
127
+ target.runtime.log?.(`[webhook] stream_refresh (streamId=${streamId}, started=${stream.started}, finished=${stream.finished}, len=${stream.content.length})`);
128
+ return buildStreamResponse(stream);
129
+ }
130
+ // ============================================================================
131
+ // enter_chat 处理
132
+ // ============================================================================
133
+ /**
134
+ * 处理 enter_chat 事件
135
+ *
136
+ * 返回可配置的欢迎消息。
137
+ */
138
+ export async function handleEnterChat(target, message) {
139
+ const welcomeText = target.account.welcomeText;
140
+ const userId = message.from?.userid ?? "unknown";
141
+ target.runtime.log?.(`[webhook] enter_chat (userId=${userId}, account=${target.account.accountId})`);
142
+ if (welcomeText) {
143
+ return {
144
+ msgtype: "text",
145
+ text: { content: welcomeText },
146
+ };
147
+ }
148
+ // 无欢迎消息配置,返回空回复
149
+ return null;
150
+ }
151
+ // ============================================================================
152
+ // template_card_event 处理
153
+ // ============================================================================
154
+ /**
155
+ * 处理模板卡片事件(对齐原版 template_card_event 逻辑)
156
+ *
157
+ * 原版流程:
158
+ * 1. msgid 去重:跳过已处理的卡片事件
159
+ * 2. 解析卡片交互数据:event_key、selected_items、task_id
160
+ * 3. 立即返回空加密回复(非阻塞)
161
+ * 4. 创建 stream 并标记开始
162
+ * 5. 存储 response_url(用于后续推送)
163
+ * 6. 构造交互描述文本,作为文本消息启动 Agent 处理
164
+ */
165
+ export async function handleTemplateCardEvent(target, message, timestamp, nonce, proxyUrl) {
166
+ const state = getMonitorState();
167
+ const { streamStore, activeReplyStore } = state;
168
+ const msgid = message.msgid ? String(message.msgid) : undefined;
169
+ // 1. msgid 去重:跳过已处理的卡片事件
170
+ if (msgid && streamStore.getStreamByMsgId(msgid)) {
171
+ target.runtime.log?.(`[webhook] template_card_event: already processed msgid=${msgid}, skipping`);
172
+ return {};
173
+ }
174
+ // 2. 解析卡片交互数据
175
+ const cardEvent = message.event?.template_card_event;
176
+ let interactionDesc = `[卡片交互] 按钮: ${String(cardEvent?.event_key ?? "unknown")}`;
177
+ // 解析选择项(selected_items.selected_item)
178
+ const selectedItems = cardEvent?.selected_items;
179
+ const selectedItemList = selectedItems?.selected_item;
180
+ if (Array.isArray(selectedItemList) && selectedItemList.length > 0) {
181
+ const selects = selectedItemList.map((i) => {
182
+ const questionKey = String(i.question_key ?? "");
183
+ const optionIds = i.option_ids?.option_id;
184
+ const optionStr = Array.isArray(optionIds) ? optionIds.join(",") : String(optionIds ?? "");
185
+ return `${questionKey}=${optionStr}`;
186
+ });
187
+ interactionDesc += ` 选择: ${selects.join("; ")}`;
188
+ }
189
+ // 解析任务 ID
190
+ if (cardEvent?.task_id) {
191
+ interactionDesc += ` (任务ID: ${String(cardEvent.task_id)})`;
192
+ }
193
+ target.runtime.log?.(`[webhook] template_card_event (event_key=${String(cardEvent?.event_key ?? "N/A")}, msgid=${msgid ?? "N/A"})`);
194
+ // 3. 创建 stream 并标记开始
195
+ const streamId = streamStore.createStream({ msgid });
196
+ streamStore.markStarted(streamId);
197
+ // 4. 存储 response_url(用于后续 Agent 输出推送)
198
+ if (message.response_url) {
199
+ activeReplyStore.store(streamId, message.response_url, proxyUrl);
200
+ }
201
+ // 5. 构造交互描述作为文本消息,异步启动 Agent 处理
202
+ const syntheticMessage = {
203
+ ...message,
204
+ msgtype: "text",
205
+ text: { content: interactionDesc },
206
+ };
207
+ // 异步启动 Agent(不阻塞 HTTP 响应)
208
+ startAgentForStream({
209
+ target,
210
+ accountId: target.account.accountId,
211
+ msg: syntheticMessage,
212
+ streamId,
213
+ mergedContents: undefined,
214
+ mergedMsgids: undefined,
215
+ }).catch((err) => {
216
+ target.runtime.error?.(`[webhook] template_card_event Agent failed: ${String(err)}`);
217
+ });
218
+ // 6. 立即返回空回复(非阻塞,原版返回 {} 加密后的)
219
+ return {};
220
+ }
221
+ // ============================================================================
222
+ // Agent 调度(startAgentForStream)
223
+ // ============================================================================
224
+ /**
225
+ * **startAgentForStream (启动 Agent 处理流程)**
226
+ *
227
+ * 将接收到的(或聚合的)消息转换为 OpenClaw 内部格式,并分发给对应的 Agent。
228
+ * 包含:
229
+ * 1. 消息解密与媒体保存。
230
+ * 2. 路由解析 (Agent Route)。
231
+ * 3. 会话记录 (Session Recording)。
232
+ * 4. 触发 Agent 响应 (Dispatch Reply)。
233
+ * 5. 处理 Agent 输出(包括文本、Markdown 表格转换、<think> 标签保护、模板卡片识别)。
234
+ */
235
+ export async function startAgentForStream(params) {
236
+ const { target, msg, streamId } = params;
237
+ const state = getMonitorState();
238
+ const { streamStore } = state;
239
+ const stream = streamStore.getStream(streamId);
240
+ if (!stream) {
241
+ target.runtime.log?.(`[webhook] stream ${streamId} 不存在,跳过 Agent 调度`);
242
+ return;
243
+ }
244
+ // WS 长连接模式标记:跳过 Webhook 专属的 Agent 私信兜底逻辑(对齐 lh 版)
245
+ const isWsMode = Boolean(stream.wsMode);
246
+ const core = target.core;
247
+ const config = target.config;
248
+ const account = target.account;
249
+ const userid = resolveWecomSenderUserId(msg) || "unknown";
250
+ const chatType = msg.chattype === "group" ? "group" : "direct";
251
+ const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
252
+ const taskKey = computeTaskKey(target, msg);
253
+ const aibotid = String(msg.aibotid ?? "").trim() || undefined;
254
+ // 更新 Stream 状态:记录上下文信息(用户ID、ChatType等)
255
+ streamStore.updateStream(streamId, (s) => {
256
+ s.userId = userid;
257
+ s.chatType = chatType === "group" ? "group" : "direct";
258
+ s.chatId = chatId;
259
+ s.taskKey = taskKey;
260
+ s.aibotid = aibotid;
261
+ });
262
+ // ──────────────────────────────────────────────────────────────────
263
+ // 1. 入站消息处理(媒体解密)—— 对齐原版 processInboundMessage
264
+ // ──────────────────────────────────────────────────────────────────
265
+ let { body: rawBody, media } = await processInboundMessage(target, msg);
266
+ // 若存在从防抖逻辑聚合来的多条消息内容,则覆盖 rawBody
267
+ if (params.mergedContents) {
268
+ rawBody = params.mergedContents;
269
+ }
270
+ // ──────────────────────────────────────────────────────────────────
271
+ // 2. P0: 本机路径文件发送
272
+ // P0: 群聊/私聊里“让 Bot 发送本机图片/文件路径”的场景,优先走 Bot 原会话交付(图片),
273
+ // 非图片文件则走 Agent 私信兜底,并确保 Bot 会话里有中文提示。
274
+ //
275
+ // 典型背景:Agent 主动发群 chatId(wr/wc...)在很多情况下会 86008,无论怎么“修复”都发不出去;
276
+ // 这种请求如果能被动回复图片,就必须由 Bot 在群内交付。
277
+ // ──────────────────────────────────────────────────────────────────
278
+ const directLocalPaths = extractLocalFilePathsFromText(rawBody);
279
+ if (directLocalPaths.length) {
280
+ target.runtime.log?.(`local-path: 检测到用户消息包含本机路径 count=${directLocalPaths.length} intent=${looksLikeSendLocalFileIntent(rawBody)}`);
281
+ }
282
+ if (directLocalPaths.length && looksLikeSendLocalFileIntent(rawBody)) {
283
+ const fs = await import("node:fs/promises");
284
+ const pathModule = await import("node:path");
285
+ const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
286
+ const imagePaths = [];
287
+ const otherPaths = [];
288
+ for (const p of directLocalPaths) {
289
+ const ext = pathModule.extname(p).slice(1).toLowerCase();
290
+ if (imageExts.has(ext))
291
+ imagePaths.push(p);
292
+ else
293
+ otherPaths.push(p);
294
+ }
295
+ // 图片:通过 Bot 原会话交付(base64 msg_item)
296
+ if (imagePaths.length > 0 && otherPaths.length === 0) {
297
+ const loaded = [];
298
+ for (const p of imagePaths) {
299
+ try {
300
+ const buf = await fs.readFile(p);
301
+ const base64 = buf.toString("base64");
302
+ const md5 = computeMd5(buf);
303
+ loaded.push({ base64, md5, path: p });
304
+ }
305
+ catch (err) {
306
+ target.runtime.error?.(`[webhook] local-path: 读取图片失败 path=${p}: ${String(err)}`);
307
+ }
308
+ }
309
+ if (loaded.length > 0) {
310
+ streamStore.updateStream(streamId, (s) => {
311
+ s.images = loaded.map(({ base64, md5 }) => ({ base64, md5 }));
312
+ s.content = loaded.length === 1
313
+ ? `已发送图片(${pathModule.basename(loaded[0].path)})`
314
+ : `已发送 ${loaded.length} 张图片`;
315
+ s.finished = true;
316
+ });
317
+ // 通过 response_url 推送(对齐 lh 版:直接 POST JSON,不加密)
318
+ const responseUrl = getActiveReplyUrl(streamId);
319
+ if (responseUrl) {
320
+ try {
321
+ const finalReply = buildStreamReplyFromState(streamStore.getStream(streamId), STREAM_MAX_BYTES);
322
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
323
+ const res = await wecomFetch(responseUrl, {
324
+ method: "POST",
325
+ headers: { "Content-Type": "application/json" },
326
+ body: JSON.stringify(finalReply),
327
+ }, { proxyUrl, timeoutMs: REQUEST_TIMEOUT_MS });
328
+ if (!res.ok)
329
+ throw new Error(`local-path image push failed: ${res.status}`);
330
+ });
331
+ target.runtime.log?.(`[webhook] local-path: 已通过 Bot response_url 推送图片 frames=final images=${loaded.length}`);
332
+ }
333
+ catch (err) {
334
+ target.runtime.error?.(`[webhook] local-path: Bot 主动推送图片失败(将依赖 stream_refresh 拉取): ${String(err)}`);
335
+ }
336
+ }
337
+ else {
338
+ target.runtime.log?.(`[webhook] local-path: 无 response_url,等待 stream_refresh 拉取最终图片`);
339
+ }
340
+ // 该消息已完成,推进队列处理下一批
341
+ streamStore.onStreamFinished(streamId);
342
+ return;
343
+ }
344
+ // 图片路径都读取失败时的兜底处理(对齐 lh 版 Webhook 模式)
345
+ const agentOk = isAgentConfigured(target);
346
+ const fallbackName = imagePaths.length === 1
347
+ ? (imagePaths[0].split("/").pop() || "image")
348
+ : `${imagePaths.length} 张图片`;
349
+ const prompt = buildFallbackPrompt({
350
+ kind: "media",
351
+ agentConfigured: agentOk,
352
+ userId: userid,
353
+ filename: fallbackName,
354
+ chatType,
355
+ });
356
+ streamStore.updateStream(streamId, (s) => {
357
+ s.fallbackMode = "error";
358
+ s.finished = true;
359
+ s.content = prompt;
360
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
361
+ });
362
+ try {
363
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
364
+ target.runtime.log?.(`[webhook] local-path: 图片读取失败后已推送兜底提示`);
365
+ }
366
+ catch (err) {
367
+ target.runtime.error?.(`[webhook] local-path: 图片读取失败后的兜底提示推送失败: ${String(err)}`);
368
+ }
369
+ // TODO: agent兜底这里需要有agent对象,待对齐lh版本
370
+ if (agentOk && userid && userid !== "unknown") {
371
+ for (const p of imagePaths) {
372
+ const guessedType = guessContentTypeFromPath(p);
373
+ try {
374
+ await agentDmMedia({
375
+ target,
376
+ userId: userid,
377
+ mediaUrlOrPath: p,
378
+ contentType: guessedType,
379
+ filename: p.split("/").pop() || "image",
380
+ });
381
+ streamStore.updateStream(streamId, (s) => {
382
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
383
+ });
384
+ target.runtime.log?.(`[webhook] local-path: 图片已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`);
385
+ }
386
+ catch (err) {
387
+ target.runtime.error?.(`[webhook] local-path: 图片 Agent 私信兜底失败 path=${p}: ${String(err)}`);
388
+ }
389
+ }
390
+ }
391
+ streamStore.onStreamFinished(streamId);
392
+ return;
393
+ }
394
+ // 非图片文件:Bot 提示 + Agent 私信兜底(对齐 lh 版 Webhook 模式)
395
+ if (otherPaths.length > 0) {
396
+ const agentOk = isAgentConfigured(target);
397
+ const filename = otherPaths.length === 1 ? otherPaths[0].split("/").pop() : `${otherPaths.length} 个文件`;
398
+ const prompt = buildFallbackPrompt({
399
+ kind: "media",
400
+ agentConfigured: agentOk,
401
+ userId: userid,
402
+ filename,
403
+ chatType,
404
+ });
405
+ streamStore.updateStream(streamId, (s) => {
406
+ s.fallbackMode = "media";
407
+ s.finished = true;
408
+ s.content = prompt;
409
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
410
+ });
411
+ try {
412
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
413
+ target.runtime.log?.(`[webhook] local-path: 文件兜底提示已推送`);
414
+ }
415
+ catch (err) {
416
+ target.runtime.error?.(`[webhook] local-path: 文件兜底提示推送失败: ${String(err)}`);
417
+ }
418
+ if (!agentOk) {
419
+ streamStore.onStreamFinished(streamId);
420
+ return;
421
+ }
422
+ if (!userid || userid === "unknown") {
423
+ target.runtime.error?.(`[webhook] local-path: 无法识别触发者 userId,无法 Agent 私信发送文件`);
424
+ streamStore.onStreamFinished(streamId);
425
+ return;
426
+ }
427
+ // TODO: agent兜底这里需要有agent对象,待对齐lh版本
428
+ for (const p of otherPaths) {
429
+ const alreadySent = streamStore.getStream(streamId)?.agentMediaKeys?.includes(p);
430
+ if (alreadySent)
431
+ continue;
432
+ const guessedType = guessContentTypeFromPath(p);
433
+ try {
434
+ await agentDmMedia({
435
+ target,
436
+ userId: userid,
437
+ mediaUrlOrPath: p,
438
+ contentType: guessedType,
439
+ filename: p.split("/").pop() || "file",
440
+ });
441
+ streamStore.updateStream(streamId, (s) => {
442
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
443
+ });
444
+ target.runtime.log?.(`[webhook] local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`);
445
+ }
446
+ catch (err) {
447
+ target.runtime.error?.(`[webhook] local-path: Agent 私信发送文件失败 path=${p}: ${String(err)}`);
448
+ }
449
+ }
450
+ streamStore.onStreamFinished(streamId);
451
+ return;
452
+ }
453
+ }
454
+ // ──────────────────────────────────────────────────────────────────
455
+ // 3. 保存媒体文件供 Agent 使用
456
+ // ──────────────────────────────────────────────────────────────────
457
+ let mediaPath;
458
+ let mediaType;
459
+ if (media) {
460
+ try {
461
+ const maxBytes = resolveWecomMediaMaxBytes(config);
462
+ const saved = await core.channel.media.saveMediaBuffer(media.buffer, media.contentType, "inbound", maxBytes, media.filename);
463
+ mediaPath = saved.path;
464
+ mediaType = saved.contentType;
465
+ target.runtime.log?.(`[webhook] 入站媒体已保存: ${mediaPath} (${mediaType})`);
466
+ }
467
+ catch (err) {
468
+ target.runtime.error?.(`[webhook] 入站媒体保存失败: ${String(err)}`);
469
+ }
470
+ }
471
+ // 3.5 视频第一帧提取(ffmpeg)
472
+ let videoFirstFramePath;
473
+ if (mediaPath && mediaType?.startsWith("video/")) {
474
+ try {
475
+ const { extractVideoFirstFrame } = await import("./video-frame.js");
476
+ videoFirstFramePath = await extractVideoFirstFrame(mediaPath);
477
+ if (videoFirstFramePath) {
478
+ target.runtime.log?.(`[webhook] video: 第一帧提取成功 ${videoFirstFramePath}`);
479
+ }
480
+ }
481
+ catch (err) {
482
+ target.runtime.log?.(`[webhook] video: 第一帧提取失败(ffmpeg 可能不可用): ${String(err)}`);
483
+ }
484
+ }
485
+ // ──────────────────────────────────────────────────────────────────
486
+ // 4. 路由解析 + 动态路由处理
487
+ // ──────────────────────────────────────────────────────────────────
488
+ const route = core.channel.routing.resolveAgentRoute({
489
+ cfg: config,
490
+ channel: "wecom",
491
+ accountId: account.accountId,
492
+ peer: { kind: chatType === "group" ? "group" : "direct", id: chatId },
493
+ });
494
+ // ===== 动态 Agent 路由处理 =====
495
+ const routingResult = processDynamicRouting({
496
+ route,
497
+ config,
498
+ core,
499
+ accountId: account.accountId,
500
+ chatType: chatType === "group" ? "group" : "dm",
501
+ chatId,
502
+ senderId: userid,
503
+ log: (msg) => target.runtime.log?.(msg.replace(/^\[dynamic-routing\]/, "[webhook]")),
504
+ error: (msg) => target.runtime.error?.(msg.replace(/^\[dynamic-routing\]/, "[webhook]")),
505
+ });
506
+ // 应用动态路由结果
507
+ if (routingResult.routeModified) {
508
+ route.agentId = routingResult.finalAgentId;
509
+ route.sessionKey = routingResult.finalSessionKey;
510
+ }
511
+ // ===== 动态 Agent 路由处理结束 =====
512
+ // ──────────────────────────────────────────────────────────────────
513
+ // 5. Agent Envelope 格式化(对齐原版)
514
+ // ──────────────────────────────────────────────────────────────────
515
+ const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`;
516
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
517
+ agentId: route.agentId,
518
+ });
519
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
520
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
521
+ storePath,
522
+ sessionKey: route.sessionKey,
523
+ });
524
+ const body = core.channel.reply.formatAgentEnvelope({
525
+ channel: "WeCom",
526
+ from: fromLabel,
527
+ previousTimestamp,
528
+ envelope: envelopeOptions,
529
+ body: rawBody,
530
+ });
531
+ // ──────────────────────────────────────────────────────────────────
532
+ // 5.5 命令授权检查(对齐 lh 版 command-auth 门禁)
533
+ // ──────────────────────────────────────────────────────────────────
534
+ const authz = await resolveWecomCommandAuthorization({
535
+ core,
536
+ cfg: config,
537
+ accountConfig: account.config,
538
+ rawBody,
539
+ senderUserId: userid,
540
+ });
541
+ const commandAuthorized = authz.commandAuthorized;
542
+ target.runtime.log?.(`[webhook] authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${userid.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(commandAuthorized)}`);
543
+ // 命令门禁:如果这是命令且未授权,必须给用户一个明确的中文回复(不能静默忽略)
544
+ if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
545
+ const prompt = buildWecomUnauthorizedCommandPrompt({
546
+ senderUserId: userid,
547
+ dmPolicy: authz.dmPolicy,
548
+ scope: "bot",
549
+ });
550
+ streamStore.updateStream(streamId, (s) => {
551
+ s.finished = true;
552
+ s.content = prompt;
553
+ });
554
+ try {
555
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
556
+ target.runtime.log?.(`[webhook] authz: 未授权命令已提示用户 streamId=${streamId}`);
557
+ }
558
+ catch (err) {
559
+ target.runtime.error?.(`[webhook] authz: 未授权命令提示推送失败 streamId=${streamId}: ${String(err)}`);
560
+ }
561
+ streamStore.onStreamFinished(streamId);
562
+ return;
563
+ }
564
+ // ──────────────────────────────────────────────────────────────────
565
+ // 6. /new /reset 命令检测
566
+ // ──────────────────────────────────────────────────────────────────
567
+ const rawBodyNormalized = rawBody.trim();
568
+ const isResetCommand = /^\/(new|reset)(?:\s|$)/i.test(rawBodyNormalized);
569
+ const resetCommandKind = isResetCommand ? (rawBodyNormalized.match(/^\/(new|reset)/i)?.[1]?.toLowerCase() ?? "new") : null;
570
+ // ──────────────────────────────────────────────────────────────────
571
+ // 7. 构造附件
572
+ // ──────────────────────────────────────────────────────────────────
573
+ const attachments = mediaPath ? [{
574
+ name: media?.filename || "file",
575
+ mimeType: mediaType,
576
+ url: pathToFileURL(mediaPath).href
577
+ }] : undefined;
578
+ // 如果提取到了视频第一帧,追加为附件让 LLM 能看到视频画面
579
+ if (videoFirstFramePath && attachments) {
580
+ const pathModule = await import("node:path");
581
+ attachments.push({
582
+ name: pathModule.basename(videoFirstFramePath),
583
+ mimeType: "image/jpeg",
584
+ url: pathToFileURL(videoFirstFramePath).href
585
+ });
586
+ }
587
+ // ──────────────────────────────────────────────────────────────────
588
+ // 8. 构造 inbound context
589
+ // ──────────────────────────────────────────────────────────────────
590
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
591
+ Body: body,
592
+ RawBody: rawBody,
593
+ CommandBody: rawBody,
594
+ Attachments: attachments,
595
+ From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`,
596
+ To: `wecom:${chatId}`,
597
+ SessionKey: route.sessionKey,
598
+ AccountId: route.accountId,
599
+ ChatType: chatType,
600
+ ConversationLabel: fromLabel,
601
+ SenderName: userid,
602
+ SenderId: userid,
603
+ Provider: "wecom",
604
+ Surface: "wecom",
605
+ MessageSid: msg.msgid,
606
+ CommandAuthorized: commandAuthorized,
607
+ OriginatingChannel: "wecom",
608
+ OriginatingTo: `wecom:${chatId}`,
609
+ MediaPath: mediaPath,
610
+ MediaType: mediaType,
611
+ MediaUrl: mediaPath,
612
+ });
613
+ // ──────────────────────────────────────────────────────────────────
614
+ // 9. 会话记录
615
+ // ──────────────────────────────────────────────────────────────────
616
+ await core.channel.session.recordInboundSession({
617
+ storePath,
618
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
619
+ ctx: ctxPayload,
620
+ onRecordError: (err) => {
621
+ target.runtime.error?.(`[webhook] session meta update failed: ${String(err)}`);
622
+ },
623
+ });
624
+ // ──────────────────────────────────────────────────────────────────
625
+ // 10. Markdown 表格模式解析
626
+ // ──────────────────────────────────────────────────────────────────
627
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
628
+ cfg: config,
629
+ channel: "wecom",
630
+ accountId: account.accountId,
631
+ });
632
+ // ──────────────────────────────────────────────────────────────────
633
+ // 11. 构造 dispatch config(禁用 message 工具)
634
+ // ──────────────────────────────────────────────────────────────────
635
+ // WeCom Bot 会话交付约束:
636
+ // - 图片应尽量由 Bot 在原会话交付(流式最终帧 msg_item)。
637
+ // - 非图片文件走 Agent 私信兜底(本文件中实现),并由 Bot 给出提示。
638
+ //
639
+ // 重要:message 工具不是 sandbox 工具,必须通过 cfg.tools.deny 禁用。
640
+ // 否则 Agent 可能直接通过 message 工具私信/发群,绕过 Bot 交付链路,导致群里“没有任何提示”。
641
+ const cfgForDispatch = buildCfgForDispatch(config);
642
+ // ──────────────────────────────────────────────────────────────────
643
+ // 12. 调度 Agent 回复(核心 deliver 回调)
644
+ // ──────────────────────────────────────────────────────────────────
645
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
646
+ ctx: ctxPayload,
647
+ cfg: cfgForDispatch,
648
+ replyOptions: {
649
+ disableBlockStreaming: false,
650
+ },
651
+ dispatcherOptions: {
652
+ deliver: async (payload, info) => {
653
+ let text = payload.text ?? "";
654
+ // ── <think> 标签保护 ──
655
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
656
+ const thinks = [];
657
+ text = text.replace(thinkRegex, (match) => {
658
+ thinks.push(match);
659
+ return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
660
+ });
661
+ // ── template_card JSON 检测与发送(对齐 lh 版:直接 wecomFetch 推送) ──
662
+ const trimmedText = text.trim();
663
+ if (trimmedText.startsWith("{") && trimmedText.includes('"template_card"')) {
664
+ try {
665
+ const parsed = JSON.parse(trimmedText);
666
+ if (parsed.template_card) {
667
+ const isSingleChat = chatType !== "group";
668
+ const responseUrl = getActiveReplyUrl(streamId);
669
+ if (responseUrl && isSingleChat) {
670
+ // 单聊且有 response_url:发送卡片
671
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
672
+ const res = await wecomFetch(responseUrl, {
673
+ method: "POST",
674
+ headers: { "Content-Type": "application/json" },
675
+ body: JSON.stringify({
676
+ msgtype: "template_card",
677
+ template_card: parsed.template_card,
678
+ }),
679
+ }, { proxyUrl, timeoutMs: REQUEST_TIMEOUT_MS });
680
+ if (!res.ok) {
681
+ throw new Error(`template_card send failed: ${res.status}`);
682
+ }
683
+ });
684
+ target.runtime.log?.(`[webhook] sent template_card: task_id=${parsed.template_card.task_id}`);
685
+ streamStore.updateStream(streamId, (s) => {
686
+ s.finished = true;
687
+ s.content = "[已发送交互卡片]";
688
+ });
689
+ target.statusSink?.({ lastOutboundAt: Date.now() });
690
+ return;
691
+ }
692
+ else {
693
+ // 群聊 或 无 response_url:降级为文本描述
694
+ target.runtime.log?.(`[webhook] template_card fallback to text (group=${!isSingleChat}, hasUrl=${!!responseUrl})`);
695
+ const cardTitle = parsed.template_card.main_title?.title || "交互卡片";
696
+ const cardDesc = parsed.template_card.main_title?.desc || "";
697
+ const buttons = parsed.template_card.button_list?.map((b) => b.text).join(" / ") || "";
698
+ text = `📋 **${cardTitle}**${cardDesc ? `\n${cardDesc}` : ""}${buttons ? `\n\n选项: ${buttons}` : ""}`;
699
+ }
700
+ }
701
+ }
702
+ catch { /* parse fail, use normal text */ }
703
+ }
704
+ // ── Markdown 表格转换 ──
705
+ text = core.channel.text.convertMarkdownTables(text, tableMode);
706
+ // ── 还原 <think> 标签 ──
707
+ thinks.forEach((think, i) => {
708
+ text = text.replace(`__THINK_PLACEHOLDER_${i}__`, think);
709
+ });
710
+ const current = streamStore.getStream(streamId);
711
+ if (!current)
712
+ return;
713
+ if (!current.images)
714
+ current.images = [];
715
+ if (!current.agentMediaKeys)
716
+ current.agentMediaKeys = [];
717
+ // ── 从输出文本推断本机图片路径(安全:要求路径也出现在用户原消息中) ──
718
+ if (!payload.mediaUrl && !(payload.mediaUrls?.length ?? 0) && text.includes("/")) {
719
+ const candidates = extractLocalImagePathsFromText({ text, mustAlsoAppearIn: rawBody });
720
+ if (candidates.length > 0) {
721
+ target.runtime.log?.(`media: 从输出文本推断到本机图片路径(来自用户原消息)count=${candidates.length}`);
722
+ for (const p of candidates) {
723
+ try {
724
+ const fs = await import("node:fs/promises");
725
+ const pathModule = await import("node:path");
726
+ const buf = await fs.readFile(p);
727
+ const ext = pathModule.extname(p).slice(1).toLowerCase();
728
+ const imageExts = {
729
+ jpg: "image/jpeg",
730
+ jpeg: "image/jpeg",
731
+ png: "image/png",
732
+ gif: "image/gif",
733
+ webp: "image/webp",
734
+ bmp: "image/bmp",
735
+ };
736
+ const contentType = imageExts[ext] ?? "application/octet-stream";
737
+ if (!contentType.startsWith("image/")) {
738
+ continue;
739
+ }
740
+ const base64 = buf.toString("base64");
741
+ const md5 = computeMd5(buf);
742
+ current.images.push({ base64, md5 });
743
+ }
744
+ catch (err) {
745
+ target.runtime.error?.(`[webhook] media: 读取本机图片失败 path=${p}: ${String(err)}`);
746
+ }
747
+ }
748
+ }
749
+ }
750
+ // ── 每次 deliver 都追加到 dmContent(不受 STREAM_MAX_BYTES 限制) ──
751
+ if (text.trim()) {
752
+ streamStore.updateStream(streamId, (s) => {
753
+ appendDmContent(s, text);
754
+ });
755
+ }
756
+ // ── 超时渐进式检测(接近 6 分钟窗口,对齐 lh 版) ──
757
+ const now = Date.now();
758
+ const deadline = current.createdAt + BOT_WINDOW_MS;
759
+ const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
760
+ const nearTimeout = !current.fallbackMode && !current.finished && now >= switchAt;
761
+ if (nearTimeout) {
762
+ const agentOk = isAgentConfigured(target);
763
+ const prompt = buildFallbackPrompt({
764
+ kind: "timeout",
765
+ agentConfigured: agentOk,
766
+ userId: current.userId,
767
+ chatType: current.chatType,
768
+ });
769
+ target.runtime.log?.(`[webhook] fallback(timeout): 触发切换(接近 6 分钟)chatType=${current.chatType} agentConfigured=${agentOk} hasResponseUrl=${Boolean(getActiveReplyUrl(streamId))}`);
770
+ streamStore.updateStream(streamId, (s) => {
771
+ s.fallbackMode = "timeout";
772
+ s.finished = true;
773
+ s.content = prompt;
774
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
775
+ });
776
+ try {
777
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
778
+ target.runtime.log?.(`[webhook] fallback(timeout): 群内提示已推送`);
779
+ }
780
+ catch (err) {
781
+ target.runtime.error?.(`[webhook] fallback(timeout) prompt push failed streamId=${streamId}: ${String(err)}`);
782
+ }
783
+ return;
784
+ }
785
+ // ── MEDIA: 指令解析 ──
786
+ const mediaDirectivePaths = [];
787
+ const mediaDirectiveRe = /^MEDIA:\s*`?([^\n`]+?)`?\s*$/gm;
788
+ let _mdMatch;
789
+ while ((_mdMatch = mediaDirectiveRe.exec(text)) !== null) {
790
+ let p = (_mdMatch[1] ?? "").trim();
791
+ if (!p)
792
+ continue;
793
+ if (p.startsWith("~/") || p === "~") {
794
+ const home = os.homedir() || "/root";
795
+ p = p.replace(/^~/, home);
796
+ }
797
+ if (!mediaDirectivePaths.includes(p))
798
+ mediaDirectivePaths.push(p);
799
+ }
800
+ if (mediaDirectivePaths.length > 0) {
801
+ text = text.replace(/^MEDIA:\s*`?[^\n`]+?`?\s*$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
802
+ }
803
+ // ── 媒体处理(对齐 lh 版) ──
804
+ const mediaUrls = Array.from(new Set([
805
+ ...(payload.mediaUrls || []),
806
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
807
+ ...mediaDirectivePaths,
808
+ ]));
809
+ for (const mPath of mediaUrls) {
810
+ let contentType;
811
+ let filename = mPath.split("/").pop() || "attachment";
812
+ try {
813
+ let buf;
814
+ const looksLikeUrl = /^https?:\/\//i.test(mPath);
815
+ if (looksLikeUrl) {
816
+ const loaded = await core.channel.media.fetchRemoteMedia({ url: mPath });
817
+ buf = loaded.buffer;
818
+ contentType = loaded.contentType;
819
+ filename = loaded.fileName ?? "attachment";
820
+ }
821
+ else {
822
+ const fs = await import("node:fs/promises");
823
+ const pathMod = await import("node:path");
824
+ buf = await fs.readFile(mPath);
825
+ filename = pathMod.basename(mPath);
826
+ const ext = pathMod.extname(mPath).slice(1).toLowerCase();
827
+ contentType = MIME_BY_EXT[ext] ?? "application/octet-stream";
828
+ }
829
+ if (contentType?.startsWith("image/")) {
830
+ // 非 WS 模式:保持原有 base64 嵌入方式
831
+ const base64 = buf.toString("base64");
832
+ const md5 = computeMd5(buf);
833
+ current.images.push({ base64, md5 });
834
+ target.runtime.log?.(`[webhook] media: 识别为图片 contentType=${contentType} filename=${filename}`);
835
+ }
836
+ else {
837
+ // Webhook 模式:统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
838
+ const agentOk = isAgentConfigured(target);
839
+ const alreadySent = current.agentMediaKeys.includes(mPath);
840
+ target.runtime.log?.(`[webhook] fallback(media): 检测到非图片文件 chatType=${current.chatType} contentType=${contentType ?? "unknown"} filename=${filename} agentConfigured=${agentOk} alreadySent=${alreadySent} hasResponseUrl=${Boolean(getActiveReplyUrl(streamId))}`);
841
+ // TODO: 补充agent
842
+ if (agentOk && !alreadySent && current.userId) {
843
+ try {
844
+ await agentDmMedia({
845
+ target,
846
+ userId: current.userId,
847
+ mediaUrlOrPath: mPath,
848
+ contentType,
849
+ filename,
850
+ });
851
+ target.runtime.log?.(`[webhook] fallback(media): 文件已通过 Agent 私信发送 user=${current.userId}`);
852
+ streamStore.updateStream(streamId, (s) => {
853
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mPath]));
854
+ });
855
+ }
856
+ catch (err) {
857
+ target.runtime.error?.(`[webhook] Agent DM 媒体发送失败: ${String(err)}`);
858
+ }
859
+ }
860
+ if (!current.fallbackMode) {
861
+ const prompt = buildFallbackPrompt({
862
+ kind: "media",
863
+ agentConfigured: agentOk,
864
+ userId: current.userId,
865
+ filename,
866
+ chatType: current.chatType,
867
+ });
868
+ streamStore.updateStream(streamId, (s) => {
869
+ s.fallbackMode = "media";
870
+ s.finished = true;
871
+ s.content = prompt;
872
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
873
+ });
874
+ try {
875
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
876
+ target.runtime.log?.(`[webhook] fallback(media): 群内提示已推送`);
877
+ }
878
+ catch (err) {
879
+ target.runtime.error?.(`[webhook] fallback(media) prompt push failed streamId=${streamId}: ${String(err)}`);
880
+ }
881
+ }
882
+ return;
883
+ }
884
+ }
885
+ catch (err) {
886
+ target.runtime.error?.(`[webhook] 媒体处理失败: ${mPath}: ${String(err)}`);
887
+ // Webhook 模式:Agent 私信兜底
888
+ const agentOk = isAgentConfigured(target);
889
+ const fallbackFilename = filename || mPath.split("/").pop() || "attachment";
890
+ // TODO: 补充agent
891
+ if (agentOk && current.userId && !current.agentMediaKeys.includes(mPath)) {
892
+ try {
893
+ await agentDmMedia({
894
+ target,
895
+ userId: current.userId,
896
+ mediaUrlOrPath: mPath,
897
+ contentType,
898
+ filename: fallbackFilename,
899
+ });
900
+ streamStore.updateStream(streamId, (s) => {
901
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mPath]));
902
+ });
903
+ target.runtime.log?.(`[webhook] fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
904
+ }
905
+ catch (sendErr) {
906
+ target.runtime.error?.(`[webhook] fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
907
+ }
908
+ }
909
+ if (!current.fallbackMode) {
910
+ const prompt = buildFallbackPrompt({
911
+ kind: "error",
912
+ agentConfigured: agentOk,
913
+ userId: current.userId,
914
+ filename: fallbackFilename,
915
+ chatType: current.chatType,
916
+ });
917
+ streamStore.updateStream(streamId, (s) => {
918
+ s.fallbackMode = "error";
919
+ s.finished = true;
920
+ s.content = prompt;
921
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
922
+ });
923
+ try {
924
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
925
+ target.runtime.log?.(`[webhook] fallback(error): 群内提示已推送`);
926
+ }
927
+ catch (pushErr) {
928
+ target.runtime.error?.(`[webhook] fallback(error) prompt push failed streamId=${streamId}: ${String(pushErr)}`);
929
+ }
930
+ }
931
+ return;
932
+ }
933
+ }
934
+ // ── fallbackMode 检查:如果已进入 fallback 模式,不再更新 content ──
935
+ const mode = streamStore.getStream(streamId)?.fallbackMode;
936
+ if (mode)
937
+ return;
938
+ // ── 累积 content(段落分隔 \n\n,truncate 保护) ──
939
+ const nextText = current.content
940
+ ? `${current.content}\n\n${text}`.trim()
941
+ : text.trim();
942
+ streamStore.updateStream(streamId, (s) => {
943
+ s.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
944
+ if (current.images?.length)
945
+ s.images = current.images;
946
+ });
947
+ target.statusSink?.({ lastOutboundAt: Date.now() });
948
+ },
949
+ onError: (err) => {
950
+ target.runtime.error?.(`[webhook] Agent reply failed (streamId=${streamId}): ${String(err)}`);
951
+ },
952
+ },
953
+ });
954
+ // ──────────────────────────────────────────────────────────────────
955
+ // 13. 后处理:/new /reset 中文回执
956
+ // ──────────────────────────────────────────────────────────────────
957
+ if (isResetCommand) {
958
+ const current = streamStore.getStream(streamId);
959
+ if (current && !current.content?.trim()) {
960
+ const ackText = resetCommandKind === "reset" ? "✅ 已重置会话。" : "✅ 已开启新会话。";
961
+ streamStore.updateStream(streamId, (s) => { s.content = ackText; s.finished = true; });
962
+ }
963
+ }
964
+ // 空内容兜底
965
+ streamStore.updateStream(streamId, (s) => {
966
+ if (!s.content.trim() && !(s.images?.length ?? 0)) {
967
+ const hasMediaDelivered = (s.agentMediaKeys?.length ?? 0) > 0;
968
+ const hasFallback = Boolean(s.fallbackMode);
969
+ if (hasMediaDelivered) {
970
+ s.content = "✅ 文件已发送。";
971
+ }
972
+ else if (!hasFallback) {
973
+ s.content = "✅ 已处理完成。";
974
+ }
975
+ }
976
+ });
977
+ streamStore.markFinished(streamId);
978
+ // ──────────────────────────────────────────────────────────────────
979
+ // 14. 超时模式下 Agent DM 最终投递(对齐 lh 版)
980
+ // ──────────────────────────────────────────────────────────────────
981
+ const finishedState = streamStore.getStream(streamId);
982
+ // TODO: 补充agent
983
+ if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt) {
984
+ if (!isAgentConfigured(target)) {
985
+ // Agent not configured - group prompt already explains the situation.
986
+ streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
987
+ }
988
+ else if (finishedState.userId) {
989
+ const dmText = (finishedState.dmContent ?? "").trim();
990
+ if (dmText) {
991
+ try {
992
+ target.runtime.log?.(`[webhook] fallback(timeout): 开始通过 Agent 私信发送剩余内容 user=${finishedState.userId} len=${dmText.length}`);
993
+ await agentDmText({ target, userId: finishedState.userId, text: dmText });
994
+ target.runtime.log?.(`[webhook] fallback(timeout): Agent 私信发送完成 user=${finishedState.userId}`);
995
+ }
996
+ catch (err) {
997
+ target.runtime.error?.(`[webhook] fallback(timeout): Agent 私信发送失败: ${String(err)}`);
998
+ }
999
+ }
1000
+ streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
1001
+ }
1002
+ }
1003
+ // ──────────────────────────────────────────────────────────────────
1004
+ // 15. 统一终结:主动推送最终流帧(对齐 lh 版:直接 POST JSON 不加密)
1005
+ // ──────────────────────────────────────────────────────────────────
1006
+ const stateAfterFinish = streamStore.getStream(streamId);
1007
+ const responseUrl = getActiveReplyUrl(streamId);
1008
+ if (stateAfterFinish && responseUrl) {
1009
+ try {
1010
+ await pushFinalStreamReplyNow(streamId);
1011
+ target.runtime.log?.(`[webhook] final stream pushed via response_url streamId=${streamId}, chatType=${chatType}, images=${stateAfterFinish.images?.length ?? 0}`);
1012
+ }
1013
+ catch (err) {
1014
+ target.runtime.error?.(`[webhook] final stream push via response_url failed streamId=${streamId}: ${String(err)}`);
1015
+ }
1016
+ }
1017
+ // ──────────────────────────────────────────────────────────────────
1018
+ // 16. 更新回执流 + 推进队列
1019
+ // ──────────────────────────────────────────────────────────────────
1020
+ target.runtime.log?.(`[webhook] queue: 当前批次结束,尝试推进下一批 streamId=${streamId}`);
1021
+ const ackStreamIds = streamStore.drainAckStreamsForBatch(streamId);
1022
+ if (ackStreamIds.length > 0) {
1023
+ const mergedDoneHint = "✅ 已合并处理完成,请查看上一条回复。";
1024
+ for (const ackId of ackStreamIds) {
1025
+ streamStore.updateStream(ackId, (s) => { s.content = mergedDoneHint; s.finished = true; });
1026
+ }
1027
+ target.runtime.log?.(`[webhook] queue: 已更新回执流 count=${ackStreamIds.length} batchStreamId=${streamId}`);
1028
+ }
1029
+ streamStore.onStreamFinished(streamId);
1030
+ }
1031
+ // ============================================================================
1032
+ // 内部辅助:response_url 推送)
1033
+ // ============================================================================
1034
+ /**
1035
+ * 获取 response_url
1036
+ */
1037
+ function getActiveReplyUrl(streamId) {
1038
+ return getMonitorState().activeReplyStore.getUrl(streamId);
1039
+ }
1040
+ /**
1041
+ * 使用 response_url 发送一次性请求
1042
+ */
1043
+ async function useActiveReplyOnce(streamId, fn) {
1044
+ return getMonitorState().activeReplyStore.use(streamId, fn);
1045
+ }
1046
+ /**
1047
+ * 通过 response_url 推送 Bot 兜底提示
1048
+ *
1049
+ * 对齐 lh 版:直接 POST JSON,不加密。
1050
+ */
1051
+ async function sendBotFallbackPromptNow(params) {
1052
+ const responseUrl = getActiveReplyUrl(params.streamId);
1053
+ if (!responseUrl) {
1054
+ throw new Error("no response_url(无法主动推送群内提示)");
1055
+ }
1056
+ await useActiveReplyOnce(params.streamId, async ({ responseUrl, proxyUrl }) => {
1057
+ const payload = {
1058
+ msgtype: "stream",
1059
+ stream: {
1060
+ id: params.streamId,
1061
+ finish: true,
1062
+ content: truncateUtf8Bytes(params.text, STREAM_MAX_BYTES) || "1",
1063
+ },
1064
+ };
1065
+ const res = await wecomFetch(responseUrl, {
1066
+ method: "POST",
1067
+ headers: { "Content-Type": "application/json" },
1068
+ body: JSON.stringify(payload),
1069
+ }, { proxyUrl, timeoutMs: REQUEST_TIMEOUT_MS });
1070
+ if (!res.ok) {
1071
+ throw new Error(`fallback prompt push failed: ${res.status}`);
1072
+ }
1073
+ });
1074
+ }
1075
+ /**
1076
+ * 通过 response_url 推送最终流帧(对齐 lh 版 pushFinalStreamReplyNow)
1077
+ *
1078
+ * 对齐 lh 版:从 StreamState 构建完整响应,直接 POST JSON,不加密。
1079
+ */
1080
+ async function pushFinalStreamReplyNow(streamId) {
1081
+ const state = getMonitorState().streamStore.getStream(streamId);
1082
+ const responseUrl = getActiveReplyUrl(streamId);
1083
+ if (!state || !responseUrl)
1084
+ return;
1085
+ const finalReply = buildStreamReplyFromState(state, STREAM_MAX_BYTES);
1086
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1087
+ const res = await wecomFetch(responseUrl, {
1088
+ method: "POST",
1089
+ headers: { "Content-Type": "application/json" },
1090
+ body: JSON.stringify(finalReply),
1091
+ }, { proxyUrl, timeoutMs: REQUEST_TIMEOUT_MS });
1092
+ if (!res.ok) {
1093
+ throw new Error(`final stream push failed: ${res.status}`);
1094
+ }
1095
+ });
1096
+ }
1097
+ /**
1098
+ * 通过 Agent 私信发送文本(对齐 lh 版 sendAgentDmText)
1099
+ *
1100
+ * 对齐 lh 版:使用 core.channel.text.chunkText 进行文本分块。
1101
+ */
1102
+ async function agentDmText(params) {
1103
+ const { target, userId, text } = params;
1104
+ if (!target.account.agent?.configured) {
1105
+ throw new Error("Agent credentials not configured");
1106
+ }
1107
+ const agent = target.account.agent;
1108
+ const chunks = target.core.channel.text.chunkText(text, 20480);
1109
+ for (const chunk of chunks) {
1110
+ const trimmed = chunk.trim();
1111
+ if (!trimmed)
1112
+ continue;
1113
+ await sendAgentText({
1114
+ agent,
1115
+ toUser: userId,
1116
+ text: trimmed,
1117
+ });
1118
+ }
1119
+ }
1120
+ /**
1121
+ * 通过 Agent 私信发送媒体(对齐 lh 版 sendAgentDmMedia)
1122
+ *
1123
+ * 对齐 lh 版:接受 mediaUrlOrPath,内部判断 URL 或本地路径。
1124
+ */
1125
+ async function agentDmMedia(params) {
1126
+ const { target, userId, mediaUrlOrPath, filename } = params;
1127
+ if (!target.account.agent?.configured) {
1128
+ throw new Error("Agent credentials not configured");
1129
+ }
1130
+ const agent = target.account.agent;
1131
+ let buffer;
1132
+ let inferredContentType = params.contentType;
1133
+ const looksLikeUrl = /^https?:\/\//i.test(mediaUrlOrPath);
1134
+ if (looksLikeUrl) {
1135
+ const res = await fetch(mediaUrlOrPath, { signal: AbortSignal.timeout(30_000) });
1136
+ if (!res.ok)
1137
+ throw new Error(`media download failed: ${res.status}`);
1138
+ buffer = Buffer.from(await res.arrayBuffer());
1139
+ inferredContentType = inferredContentType || res.headers.get("content-type") || "application/octet-stream";
1140
+ }
1141
+ else {
1142
+ const fs = await import("node:fs/promises");
1143
+ buffer = await fs.readFile(mediaUrlOrPath);
1144
+ }
1145
+ let mediaType = "file";
1146
+ const ct = (inferredContentType || "").toLowerCase();
1147
+ if (ct.startsWith("image/"))
1148
+ mediaType = "image";
1149
+ else if (ct.startsWith("audio/"))
1150
+ mediaType = "voice";
1151
+ else if (ct.startsWith("video/"))
1152
+ mediaType = "video";
1153
+ const mediaId = await uploadMedia({ agent, type: mediaType, buffer, filename });
1154
+ await sendAgentMedia({
1155
+ agent,
1156
+ toUser: userId,
1157
+ mediaId,
1158
+ mediaType,
1159
+ ...(mediaType === "video" ? { title: filename, description: "" } : {}),
1160
+ });
1161
+ }