📄 handler.ts

/**
 * WeCom Agent Webhook 处理器
 * 处理 XML 格式回调
 */

import { pathToFileURL } from "node:url";
import path from "node:path";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import type { ResolvedAgentAccount } from "../types/index.js";
import { LIMITS } from "../types/constants.js";
import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId, extractMsgId, extractFileName } from "../shared/xml-parser.js";
import { sendText, downloadMedia } from "./api-client.js";
import { getWecomRuntime } from "../runtime.js";
import type { WecomAgentInboundMessage } from "../types/index.js";
import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
import { resolveWecomMediaMaxBytes } from "../config/index.js";
import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";

/** 错误提示信息 */
const ERROR_HELP = "";

// Agent webhook 幂等去重池(防止企微回调重试导致重复回复)
// 注意:这是进程内内存去重,重启会清空;但足以覆盖企微的短周期重试。
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
const recentAgentMsgIds = new Map<string, number>();

function rememberAgentMsgId(msgId: string): boolean {
    const now = Date.now();
    const existing = recentAgentMsgIds.get(msgId);
    if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
    recentAgentMsgIds.set(msgId, now);
    // 简单清理:只在写入时做一次线性 prune,避免无界增长
    for (const [k, ts] of recentAgentMsgIds) {
        if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
    }
    return true;
}

function looksLikeTextFile(buffer: Buffer): boolean {
    const sampleSize = Math.min(buffer.length, 4096);
    if (sampleSize === 0) return true;
    let bad = 0;
    for (let i = 0; i < sampleSize; i++) {
        const b = buffer[i]!;
        const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d; // \t \n \r
        const isPrintable = b >= 0x20 && b !== 0x7f;
        if (!isWhitespace && !isPrintable) bad++;
    }
    // 非可打印字符占比太高,基本可判断为二进制
    return bad / sampleSize <= 0.02;
}

function analyzeTextHeuristic(buffer: Buffer): { sampleSize: number; badCount: number; badRatio: number } {
    const sampleSize = Math.min(buffer.length, 4096);
    if (sampleSize === 0) return { sampleSize: 0, badCount: 0, badRatio: 0 };
    let badCount = 0;
    for (let i = 0; i < sampleSize; i++) {
        const b = buffer[i]!;
        const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d;
        const isPrintable = b >= 0x20 && b !== 0x7f;
        if (!isWhitespace && !isPrintable) badCount++;
    }
    return { sampleSize, badCount, badRatio: badCount / sampleSize };
}

function previewHex(buffer: Buffer, maxBytes = 32): string {
    const n = Math.min(buffer.length, maxBytes);
    if (n <= 0) return "";
    return buffer
        .subarray(0, n)
        .toString("hex")
        .replace(/(..)/g, "$1 ")
        .trim();
}

function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefined {
    if (!looksLikeTextFile(buffer)) return undefined;
    const text = buffer.toString("utf8");
    if (!text.trim()) return undefined;
    const truncated = text.length > maxChars ? `${text.slice(0, maxChars)}\n…(已截断)` : text;
    return truncated;
}

/**
 * **AgentWebhookParams (Webhook 处理器参数)**
 * 
 * 传递给 Agent Webhook 处理函数的上下文参数集合。
 * @property req Node.js 原始请求对象
 * @property res Node.js 原始响应对象
 * @property agent 解析后的 Agent 账号信息
 * @property config 全局插件配置
 * @property core OpenClaw 插件运行时
 * @property log 可选日志输出函数
 * @property error 可选错误输出函数
 */
export type AgentWebhookParams = {
    req: IncomingMessage;
    res: ServerResponse;
    agent: ResolvedAgentAccount;
    config: OpenClawConfig;
    core: PluginRuntime;
    log?: (msg: string) => void;
    error?: (msg: string) => void;
};

/**
 * **resolveQueryParams (解析查询参数)**
 * 
 * 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
 */
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
    const url = new URL(req.url ?? "/", "http://localhost");
    return url.searchParams;
}

/**
 * **readRawBody (读取原始请求体)**
 * 
 * 异步读取 HTTP POST 请求的原始 BODY 数据(XML 字符串)。
 * 包含最大体积限制检查,防止内存溢出攻击。
 */
async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_REQUEST_BODY_SIZE): Promise<string> {
    return new Promise((resolve, reject) => {
        const chunks: Buffer[] = [];
        let size = 0;

        req.on("data", (chunk: Buffer) => {
            size += chunk.length;
            if (size > maxSize) {
                reject(new Error("Request body too large"));
                req.destroy();
                return;
            }
            chunks.push(chunk);
        });

        req.on("end", () => {
            resolve(Buffer.concat(chunks).toString("utf8"));
        });

        req.on("error", reject);
    });
}

