@marshulll/openclaw-wecom 0.1.16 → 0.1.18

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.
@@ -43,7 +43,8 @@
43
43
  "corpSecret": "CORP_SECRET",
44
44
  "agentId": 1000001,
45
45
  "callbackToken": "CALLBACK_TOKEN",
46
- "callbackAesKey": "CALLBACK_AES"
46
+ "callbackAesKey": "CALLBACK_AES",
47
+ "pushToken": "PUSH_TOKEN"
47
48
  }
48
49
  }
49
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -88,6 +88,9 @@ export function resolveWecomAccount(params: {
88
88
  const callbackAesKey = merged.callbackAesKey?.trim()
89
89
  || resolveAccountEnv(params.cfg, accountId, "CALLBACK_AES_KEY")
90
90
  || undefined;
91
+ const pushToken = merged.pushToken?.trim()
92
+ || resolveAccountEnv(params.cfg, accountId, "PUSH_TOKEN")
93
+ || undefined;
91
94
  const webhookPath = merged.webhookPath?.trim()
92
95
  || resolveAccountEnv(params.cfg, accountId, "WEBHOOK_PATH")
93
96
  || undefined;
@@ -109,6 +112,7 @@ export function resolveWecomAccount(params: {
109
112
  agentId,
110
113
  callbackToken,
111
114
  callbackAesKey,
115
+ pushToken,
112
116
  };
113
117
 
114
118
  return {
@@ -183,6 +183,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
183
183
  return { stop: () => {} };
184
184
  }
185
185
  const path = (account.config.webhookPath ?? "/wecom").trim();
186
+ const pushPath = path.endsWith("/") ? `${path}push` : `${path}/push`;
186
187
  const unregister = registerWecomWebhookTarget({
187
188
  account,
188
189
  config: ctx.cfg as ClawdbotConfig,
@@ -191,7 +192,16 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
191
192
  path,
192
193
  statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
193
194
  });
195
+ const unregisterPush = registerWecomWebhookTarget({
196
+ account,
197
+ config: ctx.cfg as ClawdbotConfig,
198
+ runtime: ctx.runtime,
199
+ core: ({} as unknown) as any,
200
+ path: pushPath,
201
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
202
+ });
194
203
  ctx.log?.info(`[${account.accountId}] wecom webhook registered at ${path}`);
204
+ ctx.log?.info(`[${account.accountId}] wecom push endpoint registered at ${pushPath}`);
195
205
  ctx.setStatus({
196
206
  accountId: account.accountId,
197
207
  running: true,
@@ -202,6 +212,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
202
212
  return {
203
213
  stop: () => {
204
214
  unregister();
215
+ unregisterPush();
205
216
  ctx.setStatus({
206
217
  accountId: account.accountId,
207
218
  running: false,
@@ -41,6 +41,7 @@ const accountSchema = z.object({
41
41
  agentId: z.union([z.string(), z.number()]).optional(),
42
42
  callbackToken: z.string().optional(),
43
43
  callbackAesKey: z.string().optional(),
44
+ pushToken: z.string().optional(),
44
45
 
45
46
  media: z.object({
46
47
  tempDir: z.string().optional(),
@@ -85,6 +86,7 @@ export const WecomConfigSchema = ensureJsonSchema(z.object({
85
86
  agentId: z.union([z.string(), z.number()]).optional(),
86
87
  callbackToken: z.string().optional(),
87
88
  callbackAesKey: z.string().optional(),
89
+ pushToken: z.string().optional(),
88
90
 
89
91
  media: z.object({
90
92
  tempDir: z.string().optional(),
@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import type { ClawdbotConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
4
 
5
5
  import type { ResolvedWecomAccount } from "./types.js";
6
- import { handleWecomAppWebhook } from "./wecom-app.js";
6
+ import { handleWecomAppWebhook, handleWecomPushRequest } from "./wecom-app.js";
7
7
  import { handleWecomBotWebhook } from "./wecom-bot.js";
8
8
 
9
9
  export type WecomRuntimeEnv = {
@@ -55,6 +55,9 @@ export async function handleWecomWebhookRequest(
55
55
  const path = resolvePath(req);
56
56
  const targets = webhookTargets.get(path);
57
57
  if (!targets || targets.length === 0) return false;
58
+ if (path.endsWith("/push")) {
59
+ return await handleWecomPushRequest({ req, res, targets });
60
+ }
58
61
  const firstTarget = targets[0];
59
62
  const ua = req.headers["user-agent"] ?? "";
60
63
  const fwd = req.headers["x-forwarded-for"] ?? "";
@@ -40,6 +40,7 @@ export type WecomAccountConfig = {
40
40
  agentId?: string | number;
41
41
  callbackToken?: string;
42
42
  callbackAesKey?: string;
43
+ pushToken?: string;
43
44
 
44
45
  // Media handling
45
46
  media?: {
@@ -1,9 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import crypto from "node:crypto";
3
3
  import { XMLParser } from "fast-xml-parser";
4
- import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
- import { join } from "node:path";
6
+ import { basename, extname, join } from "node:path";
7
7
 
8
8
  import type { WecomWebhookTarget } from "./monitor.js";
9
9
  import { decryptWecomEncrypted, verifyWecomSignature } from "./crypto.js";
@@ -101,6 +101,27 @@ async function readRequestBody(req: IncomingMessage, maxSize = MAX_REQUEST_BODY_
101
101
  });
102
102
  }
103
103
 
104
+ function resolveHeaderToken(req: IncomingMessage): string {
105
+ const auth = req.headers.authorization ?? "";
106
+ if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
107
+ return auth.slice(7).trim();
108
+ }
109
+ const token = req.headers["x-openclaw-token"];
110
+ if (typeof token === "string") return token.trim();
111
+ return "";
112
+ }
113
+
114
+ function pickFirstString(...values: unknown[]): string {
115
+ for (const value of values) {
116
+ if (typeof value === "string" && value.trim()) return value.trim();
117
+ }
118
+ return "";
119
+ }
120
+
121
+ function sleep(ms: number): Promise<void> {
122
+ return new Promise((resolve) => setTimeout(resolve, ms));
123
+ }
124
+
104
125
  function logVerbose(target: WecomWebhookTarget, message: string): void {
105
126
  target.runtime.log?.(`[wecom] ${message}`);
106
127
  }
@@ -173,6 +194,139 @@ function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file"
173
194
  return null;
174
195
  }
175
196
 
197
+ function pickString(...values: unknown[]): string {
198
+ for (const value of values) {
199
+ if (typeof value === "string" && value.trim()) return value.trim();
200
+ }
201
+ return "";
202
+ }
203
+
204
+ function resolveContentTypeFromExt(ext: string): string {
205
+ const value = ext.toLowerCase();
206
+ if (value === "png") return "image/png";
207
+ if (value === "gif") return "image/gif";
208
+ if (value === "jpg" || value === "jpeg") return "image/jpeg";
209
+ if (value === "webp") return "image/webp";
210
+ if (value === "bmp") return "image/bmp";
211
+ if (value === "amr") return "audio/amr";
212
+ if (value === "wav") return "audio/wav";
213
+ if (value === "mp3") return "audio/mpeg";
214
+ if (value === "m4a") return "audio/mp4";
215
+ if (value === "mp4") return "video/mp4";
216
+ if (value === "mov") return "video/quicktime";
217
+ if (value === "avi") return "video/x-msvideo";
218
+ if (value === "pdf") return "application/pdf";
219
+ if (value === "txt") return "text/plain";
220
+ if (value === "csv") return "text/csv";
221
+ if (value === "json") return "application/json";
222
+ if (value === "doc") return "application/msword";
223
+ if (value === "docx") return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
224
+ if (value === "xls") return "application/vnd.ms-excel";
225
+ if (value === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
226
+ if (value === "ppt") return "application/vnd.ms-powerpoint";
227
+ if (value === "pptx") return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
228
+ if (value === "zip") return "application/zip";
229
+ return "application/octet-stream";
230
+ }
231
+
232
+ function resolveMediaTypeFromContentType(contentType: string): "image" | "voice" | "video" | "file" {
233
+ const value = contentType.toLowerCase();
234
+ if (value.startsWith("image/")) return "image";
235
+ if (value.startsWith("audio/")) return "voice";
236
+ if (value.startsWith("video/")) return "video";
237
+ return "file";
238
+ }
239
+
240
+ function stripFileProtocol(rawPath: string): string {
241
+ return rawPath.startsWith("file://") ? rawPath.replace(/^file:\/\//, "") : rawPath;
242
+ }
243
+
244
+ function parseBase64Input(input: string): { data: string; mimeType?: string } {
245
+ const match = input.match(/^data:([^;]+);base64,(.*)$/i);
246
+ if (match) {
247
+ return { data: match[2], mimeType: match[1] };
248
+ }
249
+ return { data: input };
250
+ }
251
+
252
+ function resolveOutboundMediaSpec(payload: any): {
253
+ type?: string;
254
+ url?: string;
255
+ path?: string;
256
+ base64?: string;
257
+ filename?: string;
258
+ mimeType?: string;
259
+ } | null {
260
+ if (!payload || typeof payload !== "object") return null;
261
+ const mediaBlockRaw = payload.media ?? payload.attachment ?? payload.file ?? payload.files;
262
+ const mediaBlock = Array.isArray(mediaBlockRaw) ? mediaBlockRaw[0] : mediaBlockRaw;
263
+ const url = pickString(
264
+ payload.mediaUrl,
265
+ mediaBlock?.url,
266
+ mediaBlock?.mediaUrl,
267
+ mediaBlock?.fileUrl,
268
+ mediaBlock?.file_url,
269
+ );
270
+ const path = pickString(
271
+ payload.mediaPath,
272
+ payload.filePath,
273
+ mediaBlock?.path,
274
+ mediaBlock?.filePath,
275
+ mediaBlock?.localPath,
276
+ );
277
+ const base64 = pickString(
278
+ payload.mediaBase64,
279
+ payload.base64,
280
+ mediaBlock?.base64,
281
+ mediaBlock?.data,
282
+ );
283
+ const type = pickString(payload.mediaType, mediaBlock?.type, mediaBlock?.mediaType);
284
+ const filename = pickString(payload.filename, payload.fileName, mediaBlock?.filename, mediaBlock?.fileName, mediaBlock?.name);
285
+ const mimeType = pickString(payload.mimeType, payload.mediaMimeType, mediaBlock?.mimeType, mediaBlock?.contentType);
286
+ if (!url && !path && !base64) return null;
287
+ return { type, url, path, base64, filename, mimeType };
288
+ }
289
+
290
+ async function loadOutboundMedia(params: {
291
+ payload: any;
292
+ account: WecomWebhookTarget["account"];
293
+ maxBytes: number | undefined;
294
+ }): Promise<{ buffer: Buffer; contentType: string; type: "image" | "voice" | "video" | "file"; filename: string } | null> {
295
+ const spec = resolveOutboundMediaSpec(params.payload);
296
+ if (!spec) return null;
297
+
298
+ let buffer: Buffer | null = null;
299
+ let contentType = spec.mimeType ?? "";
300
+ let filename = spec.filename ?? "";
301
+
302
+ if (spec.base64) {
303
+ const parsed = parseBase64Input(spec.base64);
304
+ buffer = Buffer.from(parsed.data, "base64");
305
+ if (!contentType && parsed.mimeType) contentType = parsed.mimeType;
306
+ } else if (spec.path) {
307
+ const resolvedPath = stripFileProtocol(spec.path);
308
+ buffer = await readFile(resolvedPath);
309
+ if (!filename) filename = basename(resolvedPath);
310
+ if (!contentType) {
311
+ const ext = extname(resolvedPath).replace(".", "");
312
+ contentType = resolveContentTypeFromExt(ext);
313
+ }
314
+ } else if (spec.url) {
315
+ const media = await fetchMediaFromUrl(spec.url, params.account);
316
+ buffer = media.buffer;
317
+ if (!contentType) contentType = media.contentType;
318
+ }
319
+
320
+ if (!buffer) return null;
321
+ if (params.maxBytes && buffer.length > params.maxBytes) return null;
322
+
323
+ const type = normalizeMediaType(spec.type) ?? resolveMediaTypeFromContentType(contentType || "application/octet-stream");
324
+ const ext = resolveExtFromContentType(contentType || "application/octet-stream", type);
325
+ const safeName = sanitizeFilename(filename, `${type}.${ext}`);
326
+
327
+ return { buffer, contentType: contentType || resolveContentTypeFromExt(ext), type, filename: safeName };
328
+ }
329
+
176
330
  function sanitizeFilename(name: string, fallback: string): string {
177
331
  const base = name.split(/[/\\\\]/).pop() ?? "";
178
332
  const trimmed = base.trim();
@@ -329,38 +483,35 @@ async function startAgentForApp(params: {
329
483
  cfg: config,
330
484
  dispatcherOptions: {
331
485
  deliver: async (payload, info) => {
332
- const maybeMediaUrl = (payload as any).mediaUrl as string | undefined;
333
- const maybeMediaType = (payload as any).mediaType as string | undefined;
334
- if (maybeMediaUrl) {
335
- try {
336
- const media = await fetchMediaFromUrl(maybeMediaUrl, account);
337
- const type = normalizeMediaType(maybeMediaType) ?? "file";
338
- const ext = resolveExtFromContentType(media.contentType, type);
486
+ const maxBytes = resolveMediaMaxBytes(target);
487
+ try {
488
+ const outbound = await loadOutboundMedia({ payload, account, maxBytes });
489
+ if (outbound) {
339
490
  const mediaId = await uploadWecomMedia({
340
491
  account,
341
- type: type as "image" | "voice" | "video" | "file",
342
- buffer: media.buffer,
343
- filename: `${type}.${ext}`,
492
+ type: outbound.type,
493
+ buffer: outbound.buffer,
494
+ filename: outbound.filename,
344
495
  });
345
- if (type === "image") {
496
+ if (outbound.type === "image") {
346
497
  await sendWecomImage({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId });
347
498
  logVerbose(target, `app image reply delivered (${info.kind}) to ${fromUser}`);
348
- } else if (type === "voice") {
499
+ } else if (outbound.type === "voice") {
349
500
  await sendWecomVoice({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId });
350
501
  logVerbose(target, `app voice reply delivered (${info.kind}) to ${fromUser}`);
351
- } else if (type === "video") {
502
+ } else if (outbound.type === "video") {
352
503
  const title = (payload as any).title as string | undefined;
353
504
  const description = (payload as any).description as string | undefined;
354
505
  await sendWecomVideo({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId, title, description });
355
506
  logVerbose(target, `app video reply delivered (${info.kind}) to ${fromUser}`);
356
- } else if (type === "file") {
507
+ } else if (outbound.type === "file") {
357
508
  await sendWecomFile({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId });
358
509
  logVerbose(target, `app file reply delivered (${info.kind}) to ${fromUser}`);
359
510
  }
360
511
  target.statusSink?.({ lastOutboundAt: Date.now() });
361
- } catch (err) {
362
- target.runtime.error?.(`wecom app media reply failed: ${String(err)}`);
363
512
  }
513
+ } catch (err) {
514
+ target.runtime.error?.(`wecom app media reply failed: ${String(err)}`);
364
515
  }
365
516
 
366
517
  const text = markdownToWecomText(core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode));
@@ -599,7 +750,8 @@ async function processAppMessage(params: {
599
750
  if (cached) {
600
751
  mediaContext = { type: cached.type, path: cached.path, mimeType: cached.mimeType, url: cached.url };
601
752
  logVerbose(target, `app file cache hit: ${cached.path}`);
602
- messageText = `[用户发送了一个文件: ${fileName || "未知文件"}]\n\n请根据文件内容回复用户。`;
753
+ const cachedName = fileName || basename(cached.path) || "未知文件";
754
+ messageText = `[用户发送了一个文件: ${cachedName},已保存到: ${cached.path}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
603
755
  } else {
604
756
  const media = await downloadWecomMedia({ account: target.account, mediaId });
605
757
  const maxBytes = resolveMediaMaxBytes(target);
@@ -627,7 +779,7 @@ async function processAppMessage(params: {
627
779
  size: media.buffer.length,
628
780
  });
629
781
  logVerbose(target, `app file saved (${media.buffer.length} bytes): ${tempFilePath}`);
630
- messageText = `[用户发送了一个文件: ${safeName}]\n\n请根据文件内容回复用户。`;
782
+ messageText = `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
631
783
  }
632
784
  }
633
785
  } catch (err) {
@@ -678,6 +830,163 @@ async function processAppMessage(params: {
678
830
  }
679
831
  }
680
832
 
833
+ type PushMessage = {
834
+ text?: string;
835
+ mediaUrl?: string;
836
+ mediaPath?: string;
837
+ mediaBase64?: string;
838
+ mediaType?: string;
839
+ filename?: string;
840
+ title?: string;
841
+ description?: string;
842
+ delayMs?: number;
843
+ };
844
+
845
+ type PushPayload = PushMessage & {
846
+ accountId?: string;
847
+ toUser?: string;
848
+ chatId?: string;
849
+ token?: string;
850
+ intervalMs?: number;
851
+ messages?: PushMessage[];
852
+ };
853
+
854
+ function resolvePushToken(target: WecomWebhookTarget): string {
855
+ return target.account.config.pushToken?.trim() || "";
856
+ }
857
+
858
+ function selectPushTarget(targets: WecomWebhookTarget[], accountId?: string): WecomWebhookTarget | undefined {
859
+ const appTargets = targets.filter((candidate) => shouldHandleApp(candidate));
860
+ if (!accountId) return appTargets[0];
861
+ return appTargets.find((candidate) => candidate.account.accountId === accountId);
862
+ }
863
+
864
+ export async function handleWecomPushRequest(params: {
865
+ req: IncomingMessage;
866
+ res: ServerResponse;
867
+ targets: WecomWebhookTarget[];
868
+ }): Promise<boolean> {
869
+ const { req, res, targets } = params;
870
+ if ((req.method ?? "").toUpperCase() !== "POST") {
871
+ res.statusCode = 405;
872
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
873
+ res.end("Method Not Allowed");
874
+ return true;
875
+ }
876
+
877
+ let payload: PushPayload | null = null;
878
+ try {
879
+ const raw = await readRequestBody(req, MAX_REQUEST_BODY_SIZE);
880
+ payload = raw ? (JSON.parse(raw) as PushPayload) : {};
881
+ } catch (err) {
882
+ res.statusCode = 400;
883
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
884
+ res.end(JSON.stringify({ ok: false, error: `Invalid JSON: ${String(err)}` }));
885
+ return true;
886
+ }
887
+
888
+ const url = new URL(req.url ?? "/", "http://localhost");
889
+ const accountId = pickFirstString(payload?.accountId, url.searchParams.get("accountId"));
890
+ const target = selectPushTarget(targets, accountId);
891
+ if (!target) {
892
+ res.statusCode = 404;
893
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
894
+ res.end(JSON.stringify({ ok: false, error: "No matching WeCom app account" }));
895
+ return true;
896
+ }
897
+
898
+ const expectedToken = resolvePushToken(target);
899
+ const requestToken = pickFirstString(
900
+ payload?.token,
901
+ url.searchParams.get("token"),
902
+ resolveHeaderToken(req),
903
+ );
904
+ if (expectedToken && expectedToken !== requestToken) {
905
+ res.statusCode = 403;
906
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
907
+ res.end(JSON.stringify({ ok: false, error: "Invalid push token" }));
908
+ return true;
909
+ }
910
+
911
+ const toUser = pickFirstString(payload?.toUser, url.searchParams.get("toUser"));
912
+ const chatId = pickFirstString(payload?.chatId, url.searchParams.get("chatId"));
913
+ if (!toUser && !chatId) {
914
+ res.statusCode = 400;
915
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
916
+ res.end(JSON.stringify({ ok: false, error: "Missing toUser or chatId" }));
917
+ return true;
918
+ }
919
+
920
+ if (!target.account.corpId || !target.account.corpSecret || !target.account.agentId) {
921
+ res.statusCode = 500;
922
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
923
+ res.end(JSON.stringify({ ok: false, error: "WeCom app not configured" }));
924
+ return true;
925
+ }
926
+
927
+ const messages = Array.isArray(payload?.messages) && payload?.messages.length > 0
928
+ ? payload.messages
929
+ : [payload ?? {}];
930
+ const intervalMs = typeof payload?.intervalMs === "number" && payload.intervalMs > 0 ? payload.intervalMs : 0;
931
+ let sent = 0;
932
+
933
+ for (const message of messages) {
934
+ if (message.delayMs && message.delayMs > 0) {
935
+ await sleep(message.delayMs);
936
+ }
937
+ try {
938
+ const outbound = await loadOutboundMedia({
939
+ payload: message,
940
+ account: target.account,
941
+ maxBytes: resolveMediaMaxBytes(target),
942
+ });
943
+ if (outbound) {
944
+ const mediaId = await uploadWecomMedia({
945
+ account: target.account,
946
+ type: outbound.type,
947
+ buffer: outbound.buffer,
948
+ filename: outbound.filename,
949
+ });
950
+ if (outbound.type === "image") {
951
+ await sendWecomImage({ account: target.account, toUser, chatId: chatId || undefined, mediaId });
952
+ } else if (outbound.type === "voice") {
953
+ await sendWecomVoice({ account: target.account, toUser, chatId: chatId || undefined, mediaId });
954
+ } else if (outbound.type === "video") {
955
+ await sendWecomVideo({
956
+ account: target.account,
957
+ toUser,
958
+ chatId: chatId || undefined,
959
+ mediaId,
960
+ title: message.title,
961
+ description: message.description,
962
+ });
963
+ } else {
964
+ await sendWecomFile({ account: target.account, toUser, chatId: chatId || undefined, mediaId });
965
+ }
966
+ sent += 1;
967
+ }
968
+
969
+ const text = markdownToWecomText(message.text ?? "");
970
+ if (text) {
971
+ await sendWecomText({ account: target.account, toUser, chatId: chatId || undefined, text });
972
+ sent += 1;
973
+ }
974
+ } catch (err) {
975
+ target.runtime.error?.(`wecom push failed: ${String(err)}`);
976
+ }
977
+
978
+ if (intervalMs) {
979
+ await sleep(intervalMs);
980
+ }
981
+ }
982
+
983
+ target.statusSink?.({ lastOutboundAt: Date.now() });
984
+ res.statusCode = 200;
985
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
986
+ res.end(JSON.stringify({ ok: true, sent }));
987
+ return true;
988
+ }
989
+
681
990
  export async function handleWecomAppWebhook(params: {
682
991
  req: IncomingMessage;
683
992
  res: ServerResponse;
@@ -1,8 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import crypto from "node:crypto";
3
- import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { join } from "node:path";
5
+ import { basename, extname, join } from "node:path";
6
6
 
7
7
  import type { PluginRuntime } from "openclaw/plugin-sdk";
8
8
 
@@ -414,49 +414,44 @@ async function startAgentForStream(params: {
414
414
  cfg: config,
415
415
  dispatcherOptions: {
416
416
  deliver: async (payload) => {
417
- const maybeMediaUrl = (payload as any).mediaUrl as string | undefined;
418
- const maybeMediaType = (payload as any).mediaType as string | undefined;
419
417
  const canBridgeMedia = account.config.botMediaBridge !== false
420
418
  && Boolean(account.corpId && account.corpSecret && account.agentId);
421
419
  const toChatId = chatType === "group" ? chatId : undefined;
422
420
 
423
- if (maybeMediaUrl && canBridgeMedia) {
421
+ if (canBridgeMedia) {
424
422
  try {
425
- const media = await fetchMediaFromUrl(maybeMediaUrl, account);
426
- const type = normalizeMediaType(maybeMediaType) ?? "file";
427
- const ext = media.contentType.includes("png") ? "png"
428
- : media.contentType.includes("gif") ? "gif"
429
- : media.contentType.includes("jpeg") || media.contentType.includes("jpg") ? "jpg"
430
- : media.contentType.includes("mp4") ? "mp4"
431
- : media.contentType.includes("amr") ? "amr"
432
- : media.contentType.includes("wav") ? "wav"
433
- : media.contentType.includes("mp3") ? "mp3"
434
- : "bin";
423
+ const outbound = await loadOutboundMedia({
424
+ payload,
425
+ account,
426
+ maxBytes: resolveMediaMaxBytes(target),
427
+ });
428
+ if (outbound) {
435
429
  const mediaId = await uploadWecomMedia({
436
430
  account,
437
- type: type as "image" | "voice" | "video" | "file",
438
- buffer: media.buffer,
439
- filename: `${type}.${ext}`,
431
+ type: outbound.type,
432
+ buffer: outbound.buffer,
433
+ filename: outbound.filename,
440
434
  });
441
- if (type === "image") {
435
+ if (outbound.type === "image") {
442
436
  await sendWecomImage({ account, toUser: userid, chatId: toChatId, mediaId });
443
- } else if (type === "voice") {
437
+ } else if (outbound.type === "voice") {
444
438
  await sendWecomVoice({ account, toUser: userid, chatId: toChatId, mediaId });
445
- } else if (type === "video") {
439
+ } else if (outbound.type === "video") {
446
440
  const title = (payload as any).title as string | undefined;
447
441
  const description = (payload as any).description as string | undefined;
448
442
  await sendWecomVideo({ account, toUser: userid, chatId: toChatId, mediaId, title, description });
449
- } else if (type === "file") {
443
+ } else if (outbound.type === "file") {
450
444
  await sendWecomFile({ account, toUser: userid, chatId: toChatId, mediaId });
451
445
  }
452
446
  const current = streams.get(streamId);
453
447
  if (current) {
454
- const note = mediaSentLabel(type);
448
+ const note = mediaSentLabel(outbound.type);
455
449
  const nextText = current.content ? `${current.content}\n\n${note}` : note;
456
450
  current.content = truncateUtf8Bytes(nextText.trim(), STREAM_MAX_BYTES);
457
451
  current.updatedAt = Date.now();
458
452
  }
459
453
  target.statusSink?.({ lastOutboundAt: Date.now() });
454
+ }
460
455
  } catch (err) {
461
456
  target.runtime.error?.(`[${account.accountId}] wecom bot media bridge failed: ${String(err)}`);
462
457
  }
@@ -594,11 +589,21 @@ async function buildBotMediaMessage(params: {
594
589
  const cacheKey = buildMediaCacheKey({ url, base64 });
595
590
  const cached = await getCachedMedia(cacheKey, resolveMediaRetentionMs(target));
596
591
  if (cached) {
597
- const text = msgtype === "image" && cached.summary
598
- ? `[用户发送了一张图片]\n\n[图片识别结果]\n${cached.summary}\n\n请根据识别结果回复用户。`
599
- : buildInboundMediaPrompt(msgtype, filename);
592
+ if (msgtype === "image" && cached.summary) {
593
+ return {
594
+ text: `[用户发送了一张图片]\n\n[图片识别结果]\n${cached.summary}\n\n请根据识别结果回复用户。`,
595
+ media: cached.media,
596
+ };
597
+ }
598
+ if (msgtype === "file") {
599
+ const safeName = sanitizeFilename(filename || basename(cached.media.path), "file");
600
+ return {
601
+ text: `[用户发送了一个文件: ${safeName},已保存到: ${cached.media.path}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`,
602
+ media: cached.media,
603
+ };
604
+ }
600
605
  return {
601
- text,
606
+ text: buildInboundMediaPrompt(msgtype, filename),
602
607
  media: cached.media,
603
608
  };
604
609
  }
@@ -656,7 +661,7 @@ async function buildBotMediaMessage(params: {
656
661
  };
657
662
  storeCachedMedia(cacheKey, media, buffer.length);
658
663
  return {
659
- text: buildInboundMediaPrompt("file", safeName),
664
+ text: `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`,
660
665
  media,
661
666
  };
662
667
  }
@@ -790,6 +795,132 @@ function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file"
790
795
  return null;
791
796
  }
792
797
 
798
+ function resolveContentTypeFromExt(ext: string): string {
799
+ const value = ext.toLowerCase();
800
+ if (value === "png") return "image/png";
801
+ if (value === "gif") return "image/gif";
802
+ if (value === "jpg" || value === "jpeg") return "image/jpeg";
803
+ if (value === "webp") return "image/webp";
804
+ if (value === "bmp") return "image/bmp";
805
+ if (value === "amr") return "audio/amr";
806
+ if (value === "wav") return "audio/wav";
807
+ if (value === "mp3") return "audio/mpeg";
808
+ if (value === "m4a") return "audio/mp4";
809
+ if (value === "mp4") return "video/mp4";
810
+ if (value === "mov") return "video/quicktime";
811
+ if (value === "avi") return "video/x-msvideo";
812
+ if (value === "pdf") return "application/pdf";
813
+ if (value === "txt") return "text/plain";
814
+ if (value === "csv") return "text/csv";
815
+ if (value === "json") return "application/json";
816
+ if (value === "doc") return "application/msword";
817
+ if (value === "docx") return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
818
+ if (value === "xls") return "application/vnd.ms-excel";
819
+ if (value === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
820
+ if (value === "ppt") return "application/vnd.ms-powerpoint";
821
+ if (value === "pptx") return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
822
+ if (value === "zip") return "application/zip";
823
+ return "application/octet-stream";
824
+ }
825
+
826
+ function resolveMediaTypeFromContentType(contentType: string): "image" | "voice" | "video" | "file" {
827
+ const value = contentType.toLowerCase();
828
+ if (value.startsWith("image/")) return "image";
829
+ if (value.startsWith("audio/")) return "voice";
830
+ if (value.startsWith("video/")) return "video";
831
+ return "file";
832
+ }
833
+
834
+ function stripFileProtocol(rawPath: string): string {
835
+ return rawPath.startsWith("file://") ? rawPath.replace(/^file:\/\//, "") : rawPath;
836
+ }
837
+
838
+ function parseBase64Input(input: string): { data: string; mimeType?: string } {
839
+ const match = input.match(/^data:([^;]+);base64,(.*)$/i);
840
+ if (match) {
841
+ return { data: match[2], mimeType: match[1] };
842
+ }
843
+ return { data: input };
844
+ }
845
+
846
+ function resolveOutboundMediaSpec(payload: any): {
847
+ type?: string;
848
+ url?: string;
849
+ path?: string;
850
+ base64?: string;
851
+ filename?: string;
852
+ mimeType?: string;
853
+ } | null {
854
+ if (!payload || typeof payload !== "object") return null;
855
+ const mediaBlockRaw = payload.media ?? payload.attachment ?? payload.file ?? payload.files;
856
+ const mediaBlock = Array.isArray(mediaBlockRaw) ? mediaBlockRaw[0] : mediaBlockRaw;
857
+ const url = pickString(
858
+ payload.mediaUrl,
859
+ mediaBlock?.url,
860
+ mediaBlock?.mediaUrl,
861
+ mediaBlock?.fileUrl,
862
+ mediaBlock?.file_url,
863
+ );
864
+ const path = pickString(
865
+ payload.mediaPath,
866
+ payload.filePath,
867
+ mediaBlock?.path,
868
+ mediaBlock?.filePath,
869
+ mediaBlock?.localPath,
870
+ );
871
+ const base64 = pickString(
872
+ payload.mediaBase64,
873
+ payload.base64,
874
+ mediaBlock?.base64,
875
+ mediaBlock?.data,
876
+ );
877
+ const type = pickString(payload.mediaType, mediaBlock?.type, mediaBlock?.mediaType);
878
+ const filename = pickString(payload.filename, payload.fileName, mediaBlock?.filename, mediaBlock?.fileName, mediaBlock?.name);
879
+ const mimeType = pickString(payload.mimeType, payload.mediaMimeType, mediaBlock?.mimeType, mediaBlock?.contentType);
880
+ if (!url && !path && !base64) return null;
881
+ return { type, url, path, base64, filename, mimeType };
882
+ }
883
+
884
+ async function loadOutboundMedia(params: {
885
+ payload: any;
886
+ account: ResolvedWecomAccount;
887
+ maxBytes: number | undefined;
888
+ }): Promise<{ buffer: Buffer; contentType: string; type: "image" | "voice" | "video" | "file"; filename: string } | null> {
889
+ const spec = resolveOutboundMediaSpec(params.payload);
890
+ if (!spec) return null;
891
+
892
+ let buffer: Buffer | null = null;
893
+ let contentType = spec.mimeType ?? "";
894
+ let filename = spec.filename ?? "";
895
+
896
+ if (spec.base64) {
897
+ const parsed = parseBase64Input(spec.base64);
898
+ buffer = Buffer.from(parsed.data, "base64");
899
+ if (!contentType && parsed.mimeType) contentType = parsed.mimeType;
900
+ } else if (spec.path) {
901
+ const resolvedPath = stripFileProtocol(spec.path);
902
+ buffer = await readFile(resolvedPath);
903
+ if (!filename) filename = basename(resolvedPath);
904
+ if (!contentType) {
905
+ const ext = extname(resolvedPath).replace(".", "");
906
+ contentType = resolveContentTypeFromExt(ext);
907
+ }
908
+ } else if (spec.url) {
909
+ const media = await fetchMediaFromUrl(spec.url, params.account);
910
+ buffer = media.buffer;
911
+ if (!contentType) contentType = media.contentType;
912
+ }
913
+
914
+ if (!buffer) return null;
915
+ if (params.maxBytes && buffer.length > params.maxBytes) return null;
916
+
917
+ const type = normalizeMediaType(spec.type) ?? resolveMediaTypeFromContentType(contentType || "application/octet-stream");
918
+ const ext = resolveExtFromContentType(contentType || "application/octet-stream", type);
919
+ const safeName = sanitizeFilename(filename, `${type}.${ext}`);
920
+
921
+ return { buffer, contentType: contentType || resolveContentTypeFromExt(ext), type, filename: safeName };
922
+ }
923
+
793
924
  function mediaSentLabel(type: string): string {
794
925
  if (type === "image") return "[已发送图片]";
795
926
  if (type === "voice") return "[已发送语音]";