@marshulll/openclaw-wecom 0.1.28 → 0.1.30
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/README.en.md +2 -1
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/package.json +1 -1
- package/wecom/package.json +1 -1
- package/wecom/src/crypto.ts +15 -0
- package/wecom/src/wecom-bot.ts +143 -6
package/README.en.md
CHANGED
|
@@ -86,9 +86,10 @@ Install guide: `docs/INSTALL.md`
|
|
|
86
86
|
|
|
87
87
|
## Media handling
|
|
88
88
|
- App mode: downloads inbound media to local temp dir (`media.tempDir`)
|
|
89
|
+
- Bot inbound media: if webhook provides a media URL, it will be decrypted with `encodingAESKey` and saved locally (no App creds needed)
|
|
89
90
|
- Bot mode media bridge: if reply payload includes `mediaUrl + mediaType`,
|
|
90
91
|
and App credentials are present, media will be uploaded and sent
|
|
91
|
-
> Bot-only
|
|
92
|
+
> Bot-only: inbound image/file works via URL decrypt, but outbound media still requires App credentials.
|
|
92
93
|
|
|
93
94
|
## Extra commands (App mode)
|
|
94
95
|
- `/sendfile`: send files from server (multiple absolute paths)
|
package/README.md
CHANGED
|
@@ -88,9 +88,10 @@ openclaw gateway restart
|
|
|
88
88
|
|
|
89
89
|
## 媒体处理说明
|
|
90
90
|
- App 模式:收到媒体会下载到本地临时目录(可配置 `media.tempDir`)
|
|
91
|
+
- Bot 模式入站媒体:图片/文件若回调提供 URL,会用 `encodingAESKey` 解密并落盘(无需 App 凭据)
|
|
91
92
|
- Bot 模式媒体桥接:当 reply payload 含 `mediaUrl + mediaType` 时,
|
|
92
93
|
若已配置 App 凭据,会自动上传并发送媒体
|
|
93
|
-
> 仅配置 Bot
|
|
94
|
+
> 仅配置 Bot 时:可收图片/文件(URL 解密),但**出站媒体仍需 App 凭据**。
|
|
94
95
|
|
|
95
96
|
## 命令补充(App 模式)
|
|
96
97
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
package/README.zh.md
CHANGED
|
@@ -88,9 +88,10 @@ openclaw gateway restart
|
|
|
88
88
|
|
|
89
89
|
## 媒体处理说明
|
|
90
90
|
- App 模式:收到媒体会下载到本地临时目录(可配置 `media.tempDir`)
|
|
91
|
+
- Bot 模式入站媒体:图片/文件若回调提供 URL,会用 `encodingAESKey` 解密并落盘(无需 App 凭据)
|
|
91
92
|
- Bot 模式媒体桥接:当 reply payload 含 `mediaUrl + mediaType` 时,
|
|
92
93
|
若已配置 App 凭据,会自动上传并发送媒体
|
|
93
|
-
> 仅配置 Bot
|
|
94
|
+
> 仅配置 Bot 时:可收图片/文件(URL 解密),但**出站媒体仍需 App 凭据**。
|
|
94
95
|
|
|
95
96
|
## 命令补充(App 模式)
|
|
96
97
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
package/package.json
CHANGED
package/wecom/package.json
CHANGED
package/wecom/src/crypto.ts
CHANGED
|
@@ -107,6 +107,21 @@ export function decryptWecomEncrypted(params: {
|
|
|
107
107
|
return msg;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export function decryptWecomMedia(params: {
|
|
111
|
+
encodingAESKey: string;
|
|
112
|
+
buffer: Buffer;
|
|
113
|
+
}): Buffer {
|
|
114
|
+
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
|
|
115
|
+
const iv = aesKey.subarray(0, 16);
|
|
116
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
|
|
117
|
+
decipher.setAutoPadding(false);
|
|
118
|
+
const decryptedPadded = Buffer.concat([
|
|
119
|
+
decipher.update(params.buffer),
|
|
120
|
+
decipher.final(),
|
|
121
|
+
]);
|
|
122
|
+
return pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
export function encryptWecomPlaintext(params: {
|
|
111
126
|
encodingAESKey: string;
|
|
112
127
|
receiveId?: string;
|
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -7,7 +7,13 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
|
7
7
|
|
|
8
8
|
import type { WecomWebhookTarget } from "./monitor.js";
|
|
9
9
|
import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
computeWecomMsgSignature,
|
|
12
|
+
decryptWecomEncrypted,
|
|
13
|
+
decryptWecomMedia,
|
|
14
|
+
encryptWecomPlaintext,
|
|
15
|
+
verifyWecomSignature,
|
|
16
|
+
} from "./crypto.js";
|
|
11
17
|
import {
|
|
12
18
|
MEDIA_TOO_LARGE_ERROR,
|
|
13
19
|
downloadWecomMedia,
|
|
@@ -49,6 +55,7 @@ const mediaCache = new Map<string, { entry: InboundMedia; createdAt: number; siz
|
|
|
49
55
|
type StreamState = {
|
|
50
56
|
streamId: string;
|
|
51
57
|
msgid?: string;
|
|
58
|
+
responseUrl?: string;
|
|
52
59
|
createdAt: number;
|
|
53
60
|
updatedAt: number;
|
|
54
61
|
started: boolean;
|
|
@@ -224,7 +231,7 @@ function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; str
|
|
|
224
231
|
stream: {
|
|
225
232
|
id: streamId,
|
|
226
233
|
finish: false,
|
|
227
|
-
content: "
|
|
234
|
+
content: "\ud83e\udd14\u601d\u8003\u4e2d...",
|
|
228
235
|
},
|
|
229
236
|
};
|
|
230
237
|
}
|
|
@@ -420,9 +427,42 @@ async function startAgentForStream(params: {
|
|
|
420
427
|
}
|
|
421
428
|
}
|
|
422
429
|
|
|
423
|
-
|
|
430
|
+
let text = payload.text ?? "";
|
|
424
431
|
const current = streams.get(streamId);
|
|
425
432
|
if (!current) return;
|
|
433
|
+
|
|
434
|
+
const trimmedText = text.trim();
|
|
435
|
+
if (trimmedText.startsWith("{") && trimmedText.includes("\"template_card\"")) {
|
|
436
|
+
try {
|
|
437
|
+
const parsed = JSON.parse(trimmedText) as { template_card?: Record<string, unknown> };
|
|
438
|
+
if (parsed.template_card) {
|
|
439
|
+
const isSingleChat = chatType !== "group";
|
|
440
|
+
const responseUrl = current.responseUrl;
|
|
441
|
+
if (isSingleChat && responseUrl) {
|
|
442
|
+
await fetch(responseUrl, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: { "Content-Type": "application/json" },
|
|
445
|
+
body: JSON.stringify({ msgtype: "template_card", template_card: parsed.template_card }),
|
|
446
|
+
});
|
|
447
|
+
current.finished = true;
|
|
448
|
+
current.content = current.content || "[已发送交互卡片]";
|
|
449
|
+
current.updatedAt = Date.now();
|
|
450
|
+
target.statusSink?.({ lastOutboundAt: Date.now() });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const cardTitle = (parsed.template_card as any)?.main_title?.title || "交互卡片";
|
|
454
|
+
const cardDesc = (parsed.template_card as any)?.main_title?.desc || "";
|
|
455
|
+
const buttons = Array.isArray((parsed.template_card as any)?.button_list)
|
|
456
|
+
? (parsed.template_card as any).button_list.map((b: any) => b?.text).filter(Boolean).join(" / ")
|
|
457
|
+
: "";
|
|
458
|
+
text = `【交互卡片】${cardTitle}${cardDesc ? `\n${cardDesc}` : ""}${buttons ? `\n\n选项: ${buttons}` : ""}`;
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// ignore parse failure, treat as normal text
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
text = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
426
466
|
const nextText = current.content
|
|
427
467
|
? `${current.content}\n\n${text}`.trim()
|
|
428
468
|
: text.trim();
|
|
@@ -638,8 +678,23 @@ async function buildBotMediaMessage(params: {
|
|
|
638
678
|
}
|
|
639
679
|
} else if (url) {
|
|
640
680
|
const media = await fetchMediaFromUrl(url, target.account, maxBytes);
|
|
641
|
-
|
|
642
|
-
|
|
681
|
+
const aesKey = target.account.encodingAESKey || "";
|
|
682
|
+
if (aesKey) {
|
|
683
|
+
try {
|
|
684
|
+
buffer = decryptWecomMedia({ encodingAESKey: aesKey, buffer: media.buffer });
|
|
685
|
+
if (msgtype === "image") contentType = "image/jpeg";
|
|
686
|
+
else if (msgtype === "voice") contentType = "audio/amr";
|
|
687
|
+
else if (msgtype === "video") contentType = "video/mp4";
|
|
688
|
+
else contentType = "application/octet-stream";
|
|
689
|
+
} catch (err) {
|
|
690
|
+
target.runtime.error?.(`[${target.account.accountId}] wecom bot media decrypt failed: ${String(err)}`);
|
|
691
|
+
buffer = media.buffer;
|
|
692
|
+
contentType = media.contentType;
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
buffer = media.buffer;
|
|
696
|
+
contentType = media.contentType;
|
|
697
|
+
}
|
|
643
698
|
} else if (mediaId && hasAppCreds) {
|
|
644
699
|
const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
|
|
645
700
|
buffer = media.buffer;
|
|
@@ -1210,7 +1265,7 @@ export async function handleWecomBotWebhook(params: {
|
|
|
1210
1265
|
return true;
|
|
1211
1266
|
}
|
|
1212
1267
|
|
|
1213
|
-
if (msgid && msgidToStreamId.has(msgid)) {
|
|
1268
|
+
if (msgtype !== "event" && msgid && msgidToStreamId.has(msgid)) {
|
|
1214
1269
|
const streamId = msgidToStreamId.get(msgid) ?? "";
|
|
1215
1270
|
const reply = buildStreamPlaceholderReply(streamId);
|
|
1216
1271
|
logVerbose(target, `bot stream placeholder reply streamId=${streamId || "unknown"}`);
|
|
@@ -1225,6 +1280,87 @@ export async function handleWecomBotWebhook(params: {
|
|
|
1225
1280
|
|
|
1226
1281
|
if (msgtype === "event") {
|
|
1227
1282
|
const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
|
|
1283
|
+
if (eventtype === "template_card_event") {
|
|
1284
|
+
if (msgid && msgidToStreamId.has(msgid)) {
|
|
1285
|
+
jsonOk(res, buildEncryptedJsonReply({
|
|
1286
|
+
account: target.account,
|
|
1287
|
+
plaintextJson: {},
|
|
1288
|
+
nonce,
|
|
1289
|
+
timestamp,
|
|
1290
|
+
}));
|
|
1291
|
+
return true;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const cardEvent = (msg as any).event?.template_card_event;
|
|
1295
|
+
let interactionDesc = `[卡片交互] 按钮: ${cardEvent?.event_key || "unknown"}`;
|
|
1296
|
+
const selected = cardEvent?.selected_items?.selected_item;
|
|
1297
|
+
if (Array.isArray(selected) && selected.length > 0) {
|
|
1298
|
+
const selects = selected.map((item: any) => {
|
|
1299
|
+
const key = item?.question_key || "unknown";
|
|
1300
|
+
const options = Array.isArray(item?.option_ids?.option_id)
|
|
1301
|
+
? item.option_ids.option_id.join(",")
|
|
1302
|
+
: "";
|
|
1303
|
+
return `${key}=${options}`;
|
|
1304
|
+
}).join("; ");
|
|
1305
|
+
if (selects) interactionDesc += ` 选择: ${selects}`;
|
|
1306
|
+
}
|
|
1307
|
+
if (cardEvent?.task_id) interactionDesc += ` (任务ID: ${cardEvent.task_id})`;
|
|
1308
|
+
|
|
1309
|
+
jsonOk(res, buildEncryptedJsonReply({
|
|
1310
|
+
account: target.account,
|
|
1311
|
+
plaintextJson: {},
|
|
1312
|
+
nonce,
|
|
1313
|
+
timestamp,
|
|
1314
|
+
}));
|
|
1315
|
+
|
|
1316
|
+
const streamId = createStreamId();
|
|
1317
|
+
if (msgid) msgidToStreamId.set(msgid, streamId);
|
|
1318
|
+
streams.set(streamId, {
|
|
1319
|
+
streamId,
|
|
1320
|
+
msgid,
|
|
1321
|
+
responseUrl: typeof (msg as any).response_url === "string" ? String((msg as any).response_url).trim() : undefined,
|
|
1322
|
+
createdAt: Date.now(),
|
|
1323
|
+
updatedAt: Date.now(),
|
|
1324
|
+
started: true,
|
|
1325
|
+
finished: false,
|
|
1326
|
+
content: "",
|
|
1327
|
+
});
|
|
1328
|
+
recentEncrypts.set(encryptHash, { ts: Date.now(), streamId });
|
|
1329
|
+
|
|
1330
|
+
let core: PluginRuntime | null = null;
|
|
1331
|
+
try {
|
|
1332
|
+
core = getWecomRuntime();
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
logVerbose(target, `runtime not ready, skipping agent processing: ${String(err)}`);
|
|
1335
|
+
}
|
|
1336
|
+
if (core) {
|
|
1337
|
+
const enrichedTarget: WecomWebhookTarget = { ...target, core };
|
|
1338
|
+
const eventMsg = {
|
|
1339
|
+
...msg,
|
|
1340
|
+
msgtype: "text",
|
|
1341
|
+
text: { content: interactionDesc },
|
|
1342
|
+
} as WecomInboundMessage;
|
|
1343
|
+
startAgentForStream({ target: enrichedTarget, accountId: target.account.accountId, msg: eventMsg, streamId })
|
|
1344
|
+
.catch((err) => {
|
|
1345
|
+
const state = streams.get(streamId);
|
|
1346
|
+
if (state) {
|
|
1347
|
+
state.error = err instanceof Error ? err.message : String(err);
|
|
1348
|
+
state.content = state.content || `Error: ${state.error}`;
|
|
1349
|
+
state.finished = true;
|
|
1350
|
+
state.updatedAt = Date.now();
|
|
1351
|
+
}
|
|
1352
|
+
target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
|
|
1353
|
+
});
|
|
1354
|
+
} else {
|
|
1355
|
+
const state = streams.get(streamId);
|
|
1356
|
+
if (state) {
|
|
1357
|
+
state.finished = true;
|
|
1358
|
+
state.updatedAt = Date.now();
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1228
1364
|
if (eventtype === "enter_chat") {
|
|
1229
1365
|
const welcome = target.account.config.welcomeText?.trim();
|
|
1230
1366
|
const reply = welcome
|
|
@@ -1255,6 +1391,7 @@ export async function handleWecomBotWebhook(params: {
|
|
|
1255
1391
|
streams.set(streamId, {
|
|
1256
1392
|
streamId,
|
|
1257
1393
|
msgid,
|
|
1394
|
+
responseUrl: typeof (msg as any).response_url === "string" ? String((msg as any).response_url).trim() : undefined,
|
|
1258
1395
|
createdAt: Date.now(),
|
|
1259
1396
|
updatedAt: Date.now(),
|
|
1260
1397
|
started: false,
|