@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.
- package/docs/wecom.config.full.example.json +2 -1
- package/package.json +1 -1
- package/wecom/src/accounts.ts +4 -0
- package/wecom/src/channel.ts +11 -0
- package/wecom/src/config-schema.ts +2 -0
- package/wecom/src/monitor.ts +4 -1
- package/wecom/src/types.ts +1 -0
- package/wecom/src/wecom-app.ts +329 -20
- package/wecom/src/wecom-bot.ts +159 -28
package/package.json
CHANGED
package/wecom/src/accounts.ts
CHANGED
|
@@ -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 {
|
package/wecom/src/channel.ts
CHANGED
|
@@ -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(),
|
package/wecom/src/monitor.ts
CHANGED
|
@@ -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"] ?? "";
|
package/wecom/src/types.ts
CHANGED
package/wecom/src/wecom-app.ts
CHANGED
|
@@ -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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
342
|
-
buffer:
|
|
343
|
-
filename:
|
|
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
|
-
|
|
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;
|
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -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 (
|
|
421
|
+
if (canBridgeMedia) {
|
|
424
422
|
try {
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
:
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
438
|
-
buffer:
|
|
439
|
-
filename:
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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:
|
|
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 "[已发送语音]";
|