@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 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 config cannot send/receive media (image/voice/video/file). Full media support requires App credentials.
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 时,媒体(图片/语音/视频/文件)无法收发;需补齐 App 凭据才能启用完整媒体能力。
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 时,媒体(图片/语音/视频/文件)无法收发;需补齐 App 凭据才能启用完整媒体能力。
94
+ > 仅配置 Bot 时:可收图片/文件(URL 解密),但**出站媒体仍需 App 凭据**。
94
95
 
95
96
  ## 命令补充(App 模式)
96
97
  - `/sendfile`:发送服务器文件(支持多个绝对路径)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -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;
@@ -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 { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature } from "./crypto.js";
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: "1",
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
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
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
- buffer = media.buffer;
642
- contentType = media.contentType;
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,