@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 +9 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/wecom/api-client-send-text.js +43 -20
- package/src/wecom/bot-inbound-content.js +14 -6
- package/src/wecom/bot-inbound-executor-helpers.js +33 -7
- package/src/wecom/bot-inbound-executor.js +6 -0
- package/src/wecom/bot-long-connection-manager.js +12 -0
- package/src/wecom/bot-webhook-dispatch.js +2 -0
- package/src/wecom/outbound-delivery.js +0 -3
- package/src/wecom/outbound-webhook-sender.js +39 -16
- package/src/wecom/plugin-constants.js +1 -1
- package/src/wecom/target-utils.js +41 -5
- package/src/wecom/voice-transcription-process.js +65 -3
- package/src/wecom/voice-transcription.js +3 -2
- package/src/wecom/webhook-adapter-normalize.js +29 -0
- package/src/wecom/webhook-adapter.js +294 -59
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
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
83
|
+
const decryptAesKey = imageAesKey || String(botModeConfig?.encodingAesKey ?? "").trim();
|
|
84
|
+
if (!effectiveImageType && decryptAesKey) {
|
|
77
85
|
try {
|
|
78
86
|
const decryptedBuffer = decryptWecomMediaBuffer({
|
|
79
|
-
aesKey:
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
169
|
+
const commandNames = explicitCommand
|
|
170
|
+
? uniqueStrings([explicitCommand, ...fallbackCommandNames])
|
|
171
|
+
: uniqueStrings(fallbackCommandNames);
|
|
172
|
+
const candidates = uniqueStrings(commandNames.flatMap((command) => listCandidateCommandPaths(command)));
|
|
111
173
|
|
|
112
|
-
if (
|
|
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
|
|
50
|
-
if (!
|
|
51
|
-
const
|
|
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(
|
|
255
|
+
streamId: normalizeToken(pickNestedValue(source, [["stream", "id"], ["streamId"]])),
|
|
56
256
|
feedbackId,
|
|
57
257
|
};
|
|
58
258
|
}
|
|
59
259
|
|
|
60
|
-
const msgId =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
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 =
|
|
300
|
+
content = extractWecomBotTextContent(
|
|
301
|
+
pickNestedValue(source, [["text"], ["message", "text"]]),
|
|
302
|
+
pickNestedValue(source, [["content"], ["text_content"], ["textContent"]]),
|
|
303
|
+
);
|
|
76
304
|
} else if (msgType === "voice") {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
309
|
+
content = voicePayload.text;
|
|
310
|
+
voiceUrl = voicePayload.url;
|
|
311
|
+
voiceMediaId = voicePayload.mediaId;
|
|
312
|
+
voiceContentType = voicePayload.contentType;
|
|
86
313
|
} else if (msgType === "link") {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
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
|
|
93
|
-
const
|
|
94
|
-
const
|
|
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
|
-
|
|
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 =
|
|
333
|
+
const items = collectWecomBotMixedItems(source);
|
|
101
334
|
const parts = [];
|
|
102
335
|
for (const item of items) {
|
|
103
|
-
const itemType =
|
|
336
|
+
const itemType = inferWecomBotItemType(item);
|
|
104
337
|
if (itemType === "text") {
|
|
105
|
-
const text =
|
|
338
|
+
const text = extractWecomBotTextContent(item?.text, item?.content);
|
|
106
339
|
if (text) parts.push(text);
|
|
107
340
|
} else if (itemType === "image") {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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(
|
|
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,
|