@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
@@ -2,9 +2,12 @@ export function createWecomPolicyResolvers({
2
2
  getGatewayRuntime,
3
3
  normalizeAccountId,
4
4
  resolveWecomBotModeConfig,
5
+ resolveWecomBotModeAccountsConfig,
5
6
  resolveWecomProxyConfig,
6
7
  resolveWecomCommandPolicyConfig,
7
8
  resolveWecomAllowFromPolicyConfig,
9
+ resolveWecomDmPolicyConfig,
10
+ resolveWecomEventPolicyConfig,
8
11
  resolveWecomGroupChatConfig,
9
12
  resolveWecomDebounceConfig,
10
13
  resolveWecomStreamingConfig,
@@ -31,16 +34,59 @@ export function createWecomPolicyResolvers({
31
34
  };
32
35
  }
33
36
 
34
- function resolveWecomBotConfig(api) {
35
- return resolveWecomBotModeConfig(resolveWecomPolicyInputs(api));
37
+ function resolveWecomBotConfigs(api) {
38
+ const inputs = resolveWecomPolicyInputs(api);
39
+ if (typeof resolveWecomBotModeAccountsConfig === "function") {
40
+ return resolveWecomBotModeAccountsConfig(inputs);
41
+ }
42
+ return [resolveWecomBotModeConfig(inputs)];
43
+ }
44
+
45
+ function resolveWecomBotConfig(api, accountId = "default") {
46
+ const normalizedAccountId = normalizeAccountId(accountId ?? "default");
47
+ const configs = resolveWecomBotConfigs(api);
48
+ const matched = configs.find((item) => normalizeAccountId(item?.accountId ?? "default") === normalizedAccountId);
49
+ if (matched) return matched;
50
+ if (normalizedAccountId !== "default") {
51
+ const fallback = configs.find((item) => normalizeAccountId(item?.accountId ?? "default") === "default");
52
+ if (fallback) return fallback;
53
+ }
54
+ return configs[0] ?? resolveWecomBotModeConfig(resolveWecomPolicyInputs(api));
36
55
  }
37
56
 
38
- function resolveWecomBotProxyConfig(api) {
57
+ function resolveWecomBotProxyConfig(api, accountId = "default") {
39
58
  const inputs = resolveWecomPolicyInputs(api);
59
+ const normalizedAccountId = normalizeAccountId(accountId ?? "default");
60
+ const channelConfig = inputs.channelConfig ?? {};
61
+ const accountConfig =
62
+ normalizedAccountId === "default"
63
+ ? channelConfig
64
+ : channelConfig?.accounts && typeof channelConfig.accounts === "object"
65
+ ? channelConfig.accounts[normalizedAccountId] ?? {}
66
+ : {};
67
+ const botConfig = accountConfig?.bot && typeof accountConfig.bot === "object" ? accountConfig.bot : {};
68
+ const envVars = inputs.envVars ?? {};
69
+ const processEnvVars = inputs.processEnv ?? process.env;
70
+ const scopedBotProxyKey =
71
+ normalizedAccountId === "default" ? null : `WECOM_${normalizedAccountId.toUpperCase()}_BOT_PROXY`;
72
+ const scopedBotProxy = String(
73
+ (scopedBotProxyKey ? envVars?.[scopedBotProxyKey] ?? processEnvVars?.[scopedBotProxyKey] : undefined) ??
74
+ envVars?.WECOM_BOT_PROXY ??
75
+ processEnvVars?.WECOM_BOT_PROXY ??
76
+ "",
77
+ ).trim();
78
+ const fromBotConfig = String(botConfig?.outboundProxy ?? botConfig?.proxyUrl ?? botConfig?.proxy ?? "").trim();
79
+ if (fromBotConfig) return fromBotConfig;
80
+ if (scopedBotProxy) return scopedBotProxy;
81
+
82
+ const proxyAccountConfig = {
83
+ ...(accountConfig && typeof accountConfig === "object" ? accountConfig : {}),
84
+ ...(botConfig && typeof botConfig === "object" ? botConfig : {}),
85
+ };
40
86
  return resolveWecomProxyConfig({
41
87
  ...inputs,
42
- accountId: "bot",
43
- accountConfig: {},
88
+ accountId: normalizedAccountId,
89
+ accountConfig: proxyAccountConfig,
44
90
  });
45
91
  }
46
92
 
@@ -57,6 +103,34 @@ export function createWecomPolicyResolvers({
57
103
  });
58
104
  }
59
105
 
106
+ function resolveWecomDmPolicy(api, accountId, accountConfig = {}) {
107
+ const inputs = resolveWecomPolicyInputs(api);
108
+ if (typeof resolveWecomDmPolicyConfig !== "function") {
109
+ return { mode: "open", allowFrom: [], rejectMessage: "当前私聊账号未授权,请联系管理员。", enabled: false };
110
+ }
111
+ return resolveWecomDmPolicyConfig({
112
+ ...inputs,
113
+ accountId: normalizeAccountId(accountId ?? "default"),
114
+ accountConfig: accountConfig ?? {},
115
+ });
116
+ }
117
+
118
+ function resolveWecomEventPolicy(api, accountId, accountConfig = {}) {
119
+ const inputs = resolveWecomPolicyInputs(api);
120
+ if (typeof resolveWecomEventPolicyConfig !== "function") {
121
+ return {
122
+ enabled: true,
123
+ enterAgentWelcomeEnabled: false,
124
+ enterAgentWelcomeText: "你好,我是 AI 助手,直接发消息即可开始对话。",
125
+ };
126
+ }
127
+ return resolveWecomEventPolicyConfig({
128
+ ...inputs,
129
+ accountId: normalizeAccountId(accountId ?? "default"),
130
+ accountConfig: accountConfig ?? {},
131
+ });
132
+ }
133
+
60
134
  function resolveWecomGroupChatPolicy(api) {
61
135
  return resolveWecomGroupChatConfig(resolveWecomPolicyInputs(api));
62
136
  }
@@ -91,10 +165,13 @@ export function createWecomPolicyResolvers({
91
165
 
92
166
  return {
93
167
  resolveWecomPolicyInputs,
168
+ resolveWecomBotConfigs,
94
169
  resolveWecomBotConfig,
95
170
  resolveWecomBotProxyConfig,
96
171
  resolveWecomCommandPolicy,
97
172
  resolveWecomAllowFromPolicy,
173
+ resolveWecomDmPolicy,
174
+ resolveWecomEventPolicy,
98
175
  resolveWecomGroupChatPolicy,
99
176
  resolveWecomTextDebouncePolicy,
100
177
  resolveWecomReplyStreamingPolicy,
@@ -1,3 +1,5 @@
1
+ import { analyzeWecomAccountConflicts } from "./account-diagnostics.js";
2
+
1
3
  export function createWecomRegisterRuntime({
2
4
  setGatewayRuntime,
3
5
  syncWecomSessionQueuePolicy,
@@ -6,6 +8,8 @@ export function createWecomRegisterRuntime({
6
8
  resolveWecomObservabilityPolicy,
7
9
  resolveWecomDynamicAgentPolicy,
8
10
  resolveWecomBotConfig,
11
+ resolveWecomBotConfigs,
12
+ listEnabledWecomAccounts,
9
13
  getWecomConfig,
10
14
  wecomChannelPlugin,
11
15
  wecomRouteRegistrar,
@@ -31,6 +35,12 @@ export function createWecomRegisterRuntime({
31
35
  if (typeof resolveWecomBotConfig !== "function") {
32
36
  throw new Error("createWecomRegisterRuntime: resolveWecomBotConfig is required");
33
37
  }
38
+ if (resolveWecomBotConfigs != null && typeof resolveWecomBotConfigs !== "function") {
39
+ throw new Error("createWecomRegisterRuntime: resolveWecomBotConfigs must be a function");
40
+ }
41
+ if (listEnabledWecomAccounts != null && typeof listEnabledWecomAccounts !== "function") {
42
+ throw new Error("createWecomRegisterRuntime: listEnabledWecomAccounts must be a function");
43
+ }
34
44
  if (typeof getWecomConfig !== "function") {
35
45
  throw new Error("createWecomRegisterRuntime: getWecomConfig is required");
36
46
  }
@@ -50,14 +60,22 @@ export function createWecomRegisterRuntime({
50
60
  const dynamicAgentPolicy = resolveWecomDynamicAgentPolicy(api);
51
61
 
52
62
  const botModeConfig = resolveWecomBotConfig(api);
63
+ const botModeConfigs =
64
+ typeof resolveWecomBotConfigs === "function"
65
+ ? resolveWecomBotConfigs(api)
66
+ : [botModeConfig];
67
+ const enabledBotConfigs = (Array.isArray(botModeConfigs) ? botModeConfigs : []).filter((item) => item?.enabled === true);
53
68
  const cfg = getWecomConfig(api);
54
69
  if (cfg) {
55
70
  api.logger.info?.(
56
71
  `wecom: config loaded (corpId=${cfg.corpId?.slice(0, 8)}..., proxy=${cfg.outboundProxy ? "on" : "off"})`,
57
72
  );
58
- } else if (botModeConfig.enabled) {
73
+ } else if (enabledBotConfigs.length > 0) {
74
+ const webhookSummary = Array.from(
75
+ new Set(enabledBotConfigs.map((item) => String(item?.webhookPath || "/wecom/bot/callback"))),
76
+ ).join(", ");
59
77
  api.logger.info?.(
60
- `wecom(bot): config loaded (webhook=${botModeConfig.webhookPath}, streamExpireMs=${botModeConfig.streamExpireMs})`,
78
+ `wecom(bot): config loaded (accounts=${enabledBotConfigs.length}, webhook=${webhookSummary}, streamExpireMs=${botModeConfig.streamExpireMs})`,
61
79
  );
62
80
  } else {
63
81
  api.logger.warn?.("wecom: no configuration found (check channels.wecom in openclaw.json)");
@@ -83,6 +101,17 @@ export function createWecomRegisterRuntime({
83
101
  `wecom: dynamic-agent on (mode=${dynamicAgentPolicy.mode}, userMap=${Object.keys(dynamicAgentPolicy.userMap || {}).length}, groupMap=${Object.keys(dynamicAgentPolicy.groupMap || {}).length}, mentionMap=${Object.keys(dynamicAgentPolicy.mentionMap || {}).length})`,
84
102
  );
85
103
  }
104
+ if (typeof listEnabledWecomAccounts === "function") {
105
+ const accountDiagnostics = analyzeWecomAccountConflicts({
106
+ accounts: listEnabledWecomAccounts(api),
107
+ botConfigs: enabledBotConfigs,
108
+ });
109
+ for (const issue of accountDiagnostics.issues) {
110
+ const line = `wecom: account diagnosis ${issue.code} ${issue.message}`;
111
+ if (issue.severity === "warn") api.logger.warn?.(line);
112
+ else api.logger.info?.(line);
113
+ }
114
+ }
86
115
 
87
116
  api.registerChannel({ plugin: wecomChannelPlugin });
88
117
  const botRouteRegistered = wecomRouteRegistrar.registerWecomBotWebhookRoute(api);
@@ -1,5 +1,13 @@
1
+ import {
2
+ buildDefaultAgentWebhookPath,
3
+ buildDefaultBotWebhookPath,
4
+ buildLegacyAgentWebhookPath,
5
+ buildLegacyBotWebhookPath,
6
+ } from "./account-paths.js";
7
+
1
8
  export function createWecomRouteRegistrar({
2
9
  resolveWecomBotConfig,
10
+ resolveWecomBotConfigs,
3
11
  normalizePluginHttpPath,
4
12
  ensureBotStreamCleanupTimer,
5
13
  cleanupExpiredBotStreams,
@@ -29,8 +37,13 @@ export function createWecomRouteRegistrar({
29
37
  deliverBotReplyText,
30
38
  finishBotStream,
31
39
  groupAccountsByWebhookPath,
40
+ recordInboundMetric = () => {},
41
+ recordRuntimeErrorMetric = () => {},
32
42
  } = {}) {
33
43
  if (typeof resolveWecomBotConfig !== "function") throw new Error("createWecomRouteRegistrar: resolveWecomBotConfig is required");
44
+ if (typeof resolveWecomBotConfigs !== "function") {
45
+ throw new Error("createWecomRouteRegistrar: resolveWecomBotConfigs is required");
46
+ }
34
47
  if (typeof normalizePluginHttpPath !== "function") {
35
48
  throw new Error("createWecomRouteRegistrar: normalizePluginHttpPath is required");
36
49
  }
@@ -51,56 +64,173 @@ export function createWecomRouteRegistrar({
51
64
  }
52
65
 
53
66
  function registerWecomBotWebhookRoute(api) {
54
- const botConfig = resolveWecomBotConfig(api);
55
- if (!botConfig.enabled) return false;
56
- if (!botConfig.token || !botConfig.encodingAesKey) {
67
+ const botConfigs = resolveWecomBotConfigs(api);
68
+ const enabledBotConfigs = (Array.isArray(botConfigs) ? botConfigs : []).filter((item) => item?.enabled === true);
69
+ if (enabledBotConfigs.length === 0) return false;
70
+
71
+ const signedBotConfigs = enabledBotConfigs.filter((item) => item?.token && item?.encodingAesKey);
72
+ if (signedBotConfigs.length === 0) {
57
73
  api.logger.warn?.("wecom(bot): enabled but missing token/encodingAesKey; route not registered");
58
74
  return false;
59
75
  }
60
76
 
61
- const normalizedPath =
62
- normalizePluginHttpPath(botConfig.webhookPath ?? "/wecom/bot/callback", "/wecom/bot/callback") ??
63
- "/wecom/bot/callback";
64
- ensureBotStreamCleanupTimer(botConfig.streamExpireMs, api.logger);
65
- cleanupExpiredBotStreams(botConfig.streamExpireMs);
66
-
67
- const handler = createWecomBotWebhookHandler({
68
- api,
69
- botConfig,
70
- normalizedPath,
71
- readRequestBody,
72
- parseIncomingJson,
73
- computeMsgSignature,
74
- decryptWecom,
75
- parseWecomBotInboundMessage,
76
- describeWecomBotParsedMessage,
77
- cleanupExpiredBotStreams,
78
- getBotStream,
79
- buildWecomBotEncryptedResponse,
80
- markInboundMessageSeen,
81
- buildWecomBotSessionId,
82
- createBotStream,
83
- upsertBotResponseUrlCache,
84
- messageProcessLimiter,
85
- executeInboundTaskWithSessionQueue,
86
- processBotInboundMessage,
87
- deliverBotReplyText,
88
- finishBotStream,
89
- });
90
-
91
- api.registerHttpRoute({
92
- path: normalizedPath,
93
- auth: "plugin",
94
- handler,
95
- });
96
-
97
- api.logger.info?.(`wecom(bot): registered webhook at ${normalizedPath}`);
98
- return true;
77
+ const grouped = new Map();
78
+ const agentWebhookGroups = groupAccountsByWebhookPath(api);
79
+ const agentPathSet = new Set(
80
+ Array.from(agentWebhookGroups.keys()).map(
81
+ (path) => normalizePluginHttpPath(path ?? "/wecom/callback", "/wecom/callback") ?? "/wecom/callback",
82
+ ),
83
+ );
84
+ for (const botConfig of signedBotConfigs) {
85
+ const normalizedAccountId = String(botConfig?.accountId ?? "default").trim().toLowerCase() || "default";
86
+ const normalizedPath =
87
+ normalizePluginHttpPath(botConfig.webhookPath ?? "/wecom/bot/callback", "/wecom/bot/callback") ??
88
+ "/wecom/bot/callback";
89
+ const registerGroupedPath = (candidatePath) => {
90
+ const existing = grouped.get(candidatePath);
91
+ if (existing) existing.push(botConfig);
92
+ else grouped.set(candidatePath, [botConfig]);
93
+ };
94
+ registerGroupedPath(normalizedPath);
95
+
96
+ const normalizedDefaultPath = normalizePluginHttpPath(
97
+ buildDefaultBotWebhookPath(normalizedAccountId),
98
+ "/wecom/bot/callback",
99
+ );
100
+ if (normalizedDefaultPath && normalizedPath === normalizedDefaultPath) {
101
+ const legacyAliasPath =
102
+ normalizePluginHttpPath(buildLegacyBotWebhookPath(normalizedAccountId), "/webhooks/wecom") ??
103
+ "/webhooks/wecom";
104
+ if (legacyAliasPath !== normalizedPath) {
105
+ if (agentPathSet.has(legacyAliasPath)) {
106
+ api.logger.warn?.(
107
+ `wecom(bot): skip legacy alias ${legacyAliasPath} for account=${normalizedAccountId} (conflicts with agent webhook path)`,
108
+ );
109
+ } else {
110
+ registerGroupedPath(legacyAliasPath);
111
+ api.logger.info?.(
112
+ `wecom(bot): registered legacy alias ${legacyAliasPath} for account=${normalizedAccountId}`,
113
+ );
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ let registeredCount = 0;
120
+ for (const [normalizedPath, pathConfigs] of grouped.entries()) {
121
+ const maxStreamExpireMs = pathConfigs.reduce(
122
+ (acc, item) => Math.max(acc, Number(item?.streamExpireMs) || 0),
123
+ 0,
124
+ );
125
+ ensureBotStreamCleanupTimer(maxStreamExpireMs || 600000, api.logger);
126
+ cleanupExpiredBotStreams(maxStreamExpireMs || 600000);
127
+
128
+ const handler = createWecomBotWebhookHandler({
129
+ api,
130
+ botConfigs: pathConfigs,
131
+ normalizedPath,
132
+ readRequestBody,
133
+ parseIncomingJson,
134
+ computeMsgSignature,
135
+ decryptWecom,
136
+ parseWecomBotInboundMessage,
137
+ describeWecomBotParsedMessage,
138
+ cleanupExpiredBotStreams,
139
+ getBotStream,
140
+ buildWecomBotEncryptedResponse,
141
+ markInboundMessageSeen,
142
+ buildWecomBotSessionId,
143
+ createBotStream,
144
+ upsertBotResponseUrlCache,
145
+ messageProcessLimiter,
146
+ executeInboundTaskWithSessionQueue,
147
+ processBotInboundMessage,
148
+ deliverBotReplyText,
149
+ finishBotStream,
150
+ recordInboundMetric,
151
+ recordRuntimeErrorMetric,
152
+ });
153
+
154
+ api.registerHttpRoute({
155
+ path: normalizedPath,
156
+ auth: "plugin",
157
+ handler,
158
+ });
159
+
160
+ const accountIds = pathConfigs.map((item) => String(item?.accountId ?? "default")).join(", ");
161
+ api.logger.info?.(`wecom(bot): registered webhook at ${normalizedPath} (accounts=${accountIds})`);
162
+ registeredCount += 1;
163
+ }
164
+ return registeredCount > 0;
165
+ }
166
+
167
+ function buildBotWebhookPathSet(api) {
168
+ const botPathSet = new Set();
169
+ const botConfigs = resolveWecomBotConfigs(api);
170
+ const enabledBotConfigs = (Array.isArray(botConfigs) ? botConfigs : []).filter((item) => item?.enabled === true);
171
+ for (const botConfig of enabledBotConfigs) {
172
+ const normalizedAccountId = String(botConfig?.accountId ?? "default").trim().toLowerCase() || "default";
173
+ const normalizedPath =
174
+ normalizePluginHttpPath(botConfig.webhookPath ?? "/wecom/bot/callback", "/wecom/bot/callback") ??
175
+ "/wecom/bot/callback";
176
+ botPathSet.add(normalizedPath);
177
+
178
+ const normalizedDefaultPath = normalizePluginHttpPath(
179
+ buildDefaultBotWebhookPath(normalizedAccountId),
180
+ "/wecom/bot/callback",
181
+ );
182
+ if (normalizedDefaultPath && normalizedPath === normalizedDefaultPath) {
183
+ const legacyAliasPath =
184
+ normalizePluginHttpPath(buildLegacyBotWebhookPath(normalizedAccountId), "/webhooks/wecom") ??
185
+ "/webhooks/wecom";
186
+ botPathSet.add(legacyAliasPath);
187
+ }
188
+ }
189
+ return botPathSet;
99
190
  }
100
191
 
101
192
  function registerWecomAgentWebhookRoutes(api) {
102
193
  const webhookGroups = groupAccountsByWebhookPath(api);
194
+ const grouped = new Map();
195
+ for (const [normalizedPath, accounts] of webhookGroups.entries()) {
196
+ grouped.set(normalizedPath, [...accounts]);
197
+ }
198
+
199
+ const botPathSet = buildBotWebhookPathSet(api);
103
200
  for (const [normalizedPath, accounts] of webhookGroups.entries()) {
201
+ for (const account of accounts) {
202
+ const normalizedAccountId = String(account?.accountId ?? "default").trim().toLowerCase() || "default";
203
+ const normalizedDefaultPath =
204
+ normalizePluginHttpPath(buildDefaultAgentWebhookPath(normalizedAccountId), "/wecom/callback") ??
205
+ "/wecom/callback";
206
+ if (normalizedPath !== normalizedDefaultPath) continue;
207
+
208
+ const legacyAliasPath =
209
+ normalizePluginHttpPath(buildLegacyAgentWebhookPath(normalizedAccountId), "/webhooks/app") ??
210
+ "/webhooks/app";
211
+ if (!legacyAliasPath || legacyAliasPath === normalizedPath) continue;
212
+ if (botPathSet.has(legacyAliasPath)) {
213
+ api.logger.warn?.(
214
+ `wecom: skip legacy agent alias ${legacyAliasPath} for account=${normalizedAccountId} (conflicts with bot webhook path)`,
215
+ );
216
+ continue;
217
+ }
218
+
219
+ const existing = grouped.get(legacyAliasPath);
220
+ if (existing) {
221
+ const duplicated = existing.some(
222
+ (item) =>
223
+ (String(item?.accountId ?? "default").trim().toLowerCase() || "default") === normalizedAccountId,
224
+ );
225
+ if (!duplicated) existing.push(account);
226
+ } else {
227
+ grouped.set(legacyAliasPath, [account]);
228
+ }
229
+ api.logger.info?.(`wecom: registered legacy agent alias ${legacyAliasPath} for account=${normalizedAccountId}`);
230
+ }
231
+ }
232
+
233
+ for (const [normalizedPath, accounts] of grouped.entries()) {
104
234
  const handler = createWecomAgentWebhookHandler({
105
235
  api,
106
236
  accounts,
@@ -115,6 +245,8 @@ export function createWecomRouteRegistrar({
115
245
  messageProcessLimiter,
116
246
  executeInboundTaskWithSessionQueue,
117
247
  processInboundMessage,
248
+ recordInboundMetric,
249
+ recordRuntimeErrorMetric,
118
250
  });
119
251
  api.registerHttpRoute({
120
252
  path: normalizedPath,
@@ -4,8 +4,13 @@ export function requireEnv(name, fallback, processEnv = process.env) {
4
4
  return value;
5
5
  }
6
6
 
7
- export function buildWecomBotSessionId(userId) {
8
- return `wecom-bot:${String(userId ?? "").trim().toLowerCase()}`;
7
+ export function buildWecomBotSessionId(userId, accountId = "default") {
8
+ const normalizedUserId = String(userId ?? "").trim().toLowerCase();
9
+ const normalizedAccountId = String(accountId ?? "default").trim().toLowerCase() || "default";
10
+ if (normalizedAccountId === "default") {
11
+ return `wecom-bot:${normalizedUserId}`;
12
+ }
13
+ return `wecom-bot:${normalizedAccountId}:${normalizedUserId}`;
9
14
  }
10
15
 
11
16
  export function asNumber(value, fallback = null) {
@@ -67,11 +67,22 @@ export function parseWecomBotInboundMessage(payload) {
67
67
  const imageUrls = [];
68
68
  let fileUrl = "";
69
69
  let fileName = "";
70
+ let voiceUrl = "";
71
+ let voiceMediaId = "";
72
+ let voiceContentType = "";
70
73
 
71
74
  if (msgType === "text") {
72
75
  content = normalizeToken(payload?.text?.content);
73
76
  } else if (msgType === "voice") {
74
77
  content = normalizeToken(payload?.voice?.content);
78
+ voiceUrl = normalizeToken(
79
+ payload?.voice?.url ||
80
+ payload?.voice?.media_url ||
81
+ payload?.voice?.download_url ||
82
+ payload?.voice?.file_url,
83
+ );
84
+ voiceMediaId = normalizeToken(payload?.voice?.media_id || payload?.voice?.mediaid || payload?.voice?.id);
85
+ voiceContentType = normalizeToken(payload?.voice?.content_type || payload?.voice?.mime_type || payload?.voice?.format);
75
86
  } else if (msgType === "link") {
76
87
  const title = normalizeToken(payload?.link?.title);
77
88
  const description = normalizeToken(payload?.link?.description);
@@ -99,6 +110,51 @@ export function parseWecomBotInboundMessage(payload) {
99
110
  imageUrls.push(...itemImageUrls);
100
111
  parts.push("[图片]");
101
112
  }
113
+ } else if (itemType === "voice") {
114
+ const itemVoiceUrl = normalizeToken(
115
+ item?.voice?.url ||
116
+ item?.voice?.media_url ||
117
+ item?.voice?.download_url ||
118
+ item?.voice?.file_url,
119
+ );
120
+ const itemVoiceMediaId = normalizeToken(item?.voice?.media_id || item?.voice?.mediaid || item?.voice?.id);
121
+ const itemVoiceContentType = normalizeToken(
122
+ item?.voice?.content_type || item?.voice?.mime_type || item?.voice?.format,
123
+ );
124
+ if (itemVoiceUrl) {
125
+ voiceUrl = voiceUrl || itemVoiceUrl;
126
+ voiceMediaId = voiceMediaId || itemVoiceMediaId;
127
+ voiceContentType = voiceContentType || itemVoiceContentType;
128
+ parts.push("[语音]");
129
+ }
130
+ } else if (itemType === "file") {
131
+ const itemFileUrl = normalizeToken(
132
+ item?.file?.url ||
133
+ item?.file?.download_url ||
134
+ item?.file?.media_url ||
135
+ item?.file?.file_url,
136
+ );
137
+ const itemFileName = normalizeToken(item?.file?.name || item?.file?.filename);
138
+ if (itemFileUrl || itemFileName) {
139
+ fileUrl = fileUrl || itemFileUrl;
140
+ fileName = fileName || itemFileName;
141
+ const displayName = itemFileName || itemFileUrl || "附件";
142
+ parts.push(`[文件] ${displayName}`);
143
+ }
144
+ } else if (itemType === "link") {
145
+ const title = normalizeToken(item?.link?.title);
146
+ const description = normalizeToken(item?.link?.description);
147
+ const url = normalizeToken(item?.link?.url);
148
+ const linkText = [title ? `[链接] ${title}` : "", description, url].filter(Boolean).join("\n").trim();
149
+ if (linkText) parts.push(linkText);
150
+ } else if (itemType === "location") {
151
+ const latitude = normalizeToken(item?.location?.latitude);
152
+ const longitude = normalizeToken(item?.location?.longitude);
153
+ const name = normalizeToken(item?.location?.name || item?.location?.label);
154
+ const locationText = name ? `[位置] ${name} (${latitude}, ${longitude})` : `[位置] ${latitude}, ${longitude}`;
155
+ if (locationText.trim() !== "[位置] ,") {
156
+ parts.push(locationText);
157
+ }
102
158
  }
103
159
  }
104
160
  content = parts.join("\n").trim();
@@ -143,6 +199,9 @@ export function parseWecomBotInboundMessage(payload) {
143
199
  imageUrls: dedupeUrlList(imageUrls),
144
200
  fileUrl,
145
201
  fileName,
202
+ voiceUrl,
203
+ voiceMediaId,
204
+ voiceContentType,
146
205
  feedbackId,
147
206
  quote,
148
207
  isGroupChat: chatType === "group" || Boolean(chatId),
@@ -178,6 +237,8 @@ export function extractWecomXmlInboundEnvelope(msgObj) {
178
237
  fromUser: normalizeToken(msgObj.FromUserName),
179
238
  chatId: normalizeToken(msgObj.ChatId),
180
239
  msgId: normalizeToken(msgObj.MsgId),
240
+ eventType: normalizeLowerToken(msgObj.Event),
241
+ eventKey: normalizeToken(msgObj.EventKey),
181
242
  content: normalizeToken(msgObj.Content),
182
243
  mediaId: normalizeToken(msgObj.MediaId),
183
244
  picUrl: normalizeToken(msgObj.PicUrl),
@@ -66,6 +66,32 @@ export async function webhookSendMarkdown({
66
66
  });
67
67
  }
68
68
 
69
+ export async function webhookSendTemplateCard({
70
+ url,
71
+ key,
72
+ templateCard,
73
+ timeoutMs = 15000,
74
+ dispatcher,
75
+ fetchImpl = fetch,
76
+ } = {}) {
77
+ const sendUrl = resolveWebhookBotSendUrl({ url, key });
78
+ if (!sendUrl) throw new Error("missing webhook bot url/key");
79
+ if (!templateCard || typeof templateCard !== "object") {
80
+ throw new Error("templateCard payload is required");
81
+ }
82
+ const body = {
83
+ msgtype: "template_card",
84
+ template_card: templateCard,
85
+ };
86
+ return postWebhookJson({
87
+ url: sendUrl,
88
+ body,
89
+ timeoutMs,
90
+ dispatcher,
91
+ fetchImpl,
92
+ });
93
+ }
94
+
69
95
  export async function webhookSendImage({
70
96
  url,
71
97
  key,