@dingxiang-me/openclaw-wechat 1.4.1 → 1.7.2

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 (47) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.en.md +89 -12
  3. package/README.md +103 -15
  4. package/docs/channels/wecom.md +28 -3
  5. package/openclaw.plugin.json +467 -10
  6. package/package.json +13 -2
  7. package/src/core/agent-routing.js +6 -0
  8. package/src/core.js +564 -35
  9. package/src/wecom/account-config-core.js +28 -8
  10. package/src/wecom/account-config.js +55 -0
  11. package/src/wecom/account-diagnostics.js +121 -0
  12. package/src/wecom/account-paths.js +39 -0
  13. package/src/wecom/agent-inbound-dispatch.js +9 -0
  14. package/src/wecom/agent-inbound-guards.js +24 -4
  15. package/src/wecom/agent-inbound-processor.js +27 -0
  16. package/src/wecom/agent-webhook-handler.js +11 -0
  17. package/src/wecom/bot-context.js +2 -1
  18. package/src/wecom/bot-dispatch-fallback.js +2 -1
  19. package/src/wecom/bot-inbound-content.js +73 -3
  20. package/src/wecom/bot-inbound-dispatch-runtime.js +2 -1
  21. package/src/wecom/bot-inbound-executor-helpers.js +56 -5
  22. package/src/wecom/bot-inbound-executor.js +19 -0
  23. package/src/wecom/bot-inbound-guards.js +36 -4
  24. package/src/wecom/bot-runtime-context.js +5 -3
  25. package/src/wecom/bot-webhook-dispatch.js +45 -12
  26. package/src/wecom/bot-webhook-handler.js +45 -13
  27. package/src/wecom/command-handlers.js +26 -0
  28. package/src/wecom/command-status-text.js +76 -7
  29. package/src/wecom/observability-metrics.js +133 -0
  30. package/src/wecom/outbound-agent-push.js +2 -1
  31. package/src/wecom/outbound-bot-card.js +103 -0
  32. package/src/wecom/outbound-delivery.js +92 -7
  33. package/src/wecom/outbound-response-delivery.js +10 -6
  34. package/src/wecom/outbound-webhook-delivery.js +42 -1
  35. package/src/wecom/plugin-account-policy-services.js +19 -0
  36. package/src/wecom/plugin-base-services.js +13 -0
  37. package/src/wecom/plugin-constants.js +1 -1
  38. package/src/wecom/plugin-delivery-inbound-services.js +8 -0
  39. package/src/wecom/plugin-processing-deps.js +4 -0
  40. package/src/wecom/plugin-route-runtime-deps.js +5 -0
  41. package/src/wecom/plugin-services.js +7 -0
  42. package/src/wecom/policy-resolvers.js +82 -5
  43. package/src/wecom/register-runtime.js +31 -2
  44. package/src/wecom/route-registration.js +173 -41
  45. package/src/wecom/runtime-utils.js +7 -2
  46. package/src/wecom/webhook-adapter.js +61 -0
  47. package/src/wecom/webhook-bot.js +26 -0
@@ -4,6 +4,45 @@ function assertFunction(name, value) {
4
4
  }
5
5
  }
6
6
 
