@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
@@ -1,3 +1,5 @@
1
+ import { buildDefaultAgentWebhookPath } from "./account-paths.js";
2
+
1
3
  export function asNumber(v, fallback = null) {
2
4
  if (v == null) return fallback;
3
5
  const n = Number(v);
@@ -30,15 +32,30 @@ export function normalizeAccountConfig({ raw, accountId, normalizeWecomWebhookTa
30
32
  if (!raw || typeof raw !== "object") return null;
31
33
  if (typeof normalizeWecomWebhookTargetMap !== "function") return null;
32
34
 
33
- const corpId = String(raw.corpId ?? "").trim();
34
- const corpSecret = String(raw.corpSecret ?? "").trim();
35
- const agentId = asNumber(raw.agentId);
36
- const callbackToken = pickFirstNonEmptyString(raw.callbackToken, raw.token);
37
- const callbackAesKey = pickFirstNonEmptyString(raw.callbackAesKey, raw.encodingAesKey);
38
- const webhookPath = String(raw.webhookPath ?? "/wecom/callback").trim() || "/wecom/callback";
35
+ const legacyAgent = raw.agent && typeof raw.agent === "object" ? raw.agent : {};
36
+ const hasLegacyAgentBlock = Object.keys(legacyAgent).length > 0;
37
+
38
+ const corpId = pickFirstNonEmptyString(raw.corpId, legacyAgent.corpId);
39
+ const corpSecret = pickFirstNonEmptyString(raw.corpSecret, legacyAgent.corpSecret);
40
+ const agentId = asNumber(raw.agentId ?? legacyAgent.agentId);
41
+ const callbackToken = pickFirstNonEmptyString(
42
+ raw.callbackToken,
43
+ legacyAgent.callbackToken,
44
+ legacyAgent.token,
45
+ hasLegacyAgentBlock ? "" : raw.token,
46
+ );
47
+ const callbackAesKey = pickFirstNonEmptyString(
48
+ raw.callbackAesKey,
49
+ legacyAgent.callbackAesKey,
50
+ legacyAgent.encodingAesKey,
51
+ hasLegacyAgentBlock ? "" : raw.encodingAesKey,
52
+ );
53
+ const defaultWebhookPath = buildDefaultAgentWebhookPath(normalizedId);
54
+ const webhookPath = String(raw.webhookPath ?? legacyAgent.webhookPath ?? defaultWebhookPath).trim() || defaultWebhookPath;
55
+ const name = pickFirstNonEmptyString(raw.name, normalizedId);
39
56
  const outboundProxy = String(raw.outboundProxy ?? raw.proxyUrl ?? raw.proxy ?? "").trim();
40
57
  const webhooks = normalizeWecomWebhookTargetMap(raw.webhooks);
41
- const allowFrom = raw.allowFrom;
58
+ const allowFrom = raw.allowFrom ?? raw.dm?.allowFrom;
42
59
  const allowFromRejectMessage = String(raw.allowFromRejectMessage ?? raw.rejectUnauthorizedMessage ?? "").trim();
43
60
 
44
61
  if (!corpId || !corpSecret || !agentId) {
@@ -47,12 +64,14 @@ export function normalizeAccountConfig({ raw, accountId, normalizeWecomWebhookTa
47
64
 
48
65
  return {
49
66
  accountId: normalizedId,
67
+ name: normalizedId,
50
68
  corpId,
51
69
  corpSecret,
52
70
  agentId,
53
71
  callbackToken,
54
72
  callbackAesKey,
55
73
  webhookPath,
74
+ name,
56
75
  outboundProxy: outboundProxy || undefined,
57
76
  webhooks: Object.keys(webhooks).length > 0 ? webhooks : undefined,
58
77
  allowFrom,
@@ -84,7 +103,8 @@ export function readAccountConfigFromEnv({
84
103
  const agentId = asNumber(readVar("AGENT_ID"));
85
104
  const callbackToken = pickFirstNonEmptyString(readVar("CALLBACK_TOKEN"), readVar("TOKEN"));
86
105
  const callbackAesKey = pickFirstNonEmptyString(readVar("CALLBACK_AES_KEY"), readVar("ENCODING_AES_KEY"));
87
- const webhookPath = String(readVar("WEBHOOK_PATH") ?? "/wecom/callback").trim() || "/wecom/callback";
106
+ const defaultWebhookPath = buildDefaultAgentWebhookPath(normalizedId);
107
+ const webhookPath = String(readVar("WEBHOOK_PATH") ?? defaultWebhookPath).trim() || defaultWebhookPath;
88
108
  const outboundProxyRaw =
89
109
  readVar("PROXY") ??
90
110
  (normalizedId === "default"
@@ -7,6 +7,58 @@ import {
7
7
  } from "./account-config-core.js";
8
8
  import { normalizePluginHttpPath } from "./http-path.js";
9
9
 
10
+ const LEGACY_INLINE_ACCOUNT_RESERVED_KEYS = new Set([
11
+ "name",
12
+ "enabled",
13
+ "corpId",
14
+ "corpSecret",
15
+ "agentId",
16
+ "callbackToken",
17
+ "token",
18
+ "callbackAesKey",
19
+ "encodingAesKey",
20
+ "webhookPath",
21
+ "outboundProxy",
22
+ "proxyUrl",
23
+ "proxy",
24
+ "webhooks",
25
+ "allowFrom",
26
+ "allowFromRejectMessage",
27
+ "rejectUnauthorizedMessage",
28
+ "adminUsers",
29
+ "commandAllowlist",
30
+ "commandBlockMessage",
31
+ "commands",
32
+ "workspaceTemplate",
33
+ "groupChat",
34
+ "dynamicAgent",
35
+ "dynamicAgents",
36
+ "dm",
37
+ "debounce",
38
+ "streaming",
39
+ "bot",
40
+ "delivery",
41
+ "webhookBot",
42
+ "stream",
43
+ "observability",
44
+ "voiceTranscription",
45
+ "accounts",
46
+ "agent",
47
+ ]);
48
+
49
+ function listLegacyInlineAccountEntries(channelConfig) {
50
+ if (!channelConfig || typeof channelConfig !== "object") return [];
51
+ const entries = [];
52
+ for (const [rawKey, value] of Object.entries(channelConfig)) {
53
+ const accountId = normalizeAccountId(rawKey);
54
+ if (!accountId) continue;
55
+ if (LEGACY_INLINE_ACCOUNT_RESERVED_KEYS.has(accountId)) continue;
56
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
57
+ entries.push([accountId, value]);
58
+ }
59
+ return entries;
60
+ }
61
+
10
62
  export function createWecomAccountRegistry({
11
63
  normalizeWecomWebhookTargetMap,
12
64
  resolveWecomProxyConfig,
@@ -54,6 +106,9 @@ export function createWecomAccountRegistry({
54
106
  upsert(accountId, accountConfig);
55
107
  }
56
108
  }
109
+ for (const [accountId, accountConfig] of listLegacyInlineAccountEntries(channelConfig)) {
110
+ upsert(accountId, accountConfig);
111
+ }
57
112
 
58
113
  const envAccountIds = collectWecomEnvAccountIds({ envVars, processEnv });
59
114
  for (const accountId of envAccountIds) {
@@ -0,0 +1,121 @@
1
+ import { normalizePluginHttpPath } from "./http-path.js";
2
+ import { buildDefaultAgentWebhookPath, buildDefaultBotWebhookPath } from "./account-paths.js";
3
+
4
+ function normalizeAccountId(accountId) {
5
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
6
+ return normalized || "default";
7
+ }
8
+
9
+ function pushMapList(map, key, value) {
10
+ if (!key) return;
11
+ const existing = map.get(key);
12
+ if (existing) existing.push(value);
13
+ else map.set(key, [value]);
14
+ }
15
+
16
+ function detectDuplicateMapEntries(map, minimum = 2) {
17
+ const out = [];
18
+ for (const [key, values] of map.entries()) {
19
+ if (!Array.isArray(values) || values.length < minimum) continue;
20
+ out.push({ key, values: [...values] });
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export function analyzeWecomAccountConflicts({ accounts = [], botConfigs = [] } = {}) {
26
+ const issues = [];
27
+ const enabledAccounts = (Array.isArray(accounts) ? accounts : []).filter((item) => item?.enabled !== false);
28
+ const enabledBotConfigs = (Array.isArray(botConfigs) ? botConfigs : []).filter((item) => item?.enabled === true);
29
+
30
+ const agentTokenToAccounts = new Map();
31
+ const corpAgentToAccounts = new Map();
32
+ const agentPathToAccounts = new Map();
33
+ const botTokenToAccounts = new Map();
34
+ const botPathToAccounts = new Map();
35
+
36
+ for (const account of enabledAccounts) {
37
+ const accountId = normalizeAccountId(account?.accountId);
38
+ const callbackToken = String(account?.callbackToken ?? "").trim();
39
+ const corpId = String(account?.corpId ?? "").trim().toLowerCase();
40
+ const agentId = String(account?.agentId ?? "").trim();
41
+ const normalizedPath =
42
+ normalizePluginHttpPath(
43
+ String(account?.webhookPath ?? "").trim() || buildDefaultAgentWebhookPath(accountId),
44
+ "/wecom/callback",
45
+ ) ?? "/wecom/callback";
46
+
47
+ if (callbackToken) pushMapList(agentTokenToAccounts, callbackToken, accountId);
48
+ if (corpId && agentId) pushMapList(corpAgentToAccounts, `${corpId}:${agentId}`, accountId);
49
+ pushMapList(agentPathToAccounts, normalizedPath, accountId);
50
+ }
51
+
52
+ for (const botConfig of enabledBotConfigs) {
53
+ const accountId = normalizeAccountId(botConfig?.accountId);
54
+ const token = String(botConfig?.token ?? "").trim();
55
+ const normalizedPath =
56
+ normalizePluginHttpPath(
57
+ String(botConfig?.webhookPath ?? "").trim() || buildDefaultBotWebhookPath(accountId),
58
+ "/wecom/bot/callback",
59
+ ) ?? "/wecom/bot/callback";
60
+ if (token) pushMapList(botTokenToAccounts, token, accountId);
61
+ pushMapList(botPathToAccounts, normalizedPath, accountId);
62
+ }
63
+
64
+ for (const dup of detectDuplicateMapEntries(agentTokenToAccounts)) {
65
+ issues.push({
66
+ severity: "warn",
67
+ code: "agent-duplicate-callback-token",
68
+ message: `Agent callbackToken duplicated across accounts: ${dup.values.join(", ")}`,
69
+ value: dup.key,
70
+ accounts: dup.values,
71
+ });
72
+ }
73
+ for (const dup of detectDuplicateMapEntries(corpAgentToAccounts)) {
74
+ issues.push({
75
+ severity: "warn",
76
+ code: "agent-duplicate-corp-agent",
77
+ message: `Agent corpId+agentId duplicated across accounts: ${dup.values.join(", ")}`,
78
+ value: dup.key,
79
+ accounts: dup.values,
80
+ });
81
+ }
82
+ for (const dup of detectDuplicateMapEntries(botTokenToAccounts)) {
83
+ issues.push({
84
+ severity: "warn",
85
+ code: "bot-duplicate-token",
86
+ message: `Bot token duplicated across accounts: ${dup.values.join(", ")}`,
87
+ value: dup.key,
88
+ accounts: dup.values,
89
+ });
90
+ }
91
+ for (const dup of detectDuplicateMapEntries(agentPathToAccounts)) {
92
+ issues.push({
93
+ severity: "info",
94
+ code: "agent-shared-webhook-path",
95
+ message: `Agent webhook path shared by accounts: ${dup.key} <- ${dup.values.join(", ")}`,
96
+ value: dup.key,
97
+ accounts: dup.values,
98
+ });
99
+ }
100
+ for (const dup of detectDuplicateMapEntries(botPathToAccounts)) {
101
+ issues.push({
102
+ severity: "info",
103
+ code: "bot-shared-webhook-path",
104
+ message: `Bot webhook path shared by accounts: ${dup.key} <- ${dup.values.join(", ")}`,
105
+ value: dup.key,
106
+ accounts: dup.values,
107
+ });
108
+ }
109
+
110
+ return {
111
+ ok: !issues.some((item) => item.severity === "warn"),
112
+ issues,
113
+ counts: {
114
+ accounts: enabledAccounts.length,
115
+ botAccounts: enabledBotConfigs.length,
116
+ warnings: issues.filter((item) => item.severity === "warn").length,
117
+ info: issues.filter((item) => item.severity === "info").length,
118
+ },
119
+ };
120
+ }
121
+
@@ -0,0 +1,39 @@
1
+ function normalizeAccountId(accountId) {
2
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
3
+ return normalized || "default";
4
+ }
5
+
6
+ export function buildWebhookPathAccountSlug(accountId) {
7
+ const normalizedId = normalizeAccountId(accountId);
8
+ if (normalizedId === "default") return "default";
9
+ const slug = normalizedId.replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
10
+ return slug || "default";
11
+ }
12
+
13
+ export function buildDefaultAgentWebhookPath(accountId) {
14
+ const normalizedId = normalizeAccountId(accountId);
15
+ if (normalizedId === "default") return "/wecom/callback";
16
+ const slug = buildWebhookPathAccountSlug(normalizedId);
17
+ return `/wecom/${slug}/callback`;
18
+ }
19
+
20
+ export function buildLegacyAgentWebhookPath(accountId) {
21
+ const normalizedId = normalizeAccountId(accountId);
22
+ if (normalizedId === "default") return "/webhooks/app";
23
+ const slug = buildWebhookPathAccountSlug(normalizedId);
24
+ return `/webhooks/app/${slug}`;
25
+ }
26
+
27
+ export function buildDefaultBotWebhookPath(accountId) {
28
+ const normalizedId = normalizeAccountId(accountId);
29
+ if (normalizedId === "default") return "/wecom/bot/callback";
30
+ const slug = buildWebhookPathAccountSlug(normalizedId);
31
+ return `/wecom/${slug}/bot/callback`;
32
+ }
33
+
34
+ export function buildLegacyBotWebhookPath(accountId) {
35
+ const normalizedId = normalizeAccountId(accountId);
36
+ if (normalizedId === "default") return "/webhooks/wecom";
37
+ const slug = buildWebhookPathAccountSlug(normalizedId);
38
+ return `/webhooks/wecom/${slug}`;
39
+ }
@@ -53,6 +53,15 @@ const ASYNC_INBOUND_HANDLERS = {
53
53
  linkPicUrl: inbound.linkPicUrl,
54
54
  }),
55
55
  },
56
+ event: {
57
+ requiresMediaId: false,
58
+ errorLabel: "event",
59
+ buildTaskPayload: (inbound) => ({
60
+ msgType: "event",
61
+ eventType: inbound.eventType,
62
+ eventKey: inbound.eventKey,
63
+ }),
64
+ },
56
65
  };
57
66
 
58
67
  function enqueueInboundTask({
@@ -20,6 +20,7 @@ export async function applyWecomAgentInboundGuards({
20
20
  stripWecomGroupMentions,
21
21
  resolveWecomCommandPolicy,
22
22
  resolveWecomAllowFromPolicy,
23
+ resolveWecomDmPolicy,
23
24
  isWecomSenderAllowed,
24
25
  extractLeadingSlashCommand,
25
26
  COMMANDS,
@@ -31,6 +32,7 @@ export async function applyWecomAgentInboundGuards({
31
32
  assertFunction("stripWecomGroupMentions", stripWecomGroupMentions);
32
33
  assertFunction("resolveWecomCommandPolicy", resolveWecomCommandPolicy);
33
34
  assertFunction("resolveWecomAllowFromPolicy", resolveWecomAllowFromPolicy);
35
+ assertFunction("resolveWecomDmPolicy", resolveWecomDmPolicy);
34
36
  assertFunction("isWecomSenderAllowed", isWecomSenderAllowed);
35
37
  assertFunction("extractLeadingSlashCommand", extractLeadingSlashCommand);
36
38
  assertFunction("sendTextToUser", sendTextToUser);
@@ -59,6 +61,23 @@ export async function applyWecomAgentInboundGuards({
59
61
 
60
62
  const commandPolicy = resolveWecomCommandPolicy(api);
61
63
  const isAdminUser = commandPolicy.adminUsers.includes(normalizedFromUser);
64
+ const dmPolicy = resolveWecomDmPolicy(api, config?.accountId || accountId || "default", config);
65
+ if (!isGroupChat) {
66
+ if (dmPolicy.mode === "deny") {
67
+ await sendTextToUser(dmPolicy.rejectMessage || "当前渠道私聊已关闭,请联系管理员。");
68
+ return { ok: false, commandBody: nextCommandBody, isAdminUser };
69
+ }
70
+ if (dmPolicy.mode === "allowlist") {
71
+ const dmSenderAllowed = isAdminUser || isWecomSenderAllowed({
72
+ senderId: normalizedFromUser,
73
+ allowFrom: dmPolicy.allowFrom,
74
+ });
75
+ if (!dmSenderAllowed) {
76
+ await sendTextToUser(dmPolicy.rejectMessage || "当前私聊账号未授权,请联系管理员。");
77
+ return { ok: false, commandBody: nextCommandBody, isAdminUser };
78
+ }
79
+ }
80
+ }
62
81
  const allowFromPolicy = resolveWecomAllowFromPolicy(api, config?.accountId || accountId || "default", config);
63
82
  const senderAllowed = isAdminUser || isWecomSenderAllowed({
64
83
  senderId: normalizedFromUser,
@@ -76,15 +95,16 @@ export async function applyWecomAgentInboundGuards({
76
95
 
77
96
  if (msgType === "text") {
78
97
  let commandKey = extractLeadingSlashCommand(nextCommandBody);
79
- if (commandKey === "/clear") {
80
- api?.logger?.info?.("wecom: translating /clear to native /reset command");
81
- nextCommandBody = nextCommandBody.replace(/^\/clear\b/i, "/reset");
98
+ if (commandKey === "/clear" || commandKey === "/new") {
99
+ api?.logger?.info?.(`wecom: translating ${commandKey} to native /reset command`);
100
+ nextCommandBody = nextCommandBody.replace(/^\/(?:clear|new)\b/i, "/reset");
82
101
  commandKey = "/reset";
83
102
  }
84
103
  if (commandKey) {
85
104
  const commandAllowed =
86
105
  commandPolicy.allowlist.includes(commandKey) ||
87
- (commandKey === "/reset" && commandPolicy.allowlist.includes("/clear"));
106
+ (commandKey === "/reset" &&
107
+ (commandPolicy.allowlist.includes("/clear") || commandPolicy.allowlist.includes("/new")));
88
108
  if (commandPolicy.enabled && !isAdminUser && !commandAllowed) {
89
109
  api?.logger?.info?.(`wecom: command blocked by allowlist user=${fromUser} command=${commandKey}`);
90
110
  await sendTextToUser(commandPolicy.rejectMessage);
@@ -17,6 +17,12 @@ export function createWecomAgentInboundProcessor(deps = {}) {
17
17
  stripWecomGroupMentions,
18
18
  resolveWecomCommandPolicy,
19
19
  resolveWecomAllowFromPolicy,
20
+ resolveWecomDmPolicy,
21
+ resolveWecomEventPolicy = () => ({
22
+ enabled: true,
23
+ enterAgentWelcomeEnabled: false,
24
+ enterAgentWelcomeText: "",
25
+ }),
20
26
  isWecomSenderAllowed,
21
27
  sendWecomText,
22
28
  extractLeadingSlashCommand,
@@ -65,6 +71,7 @@ export function createWecomAgentInboundProcessor(deps = {}) {
65
71
  fromUser,
66
72
  content,
67
73
  msgType,
74
+ eventType,
68
75
  mediaId,
69
76
  picUrl,
70
77
  recognition,
@@ -124,6 +131,7 @@ export function createWecomAgentInboundProcessor(deps = {}) {
124
131
  stripWecomGroupMentions,
125
132
  resolveWecomCommandPolicy,
126
133
  resolveWecomAllowFromPolicy,
134
+ resolveWecomDmPolicy,
127
135
  isWecomSenderAllowed,
128
136
  extractLeadingSlashCommand,
129
137
  COMMANDS,
@@ -140,6 +148,25 @@ export function createWecomAgentInboundProcessor(deps = {}) {
140
148
  isGroupChat,
141
149
  },
142
150
  });
151
+
152
+ if (String(msgType ?? "").trim().toLowerCase() === "event") {
153
+ const normalizedEventType = String(eventType ?? "").trim().toLowerCase();
154
+ const eventPolicy = resolveWecomEventPolicy(api, config.accountId || accountId, config);
155
+ if (!eventPolicy?.enabled) {
156
+ api.logger.info?.(`wecom: event skipped (disabled) type=${normalizedEventType || "unknown"}`);
157
+ return;
158
+ }
159
+ if (normalizedEventType === "enter_agent" && eventPolicy.enterAgentWelcomeEnabled) {
160
+ const welcomeText = String(eventPolicy.enterAgentWelcomeText ?? "").trim();
161
+ if (welcomeText) {
162
+ await sendTextToUser(welcomeText);
163
+ api.logger.info?.(`wecom: enter_agent welcome sent account=${config.accountId || accountId}`);
164
+ }
165
+ return;
166
+ }
167
+ api.logger.info?.(`wecom: event ignored type=${normalizedEventType || "unknown"}`);
168
+ return;
169
+ }
143
170
  if (!guardResult.ok) return;
144
171
  commandBody = guardResult.commandBody;
145
172
  const isAdminUser = guardResult.isAdminUser === true;
@@ -14,6 +14,8 @@ export function createWecomAgentWebhookHandler({
14
14
  messageProcessLimiter,
15
15
  executeInboundTaskWithSessionQueue,
16
16
  processInboundMessage,
17
+ recordInboundMetric = () => {},
18
+ recordRuntimeErrorMetric = () => {},
17
19
  } = {}) {
18
20
  const dispatchInbound = createWecomAgentInboundDispatcher({
19
21
  api,
@@ -150,6 +152,11 @@ export function createWecomAgentWebhookHandler({
150
152
  api.logger.info?.(
151
153
  `wecom inbound: account=${matchedAccount.accountId} from=${fromUser} msgType=${msgType} chatId=${chatId || "N/A"} content=${(inbound?.content ?? "").slice?.(0, 80)}`,
152
154
  );
155
+ recordInboundMetric({
156
+ mode: "agent",
157
+ msgType,
158
+ accountId: matchedAccount.accountId,
159
+ });
153
160
 
154
161
  if (!fromUser) {
155
162
  api.logger.warn?.("wecom: inbound message missing FromUserName, dropped");
@@ -173,6 +180,10 @@ export function createWecomAgentWebhookHandler({
173
180
  }
174
181
  } catch (err) {
175
182
  api.logger.error?.(`wecom: webhook handler failed: ${String(err?.message || err)}`);
183
+ recordRuntimeErrorMetric({
184
+ scope: "agent-webhook",
185
+ reason: String(err?.message || err),
186
+ });
176
187
  if (!res.writableEnded) {
177
188
  res.statusCode = 500;
178
189
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
@@ -25,6 +25,7 @@ export function buildWecomBotInboundContextPayload({
25
25
  commandBody,
26
26
  fromAddress,
27
27
  sessionId,
28
+ accountId = "default",
28
29
  isGroupChat,
29
30
  chatId,
30
31
  fromUser,
@@ -39,7 +40,7 @@ export function buildWecomBotInboundContextPayload({
39
40
  From: fromAddress,
40
41
  To: fromAddress,
41
42
  SessionKey: sessionId,
42
- AccountId: "bot",
43
+ AccountId: accountId,
43
44
  ChatType: isGroupChat ? "group" : "direct",
44
45
  ConversationLabel: isGroupChat && chatId ? `group:${chatId}` : fromUser,
45
46
  SenderName: fromUser,
@@ -50,6 +50,7 @@ export async function handleWecomBotDispatchError({
50
50
  startLateReplyWatcher,
51
51
  sessionId,
52
52
  fromUser,
53
+ accountId = "default",
53
54
  buildWecomBotSessionId,
54
55
  runtime,
55
56
  cfg,
@@ -78,7 +79,7 @@ export async function handleWecomBotDispatchError({
78
79
  }
79
80
 
80
81
  try {
81
- const runtimeSessionId = sessionId || buildWecomBotSessionId(fromUser);
82
+ const runtimeSessionId = sessionId || buildWecomBotSessionId(fromUser, accountId);
82
83
  const runtimeStorePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
83
84
  agentId: routedAgentId || "main",
84
85
  });
@@ -9,6 +9,8 @@ export function createWecomBotInboundContentBuilder({
9
9
  detectImageContentTypeFromBuffer,
10
10
  decryptWecomMediaBuffer,
11
11
  pickImageFileExtension,
12
+ resolveWecomVoiceTranscriptionConfig,
13
+ transcribeInboundVoice,
12
14
  inferFilenameFromMediaDownload,
13
15
  smartDecryptWecomFileBuffer,
14
16
  basename,
@@ -22,6 +24,8 @@ export function createWecomBotInboundContentBuilder({
22
24
  assertFunction("detectImageContentTypeFromBuffer", detectImageContentTypeFromBuffer);
23
25
  assertFunction("decryptWecomMediaBuffer", decryptWecomMediaBuffer);
24
26
  assertFunction("pickImageFileExtension", pickImageFileExtension);
27
+ assertFunction("resolveWecomVoiceTranscriptionConfig", resolveWecomVoiceTranscriptionConfig);
28
+ assertFunction("transcribeInboundVoice", transcribeInboundVoice);
25
29
  assertFunction("inferFilenameFromMediaDownload", inferFilenameFromMediaDownload);
26
30
  assertFunction("smartDecryptWecomFileBuffer", smartDecryptWecomFileBuffer);
27
31
  assertFunction("basename", basename);
@@ -39,6 +43,10 @@ export function createWecomBotInboundContentBuilder({
39
43
  normalizedImageUrls = [],
40
44
  normalizedFileUrl = "",
41
45
  normalizedFileName = "",
46
+ normalizedVoiceUrl = "",
47
+ normalizedVoiceMediaId = "",
48
+ normalizedVoiceContentType = "",
49
+ voiceInputMessageId = "",
42
50
  normalizedQuote = null,
43
51
  } = {}) {
44
52
  const tempPathsToCleanup = [];
@@ -124,7 +132,8 @@ export function createWecomBotInboundContentBuilder({
124
132
  }
125
133
  }
126
134
 
127
- if (msgType === "file") {
135
+ const shouldHandleFile = msgType === "file" || (msgType === "mixed" && Boolean(normalizedFileUrl));
136
+ if (shouldHandleFile) {
128
137
  const displayName =
129
138
  inferFilenameFromMediaDownload({
130
139
  explicitName: normalizedFileName,
@@ -162,22 +171,83 @@ export function createWecomBotInboundContentBuilder({
162
171
  );
163
172
  await writeFile(fileTempPath, decrypted.buffer);
164
173
  tempPathsToCleanup.push(fileTempPath);
165
- messageText =
174
+ const fileInstruction =
166
175
  `[用户发送了一个文件: ${safeName},已保存到: ${fileTempPath}]` +
167
176
  "\n\n请根据文件内容回复用户;如需读取详情请使用 Read 工具。";
177
+ if (msgType === "mixed" && messageText) {
178
+ messageText = `${messageText}\n${fileInstruction}`.trim();
179
+ } else {
180
+ messageText = fileInstruction;
181
+ }
168
182
  api?.logger?.info?.(
169
183
  `wecom(bot): saved file to ${fileTempPath}, size=${decrypted.buffer.length} bytes` +
170
184
  `, decrypted=${decrypted.decrypted ? "yes" : "no"} source=${downloaded.source || "unknown"}`,
171
185
  );
172
186
  } catch (fileErr) {
173
187
  api?.logger?.warn?.(`wecom(bot): failed to fetch file url: ${String(fileErr?.message || fileErr)}`);
174
- messageText = `[用户发送了一个文件: ${displayName},但下载失败]\n\n请提示用户重新发送文件。`;
188
+ const failedFileHint = `[用户发送了一个文件: ${displayName},但下载失败]\n\n请提示用户重新发送文件。`;
189
+ if (msgType === "mixed" && messageText) {
190
+ messageText = `${messageText}\n${failedFileHint}`.trim();
191
+ } else {
192
+ messageText = failedFileHint;
193
+ }
175
194
  }
176
195
  } else if (!messageText) {
177
196
  messageText = `[用户发送了一个文件: ${displayName}]`;
178
197
  }
179
198
  }
180
199
 
200
+ const shouldHandleVoice = msgType === "voice" || (msgType === "mixed" && Boolean(normalizedVoiceUrl));
201
+ if (shouldHandleVoice) {
202
+ const existingVoiceText = String(messageText ?? "").trim();
203
+ const voiceUrl = String(normalizedVoiceUrl ?? "").trim();
204
+ const voiceMediaId = String(normalizedVoiceMediaId ?? "").trim() || String(voiceInputMessageId ?? "").trim();
205
+ if (existingVoiceText && existingVoiceText !== "[语音]") {
206
+ if (msgType === "mixed") {
207
+ messageText = `${existingVoiceText}\n[用户发送了一条语音]`;
208
+ } else {
209
+ messageText = `[用户发送了一条语音]\n转写: ${existingVoiceText}`;
210
+ }
211
+ } else if (!voiceUrl) {
212
+ messageText = "语音接收成功,但未提供可下载的语音链接,请用户改发文字。";
213
+ } else {
214
+ const voiceConfig = resolveWecomVoiceTranscriptionConfig(api);
215
+ if (!voiceConfig.enabled) {
216
+ messageText = "已收到语音消息,但当前未启用语音转写,请改发文字。";
217
+ } else {
218
+ try {
219
+ const downloadedVoice = await fetchMediaFromUrl(voiceUrl, {
220
+ proxyUrl: botProxyUrl,
221
+ logger: api?.logger,
222
+ forceProxy: Boolean(botProxyUrl),
223
+ maxBytes: Math.max(voiceConfig.maxBytes || 0, 2 * 1024 * 1024),
224
+ });
225
+ const transcript = await transcribeInboundVoice({
226
+ api,
227
+ buffer: downloadedVoice.buffer,
228
+ contentType: normalizedVoiceContentType || downloadedVoice.contentType,
229
+ mediaId: voiceMediaId || `bot-voice-${Date.now()}`,
230
+ voiceConfig,
231
+ });
232
+ const voiceText = `[用户发送了一条语音]\n转写: ${String(transcript ?? "").trim()}`;
233
+ if (msgType === "mixed" && messageText) {
234
+ messageText = `${messageText}\n${voiceText}`.trim();
235
+ } else {
236
+ messageText = voiceText;
237
+ }
238
+ } catch (voiceErr) {
239
+ api?.logger?.warn?.(`wecom(bot): voice transcription failed: ${String(voiceErr?.message || voiceErr)}`);
240
+ return {
241
+ aborted: true,
242
+ abortText: "语音识别失败,请稍后重试。",
243
+ messageText: "",
244
+ tempPathsToCleanup,
245
+ };
246
+ }
247
+ }
248
+ }
249
+ }
250
+
181
251
  if (normalizedQuote?.content) {
182
252
  const quoteLabel = normalizedQuote.msgType === "image" ? "[引用图片]" : `> ${normalizedQuote.content}`;
183
253
  messageText = `${quoteLabel}\n\n${String(messageText ?? "").trim()}`.trim();
@@ -18,6 +18,7 @@ export async function executeWecomBotDispatchRuntime({
18
18
  cfg,
19
19
  ctxPayload,
20
20
  streamId,
21
+ accountId = "default",
21
22
  sessionId,
22
23
  routedAgentId,
23
24
  storePath,
@@ -96,7 +97,7 @@ export async function executeWecomBotDispatchRuntime({
96
97
  ? {
97
98
  sessionKey: sessionId,
98
99
  agentId: routedAgentId,
99
- accountId: "bot",
100
+ accountId,
100
101
  }
101
102
  : undefined,
102
103
  },