import { IncomingMessage, ServerResponse } from "node:http"; import { Socket } from "node:net"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedBotAccount } from "./types/index.js"; import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js"; import { handleWecomWebhookRequest, registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js"; function createMockRequest(params: { method: "GET" | "POST"; url: string; body?: unknown; rawBody?: string; }): IncomingMessage { const socket = new Socket(); const req = new IncomingMessage(socket); req.method = params.method; req.url = params.url; if (params.method === "POST") { if (typeof params.rawBody === "string") { req.push(params.rawBody); } else { req.push(JSON.stringify(params.body ?? {})); } } req.push(null); return req; } function createMockResponse(): ServerResponse & { _getData: () => string; _getStatusCode: () => number; } { const req = new IncomingMessage(new Socket()); const res = new ServerResponse(req); let data = ""; res.write = (chunk: any) => { data += String(chunk); return true; }; res.end = (chunk: any) => { if (chunk) data += String(chunk); return res; }; (res as any)._getData = () => data; (res as any)._getStatusCode = () => res.statusCode; return res as any; } function createBotAccount(params: { accountId?: string; token: string; encodingAESKey: string; receiveId?: string; streamPlaceholderContent?: string; aibotid?: string; }): ResolvedBotAccount { return { accountId: params.accountId ?? "default", configured: true, primaryTransport: "webhook", wsConfigured: false, webhookConfigured: true, config: { streamPlaceholderContent: params.streamPlaceholderContent, aibotid: params.aibotid, webhook: { token: params.token, encodingAESKey: params.encodingAESKey, receiveId: params.receiveId ?? "", }, }, token: params.token, encodingAESKey: params.encodingAESKey, receiveId: params.receiveId ?? "", botId: "", secret: "", webhook: { token: params.token, encodingAESKey: params.encodingAESKey, receiveId: params.receiveId ?? "", }, }; } describe("handleWecomWebhookRequest", () => { const token = "test-token"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; it("handles GET url verification", async () => { const account = createBotAccount({ token, encodingAESKey, }); const unregister = registerWecomWebhookTarget({ account, config: {} as OpenClawConfig, runtime: {}, core: {} as any, path: "/hook", }); try { const timestamp = "13500001234"; const nonce = "123412323"; const echostr = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: "ping", }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr }); const req = createMockRequest({ method: "GET", url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); expect(res._getData()).toBe("ping"); } finally { unregister(); } }); it("handles POST callback and returns encrypted stream placeholder", async () => { const account = createBotAccount({ token, encodingAESKey, }); const unregister = registerWecomWebhookTarget({ account, config: {} as OpenClawConfig, runtime: {}, core: {} as any, path: "/hook", }); try { const timestamp = "1700000000"; const nonce = "nonce"; const plain = JSON.stringify({ msgid: "MSGID", aibotid: "AIBOTID", chattype: "single", from: { userid: "USERID" }, response_url: "RESPONSEURL", msgtype: "text", text: { content: "hello" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); const req = createMockRequest({ method: "POST", url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); const json = JSON.parse(res._getData()) as any; expect(typeof json.encrypt).toBe("string"); expect(typeof json.msgsignature).toBe("string"); expect(typeof json.timestamp).toBe("string"); expect(typeof json.nonce).toBe("string"); const replyPlain = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json.encrypt, }); const reply = JSON.parse(replyPlain) as any; expect(reply.msgtype).toBe("stream"); expect(reply.stream?.content).toBe("1"); expect(reply.stream?.finish).toBe(false); expect(typeof reply.stream?.id).toBe("string"); expect(reply.stream?.id.length).toBeGreaterThan(0); const expectedSig = computeWecomMsgSignature({ token, timestamp: String(json.timestamp), nonce: String(json.nonce), encrypt: String(json.encrypt), }); expect(json.msgsignature).toBe(expectedSig); } finally { unregister(); } }); it("supports custom streamPlaceholderContent", async () => { const account = createBotAccount({ token, encodingAESKey, streamPlaceholderContent: "正在思考...", }); const unregister = registerWecomWebhookTarget({ account, config: {} as OpenClawConfig, runtime: {}, core: {} as any, path: "/hook", }); try { const timestamp = "1700000001"; const nonce = "nonce2"; const plain = JSON.stringify({ msgid: "MSGID2", aibotid: "AIBOTID", chattype: "single", from: { userid: "USERID2" }, response_url: "RESPONSEURL", msgtype: "text", text: { content: "hello" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); const req = createMockRequest({ method: "POST", url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); const json = JSON.parse(res._getData()) as any; const replyPlain = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json.encrypt, }); const reply = JSON.parse(replyPlain) as any; expect(reply.msgtype).toBe("stream"); expect(reply.stream?.content).toBe("正在思考..."); expect(reply.stream?.finish).toBe(false); } finally { unregister(); } }); it("skips bot callbacks with missing sender and returns empty ack", async () => { const account = createBotAccount({ token, encodingAESKey, }); const unregister = registerWecomWebhookTarget({ account, config: {} as OpenClawConfig, runtime: {}, core: {} as any, path: "/hook", }); try { const timestamp = "1700000001"; const nonce = "nonce-missing-sender"; const plain = JSON.stringify({ msgid: "MSGID-MISSING-SENDER", aibotid: "AIBOTID", chattype: "single", msgtype: "text", text: { content: "hello" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); const req = createMockRequest({ method: "POST", url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); const json = JSON.parse(res._getData()) as any; const replyPlain = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json.encrypt, }); expect(JSON.parse(replyPlain)).toEqual({}); } finally { unregister(); } }); it("returns a queued stream for 2, and an ack stream for merged follow-ups", async () => { const token = "TOKEN"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const account: any = { accountId: "default", configured: true, token, encodingAESKey, receiveId: "", config: { streamPlaceholderContent: "正在思考...", debounceMs: 10_000, }, }; const unregister = registerWecomWebhookTarget({ account, config: {} as OpenClawConfig, runtime: {}, core: {} as any, path: "/hook-merge", }); try { const timestamp = "1700000002"; const nonce = "nonce-merge"; const makeReq = (msgid: string) => { const plain = JSON.stringify({ msgid, aibotid: "AIBOTID", chattype: "single", from: { userid: "USERID_QUEUE" }, response_url: "RESPONSEURL", msgtype: "text", text: { content: "hello" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); return createMockRequest({ method: "POST", url: `/hook-merge?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); }; const res1 = createMockResponse(); await handleWecomWebhookRequest(makeReq("MSGID-M1"), res1); const json1 = JSON.parse(res1._getData()) as any; const replyPlain1 = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json1.encrypt }); const reply1 = JSON.parse(replyPlain1) as any; expect(reply1.msgtype).toBe("stream"); expect(reply1.stream?.finish).toBe(false); const res2 = createMockResponse(); await handleWecomWebhookRequest(makeReq("MSGID-M2"), res2); const json2 = JSON.parse(res2._getData()) as any; const replyPlain2 = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json2.encrypt }); const reply2 = JSON.parse(replyPlain2) as any; expect(reply2.msgtype).toBe("stream"); expect(reply2.stream?.finish).toBe(false); expect(reply2.stream?.id).not.toBe(reply1.stream?.id); expect(reply2.stream?.content).toContain("排队"); const res3 = createMockResponse(); await handleWecomWebhookRequest(makeReq("MSGID-M3"), res3); const json3 = JSON.parse(res3._getData()) as any; const replyPlain3 = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json3.encrypt }); const reply3 = JSON.parse(replyPlain3) as any; expect(reply3.msgtype).toBe("stream"); // merged follow-up should get its own ack stream (not finished yet); // it will be updated to a final hint after the merged batch completes. expect(reply3.stream?.finish).toBe(false); expect(reply3.stream?.id).not.toBe(reply1.stream?.id); expect(reply3.stream?.id).not.toBe(reply2.stream?.id); expect(reply3.stream?.content).toContain("合并"); } finally { unregister(); } }); it("routes bot callback by explicit plugin account path", async () => { const token = "MATRIX-TOKEN"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const unregisterA = registerWecomWebhookTarget({ account: { accountId: "acct-a", configured: true, token, encodingAESKey, receiveId: "", config: { token, encodingAESKey, streamPlaceholderContent: "A处理中", } as any, } as any, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, core: {} as any, path: "/plugins/wecom/bot/acct-a", }); const unregisterB = registerWecomWebhookTarget({ account: { accountId: "acct-b", configured: true, token, encodingAESKey, receiveId: "", config: { token, encodingAESKey, streamPlaceholderContent: "B处理中", } as any, } as any, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, core: {} as any, path: "/plugins/wecom/bot/acct-b", }); try { const timestamp = "1700000999"; const nonce = "nonce-plugin-account"; const plain = JSON.stringify({ msgid: "MATRIX-MSG-1", aibotid: "BOT_B", chattype: "single", from: { userid: "USERID_B" }, response_url: "RESPONSEURL", msgtype: "text", text: { content: "hello plugin account path" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); const req = createMockRequest({ method: "POST", url: `/plugins/wecom/bot/acct-b?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); const json = JSON.parse(res._getData()) as any; const replyPlain = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json.encrypt, }); const reply = JSON.parse(replyPlain) as any; expect(reply.stream?.content).toBe("B处理中"); } finally { unregisterA(); unregisterB(); } }); it("routes bot callback by explicit plugin namespace path", async () => { const token = "MATRIX-TOKEN-PLUGIN"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const unregisterA = registerWecomWebhookTarget({ account: { accountId: "acct-a", configured: true, token, encodingAESKey, receiveId: "", config: { token, encodingAESKey, streamPlaceholderContent: "A处理中", } as any, } as any, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, core: {} as any, path: "/plugins/wecom/bot/acct-a", }); const unregisterB = registerWecomWebhookTarget({ account: { accountId: "acct-b", configured: true, token, encodingAESKey, receiveId: "", config: { token, encodingAESKey, streamPlaceholderContent: "B处理中", } as any, } as any, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, core: {} as any, path: "/plugins/wecom/bot/acct-b", }); try { const timestamp = "1700001000"; const nonce = "nonce-matrix-plugin"; const plain = JSON.stringify({ msgid: "MATRIX-MSG-PLUGIN-1", aibotid: "BOT_B", chattype: "single", from: { userid: "USERID_B_PLUGIN" }, response_url: "RESPONSEURL", msgtype: "text", text: { content: "hello matrix plugin path" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); const req = createMockRequest({ method: "POST", url: `/plugins/wecom/bot/acct-b?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); const json = JSON.parse(res._getData()) as any; const replyPlain = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json.encrypt, }); const reply = JSON.parse(replyPlain) as any; expect(reply.stream?.content).toBe("B处理中"); } finally { unregisterA(); unregisterB(); } }); it("handles default explicit bot path through the shared router only", async () => { const unregisterTarget = registerWecomWebhookTarget({ account: createBotAccount({ token, encodingAESKey, }), config: {} as OpenClawConfig, runtime: {}, core: {} as any, path: "/plugins/wecom/bot/default", }); try { const timestamp = "1700001002"; const nonce = "nonce-default-route"; const echostr = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: "ping", }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr }); const req = createMockRequest({ method: "GET", url: `/plugins/wecom/bot/default?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); expect(res._getData()).toBe("ping"); } finally { unregisterTarget(); } }); it("does not reject when aibotid mismatches configured value", async () => { const token = "MATRIX-TOKEN-2"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const unregister = registerWecomWebhookTarget({ account: { accountId: "acct-a", configured: true, token, encodingAESKey, receiveId: "", config: { token, encodingAESKey, aibotid: "BOT_ONLY", } as any, } as any, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, core: {} as any, path: "/hook-matrix-mismatch", }); try { const timestamp = "1700001001"; const nonce = "nonce-matrix-mismatch"; const plain = JSON.stringify({ msgid: "MATRIX-MSG-2", aibotid: "BOT_OTHER", chattype: "single", from: { userid: "USERID_X" }, response_url: "RESPONSEURL", msgtype: "text", text: { content: "hello mismatch" }, }); const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); const req = createMockRequest({ method: "POST", url: `/hook-matrix-mismatch?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, body: { encrypt }, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); } finally { unregister(); } }); it("rejects legacy paths and accountless plugin paths", async () => { const token = "MATRIX-TOKEN-3"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const unregister = registerWecomWebhookTarget({ account: { accountId: "acct-a", configured: true, token, encodingAESKey, receiveId: "", config: { token, encodingAESKey } as any, } as any, config: { channels: { wecom: { accounts: { "acct-a": { bot: {} } } } } } as OpenClawConfig, runtime: {}, core: {} as any, path: "/plugins/wecom/bot/acct-a", }); try { const req = createMockRequest({ method: "GET", url: "/wecom/bot?timestamp=t&nonce=n&msg_signature=s&echostr=e", }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(401); expect(JSON.parse(res._getData())).toMatchObject({ error: "wecom_matrix_path_required", }); const pluginReq = createMockRequest({ method: "GET", url: "/plugins/wecom/bot?timestamp=t&nonce=n&msg_signature=s&echostr=e", }); const pluginRes = createMockResponse(); const pluginHandled = await handleWecomWebhookRequest(pluginReq, pluginRes); expect(pluginHandled).toBe(true); expect(pluginRes._getStatusCode()).toBe(401); expect(JSON.parse(pluginRes._getData())).toMatchObject({ error: "wecom_matrix_path_required", }); } finally { unregister(); } }); it("returns account conflict for agent GET verification when multiple accounts share token", async () => { const token = "AGENT-TOKEN"; const timestamp = "1700002001"; const nonce = "nonce-agent"; const echostr = "ECHOSTR"; const signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr }); const unregisterA = registerAgentWebhookTarget({ agent: { accountId: "agent-a", configured: true, corpId: "corp-a", corpSecret: "secret-a", agentId: 1001, token, encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", config: {} as any, }, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, path: "/plugins/wecom/agent/default", } as any); const unregisterB = registerAgentWebhookTarget({ agent: { accountId: "agent-b", configured: true, corpId: "corp-b", corpSecret: "secret-b", agentId: 1002, token, encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", config: {} as any, }, config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig, runtime: {}, path: "/plugins/wecom/agent/default", } as any); try { const req = createMockRequest({ method: "GET", url: `/plugins/wecom/agent/default?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(401); expect(JSON.parse(res._getData())).toMatchObject({ error: "wecom_account_conflict", }); } finally { unregisterA(); unregisterB(); } }); it("accepts default agent verification on /wecom/agent/default", async () => { const token = "AGENT-TOKEN-DEFAULT"; const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const corpId = "corp-default"; const timestamp = "1700002002"; const nonce = "nonce-agent-default"; const echostr = encryptWecomPlaintext({ encodingAESKey, receiveId: corpId, plaintext: "echo-agent-default", }); const signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr }); const unregister = registerAgentWebhookTarget({ agent: { accountId: "default", configured: true, callbackConfigured: true, apiConfigured: true, corpId, corpSecret: "secret-default", agentId: 1001, token, encodingAESKey, config: {} as any, }, config: { channels: { wecom: {} } } as OpenClawConfig, runtimeEnv: {}, path: "/wecom/agent/default", }); try { const req = createMockRequest({ method: "GET", url: `/wecom/agent/default?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`, }); const res = createMockResponse(); const handled = await handleWecomWebhookRequest(req, res); expect(handled).toBe(true); expect(res._getStatusCode()).toBe(200); expect(res._getData()).toBe("echo-agent-default"); } finally { unregister(); } }); });