7
+ const UNSUPPORTED_BOT_GROUP_TRIGGER_WARNED = new Set();
8
+
9
+ function warnUnsupportedBotGroupTriggerOnce(triggerMode, logger) {
10
+ const mode = String(triggerMode ?? "").trim().toLowerCase();
11
+ if (!mode || UNSUPPORTED_BOT_GROUP_TRIGGER_WARNED.has(mode)) return;
12
+ UNSUPPORTED_BOT_GROUP_TRIGGER_WARNED.add(mode);
13
+ logger?.warn?.(
14
+ `wecom(bot): groupChat.triggerMode=${mode} is not supported by WeCom Bot group callbacks; forcing mention mode (@).`,
15
+ );
16
+ }
17
+
18
+ export function normalizeWecomBotGroupChatPolicy(groupChatPolicy = {}, logger) {
19
+ const policy = groupChatPolicy && typeof groupChatPolicy === "object" ? groupChatPolicy : {};
20
+ const enabled = policy.enabled !== false;
21
+ const mentionPatterns =
22
+ Array.isArray(policy.mentionPatterns) && policy.mentionPatterns.length > 0 ? policy.mentionPatterns : ["@"];
23
+
24
+ if (!enabled) {
25
+ return {
26
+ ...policy,
27
+ enabled: false,
28
+ mentionPatterns,
29
+ };
30
+ }
31
+
32
+ const triggerMode = String(policy.triggerMode ?? "").trim().toLowerCase();
33
+ if (triggerMode && triggerMode !== "mention") {
34
+ warnUnsupportedBotGroupTriggerOnce(triggerMode, logger);
35
+ }
36
+
37
+ return {
38
+ ...policy,
39
+ enabled: true,
40
+ triggerMode: "mention",
41
+ requireMention: true,
42
+ mentionPatterns,
43
+ };
44
+ }
45
+
7
46
  export function assertWecomBotInboundFlowDeps({ api, ...deps } = {}) {
8
47
  if (!api || typeof api !== "object") {
9
48
  throw new Error("executeWecomBotInboundFlow: api is required");
@@ -23,6 +62,7 @@ export function assertWecomBotInboundFlowDeps({ api, ...deps } = {}) {
23
62
  "stripWecomGroupMentions",
24
63
  "resolveWecomCommandPolicy",
25
64
  "resolveWecomAllowFromPolicy",
65
+ "resolveWecomDmPolicy",
26
66
  "isWecomSenderAllowed",
27
67
  "extractLeadingSlashCommand",
28
68
  "buildWecomBotHelpText",
@@ -48,11 +88,15 @@ export function assertWecomBotInboundFlowDeps({ api, ...deps } = {}) {
48
88
 
49
89
  export function createWecomBotInboundFlowState({
50
90
  api,
91
+ accountId = "default",
51
92
  fromUser,
52
93
  content,
53
94
  imageUrls,
54
95
  fileUrl,
55
96
  fileName,
97
+ voiceUrl,
98
+ voiceMediaId,
99
+ voiceContentType,
56
100
  quote,
57
101
  buildWecomBotSessionId,
58
102
  resolveWecomBotConfig,
@@ -62,23 +106,29 @@ export function createWecomBotInboundFlowState({
62
106
  } = {}) {
63
107
  const runtime = api.runtime;
64
108
  const cfg = api.config;
65
- const baseSessionId = buildWecomBotSessionId(fromUser);
109
+ const normalizedAccountId = String(accountId ?? "default").trim().toLowerCase() || "default";
110
+ const baseSessionId = buildWecomBotSessionId(fromUser, normalizedAccountId);
66
111
  const state = {
67
112
  runtime,
68
113
  cfg,
114
+ accountId: normalizedAccountId,
69
115
  baseSessionId,
70
116
  sessionId: baseSessionId,
71
117
  routedAgentId: "",
72
- fromAddress: `wecom-bot:${fromUser}`,
118
+ fromAddress:
119
+ normalizedAccountId === "default" ? `wecom-bot:${fromUser}` : `wecom-bot:${normalizedAccountId}:${fromUser}`,
73
120
  normalizedFromUser: String(fromUser ?? "").trim().toLowerCase(),
74
121
  originalContent: String(content ?? ""),
75
122
  commandBody: String(content ?? ""),
76
123
  dispatchStartedAt: Date.now(),
77
124
  tempPathsToCleanup: [],
78
- botModeConfig: resolveWecomBotConfig(api),
79
- botProxyUrl: resolveWecomBotProxyConfig(api),
125
+ botModeConfig: resolveWecomBotConfig(api, normalizedAccountId),
126
+ botProxyUrl: resolveWecomBotProxyConfig(api, normalizedAccountId),
80
127
  normalizedFileUrl: String(fileUrl ?? "").trim(),
81
128
  normalizedFileName: String(fileName ?? "").trim(),
129
+ normalizedVoiceUrl: String(voiceUrl ?? "").trim(),
130
+ normalizedVoiceMediaId: String(voiceMediaId ?? "").trim(),
131
+ normalizedVoiceContentType: String(voiceContentType ?? "").trim(),
82
132
  normalizedQuote:
83
133
  quote && typeof quote === "object"
84
134
  ? {
@@ -93,7 +143,7 @@ export function createWecomBotInboundFlowState({
93
143
  .filter(Boolean),
94
144
  ),
95
145
  ),
96
- groupChatPolicy: resolveWecomGroupChatPolicy(api),
146
+ groupChatPolicy: normalizeWecomBotGroupChatPolicy(resolveWecomGroupChatPolicy(api), api?.logger),
97
147
  dynamicAgentPolicy: resolveWecomDynamicAgentPolicy(api),
98
148
  isAdminUser: false,
99
149
  };
@@ -129,6 +179,7 @@ export function createWecomBotSafeReplyHelpers({
129
179
  const result = await deliverBotReplyText({
130
180
  api,
131
181
  fromUser,
182
+ accountId: state.accountId,
132
183
  sessionId: state.sessionId,
133
184
  streamId,
134
185
  responseUrl,
@@ -13,6 +13,7 @@ export async function executeWecomBotInboundFlow(payload = {}) {
13
13
  const {
14
14
  api,
15
15
  streamId,
16
+ accountId = "default",
16
17
  fromUser,
17
18
  content,
18
19
  msgType = "text",
@@ -22,6 +23,9 @@ export async function executeWecomBotInboundFlow(payload = {}) {
22
23
  imageUrls = [],
23
24
  fileUrl = "",
24
25
  fileName = "",
26
+ voiceUrl = "",
27
+ voiceMediaId = "",
28
+ voiceContentType = "",
25
29
  quote = null,
26
30
  responseUrl = "",
27
31
  buildWecomBotSessionId,
@@ -38,6 +42,7 @@ export async function executeWecomBotInboundFlow(payload = {}) {
38
42
  stripWecomGroupMentions,
39
43
  resolveWecomCommandPolicy,
40
44
  resolveWecomAllowFromPolicy,
45
+ resolveWecomDmPolicy,
41
46
  isWecomSenderAllowed,
42
47
  extractLeadingSlashCommand,
43
48
  buildWecomBotHelpText,
@@ -65,11 +70,15 @@ export async function executeWecomBotInboundFlow(payload = {}) {
65
70
 
66
71
  const state = createWecomBotInboundFlowState({
67
72
  api,
73
+ accountId,
68
74
  fromUser,
69
75
  content,
70
76
  imageUrls,
71
77
  fileUrl,
72
78
  fileName,
79
+ voiceUrl,
80
+ voiceMediaId,
81
+ voiceContentType,
73
82
  quote,
74
83
  buildWecomBotSessionId,
75
84
  resolveWecomBotConfig,
@@ -111,12 +120,15 @@ export async function executeWecomBotInboundFlow(payload = {}) {
111
120
 
112
121
  const commandGuardResult = applyWecomBotCommandAndSenderGuard({
113
122
  api,
123
+ accountId: state.accountId,
114
124
  fromUser,
125
+ isGroupChat,
115
126
  msgType,
116
127
  commandBody: state.commandBody,
117
128
  normalizedFromUser: state.normalizedFromUser,
118
129
  resolveWecomCommandPolicy,
119
130
  resolveWecomAllowFromPolicy,
131
+ resolveWecomDmPolicy,
120
132
  isWecomSenderAllowed,
121
133
  extractLeadingSlashCommand,
122
134
  buildWecomBotHelpText,
@@ -138,6 +150,10 @@ export async function executeWecomBotInboundFlow(payload = {}) {
138
150
  normalizedImageUrls: state.normalizedImageUrls,
139
151
  normalizedFileUrl: state.normalizedFileUrl,
140
152
  normalizedFileName: state.normalizedFileName,
153
+ normalizedVoiceUrl: state.normalizedVoiceUrl,
154
+ normalizedVoiceMediaId: state.normalizedVoiceMediaId,
155
+ normalizedVoiceContentType: state.normalizedVoiceContentType,
156
+ voiceInputMessageId: msgId,
141
157
  normalizedQuote: state.normalizedQuote,
142
158
  });
143
159
  if (Array.isArray(inboundContentResult.tempPathsToCleanup)) {
@@ -166,6 +182,7 @@ export async function executeWecomBotInboundFlow(payload = {}) {
166
182
  commandBody: state.commandBody,
167
183
  originalContent: state.originalContent,
168
184
  fromAddress: state.fromAddress,
185
+ accountId: state.accountId,
169
186
  groupChatPolicy: state.groupChatPolicy,
170
187
  dynamicAgentPolicy: state.dynamicAgentPolicy,
171
188
  isAdminUser: state.isAdminUser,
@@ -186,6 +203,7 @@ export async function executeWecomBotInboundFlow(payload = {}) {
186
203
  cfg,
187
204
  ctxPayload,
188
205
  streamId,
206
+ accountId: state.accountId,
189
207
  sessionId: state.sessionId,
190
208
  routedAgentId: state.routedAgentId,
191
209
  storePath,
@@ -225,6 +243,7 @@ export async function executeWecomBotInboundFlow(payload = {}) {
225
243
  readTranscriptFallbackResult,
226
244
  safeDeliverReply,
227
245
  markTranscriptReplyDelivered,
246
+ accountId: state.accountId,
228
247
  });
229
248
  if (shouldReturnFromError) return;
230
249
  } finally {
@@ -52,12 +52,15 @@ export function applyWecomBotGroupChatGuard({
52
52
 
53
53
  export function applyWecomBotCommandAndSenderGuard({
54
54
  api,
55
+ accountId = "default",
55
56
  fromUser,
57
+ isGroupChat = false,
56
58
  msgType = "text",
57
59
  commandBody = "",
58
60
  normalizedFromUser = "",
59
61
  resolveWecomCommandPolicy,
60
62
  resolveWecomAllowFromPolicy,
63
+ resolveWecomDmPolicy,
61
64
  isWecomSenderAllowed,
62
65
  extractLeadingSlashCommand,
63
66
  buildWecomBotHelpText,
@@ -65,6 +68,7 @@ export function applyWecomBotCommandAndSenderGuard({
65
68
  } = {}) {
66
69
  assertFunction("resolveWecomCommandPolicy", resolveWecomCommandPolicy);
67
70
  assertFunction("resolveWecomAllowFromPolicy", resolveWecomAllowFromPolicy);
71
+ assertFunction("resolveWecomDmPolicy", resolveWecomDmPolicy);
68
72
  assertFunction("isWecomSenderAllowed", isWecomSenderAllowed);
69
73
  assertFunction("extractLeadingSlashCommand", extractLeadingSlashCommand);
70
74
  assertFunction("buildWecomBotHelpText", buildWecomBotHelpText);
@@ -72,7 +76,34 @@ export function applyWecomBotCommandAndSenderGuard({
72
76
 
73
77
  const commandPolicy = resolveWecomCommandPolicy(api);
74
78
  const isAdminUser = commandPolicy.adminUsers.includes(String(normalizedFromUser ?? "").trim().toLowerCase());
75
- const allowFromPolicy = resolveWecomAllowFromPolicy(api, "default", {});
79
+ const dmPolicy = resolveWecomDmPolicy(api, accountId, {});
80
+ if (!isGroupChat) {
81
+ if (dmPolicy.mode === "deny") {
82
+ return {
83
+ ok: false,
84
+ finishText: dmPolicy.rejectMessage || "当前渠道私聊已关闭,请联系管理员。",
85
+ commandBody: String(commandBody ?? ""),
86
+ isAdminUser,
87
+ commandPolicy,
88
+ };
89
+ }
90
+ if (dmPolicy.mode === "allowlist") {
91
+ const dmSenderAllowed = isAdminUser || isWecomSenderAllowed({
92
+ senderId: normalizedFromUser,
93
+ allowFrom: dmPolicy.allowFrom,
94
+ });
95
+ if (!dmSenderAllowed) {
96
+ return {
97
+ ok: false,
98
+ finishText: dmPolicy.rejectMessage || "当前私聊账号未授权,请联系管理员。",
99
+ commandBody: String(commandBody ?? ""),
100
+ isAdminUser,
101
+ commandPolicy,
102
+ };
103
+ }
104
+ }
105
+ }
106
+ const allowFromPolicy = resolveWecomAllowFromPolicy(api, accountId, {});
76
107
  const senderAllowed = isAdminUser || isWecomSenderAllowed({
77
108
  senderId: normalizedFromUser,
78
109
  allowFrom: allowFromPolicy.allowFrom,
@@ -90,14 +121,15 @@ export function applyWecomBotCommandAndSenderGuard({
90
121
  let nextCommandBody = String(commandBody ?? "");
91
122
  if (msgType === "text") {
92
123
  let commandKey = extractLeadingSlashCommand(nextCommandBody);
93
- if (commandKey === "/clear") {
94
- nextCommandBody = nextCommandBody.replace(/^\/clear\b/i, "/reset");
124
+ if (commandKey === "/clear" || commandKey === "/new") {
125
+ nextCommandBody = nextCommandBody.replace(/^\/(?:clear|new)\b/i, "/reset");
95
126
  commandKey = "/reset";
96
127
  }
97
128
  if (commandKey) {
98
129
  const commandAllowed =
99
130
  commandPolicy.allowlist.includes(commandKey) ||
100
- (commandKey === "/reset" && commandPolicy.allowlist.includes("/clear"));
131
+ (commandKey === "/reset" &&
132
+ (commandPolicy.allowlist.includes("/clear") || commandPolicy.allowlist.includes("/new")));
101
133
  if (commandPolicy.enabled && !isAdminUser && !commandAllowed) {
102
134
  return {
103
135
  ok: false,
@@ -8,6 +8,7 @@ export async function prepareWecomBotRuntimeContext({
8
8
  api,
9
9
  runtime,
10
10
  cfg,
11
+ accountId = "default",
11
12
  baseSessionId,
12
13
  fromUser,
13
14
  chatId,
@@ -34,7 +35,7 @@ export async function prepareWecomBotRuntimeContext({
34
35
  runtime,
35
36
  cfg,
36
37
  channel: "wecom",
37
- accountId: "bot",
38
+ accountId,
38
39
  sessionKey: baseSessionId,
39
40
  fromUser,
40
41
  chatId,
@@ -83,6 +84,7 @@ export async function prepareWecomBotRuntimeContext({
83
84
  commandBody,
84
85
  fromAddress,
85
86
  sessionId,
87
+ accountId,
86
88
  isGroupChat,
87
89
  chatId,
88
90
  fromUser,
@@ -100,7 +102,7 @@ export async function prepareWecomBotRuntimeContext({
100
102
  sessionKey: sessionId,
101
103
  channel: "wecom",
102
104
  to: fromUser,
103
- accountId: "bot",
105
+ accountId,
104
106
  },
105
107
  onRecordError: (err) => {
106
108
  api?.logger?.warn?.(`wecom(bot): failed to record session: ${err}`);
@@ -109,7 +111,7 @@ export async function prepareWecomBotRuntimeContext({
109
111
 
110
112
  runtime.channel.activity.record({
111
113
  channel: "wecom",
112
- accountId: "bot",
114
+ accountId,
113
115
  direction: "inbound",
114
116
  });
115
117
 
@@ -52,7 +52,6 @@ function buildEncryptedStreamPayload({
52
52
 
53
53
  export function createWecomBotParsedDispatcher({
54
54
  api,
55
- botConfig,
56
55
  cleanupExpiredBotStreams,
57
56
  getBotStream,
58
57
  buildWecomBotEncryptedResponse,
@@ -65,6 +64,8 @@ export function createWecomBotParsedDispatcher({
65
64
  processBotInboundMessage,
66
65
  deliverBotReplyText,
67
66
  finishBotStream,
67
+ recordInboundMetric = () => {},
68
+ recordRuntimeErrorMetric = () => {},
68
69
  randomUuid = () => "",
69
70
  } = {}) {
70
71
  assertFunction("cleanupExpiredBotStreams", cleanupExpiredBotStreams);
@@ -83,20 +84,29 @@ export function createWecomBotParsedDispatcher({
83
84
  assertFunction("finishBotStream", finishBotStream);
84
85
  assertFunction("randomUuid", randomUuid);
85
86
 
86
- function buildStreamId() {
87
+ function buildStreamId(accountId = "default") {
87
88
  const normalized = String(randomUuid() || "").trim();
89
+ const accountSlug = String(accountId ?? "default")
90
+ .trim()
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9_-]/g, "_") || "default";
88
93
  if (normalized) return `stream_${normalized}`;
89
- return `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
94
+ return `stream_${accountSlug}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
90
95
  }
91
96
 
92
97
  function respondStreamRefresh({ parsed, res, timestamp, nonce }) {
93
- cleanupExpiredBotStreams(botConfig.streamExpireMs);
98
+ const activeBotConfig = parsed?._botConfig;
99
+ if (!activeBotConfig?.token || !activeBotConfig?.encodingAesKey) {
100
+ sendPlainText(res, 401, "Invalid bot account config");
101
+ return;
102
+ }
103
+ cleanupExpiredBotStreams(activeBotConfig.streamExpireMs);
94
104
  const streamId = parsed.streamId || `stream-${Date.now()}`;
95
105
  const stream = getBotStream(streamId);
96
106
  const feedbackId = String(parsed.feedbackId || stream?.feedbackId || "").trim();
97
107
  const encryptedResponse = buildEncryptedStreamPayload({
98
108
  buildWecomBotEncryptedResponse,
99
- botConfig,
109
+ botConfig: activeBotConfig,
100
110
  timestamp,
101
111
  nonce,
102
112
  streamId,
@@ -130,17 +140,27 @@ export function createWecomBotParsedDispatcher({
130
140
  fileName: parsed.fileName,
131
141
  quote: parsed.quote,
132
142
  responseUrl: parsed.responseUrl,
143
+ accountId: parsed.accountId,
144
+ voiceUrl: parsed.voiceUrl,
145
+ voiceMediaId: parsed.voiceMediaId,
146
+ voiceContentType: parsed.voiceContentType,
133
147
  }),
134
148
  }),
135
149
  )
136
150
  .catch((err) => {
137
151
  api.logger.error?.(`wecom(bot): async message processing failed: ${String(err?.message || err)}`);
152
+ recordRuntimeErrorMetric({
153
+ scope: "bot-dispatch",
154
+ reason: String(err?.message || err),
155
+ accountId: parsed.accountId,
156
+ });
138
157
  deliverBotReplyText({
139
158
  api,
140
159
  fromUser: parsed.fromUser,
141
160
  sessionId: botSessionId,
142
161
  streamId,
143
162
  responseUrl: parsed.responseUrl,
163
+ accountId: parsed.accountId,
144
164
  text: `抱歉,当前模型请求失败,请稍后重试。\n故障信息: ${String(err?.message || err).slice(0, 160)}`,
145
165
  reason: "bot-async-processing-error",
146
166
  }).catch((deliveryErr) => {
@@ -154,6 +174,17 @@ export function createWecomBotParsedDispatcher({
154
174
  }
155
175
 
156
176
  function respondMessage({ parsed, res, timestamp, nonce }) {
177
+ const activeBotConfig = parsed?._botConfig;
178
+ if (!activeBotConfig?.token || !activeBotConfig?.encodingAesKey) {
179
+ sendPlainText(res, 401, "Invalid bot account config");
180
+ return;
181
+ }
182
+ const accountId = String(parsed?.accountId ?? "default").trim().toLowerCase() || "default";
183
+ recordInboundMetric({
184
+ mode: "bot",
185
+ msgType: parsed.msgType || parsed.kind || "unknown",
186
+ accountId,
187
+ });
157
188
  const dedupeStub = {
158
189
  MsgId: parsed.msgId,
159
190
  FromUserName: parsed.fromUser,
@@ -161,17 +192,18 @@ export function createWecomBotParsedDispatcher({
161
192
  Content: parsed.content,
162
193
  CreateTime: String(Math.floor(Date.now() / 1000)),
163
194
  };
164
- if (!markInboundMessageSeen(dedupeStub, "bot")) {
195
+ if (!markInboundMessageSeen(dedupeStub, `bot:${accountId}`)) {
165
196
  sendPlainText(res, 200, "success");
166
197
  return;
167
198
  }
168
199
 
169
- const botSessionId = buildWecomBotSessionId(parsed.fromUser);
170
- const streamId = buildStreamId();
200
+ const botSessionId = buildWecomBotSessionId(parsed.fromUser, accountId);
201
+ const streamId = buildStreamId(accountId);
171
202
  const feedbackId = String(parsed.feedbackId ?? "").trim();
172
- createBotStream(streamId, botConfig.placeholderText, {
203
+ createBotStream(streamId, activeBotConfig.placeholderText, {
173
204
  feedbackId,
174
205
  sessionId: botSessionId,
206
+ accountId,
175
207
  });
176
208
  if (parsed.responseUrl) {
177
209
  upsertBotResponseUrlCache({
@@ -181,11 +213,11 @@ export function createWecomBotParsedDispatcher({
181
213
  }
182
214
  const encryptedResponse = buildEncryptedStreamPayload({
183
215
  buildWecomBotEncryptedResponse,
184
- botConfig,
216
+ botConfig: activeBotConfig,
185
217
  timestamp,
186
218
  nonce,
187
219
  streamId,
188
- content: botConfig.placeholderText,
220
+ content: activeBotConfig.placeholderText,
189
221
  finish: false,
190
222
  feedbackId,
191
223
  });
@@ -197,11 +229,12 @@ export function createWecomBotParsedDispatcher({
197
229
  });
198
230
  }
199
231
 
200
- return async function dispatchParsed({ parsed, res, timestamp, nonce } = {}) {
232
+ return async function dispatchParsed({ parsed, res, timestamp, nonce, botConfig } = {}) {
201
233
  if (!parsed || typeof parsed !== "object") {
202
234
  sendPlainText(res, 200, "success");
203
235
  return true;
204
236
  }
237
+ parsed._botConfig = botConfig;
205
238
  if (parsed.kind === "stream-refresh") {
206
239
  respondStreamRefresh({ parsed, res, timestamp, nonce });
207
240
  return true;
@@ -4,6 +4,7 @@ import { createWecomBotParsedDispatcher } from "./bot-webhook-dispatch.js";
4
4
  export function createWecomBotWebhookHandler({
5
5
  api,
6
6
  botConfig,
7
+ botConfigs,
7
8
  normalizedPath,
8
9
  readRequestBody,
9
10
  parseIncomingJson,
@@ -23,10 +24,27 @@ export function createWecomBotWebhookHandler({
23
24
  processBotInboundMessage,
24
25
  deliverBotReplyText,
25
26
  finishBotStream,
27
+ recordInboundMetric = () => {},
28
+ recordRuntimeErrorMetric = () => {},
26
29
  } = {}) {
30
+ const configuredBotConfigs = Array.isArray(botConfigs) && botConfigs.length > 0 ? botConfigs : [botConfig];
31
+ const signedBotConfigs = configuredBotConfigs.filter((item) => item?.token && item?.encodingAesKey);
32
+ function pickBotConfigBySignature({ msgSignature, timestamp, nonce, encrypt }) {
33
+ if (!msgSignature || !encrypt) return null;
34
+ for (const cfg of signedBotConfigs) {
35
+ const expected = computeMsgSignature({
36
+ token: cfg.token,
37
+ timestamp,
38
+ nonce,
39
+ encrypt,
40
+ });
41
+ if (expected === msgSignature) return cfg;
42
+ }
43
+ return null;
44
+ }
45
+
27
46
  const dispatchParsed = createWecomBotParsedDispatcher({
28
47
  api,
29
- botConfig,
30
48
  cleanupExpiredBotStreams,
31
49
  getBotStream,
32
50
  buildWecomBotEncryptedResponse,
@@ -39,6 +57,8 @@ export function createWecomBotWebhookHandler({
39
57
  processBotInboundMessage,
40
58
  deliverBotReplyText,
41
59
  finishBotStream,
60
+ recordInboundMetric,
61
+ recordRuntimeErrorMetric,
42
62
  randomUuid: () => crypto.randomUUID?.(),
43
63
  });
44
64
 
@@ -51,9 +71,9 @@ export function createWecomBotWebhookHandler({
51
71
  const echostr = url.searchParams.get("echostr") ?? "";
52
72
 
53
73
  if (req.method === "GET" && !echostr) {
54
- res.statusCode = 200;
74
+ res.statusCode = signedBotConfigs.length > 0 ? 200 : 500;
55
75
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
56
- res.end("wecom bot webhook ok");
76
+ res.end(signedBotConfigs.length > 0 ? "wecom bot webhook ok" : "wecom bot webhook not configured");
57
77
  return;
58
78
  }
59
79
 
@@ -64,26 +84,28 @@ export function createWecomBotWebhookHandler({
64
84
  res.end("Missing query params");
65
85
  return;
66
86
  }
67
- const expected = computeMsgSignature({
68
- token: botConfig.token,
87
+ const matchedBotConfig = pickBotConfigBySignature({
88
+ msgSignature: msg_signature,
69
89
  timestamp,
70
90
  nonce,
71
91
  encrypt: echostr,
72
92
  });
73
- if (expected !== msg_signature) {
93
+ if (!matchedBotConfig) {
74
94
  res.statusCode = 401;
75
95
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
76
96
  res.end("Invalid signature");
77
97
  return;
78
98
  }
79
99
  const { msg: plainEchostr } = decryptWecom({
80
- aesKey: botConfig.encodingAesKey,
100
+ aesKey: matchedBotConfig.encodingAesKey,
81
101
  cipherTextBase64: echostr,
82
102
  });
83
103
  res.statusCode = 200;
84
104
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
85
105
  res.end(plainEchostr);
86
- api.logger.info?.(`wecom(bot): verified callback URL at ${normalizedPath}`);
106
+ api.logger.info?.(
107
+ `wecom(bot): verified callback URL at ${normalizedPath} (account=${matchedBotConfig.accountId || "default"})`,
108
+ );
87
109
  return;
88
110
  }
89
111
 
@@ -114,13 +136,13 @@ export function createWecomBotWebhookHandler({
114
136
  return;
115
137
  }
116
138
 
117
- const expected = computeMsgSignature({
118
- token: botConfig.token,
139
+ const matchedBotConfig = pickBotConfigBySignature({
140
+ msgSignature: msg_signature,
119
141
  timestamp,
120
142
  nonce,
121
143
  encrypt: encryptedBody,
122
144
  });
123
- if (expected !== msg_signature) {
145
+ if (!matchedBotConfig) {
124
146
  res.statusCode = 401;
125
147
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
126
148
  res.end("Invalid signature");
@@ -130,7 +152,7 @@ export function createWecomBotWebhookHandler({
130
152
  let incomingPayload = null;
131
153
  try {
132
154
  const { msg: decryptedPayload } = decryptWecom({
133
- aesKey: botConfig.encodingAesKey,
155
+ aesKey: matchedBotConfig.encodingAesKey,
134
156
  cipherTextBase64: encryptedBody,
135
157
  });
136
158
  incomingPayload = parseIncomingJson(decryptedPayload);
@@ -143,12 +165,18 @@ export function createWecomBotWebhookHandler({
143
165
  }
144
166
 
145
167
  const parsed = parseWecomBotInboundMessage(incomingPayload);
146
- api.logger.info?.(`wecom(bot): inbound ${describeWecomBotParsedMessage(parsed)}`);
168
+ if (parsed && typeof parsed === "object") {
169
+ parsed.accountId = String(matchedBotConfig.accountId ?? "default").trim().toLowerCase() || "default";
170
+ }
171
+ api.logger.info?.(
172
+ `wecom(bot): inbound ${describeWecomBotParsedMessage(parsed)} account=${matchedBotConfig.accountId || "default"}`,
173
+ );
147
174
  const handled = await dispatchParsed({
148
175
  parsed,
149
176
  res,
150
177
  timestamp,
151
178
  nonce,
179
+ botConfig: matchedBotConfig,
152
180
  });
153
181
  if (handled) {
154
182
  return;
@@ -159,6 +187,10 @@ export function createWecomBotWebhookHandler({
159
187
  res.end("success");
160
188
  } catch (err) {
161
189
  api.logger.error?.(`wecom(bot): webhook handler failed: ${String(err?.message || err)}`);
190
+ recordRuntimeErrorMetric({
191
+ scope: "bot-webhook",
192
+ reason: String(err?.message || err),
193
+ });
162
194
  if (!res.writableEnded) {
163
195
  res.statusCode = 500;
164
196
  res.setHeader("Content-Type", "text/plain; charset=utf-8");