Merge pull request #7212 from gaord/feat/wecom-media-messages

feat(bridges): accept media and voice messages on the WeCom bridge
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-11-06 08:36:11 +08:00
commit 0ba20139e7
3 files changed
+87 -9

No files matched your search

+61 -8
View File
@@ -1,4 +1,6 @@
import { WSClient, generateReqId } from "@wecom/aibot-node-sdk";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
activeTurnBlock,
@@ -12,6 +14,7 @@ import {
parseBool,
parseCommand,
parseList,
sanitizeFilename,
parseApprovalDecisionArgs,
isApprovalResponse,
isDenyResponse,
@@ -45,6 +48,7 @@ const config = {
runtimeUrl: (process.env.CODEWHALE_RUNTIME_URL || "http://127.0.0.1:7878").replace(/\/+$/, ""),
runtimeToken: requiredEnv("CODEWHALE_RUNTIME_TOKEN"),
workspace: process.env.CODEWHALE_WORKSPACE || process.cwd(),
mediaDir: process.env.WECOM_MEDIA_DIR || "",
model: process.env.CODEWHALE_MODEL || "auto",
mode: process.env.CODEWHALE_MODE || "agent",
allowShell: parseBool(process.env.CODEWHALE_ALLOW_SHELL, true),
@@ -107,6 +111,45 @@ function replyText(frame, text) {
);
}
function isMediaMessage(msgtype) {
return msgtype === "video" || msgtype === "file" || msgtype === "image";
}
function mediaStorageDir() {
return config.mediaDir || path.join(config.workspace, "media");
}
async function handleMediaMessage(frame, body, identity) {
const kind = body.msgtype;
const content = body.video || body.file || body.image || {};
if (!content.url) {
await replyText(frame, "The media message carried no downloadable URL.");
return;
}
const zh = kind === "video" ? "video" : kind === "image" ? "image" : "file";
// Download URLs stay valid for ~5 minutes; tell the user before starting.
await replyText(frame, `Received ${zh}, downloading (the link is valid for ~5 minutes)…`);
try {
const { buffer, filename } = await client.downloadFile(content.url, content.aeskey);
const dir = mediaStorageDir();
mkdirSync(dir, { recursive: true });
const fallbackExt = kind === "image" ? "jpg" : kind === "video" ? "mp4" : "bin";
const name = sanitizeFilename(filename, `upload-${Date.now()}.${fallbackExt}`);
const target = path.join(dir, name);
writeFileSync(target, buffer);
await replyText(
frame,
`Saved ${name} (${(buffer.length / 1048576).toFixed(1)} MB) to ${target}\n` +
`Send follow-up instructions in chat to use it.`
);
} catch (error) {
await replyText(
frame,
`Download failed (links expire after ~5 minutes; large files should be re-sent promptly): ${error?.errmsg || error?.message || error}`
);
}
}
async function reportHandlerError(frame, context, error) {
console.error(context, error);
try {
@@ -127,17 +170,22 @@ async function handleIncomingMessage(frame) {
console.log(`Incoming message: chatId=${identity.chatId} userId=${identity.userId} chatType=${identity.chatType}`);
if (!identity.chatId || !identity.messageId) return;
if (body.msgtype && body.msgtype !== "text") {
await replyText(frame, "目前仅支持文本消息。");
if (body.msgtype && body.msgtype !== "text" && body.msgtype !== "voice" &&
!isMediaMessage(body.msgtype)) {
await replyText(frame, `Only text, voice, image, video and file messages are supported (got ${body.msgtype}).`);
return;
}
const textContent = body.text?.content || "";
const scoped = stripGroupPrefix(textContent, {
chatType: identity.chatType,
requirePrefix: identity.chatType === "group",
prefix: "/ds"
});
const textContent = body.msgtype === "voice" ? (body.voice?.content || "") : (body.text?.content || "");
// Media frames carry no text and must not go through the group-prefix
// filter, which rejects empty text and would drop them silently.
const scoped = isMediaMessage(body.msgtype)
? { accepted: true, text: "" }
: stripGroupPrefix(textContent, {
chatType: identity.chatType,
requirePrefix: identity.chatType === "group",
prefix: "/ds"
});
if (!scoped.accepted) return;
if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
@@ -145,6 +193,11 @@ async function handleIncomingMessage(frame) {
return;
}
if (isMediaMessage(body.msgtype)) {
await handleMediaMessage(frame, body, identity);
return;
}
const command = parseCommand(scoped.text);
await handleCommand(identity.chatId, command, frame);
}
+12
View File
@@ -79,6 +79,18 @@ export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) {
});
}
/** Reduce a provider-supplied filename to a safe single path component. */
export function sanitizeFilename(name, fallback = "upload.bin") {
const cleaned = String(name || "")
.replace(/[\\/]/g, "_")
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f]/g, "")
.replace(/^[.\s]+/, "")
.replace(/[\s.]+$/, "")
.slice(0, 120);
return cleaned || fallback;
}
/** Check if text is a natural-language approval response (Chinese or English). */
export function isApprovalResponse(text) {
const t = String(text || "").trim().toLowerCase();
+14 -1
View File
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { ThreadStore, validateBridgeConfig, isApprovalResponse, isDenyResponse } from "../src/lib.mjs";
import { ThreadStore, validateBridgeConfig, isApprovalResponse, isDenyResponse, sanitizeFilename } from "../src/lib.mjs";
// ─── ThreadStore ─────────────────────────────────────────────────
@@ -164,3 +164,16 @@ test("WeCom accepted turn origin survives reload and cannot transfer to a later
await rm(dir, { recursive: true, force: true });
}
});
// ─── sanitizeFilename ────────────────────────────────────────────
test("sanitizeFilename strips path components and keeps the name", () => {
const risky = sanitizeFilename("../../etc/passwd");
assert.ok(!risky.includes("/"), risky);
assert.ok(!risky.startsWith("."), risky);
assert.equal(sanitizeFilename("clip final.mp4"), "clip final.mp4");
assert.equal(sanitizeFilename(" .hidden. "), "hidden");
assert.equal(sanitizeFilename("", "fallback.bin"), "fallback.bin");
const long = "a".repeat(500) + ".mp4";
assert.ok(sanitizeFilename(long).length <= 120);
});