@dingxiang-me/openclaw-wechat 2.0.0 → 2.0.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.1] - 2026-03-14
8
+
9
+ ### Fixed
10
+ - 修复 WeCom Agent 回复 `/workspace/...` 文件路径时因动态会话工作区缺少目标文件,最终只回显路径文本而不实际发送图片/文件的问题
11
+ - 修复 WeCom Bot 长连接对附件回调 payload 形态兼容不足,导致 PDF/文件消息被当成普通文本继续处理的问题;补充 `msg_type`、嵌套 `message`、`attachments/items`、`file_url/download_url/file_name/aes_key` 等兼容解析
12
+ - 修复 WeCom Bot 入站文件/图片处理未完整透传媒体级 AES Key 的问题,降低 PDF/图片解密失败和内容损坏风险
13
+ - 修复本地 `whisper-cli` 转写时临时音频文件在子进程真正读取前被提前清理,导致语音识别报错 `input file not found`
14
+ - 补充 WeCom Bot 长连接未解析 callback 的运行日志,便于继续定位企业微信新回调形态
15
+
7
16
  ## [2.0.0] - 2026-03-13
8
17
 
9
18
  ### Changed
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-wechat",
3
3
  "name": "OpenClaw-Wechat",
4
- "version": "2.0.0",
4
+ "version": "2.0.1",
5
5
  "description": "WeCom (企业微信) channel plugin for OpenClaw (自建应用回调 + 发送 API).",