/**
 * **handleUrlVerification (处理 URL 验证)**
 * 
 * 处理企业微信 Agent 配置时的 GET 请求验证。
 * 流程:
 * 1. 验证 msg_signature 签名。
 * 2. 解密 echostr 参数。
 * 3. 返回解密后的明文 echostr。
 */
async function handleUrlVerification(
    req: IncomingMessage,
    res: ServerResponse,
    agent: ResolvedAgentAccount,
): Promise<boolean> {
    const query = resolveQueryParams(req);
    const timestamp = query.get("timestamp") ?? "";
    const nonce = query.get("nonce") ?? "";
    const echostr = query.get("echostr") ?? "";
    const signature = query.get("msg_signature") ?? "";
    const remote = req.socket?.remoteAddress ?? "unknown";

    // 不输出敏感参数内容,仅输出存在性
    // 用于排查:是否有请求打到 /wecom/agent
    // 以及是否带齐 timestamp/nonce/msg_signature/echostr
    // eslint-disable-next-line no-unused-vars
    const _debug = { remote, hasTimestamp: Boolean(timestamp), hasNonce: Boolean(nonce), hasSig: Boolean(signature), hasEchostr: Boolean(echostr) };

    const valid = verifyWecomSignature({
        token: agent.token,
        timestamp,
        nonce,
        encrypt: echostr,
        signature,
    });

    if (!valid) {
        res.statusCode = 401;
        res.setHeader("Content-Type", "text/plain; charset=utf-8");
        res.end(`unauthorized - 签名验证失败,请检查 Token 配置${ERROR_HELP}`);
        return true;
    }

    try {
        const plain = decryptWecomEncrypted({
            encodingAESKey: agent.encodingAESKey,
            receiveId: agent.corpId,
            encrypt: echostr,
        });
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/plain; charset=utf-8");
        res.end(plain);
        return true;
    } catch {
        res.statusCode = 400;
        res.setHeader("Content-Type", "text/plain; charset=utf-8");
        res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey 配置${ERROR_HELP}`);
        return true;
    }
}

/**
 * 处理消息回调 (POST)
 */
async function handleMessageCallback(params: AgentWebhookParams): Promise<boolean> {
    const { req, res, agent, config, core, log, error } = params;

    try {
        log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
        const rawXml = await readRawBody(req);
        log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
        const encrypted = extractEncryptFromXml(rawXml);
        log?.(`[wecom-agent] inbound: hasEncrypt=${Boolean(encrypted)} encryptLen=${encrypted ? String(encrypted).length : 0}`);

        const query = resolveQueryParams(req);
        const timestamp = query.get("timestamp") ?? "";
        const nonce = query.get("nonce") ?? "";
        const signature = query.get("msg_signature") ?? "";
        log?.(
            `[wecom-agent] inbound: query timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"}`,
        );

        // 验证签名
        const valid = verifyWecomSignature({
            token: agent.token,
            timestamp,
            nonce,
            encrypt: encrypted,
            signature,
        });

        if (!valid) {
            error?.(`[wecom-agent] inbound: signature invalid`);
            res.statusCode = 401;
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
            return true;
        }

        // 解密
        const decrypted = decryptWecomEncrypted({
            encodingAESKey: agent.encodingAESKey,
            receiveId: agent.corpId,
            encrypt: encrypted,
        });
        log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);

        // 解析 XML
        const msg = parseXml(decrypted);
        const msgType = extractMsgType(msg);
        const fromUser = extractFromUser(msg);
        const chatId = extractChatId(msg);
        const msgId = extractMsgId(msg);
        if (msgId) {
            const ok = rememberAgentMsgId(msgId);
            if (!ok) {
                log?.(`[wecom-agent] duplicate msgId=${msgId} from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}; skipped`);
                res.statusCode = 200;
                res.setHeader("Content-Type", "text/plain; charset=utf-8");
                res.end("success");
                return true;
            }
        }
        const content = String(extractContent(msg) ?? "");

        const preview = content.length > 100 ? `${content.slice(0, 100)}…` : content;
        log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} msgId=${msgId ?? "N/A"} content=${preview}`);

        // 先返回 success (Agent 模式使用 API 发送回复,不用被动回复)
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/plain; charset=utf-8");
        res.end("success");

        // 异步处理消息
        processAgentMessage({
            agent,
            config,
            core,
            fromUser,
            chatId,
            msgType,
            content,
            msg,
            log,
            error,
        }).catch((err) => {
            error?.(`[wecom-agent] process failed: ${String(err)}`);
        });

        return true;
    } catch (err) {
        error?.(`[wecom-agent] callback failed: ${String(err)}`);
        res.statusCode = 400;
        res.setHeader("Content-Type", "text/plain; charset=utf-8");
        res.end(`error - 回调处理失败${ERROR_HELP}`);
        return true;
    }
}

/**
 * **processAgentMessage (处理 Agent 消息)**
 * 
 * 异步处理解密后的消息内容,并触发 OpenClaw Agent。
 * 流程:
 * 1. 路由解析:根据 userid或群ID 确定 Agent 路由。
 * 2. 媒体处理:如果是图片/文件等,下载资源。
 * 3. 上下文构建:创建 Inbound Context。
 * 4. 会话记录:更新 Session 状态。
 * 5. 调度回复:将 Agent 的响应通过 `api-client` 发送回企业微信。
 */
async function processAgentMessage(params: {
    agent: ResolvedAgentAccount;
    config: OpenClawConfig;
    core: PluginRuntime;
    fromUser: string;
    chatId?: string;
    msgType: string;
    content: string;
    msg: WecomAgentInboundMessage;
    log?: (msg: string) => void;
    error?: (msg: string) => void;
}): Promise<void> {
    const { agent, config, core, fromUser, chatId, content, msg, msgType, log, error } = params;

    const isGroup = Boolean(chatId);
    const peerId = isGroup ? chatId! : fromUser;
    const mediaMaxBytes = resolveWecomMediaMaxBytes(config);

    // 处理媒体文件
    const attachments: any[] = []; // TODO: define specific type
    let finalContent = content;
    let mediaPath: string | undefined;
    let mediaType: string | undefined;

    if (["image", "voice", "video", "file"].includes(msgType)) {
        const mediaId = extractMediaId(msg);
        if (mediaId) {
            try {
                log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
                const { buffer, contentType, filename: headerFileName } = await downloadMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
                const xmlFileName = extractFileName(msg);
                const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
                const heuristic = analyzeTextHeuristic(buffer);

                // 推断文件名后缀
                const extMap: Record<string, string> = {
                    "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
                    "audio/amr": "amr", "audio/speex": "speex", "video/mp4": "mp4",
                };
                const textPreview = msgType === "file" ? buildTextFilePreview(buffer, 12_000) : undefined;
                const looksText = Boolean(textPreview);
                const originalExt = path.extname(originalFileName).toLowerCase();
                const normalizedContentType =
                    looksText && originalExt === ".md" ? "text/markdown" :
                    looksText && (!contentType || contentType === "application/octet-stream")
                        ? "text/plain; charset=utf-8"
                        : contentType;

                const ext = extMap[normalizedContentType] || (looksText ? "txt" : "bin");
                const filename = `${mediaId}.${ext}`;

                log?.(
                    `[wecom-agent] file meta: msgType=${msgType} mediaId=${mediaId} size=${buffer.length} maxBytes=${mediaMaxBytes} ` +
                    `contentType=${contentType} normalizedContentType=${normalizedContentType} originalFileName=${originalFileName} ` +
                    `xmlFileName=${xmlFileName ?? "N/A"} headerFileName=${headerFileName ?? "N/A"} ` +
                    `textHeuristic(sample=${heuristic.sampleSize}, bad=${heuristic.badCount}, ratio=${heuristic.badRatio.toFixed(4)}) ` +
                    `headHex="${previewHex(buffer)}"`,
                );

                // 使用 Core SDK 保存媒体文件
                const saved = await core.channel.media.saveMediaBuffer(
                    buffer,
                    normalizedContentType,
                    "inbound", // context/scope
                    mediaMaxBytes, // limit
                    originalFileName
                );

                log?.(`[wecom-agent] media saved to: ${saved.path}`);
                mediaPath = saved.path;
                mediaType = normalizedContentType;

                // 构建附件
                attachments.push({
                    name: originalFileName,
                    mimeType: normalizedContentType,
                    url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
                });

                // 更新文本提示
                if (textPreview) {
                    finalContent = [
                        content,
                        "",
                        "文件内容预览:",
                        "```",
                        textPreview,
                        "```",
                        `(已下载 ${buffer.length} 字节)`,
                    ].join("\n");
                } else {
                    if (msgType === "file") {
                        finalContent = [
                            content,
                            "",
                            `已收到文件:${originalFileName}`,
                            `文件类型:${normalizedContentType || contentType || "未知"}`,
                            "提示:当前仅对文本/Markdown/JSON/CSV/HTML/PDF(可选)做内容抽取;其他二进制格式请转为 PDF 或复制文本内容。",
                            `(已下载 ${buffer.length} 字节)`,
                        ].join("\n");
                    } else {
                        finalContent = `${content} (已下载 ${buffer.length} 字节)`;
                    }
                }
                log?.(`[wecom-agent] file preview: enabled=${looksText} finalContentLen=${finalContent.length} attachments=${attachments.length}`);
            } catch (err) {
                error?.(`[wecom-agent] media processing failed: ${String(err)}`);
                finalContent = [
                    content,
                    "",
                    `媒体处理失败:${String(err)}`,
                    `提示:可在 OpenClaw 配置中提高 channels.wecom.media.maxBytes(当前=${mediaMaxBytes})`,
                    `例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
                ].join("\n");
            }
        } else {
            const keys = Object.keys((msg as unknown as Record<string, unknown>) ?? {}).slice(0, 50).join(",");
            error?.(`[wecom-agent] mediaId not found for ${msgType}; keys=${keys}`);
        }
    }

    // 解析路由
    const route = core.channel.routing.resolveAgentRoute({
        cfg: config,
        channel: "wecom",
        accountId: agent.accountId,
        peer: { kind: isGroup ? "group" : "dm", id: peerId },
    });

    // ===== 动态 Agent 路由注入 =====
    const useDynamicAgent = shouldUseDynamicAgent({
        chatType: isGroup ? "group" : "dm",
        senderId: fromUser,
        config,
    });

    if (useDynamicAgent) {
        const targetAgentId = generateAgentId(
            isGroup ? "group" : "dm",
            peerId
        );
        route.agentId = targetAgentId;
        route.sessionKey = `agent:${targetAgentId}:${isGroup ? "group" : "dm"}:${peerId}`;
        // 异步添加到 agents.list(不阻塞)
        ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
        log?.(`[wecom-agent] dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
    }
    // ===== 动态 Agent 路由注入结束 =====

    // 构建上下文
    const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
    const storePath = core.channel.session.resolveStorePath(config.session?.store, {
        agentId: route.agentId,
    });
    const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
    const previousTimestamp = core.channel.session.readSessionUpdatedAt({
        storePath,
        sessionKey: route.sessionKey,
    });
    const body = core.channel.reply.formatAgentEnvelope({
        channel: "WeCom",
        from: fromLabel,
        previousTimestamp,
        envelope: envelopeOptions,
        body: finalContent,
    });

    const authz = await resolveWecomCommandAuthorization({
        core,
        cfg: config,
        // Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
        accountConfig: agent.config as any,
        rawBody: finalContent,
        senderUserId: fromUser,
    });
    log?.(`[wecom-agent] authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${fromUser.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(authz.commandAuthorized)}`);

    // 命令门禁:未授权时必须明确回复(Agent 侧用私信提示)
    if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
        const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: fromUser, dmPolicy: authz.dmPolicy, scope: "agent" });
        try {
            await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
            log?.(`[wecom-agent] unauthorized command: replied via DM to ${fromUser}`);
        } catch (err: unknown) {
            error?.(`[wecom-agent] unauthorized command reply failed: ${String(err)}`);
        }
        return;
    }

    const ctxPayload = core.channel.reply.finalizeInboundContext({
        Body: body,
        RawBody: finalContent,
        CommandBody: finalContent,
        Attachments: attachments.length > 0 ? attachments : undefined,
        From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
        To: `wecom:${peerId}`,
        SessionKey: route.sessionKey,
        AccountId: route.accountId,
        ChatType: isGroup ? "group" : "direct",
        ConversationLabel: fromLabel,
        SenderName: fromUser,
        SenderId: fromUser,
        Provider: "wecom",
        Surface: "wecom",
        OriginatingChannel: "wecom",
        // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
        // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
        // - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
        OriginatingTo: `wecom-agent:${fromUser}`,
        CommandAuthorized: authz.commandAuthorized ?? true,
        MediaPath: mediaPath,
        MediaType: mediaType,
        MediaUrl: mediaPath,
    });

    // 记录会话
    await core.channel.session.recordInboundSession({
        storePath,
        sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
        ctx: ctxPayload,
        onRecordError: (err: unknown) => {
            error?.(`[wecom-agent] session record failed: ${String(err)}`);
        },
    });

    // 调度回复
    await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
        ctx: ctxPayload,
        cfg: config,
        dispatcherOptions: {
            deliver: async (payload: { text?: string }, info: { kind: string }) => {
                const text = payload.text ?? "";
                if (!text) return;

                try {
                    // 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
                    await sendText({ agent, toUser: fromUser, chatId: undefined, text });
                    log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
                } catch (err: unknown) {
                    error?.(`[wecom-agent] reply failed: ${String(err)}`);
                }
            },
            onError: (err: unknown, info: { kind: string }) => {
                error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
            },
        },
        replyOptions: {
            disableBlockStreaming: true,
        },
    });
}

/**
 * **handleAgentWebhook (Agent Webhook 入口)**
 * 
 * 统一处理 Agent 模式的 Webhook 请求。
 * 根据 HTTP 方法分发到 URL 验证 (GET) 或 消息处理 (POST)。
 */
export async function handleAgentWebhook(params: AgentWebhookParams): Promise<boolean> {
    const { req } = params;

    if (req.method === "GET") {
        return handleUrlVerification(req, params.res, params.agent);
    }

    if (req.method === "POST") {
        return handleMessageCallback(params);
    }

    return false;
}