6
6
  "channels": [
7
7
  "wecom"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingxiang-me/openclaw-wechat",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "description": "WeCom (企业微信) channel plugin for OpenClaw (自建应用回调 + 发送 API).",
6
6
  "license": "MIT",
@@ -11,6 +11,26 @@ export function createWecomTextSender({
11
11
  throw new Error("createWecomTextSender: sendWecomTypedMessage is required");
12
12
  }
13
13
 
14
+ const targetSendChains = new Map();
15
+
16
+ function buildTargetKey({ corpId, agentId, toUser, toParty, toTag, chatId } = {}) {
17
+ const accountKey = `${corpId || "corp:unknown"}:${agentId || "agent:unknown"}`;
18
+ if (chatId) return `${accountKey}:chat:${chatId}`;
19
+ return `${accountKey}:direct:${[toUser, toParty, toTag].filter(Boolean).join("|") || "unknown"}`;
20
+ }
21
+
22
+ async function enqueueTargetSend(targetKey, task) {
23
+ const previous = targetSendChains.get(targetKey) || Promise.resolve();
24
+ const run = previous.catch(() => {}).then(task);
25
+ const tracked = run.finally(() => {
26
+ if (targetSendChains.get(targetKey) === tracked) {
27
+ targetSendChains.delete(targetKey);
28
+ }
29
+ });
30
+ targetSendChains.set(targetKey, tracked);
31
+ return run;
32
+ }
33
+
14
34
  async function sendWecomTextSingle({
15
35
  corpId,
16
36
  corpSecret,
@@ -57,29 +77,32 @@ export function createWecomTextSender({
57
77
  logger,
58
78
  proxyUrl,
59
79
  }) {
60
- const chunks = splitWecomText(text);
61
- logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
80
+ const targetKey = buildTargetKey({ corpId, agentId, toUser, toParty, toTag, chatId });
81
+ return enqueueTargetSend(targetKey, async () => {
82
+ const chunks = splitWecomText(text);
83
+ logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
62
84
 
63
- for (let i = 0; i < chunks.length; i += 1) {
64
- logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
65
- // eslint-disable-next-line no-await-in-loop
66
- await sendWecomTextSingle({
67
- corpId,
68
- corpSecret,
69
- agentId,
70
- toUser,
71
- toParty,
72
- toTag,
73
- chatId,
74
- text: chunks[i],
75
- logger,
76
- proxyUrl,
77
- });
78
- if (i < chunks.length - 1) {
85
+ for (let i = 0; i < chunks.length; i += 1) {
86
+ logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
79
87
  // eslint-disable-next-line no-await-in-loop
80
- await sleep(300);
88
+ await sendWecomTextSingle({
89
+ corpId,
90
+ corpSecret,
91
+ agentId,
92
+ toUser,
93
+ toParty,
94
+ toTag,
95
+ chatId,
96
+ text: chunks[i],
97
+ logger,
98
+ proxyUrl,
99
+ });
100
+ if (i < chunks.length - 1) {
101
+ // eslint-disable-next-line no-await-in-loop
102
+ await sleep(300);
103
+ }
81
104
  }
82
- }
105
+ });
83
106
  }
84
107
 
85
108
  return {
@@ -40,9 +40,11 @@ export function createWecomBotInboundContentBuilder({
40
40
  botProxyUrl,
41
41
  msgType = "text",
42
42
  commandBody = "",
43
+ normalizedImageEntries = [],
43
44
  normalizedImageUrls = [],
44
45
  normalizedFileUrl = "",
45
46
  normalizedFileName = "",
47
+ normalizedFileAesKey = "",
46
48
  normalizedVoiceUrl = "",
47
49
  normalizedVoiceMediaId = "",
48
50
  normalizedVoiceContentType = "",
@@ -52,12 +54,17 @@ export function createWecomBotInboundContentBuilder({
52
54
  const tempPathsToCleanup = [];
53
55
  let messageText = String(commandBody ?? "").trim();
54
56
 
55
- if (normalizedImageUrls.length > 0) {
57
+ if (normalizedImageUrls.length > 0 || normalizedImageEntries.length > 0) {
56
58
  const fetchedImagePaths = [];
57
- const imageUrlsToFetch = normalizedImageUrls.slice(0, 3);
59
+ const imageEntriesToFetch =
60
+ Array.isArray(normalizedImageEntries) && normalizedImageEntries.length > 0
61
+ ? normalizedImageEntries.slice(0, 3)
62
+ : normalizedImageUrls.slice(0, 3).map((url) => ({ url, aesKey: "" }));
58
63
  const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
59
64
  await mkdir(tempDir, { recursive: true });
60
- for (const imageUrl of imageUrlsToFetch) {
65
+ for (const imageEntry of imageEntriesToFetch) {
66
+ const imageUrl = String(imageEntry?.url ?? "").trim();
67
+ const imageAesKey = String(imageEntry?.aesKey ?? "").trim();
61
68
  try {
62
69
  const { buffer, contentType } = await fetchMediaFromUrl(imageUrl, {
63
70
  proxyUrl: botProxyUrl,
@@ -73,10 +80,11 @@ export function createWecomBotInboundContentBuilder({
73
80
  let effectiveBuffer = buffer;
74
81
  let effectiveImageType =
75
82
  normalizedType.startsWith("image/") ? normalizedType : detectImageContentTypeFromBuffer(buffer);
76
- if (!effectiveImageType && botModeConfig?.encodingAesKey) {
83
+ const decryptAesKey = imageAesKey || String(botModeConfig?.encodingAesKey ?? "").trim();
84
+ if (!effectiveImageType && decryptAesKey) {
77
85
  try {
78
86
  const decryptedBuffer = decryptWecomMediaBuffer({
79
- aesKey: botModeConfig.encodingAesKey,
87
+ aesKey: decryptAesKey,
80
88
  encryptedBuffer: buffer,
81
89
  });
82
90
  const decryptedImageType = detectImageContentTypeFromBuffer(decryptedBuffer);
@@ -158,7 +166,7 @@ export function createWecomBotInboundContentBuilder({
158
166
  });
159
167
  const decrypted = smartDecryptWecomFileBuffer({
160
168
  buffer: downloaded.buffer,
161
- aesKey: botModeConfig?.encodingAesKey,
169
+ aesKey: normalizedFileAesKey || botModeConfig?.encodingAesKey,
162
170
  contentType: downloaded.contentType,
163
171
  sourceUrl: downloaded.finalUrl || normalizedFileUrl,
164
172
  decryptFn: decryptWecomMediaBuffer,
@@ -4,6 +4,33 @@ function assertFunction(name, value) {
4
4
  }
5
5
  }
6
6
 
7
+ function normalizeBotImageEntries({ imageEntries, imageUrls } = {}) {
8
+ const normalized = [];
9
+ const seen = new Map();
10
+ const sourceEntries = Array.isArray(imageEntries) && imageEntries.length > 0
11
+ ? imageEntries
12
+ : Array.isArray(imageUrls)
13
+ ? imageUrls.map((url) => ({ url }))
14
+ : [];
15
+ for (const rawEntry of sourceEntries) {
16
+ if (rawEntry == null) continue;
17
+ const entry = typeof rawEntry === "string" ? { url: rawEntry } : rawEntry;
18
+ const url = String(entry?.url ?? "").trim();
19
+ if (!url) continue;
20
+ const aesKey = String(entry?.aesKey ?? "").trim();
21
+ const existingIndex = seen.get(url);
22
+ if (existingIndex == null) {
23
+ seen.set(url, normalized.length);
24
+ normalized.push({ url, aesKey });
25
+ continue;
26
+ }
27
+ if (!normalized[existingIndex].aesKey && aesKey) {
28
+ normalized[existingIndex] = { url, aesKey };
29
+ }
30
+ }
31
+ return normalized;
32
+ }
33
+
7
34
  const UNSUPPORTED_BOT_GROUP_TRIGGER_WARNED = new Set();
8
35
 
9
36
  function warnUnsupportedBotGroupTriggerOnce(triggerMode, logger) {
@@ -91,9 +118,11 @@ export function createWecomBotInboundFlowState({
91
118
  accountId = "default",
92
119
  fromUser,
93
120
  content,
121
+ imageEntries,
94
122
  imageUrls,
95
123
  fileUrl,
96
124
  fileName,
125
+ fileAesKey,
97
126
  voiceUrl,
98
127
  voiceMediaId,
99
128
  voiceContentType,
@@ -109,6 +138,7 @@ export function createWecomBotInboundFlowState({
109
138
  const normalizedAccountId = String(accountId ?? "default").trim().toLowerCase() || "default";
110
139
  const normalizedFromUser = String(fromUser ?? "").trim().toLowerCase();
111
140
  const baseSessionId = buildWecomBotSessionId(fromUser, normalizedAccountId);
141
+ const normalizedImageEntries = normalizeBotImageEntries({ imageEntries, imageUrls });
112
142
  const state = {
113
143
  runtime,
114
144
  cfg,
@@ -127,8 +157,11 @@ export function createWecomBotInboundFlowState({
127
157
  tempPathsToCleanup: [],
128
158
  botModeConfig: resolveWecomBotConfig(api, normalizedAccountId),
129
159
  botProxyUrl: resolveWecomBotProxyConfig(api, normalizedAccountId),
160
+ normalizedImageEntries,
161
+ normalizedImageUrls: normalizedImageEntries.map((entry) => entry.url),
130
162
  normalizedFileUrl: String(fileUrl ?? "").trim(),
131
163
  normalizedFileName: String(fileName ?? "").trim(),
164
+ normalizedFileAesKey: String(fileAesKey ?? "").trim(),
132
165
  normalizedVoiceUrl: String(voiceUrl ?? "").trim(),
133
166
  normalizedVoiceMediaId: String(voiceMediaId ?? "").trim(),
134
167
  normalizedVoiceContentType: String(voiceContentType ?? "").trim(),
@@ -139,13 +172,6 @@ export function createWecomBotInboundFlowState({
139
172
  content: String(quote.content ?? "").trim(),
140
173
  }
141
174
  : null,
142
- normalizedImageUrls: Array.from(
143
- new Set(
144
- (Array.isArray(imageUrls) ? imageUrls : [])
145
- .map((item) => String(item ?? "").trim())
146
- .filter(Boolean),
147
- ),
148
- ),
149
175
  groupChatPolicy: normalizeWecomBotGroupChatPolicy(resolveWecomGroupChatPolicy(api), api?.logger),
150
176
  dynamicAgentPolicy: resolveWecomDynamicAgentPolicy(api),
151
177
  isAdminUser: false,
@@ -21,8 +21,10 @@ export async function executeWecomBotInboundFlow(payload = {}) {
21
21
  chatId,
22
22
  isGroupChat = false,
23
23
  imageUrls = [],
24
+ imageEntries = [],
24
25
  fileUrl = "",
25
26
  fileName = "",
27
+ fileAesKey = "",
26
28
  voiceUrl = "",
27
29
  voiceMediaId = "",
28
30
  voiceContentType = "",
@@ -76,9 +78,11 @@ export async function executeWecomBotInboundFlow(payload = {}) {
76
78
  accountId,
77
79
  fromUser,
78
80
  content,
81
+ imageEntries,
79
82
  imageUrls,
80
83
  fileUrl,
81
84
  fileName,
85
+ fileAesKey,
82
86
  voiceUrl,
83
87
  voiceMediaId,
84
88
  voiceContentType,
@@ -175,9 +179,11 @@ export async function executeWecomBotInboundFlow(payload = {}) {
175
179
  botProxyUrl: state.botProxyUrl,
176
180
  msgType,
177
181
  commandBody: state.commandBody,
182
+ normalizedImageEntries: state.normalizedImageEntries,
178
183
  normalizedImageUrls: state.normalizedImageUrls,
179
184
  normalizedFileUrl: state.normalizedFileUrl,
180
185
  normalizedFileName: state.normalizedFileName,
186
+ normalizedFileAesKey: state.normalizedFileAesKey,
181
187
  normalizedVoiceUrl: state.normalizedVoiceUrl,
182
188
  normalizedVoiceMediaId: state.normalizedVoiceMediaId,
183
189
  normalizedVoiceContentType: state.normalizedVoiceContentType,
@@ -513,8 +513,10 @@ export function createWecomBotLongConnectionManager({
513
513
  chatId: parsed.chatId,
514
514
  isGroupChat: parsed.isGroupChat,
515
515
  imageUrls: parsed.imageUrls,
516
+ imageEntries: parsed.imageEntries,
516
517
  fileUrl: parsed.fileUrl,
517
518
  fileName: parsed.fileName,
519
+ fileAesKey: parsed.fileAesKey,
518
520
  quote: parsed.quote,
519
521
  responseUrl: parsed.responseUrl,
520
522
  accountId: parsed.accountId,
@@ -691,6 +693,16 @@ export function createWecomBotLongConnectionManager({
691
693
  ? { ...payload.body, msgtype: "event" }
692
694
  : payload?.body;
693
695
  const parsed = parseWecomBotInboundMessage(normalizedBody);
696
+ if (!parsed) {
697
+ const bodyKeys =
698
+ normalizedBody && typeof normalizedBody === "object"
699
+ ? Object.keys(normalizedBody).slice(0, 12).join(",")
700
+ : "non-object";
701
+ api?.logger?.warn?.(
702
+ `wecom(bot-longconn): ignored unparsed callback account=${client.accountId} cmd=${command} bodyKeys=${bodyKeys || "n/a"}`,
703
+ );
704
+ return;
705
+ }
694
706
  if (parsed && typeof parsed === "object") {
695
707
  parsed.reqId = reqId || buildRequestId(CMD_CALLBACK);
696
708
  }
@@ -141,8 +141,10 @@ export function createWecomBotParsedDispatcher({
141
141
  chatId: parsed.chatId,
142
142
  isGroupChat: parsed.isGroupChat,
143
143
  imageUrls: parsed.imageUrls,
144
+ imageEntries: parsed.imageEntries,
144
145
  fileUrl: parsed.fileUrl,
145
146
  fileName: parsed.fileName,
147
+ fileAesKey: parsed.fileAesKey,
146
148
  quote: parsed.quote,
147
149
  responseUrl: parsed.responseUrl,
148
150
  accountId: parsed.accountId,
@@ -260,9 +260,6 @@ export function createWecomBotReplyDeliverer({
260
260
  });
261
261
  },
262
262
  active_stream: async ({ text: content }) => {
263
- if (longConnectionContext) {
264
- return { ok: false, reason: "long-connection-context" };
265
- }
266
263
  return deliverActiveStreamReply({
267
264
  streamId,
268
265
  sessionId: normalizedSessionId,
@@ -32,6 +32,26 @@ export function createWecomWebhookOutboundSender({
32
32
  assertFunction("createHash", createHash);
33
33
  assertFunction("sleep", sleep);
34
34
 
35
+ const webhookSendChains = new Map();
36
+
37
+ function buildWebhookTargetKey({ target, sendUrl }) {
38
+ return [String(target?.url ?? "").trim(), String(target?.key ?? "").trim(), String(sendUrl ?? "").trim()]
39
+ .filter(Boolean)
40
+ .join("|");
41
+ }
42
+
43
+ async function enqueueWebhookSend(targetKey, task) {
44
+ const previous = webhookSendChains.get(targetKey) || Promise.resolve();
45
+ const run = previous.catch(() => {}).then(task);
46
+ const tracked = run.finally(() => {
47
+ if (webhookSendChains.get(targetKey) === tracked) {
48
+ webhookSendChains.delete(targetKey);
49
+ }
50
+ });
51
+ webhookSendChains.set(targetKey, tracked);
52
+ return run;
53
+ }
54
+
35
55
  function resolveWebhookSendContext({ webhook, webhookTargets, proxyUrl, logger }) {
36
56
  const target = resolveWecomWebhookTargetConfig(webhook, webhookTargets);
37
57
  if (!target) {
@@ -45,31 +65,34 @@ export function createWecomWebhookOutboundSender({
45
65
  throw new Error("invalid webhook target url/key");
46
66
  }
47
67
  const dispatcher = attachWecomProxyDispatcher(sendUrl, {}, { proxyUrl, logger })?.dispatcher;
48
- return { target, dispatcher };
68
+ return { target, dispatcher, sendUrl };
49
69
  }
50
70
 
51
71
  async function sendWecomWebhookText({ webhook, webhookTargets, text, logger, proxyUrl }) {
52
- const { target, dispatcher } = resolveWebhookSendContext({
72
+ const { target, dispatcher, sendUrl } = resolveWebhookSendContext({
53
73
  webhook,
54
74
  webhookTargets,
55
75
  proxyUrl,
56
76
  logger,
57
77
  });
58
- const chunks = splitWecomText(String(text ?? ""));
59
- for (let i = 0; i < chunks.length; i += 1) {
60
- await webhookSendText({
61
- url: target.url,
62
- key: target.key,
63
- content: chunks[i],
64
- timeoutMs: 15000,
65
- dispatcher,
66
- fetchImpl,
67
- });
68
- if (i < chunks.length - 1) {
69
- await sleep(200);
78
+ const targetKey = buildWebhookTargetKey({ target, sendUrl });
79
+ return enqueueWebhookSend(targetKey, async () => {
80
+ const chunks = splitWecomText(String(text ?? ""));
81
+ for (let i = 0; i < chunks.length; i += 1) {
82
+ await webhookSendText({
83
+ url: target.url,
84
+ key: target.key,
85
+ content: chunks[i],
86
+ timeoutMs: 15000,
87
+ dispatcher,
88
+ fetchImpl,
89
+ });
90
+ if (i < chunks.length - 1) {
91
+ await sleep(200);
92
+ }
70
93
  }
71
- }
72
- logger?.info?.(`wecom: webhook text sent chunks=${chunks.length}`);
94
+ logger?.info?.(`wecom: webhook text sent chunks=${chunks.length}`);
95
+ });
73
96
  }
74
97
 
75
98
  async function sendWecomWebhookMediaBatch({
@@ -1,5 +1,5 @@
1
1
  export const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
2
- export const PLUGIN_VERSION = "2.0.0";
2
+ export const PLUGIN_VERSION = "2.0.1";
3
3
  export const WECOM_TEMP_DIR_NAME = "openclaw-wechat";
4
4
  export const WECOM_TEMP_FILE_RETENTION_MS = 30 * 60 * 1000;
5
5
  export const WECOM_MIN_FILE_SIZE = 5;
@@ -3,13 +3,38 @@ export function createWecomTargetResolver({ resolveWecomTarget } = {}) {
3
3
  throw new Error("createWecomTargetResolver: resolveWecomTarget is required");
4
4
  }
5
5
 
6
+ function readString(value) {
7
+ return String(value ?? "").trim();
8
+ }
9
+
10
+ function pickFirstString(...values) {
11
+ for (const value of values) {
12
+ const normalized = readString(value);
13
+ if (normalized) return normalized;
14
+ }
15
+ return "";
16
+ }
17
+
6
18
  function normalizeWecomResolvedTarget(rawTarget) {
7
19
  if (rawTarget && typeof rawTarget === "object") {
8
- const toUser = String(rawTarget.toUser ?? "").trim();
9
- const toParty = String(rawTarget.toParty ?? "").trim();
10
- const toTag = String(rawTarget.toTag ?? "").trim();
11
- const chatId = String(rawTarget.chatId ?? "").trim();
12
- const webhook = String(rawTarget.webhook ?? "").trim();
20
+ const toUser = pickFirstString(
21
+ rawTarget.toUser,
22
+ rawTarget.userId,
23
+ rawTarget.userid,
24
+ rawTarget.user,
25
+ rawTarget.username,
26
+ );
27
+ const toParty = pickFirstString(
28
+ rawTarget.toParty,
29
+ rawTarget.partyId,
30
+ rawTarget.partyid,
31
+ rawTarget.deptId,
32
+ rawTarget.deptid,
33
+ rawTarget.departmentId,
34
+ );
35
+ const toTag = pickFirstString(rawTarget.toTag, rawTarget.tagId, rawTarget.tagid);
36
+ const chatId = pickFirstString(rawTarget.chatId, rawTarget.chatid, rawTarget.groupId, rawTarget.groupid);
37
+ const webhook = pickFirstString(rawTarget.webhook, rawTarget.webhookId, rawTarget.webhookTarget);
13
38
  if (toUser || toParty || toTag || chatId || webhook) {
14
39
  return {
15
40
  ...(toUser ? { toUser } : {}),
@@ -19,6 +44,17 @@ export function createWecomTargetResolver({ resolveWecomTarget } = {}) {
19
44
  ...(webhook ? { webhook } : {}),
20
45
  };
21
46
  }
47
+ const nestedTarget = pickFirstString(
48
+ rawTarget.to,
49
+ rawTarget.target,
50
+ rawTarget.value,
51
+ rawTarget.address,
52
+ rawTarget.rawTarget,
53
+ );
54
+ if (nestedTarget) {
55
+ const resolvedNestedTarget = resolveWecomTarget(nestedTarget);
56
+ return resolvedNestedTarget && typeof resolvedNestedTarget === "object" ? resolvedNestedTarget : null;
57
+ }
22
58
  }
23
59
  const resolved = resolveWecomTarget(rawTarget);
24
60
  return resolved && typeof resolved === "object" ? resolved : null;
@@ -1,4 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { access } from "node:fs/promises";
4
+ import { delimiter } from "node:path";
2
5
 
3
6
  function assertFunction(name, value) {
4
7
  if (typeof value !== "function") {
@@ -9,6 +12,8 @@ function assertFunction(name, value) {
9
12
  export function createVoiceTranscriptionProcessRuntime({
10
13
  runProcessWithTimeoutImpl,
11
14
  checkCommandAvailableImpl,
15
+ processEnv = process.env,
16
+ accessImpl = access,
12
17
  } = {}) {
13
18
  const ffmpegPathCheckCache = {
14
19
  checked: false,
@@ -16,6 +21,50 @@ export function createVoiceTranscriptionProcessRuntime({
16
21
  };
17
22
  const commandPathCheckCache = new Map();
18
23
 
24
+ function readString(value) {
25
+ return String(value ?? "").trim();
26
+ }
27
+
28
+ function uniqueStrings(values) {
29
+ return Array.from(
30
+ new Set(
31
+ values
32
+ .map((value) => readString(value))
33
+ .filter(Boolean),
34
+ ),
35
+ );
36
+ }
37
+
38
+ function listCandidateCommandPaths(command) {
39
+ const normalizedCommand = readString(command);
40
+ if (!normalizedCommand) return [];
41
+ if (normalizedCommand.includes("/")) {
42
+ return [normalizedCommand];
43
+ }
44
+ const homeDir = readString(processEnv?.HOME);
45
+ const pathDirs = readString(processEnv?.PATH)
46
+ .split(delimiter)
47
+ .map((entry) => readString(entry))
48
+ .filter(Boolean);
49
+ const pythonUserBins = homeDir
50
+ ? ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"].map(
51
+ (version) => `${homeDir}/Library/Python/${version}/bin`,
52
+ )
53
+ : [];
54
+ const searchDirs = uniqueStrings([
55
+ ...pathDirs,
56
+ homeDir ? `${homeDir}/.local/bin` : "",
57
+ homeDir ? `${homeDir}/bin` : "",
58
+ ...pythonUserBins,
59
+ "/usr/local/bin",
60
+ "/opt/homebrew/bin",
61
+ ]);
62
+ return uniqueStrings([
63
+ normalizedCommand,
64
+ ...searchDirs.map((dir) => `${dir}/${normalizedCommand}`),
65
+ ]);
66
+ }
67
+
19
68
  function runProcessWithTimeout({ command, args, timeoutMs = 15000, allowNonZeroExitCode = false }) {
20
69
  if (typeof runProcessWithTimeoutImpl === "function") {
21
70
  return runProcessWithTimeoutImpl({ command, args, timeoutMs, allowNonZeroExitCode });
@@ -72,6 +121,16 @@ export function createVoiceTranscriptionProcessRuntime({
72
121
  if (commandPathCheckCache.has(normalized)) {
73
122
  return commandPathCheckCache.get(normalized);
74
123
  }
124
+ if (normalized.includes("/")) {
125
+ try {
126
+ await accessImpl(normalized, fsConstants.X_OK);
127
+ commandPathCheckCache.set(normalized, true);
128
+ return true;
129
+ } catch {
130
+ commandPathCheckCache.set(normalized, false);
131
+ return false;
132
+ }
133
+ }
75
134
  try {
76
135
  await runProcessWithTimeout({
77
136
  command: normalized,
@@ -101,15 +160,18 @@ export function createVoiceTranscriptionProcessRuntime({
101
160
  function listLocalWhisperCommandCandidates({ voiceConfig } = {}) {
102
161
  const provider = String(voiceConfig?.provider ?? "").trim().toLowerCase();
103
162
  const explicitCommand = String(voiceConfig?.command ?? "").trim();
104
- const fallbackCandidates =
163
+ const fallbackCommandNames =
105
164
  provider === "local-whisper"
106
165
  ? ["whisper"]
107
166
  : provider === "local-whisper-cli"
108
167
  ? ["whisper-cli"]
109
168
  : [];
110
- const candidates = explicitCommand ? [explicitCommand, ...fallbackCandidates] : fallbackCandidates;
169
+ const commandNames = explicitCommand
170
+ ? uniqueStrings([explicitCommand, ...fallbackCommandNames])
171
+ : uniqueStrings(fallbackCommandNames);
172
+ const candidates = uniqueStrings(commandNames.flatMap((command) => listCandidateCommandPaths(command)));
111
173
 
112
- if (candidates.length === 0) {
174
+ if (commandNames.length === 0) {
113
175
  return {
114
176
  provider,
115
177
  explicitCommand,
@@ -29,6 +29,7 @@ export function createWecomVoiceTranscriber({
29
29
  const processRuntime = createVoiceTranscriptionProcessRuntime({
30
30
  runProcessWithTimeoutImpl,
31
31
  checkCommandAvailableImpl,
32
+ processEnv,
32
33
  });
33
34
  const {
34
35
  runProcessWithTimeout,
@@ -206,7 +207,7 @@ export function createWecomVoiceTranscriber({
206
207
  if (voiceConfig.requireModelPath !== false && !voiceConfig.modelPath) {
207
208
  throw new Error("voiceTranscription.modelPath is required for local-whisper-cli (or set requireModelPath=false)");
208
209
  }
209
- return transcribeWithWhisperCli({
210
+ return await transcribeWithWhisperCli({
210
211
  command,
211
212
  modelPath: voiceConfig.modelPath,
212
213
  audioPath,
@@ -217,7 +218,7 @@ export function createWecomVoiceTranscriber({
217
218
  }
218
219
 
219
220
  if (provider === "local-whisper") {
220
- return transcribeWithWhisperPython({
221
+ return await transcribeWithWhisperPython({
221
222
  command,
222
223
  model: voiceConfig.model,
223
224
  audioPath,
@@ -18,6 +18,25 @@ export function dedupeUrlList(urls) {
18
18
  return out;
19
19
  }
20
20
 
21
+ function dedupeMediaEntries(entries) {
22
+ const seen = new Map();
23
+ for (const rawEntry of Array.isArray(entries) ? entries : []) {
24
+ if (!rawEntry || typeof rawEntry !== "object") continue;
25
+ const url = normalizeToken(rawEntry.url);
26
+ if (!url) continue;
27
+ const aesKey = normalizeToken(rawEntry.aesKey);
28
+ const existing = seen.get(url);
29
+ if (!existing) {
30
+ seen.set(url, { url, aesKey });
31
+ continue;
32
+ }
33
+ if (!existing.aesKey && aesKey) {
34
+ seen.set(url, { url, aesKey });
35
+ }
36
+ }
37
+ return Array.from(seen.values());
38
+ }
39
+
21
40
  export function collectWecomBotImageUrls(imageLike) {
22
41
  return dedupeUrlList([
23
42
  imageLike?.url,
@@ -28,6 +47,16 @@ export function collectWecomBotImageUrls(imageLike) {
28
47
  ]);
29
48
  }
30
49
 
50
+ export function collectWecomBotImageEntries(imageLike) {
51
+ const aesKey = normalizeToken(imageLike?.aeskey || imageLike?.aes_key || imageLike?.aesKey);
52
+ return dedupeMediaEntries(
53
+ collectWecomBotImageUrls(imageLike).map((url) => ({
54
+ url,
55
+ aesKey,
56
+ })),
57
+ );
58
+ }
59
+
31
60
  export function normalizeWecomBotOutboundMediaUrls(payload = {}) {
32
61
  return dedupeUrlList([
33
62
  payload?.mediaUrl,
@@ -1,4 +1,5 @@
1
1
  import {
2
+ collectWecomBotImageEntries,
2
3
  collectWecomBotImageUrls,
3
4
  dedupeUrlList,
4
5
  normalizeLowerToken,
@@ -7,7 +8,202 @@ import {
7
8
  normalizeWecomBotOutboundMediaUrls,
8
9
  } from "./webhook-adapter-normalize.js";
9
10
 
10
- export { collectWecomBotImageUrls, normalizeWecomBotOutboundMediaUrls };
11
+ export { collectWecomBotImageEntries, collectWecomBotImageUrls, normalizeWecomBotOutboundMediaUrls };
12
+
13
+ function dedupeMediaEntries(entries) {
14
+ const seen = new Map();
15
+ for (const rawEntry of Array.isArray(entries) ? entries : []) {
16
+ if (!rawEntry || typeof rawEntry !== "object") continue;
17
+ const url = normalizeToken(rawEntry.url);
18
+ if (!url) continue;
19
+ const aesKey = normalizeToken(rawEntry.aesKey);
20
+ const existing = seen.get(url);
21
+ if (!existing) {
22
+ seen.set(url, { url, aesKey });
23
+ continue;
24
+ }
25
+ if (!existing.aesKey && aesKey) {
26
+ seen.set(url, { url, aesKey });
27
+ }
28
+ }
29
+ return Array.from(seen.values());
30
+ }
31
+
32
+ function pickNestedValue(source, paths = []) {
33
+ if (!source || typeof source !== "object") return undefined;
34
+ for (const rawPath of Array.isArray(paths) ? paths : []) {
35
+ const segments = Array.isArray(rawPath)
36
+ ? rawPath
37
+ : String(rawPath ?? "")
38
+ .split(".")
39
+ .map((part) => String(part ?? "").trim())
40
+ .filter(Boolean);
41
+ if (segments.length === 0) continue;
42
+ let current = source;
43
+ let found = true;
44
+ for (const segment of segments) {
45
+ if (!current || typeof current !== "object" || !Object.hasOwn(current, segment)) {
46
+ found = false;
47
+ break;
48
+ }
49
+ current = current[segment];
50
+ }
51
+ if (found && current != null) {
52
+ return current;
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function collectWecomBotMixedItems(payload) {
59
+ const directItems = pickNestedValue(payload, [
60
+ ["mixed", "msg_item"],
61
+ ["mixed", "msgItem"],
62
+ ["mixed", "items"],
63
+ ["mixed", "msgItems"],
64
+ ["msg_item"],
65
+ ["msgItem"],
66
+ ["items"],
67
+ ["attachments"],
68
+ ["attachment"],
69
+ ["message", "attachments"],
70
+ ["message", "items"],
71
+ ["message", "msg_item"],
72
+ ["message", "msgItem"],
73
+ ]);
74
+ if (Array.isArray(directItems)) {
75
+ return directItems.filter((item) => item && typeof item === "object");
76
+ }
77
+ return [];
78
+ }
79
+
80
+ function inferWecomBotItemType(item) {
81
+ const explicitType = normalizeLowerToken(
82
+ item?.msgtype ||
83
+ item?.msg_type ||
84
+ item?.msgType ||
85
+ item?.type ||
86
+ item?.message_type ||
87
+ item?.messageType ||
88
+ item?.kind,
89
+ );
90
+ if (explicitType) return explicitType;
91
+ if (item?.text || item?.content_type === "text" || item?.contentType === "text") return "text";
92
+ if (item?.image || item?.pic_url || item?.image_url || item?.imageUrl) return "image";
93
+ if (item?.voice || item?.voice_url || item?.voiceUrl || item?.audio || item?.audio_url || item?.audioUrl) return "voice";
94
+ if (
95
+ item?.file ||
96
+ item?.file_url ||
97
+ item?.fileUrl ||
98
+ item?.download_url ||
99
+ item?.downloadUrl ||
100
+ item?.filename ||
101
+ item?.file_name
102
+ ) {
103
+ return "file";
104
+ }
105
+ if (item?.link || item?.url) return "link";
106
+ if (item?.location || item?.latitude || item?.longitude) return "location";
107
+ return "";
108
+ }
109
+
110
+ function normalizeWecomBotMediaPayload(payload) {
111
+ if (!payload || typeof payload !== "object") return null;
112
+ const nestedMessage = pickNestedValue(payload, [["message"], ["msg"], ["data"], ["event", "message"]]);
113
+ const candidates = [payload];
114
+ if (nestedMessage && typeof nestedMessage === "object" && nestedMessage !== payload) {
115
+ candidates.unshift(nestedMessage);
116
+ }
117
+
118
+ for (const candidate of candidates) {
119
+ const msgType = normalizeLowerToken(
120
+ pickNestedValue(candidate, [
121
+ ["msgtype"],
122
+ ["msg_type"],
123
+ ["msgType"],
124
+ ["message_type"],
125
+ ["messageType"],
126
+ ["type"],
127
+ ]),
128
+ );
129
+ if (!msgType) continue;
130
+ return {
131
+ source: candidate,
132
+ msgType,
133
+ };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function extractWecomBotTextContent(textLike, fallbackContent = "") {
139
+ return normalizeToken(
140
+ textLike?.content ||
141
+ textLike?.text ||
142
+ textLike?.body ||
143
+ textLike?.message ||
144
+ fallbackContent,
145
+ );
146
+ }
147
+
148
+ function extractWecomBotVoicePayload(voiceLike, fallback = {}) {
149
+ const source = voiceLike && typeof voiceLike === "object" ? voiceLike : fallback;
150
+ return {
151
+ url: normalizeToken(
152
+ source?.url ||
153
+ source?.media_url ||
154
+ source?.mediaUrl ||
155
+ source?.download_url ||
156
+ source?.downloadUrl ||
157
+ source?.file_url ||
158
+ source?.fileUrl ||
159
+ source?.voice_url ||
160
+ source?.voiceUrl ||
161
+ source?.audio_url ||
162
+ source?.audioUrl,
163
+ ),
164
+ mediaId: normalizeToken(
165
+ source?.media_id ||
166
+ source?.mediaid ||
167
+ source?.mediaId ||
168
+ source?.id,
169
+ ),
170
+ contentType: normalizeToken(
171
+ source?.content_type ||
172
+ source?.contentType ||
173
+ source?.mime_type ||
174
+ source?.mimeType ||
175
+ source?.format,
176
+ ),
177
+ text: extractWecomBotTextContent(source, source?.content),
178
+ };
179
+ }
180
+
181
+ function extractWecomBotFilePayload(fileLike, fallback = {}) {
182
+ const source = fileLike && typeof fileLike === "object" ? fileLike : fallback;
183
+ return {
184
+ url: normalizeToken(
185
+ source?.url ||
186
+ source?.download_url ||
187
+ source?.downloadUrl ||
188
+ source?.media_url ||
189
+ source?.mediaUrl ||
190
+ source?.file_url ||
191
+ source?.fileUrl,
192
+ ),
193
+ name: normalizeToken(
194
+ source?.name ||
195
+ source?.filename ||
196
+ source?.file_name ||
197
+ source?.fileName ||
198
+ source?.title,
199
+ ),
200
+ aesKey: normalizeToken(
201
+ source?.aeskey ||
202
+ source?.aes_key ||
203
+ source?.aesKey,
204
+ ),
205
+ };
206
+ }
11
207
 
12
208
  export function buildWecomBotMixedPayload({ text = "", mediaUrl, mediaUrls } = {}) {
13
209
  const normalizedText = normalizeToken(text);
@@ -46,81 +242,113 @@ export function buildWecomBotMixedPayload({ text = "", mediaUrl, mediaUrls } = {
46
242
 
47
243
  export function parseWecomBotInboundMessage(payload) {
48
244
  if (!payload || typeof payload !== "object") return null;
49
- const msgType = normalizeLowerToken(payload.msgtype);
50
- if (!msgType) return null;
51
- const feedbackId = normalizeToken(payload?.feedback?.id || payload?.stream?.feedback?.id);
245
+ const normalizedPayload = normalizeWecomBotMediaPayload(payload);
246
+ if (!normalizedPayload) return null;
247
+ const source = normalizedPayload.source;
248
+ const msgType = normalizedPayload.msgType;
249
+ const feedbackId = normalizeToken(
250
+ pickNestedValue(source, [["feedback", "id"], ["stream", "feedback", "id"], ["stream", "feedbackId"], ["feedbackId"]]),
251
+ );
52
252
  if (msgType === "stream") {
53
253
  return {
54
254
  kind: "stream-refresh",
55
- streamId: normalizeToken(payload?.stream?.id),
255
+ streamId: normalizeToken(pickNestedValue(source, [["stream", "id"], ["streamId"]])),
56
256
  feedbackId,
57
257
  };
58
258
  }
59
259
 
60
- const msgId = normalizeToken(payload.msgid) || `wecom-bot-${Date.now()}`;
61
- const fromUser = normalizeToken(payload?.from?.userid);
62
- const chatType = normalizeLowerToken(payload.chattype || "single") || "single";
63
- const chatId = normalizeToken(payload.chatid);
64
- const responseUrl = normalizeToken(payload.response_url);
65
- const quote = normalizeQuotePayload(payload.quote);
260
+ const msgId =
261
+ normalizeToken(
262
+ pickNestedValue(source, [["msgid"], ["msg_id"], ["msgId"], ["message_id"], ["messageId"], ["id"]]),
263
+ ) || `wecom-bot-${Date.now()}`;
264
+ const fromUser = normalizeToken(
265
+ pickNestedValue(source, [
266
+ ["from", "userid"],
267
+ ["from", "user_id"],
268
+ ["from", "userId"],
269
+ ["sender", "userid"],
270
+ ["sender", "user_id"],
271
+ ["sender", "userId"],
272
+ ["sender", "id"],
273
+ ["userid"],
274
+ ["user_id"],
275
+ ["userId"],
276
+ ]),
277
+ );
278
+ const chatType =
279
+ normalizeLowerToken(
280
+ pickNestedValue(source, [["chattype"], ["chat_type"], ["chatType"]]) || "single",
281
+ ) || "single";
282
+ const chatId = normalizeToken(
283
+ pickNestedValue(source, [["chatid"], ["chat_id"], ["chatId"], ["conversation_id"], ["conversationId"]]),
284
+ );
285
+ const responseUrl = normalizeToken(
286
+ pickNestedValue(source, [["response_url"], ["responseUrl"], ["reply_url"], ["replyUrl"]]),
287
+ );
288
+ const quote = normalizeQuotePayload(pickNestedValue(source, [["quote"], ["quoted_message"], ["quotedMessage"]]));
66
289
  let content = "";
290
+ const imageEntries = [];
67
291
  const imageUrls = [];
68
292
  let fileUrl = "";
69
293
  let fileName = "";
294
+ let fileAesKey = "";
70
295
  let voiceUrl = "";
71
296
  let voiceMediaId = "";
72
297
  let voiceContentType = "";
73
298
 
74
299
  if (msgType === "text") {
75
- content = normalizeToken(payload?.text?.content);
300
+ content = extractWecomBotTextContent(
301
+ pickNestedValue(source, [["text"], ["message", "text"]]),
302
+ pickNestedValue(source, [["content"], ["text_content"], ["textContent"]]),
303
+ );
76
304
  } else if (msgType === "voice") {
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,
305
+ const voicePayload = extractWecomBotVoicePayload(
306
+ pickNestedValue(source, [["voice"], ["audio"], ["message", "voice"], ["message", "audio"]]),
307
+ source,
83
308
  );
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);
309
+ content = voicePayload.text;
310
+ voiceUrl = voicePayload.url;
311
+ voiceMediaId = voicePayload.mediaId;
312
+ voiceContentType = voicePayload.contentType;
86
313
  } else if (msgType === "link") {
87
- const title = normalizeToken(payload?.link?.title);
88
- const description = normalizeToken(payload?.link?.description);
89
- const url = normalizeToken(payload?.link?.url);
314
+ const linkPayload = pickNestedValue(source, [["link"], ["message", "link"]]) || source;
315
+ const title = normalizeToken(linkPayload?.title);
316
+ const description = normalizeToken(linkPayload?.description);
317
+ const url = normalizeToken(linkPayload?.url);
90
318
  content = [title ? `[链接] ${title}` : "", description, url].filter(Boolean).join("\n").trim();
91
319
  } else if (msgType === "location") {
92
- const latitude = normalizeToken(payload?.location?.latitude);
93
- const longitude = normalizeToken(payload?.location?.longitude);
94
- const name = normalizeToken(payload?.location?.name || payload?.location?.label);
320
+ const locationPayload = pickNestedValue(source, [["location"], ["message", "location"]]) || source;
321
+ const latitude = normalizeToken(locationPayload?.latitude);
322
+ const longitude = normalizeToken(locationPayload?.longitude);
323
+ const name = normalizeToken(locationPayload?.name || locationPayload?.label);
95
324
  content = name ? `[位置] ${name} (${latitude}, ${longitude})` : `[位置] ${latitude}, ${longitude}`;
96
325
  } else if (msgType === "image") {
97
- imageUrls.push(...collectWecomBotImageUrls(payload?.image));
326
+ const topLevelImageEntries = collectWecomBotImageEntries(
327
+ pickNestedValue(source, [["image"], ["message", "image"]]) || source,
328
+ );
329
+ imageEntries.push(...topLevelImageEntries);
330
+ imageUrls.push(...topLevelImageEntries.map((entry) => entry.url));
98
331
  content = "[图片]";
99
332
  } else if (msgType === "mixed") {
100
- const items = Array.isArray(payload?.mixed?.msg_item) ? payload.mixed.msg_item : [];
333
+ const items = collectWecomBotMixedItems(source);
101
334
  const parts = [];
102
335
  for (const item of items) {
103
- const itemType = normalizeLowerToken(item?.msgtype);
336
+ const itemType = inferWecomBotItemType(item);
104
337
  if (itemType === "text") {
105
- const text = normalizeToken(item?.text?.content);
338
+ const text = extractWecomBotTextContent(item?.text, item?.content);
106
339
  if (text) parts.push(text);
107
340
  } else if (itemType === "image") {
108
- const itemImageUrls = collectWecomBotImageUrls(item?.image);
109
- if (itemImageUrls.length > 0) {
110
- imageUrls.push(...itemImageUrls);
341
+ const itemImageEntries = collectWecomBotImageEntries(item?.image || item);
342
+ if (itemImageEntries.length > 0) {
343
+ imageEntries.push(...itemImageEntries);
344
+ imageUrls.push(...itemImageEntries.map((entry) => entry.url));
111
345
  parts.push("[图片]");
112
346
  }
113
347
  } 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
- );
348
+ const voicePayload = extractWecomBotVoicePayload(item?.voice || item?.audio, item);
349
+ const itemVoiceUrl = voicePayload.url;
350
+ const itemVoiceMediaId = voicePayload.mediaId;
351
+ const itemVoiceContentType = voicePayload.contentType;
124
352
  if (itemVoiceUrl) {
125
353
  voiceUrl = voiceUrl || itemVoiceUrl;
126
354
  voiceMediaId = voiceMediaId || itemVoiceMediaId;
@@ -128,29 +356,27 @@ export function parseWecomBotInboundMessage(payload) {
128
356
  parts.push("[语音]");
129
357
  }
130
358
  } 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);
359
+ const filePayload = extractWecomBotFilePayload(item?.file, item);
360
+ const itemFileUrl = filePayload.url;
361
+ const itemFileName = filePayload.name;
362
+ const itemFileAesKey = filePayload.aesKey;
138
363
  if (itemFileUrl || itemFileName) {
139
364
  fileUrl = fileUrl || itemFileUrl;
140
365
  fileName = fileName || itemFileName;
366
+ fileAesKey = fileAesKey || itemFileAesKey;
141
367
  const displayName = itemFileName || itemFileUrl || "附件";
142
368
  parts.push(`[文件] ${displayName}`);
143
369
  }
144
370
  } 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);
371
+ const title = normalizeToken(item?.link?.title || item?.title);
372
+ const description = normalizeToken(item?.link?.description || item?.description);
373
+ const url = normalizeToken(item?.link?.url || item?.url);
148
374
  const linkText = [title ? `[链接] ${title}` : "", description, url].filter(Boolean).join("\n").trim();
149
375
  if (linkText) parts.push(linkText);
150
376
  } 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);
377
+ const latitude = normalizeToken(item?.location?.latitude || item?.latitude);
378
+ const longitude = normalizeToken(item?.location?.longitude || item?.longitude);
379
+ const name = normalizeToken(item?.location?.name || item?.location?.label || item?.name || item?.label);
154
380
  const locationText = name ? `[位置] ${name} (${latitude}, ${longitude})` : `[位置] ${latitude}, ${longitude}`;
155
381
  if (locationText.trim() !== "[位置] ,") {
156
382
  parts.push(locationText);
@@ -159,14 +385,21 @@ export function parseWecomBotInboundMessage(payload) {
159
385
  }
160
386
  content = parts.join("\n").trim();
161
387
  } else if (msgType === "file") {
162
- fileUrl = normalizeToken(payload?.file?.url);
163
- fileName = normalizeToken(payload?.file?.name || payload?.file?.filename);
388
+ const filePayload = extractWecomBotFilePayload(
389
+ pickNestedValue(source, [["file"], ["message", "file"], ["attachment"], ["document"]]),
390
+ source,
391
+ );
392
+ fileUrl = filePayload.url;
393
+ fileName = filePayload.name;
394
+ fileAesKey = filePayload.aesKey;
164
395
  const displayName = fileName || fileUrl || "附件";
165
396
  content = `[文件] ${displayName}`;
166
397
  } else if (msgType === "event") {
167
398
  return {
168
399
  kind: "event",
169
- eventType: normalizeToken(payload?.event?.event_type || payload?.event),
400
+ eventType: normalizeToken(
401
+ pickNestedValue(source, [["event", "event_type"], ["event", "eventType"], ["event"], ["event_type"], ["eventType"]]),
402
+ ),
170
403
  fromUser,
171
404
  };
172
405
  } else {
@@ -197,8 +430,10 @@ export function parseWecomBotInboundMessage(payload) {
197
430
  responseUrl,
198
431
  content,
199
432
  imageUrls: dedupeUrlList(imageUrls),
433
+ imageEntries: dedupeMediaEntries(imageEntries),
200
434
  fileUrl,
201
435
  fileName,
436
+ fileAesKey,
202
437
  voiceUrl,
203
438
  voiceMediaId,
204
439
  voiceContentType,