@yanhaidao/wecom 1.0.1 → 2.0.1

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.
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
3
+ import { encryptWecomPlaintext, computeWecomMsgSignature, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
4
+ import * as runtime from "./runtime.js";
5
+ import axios from "axios";
6
+ import crypto from "node:crypto";
7
+ import { IncomingMessage, ServerResponse } from "node:http";
8
+ import { Socket } from "node:net";
9
+
10
+ vi.mock("axios");
11
+
12
+ // Helpers to simulate HTTP request
13
+ function createMockRequest(bodyObj: any, query: URLSearchParams): IncomingMessage {
14
+ const socket = new Socket();
15
+ const req = new IncomingMessage(socket);
16
+ req.method = "POST";
17
+ req.url = `/wecom?${query.toString()}`;
18
+ req.push(JSON.stringify(bodyObj));
19
+ req.push(null);
20
+ return req;
21
+ }
22
+
23
+ function createMockResponse(): ServerResponse & { _getData: () => string, _getStatusCode: () => number } {
24
+ const req = new IncomingMessage(new Socket());
25
+ const res = new ServerResponse(req);
26
+ let data = "";
27
+ res.write = (chunk: any) => { data += chunk; return true; };
28
+ res.end = (chunk: any) => { if (chunk) data += chunk; return res; };
29
+ (res as any)._getData = () => data;
30
+ (res as any)._getStatusCode = () => res.statusCode;
31
+ return res as any;
32
+ }
33
+
34
+ // PKCS7 Pad Helper for manual encryption
35
+ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
36
+ const mod = buf.length % blockSize;
37
+ const pad = mod === 0 ? blockSize : blockSize - mod;
38
+ const padByte = Buffer.from([pad]);
39
+ return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
40
+ }
41
+
42
+ describe("Monitor Integration: Inbound Image", () => {
43
+ const token = "MY_TOKEN";
44
+ const encodingAESKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes key
45
+ const receiveId = "MY_CORPID";
46
+ let unregisterTarget: (() => void) | null = null;
47
+
48
+ // Mock Core Runtime
49
+ const mockDeliver = vi.fn();
50
+ const mockCore = {
51
+ channel: {
52
+ routing: { resolveAgentRoute: () => ({ agentId: "agent-1", sessionKey: "sess-1", accountId: "acc-1" }) },
53
+ session: {
54
+ resolveStorePath: () => "store/path",
55
+ readSessionUpdatedAt: () => 0,
56
+ recordInboundSession: vi.fn(),
57
+ },
58
+ reply: {
59
+ formatAgentEnvelope: () => "formatted-body",
60
+ finalizeInboundContext: (ctx: any) => ctx,
61
+ resolveEnvelopeFormatOptions: () => ({}),
62
+ dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => {
63
+ // Simulate Agent processing by calling deliver immediately or later
64
+ // For this test, verifying the Inbound Body is enough.
65
+ // The delivery payload is what the AGENT sees.
66
+ // But wait, dispatchReply... is for OUTBOUND streaming replies.
67
+ // startAgentForStream calls it.
68
+ // We really want to spy on what `rawBody` was passed to startAgentForStream context.
69
+
70
+ // Actually `recordInboundSession` receives `ctx` which contains `RawBody`.
71
+ return;
72
+ },
73
+ },
74
+ text: { resolveMarkdownTableMode: () => "off", convertMarkdownTables: (t: string) => t },
75
+ },
76
+ logging: { shouldLogVerbose: () => true },
77
+ };
78
+
79
+ beforeEach(() => {
80
+ vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
81
+
82
+ unregisterTarget?.();
83
+ unregisterTarget = registerWecomWebhookTarget({
84
+ account: {
85
+ accountId: "test-acc",
86
+ name: "Test",
87
+ enabled: true,
88
+ configured: true,
89
+ token,
90
+ encodingAESKey,
91
+ receiveId,
92
+ config: {} as any
93
+ },
94
+ config: {} as any,
95
+ runtime: { log: console.log, error: console.error },
96
+ core: mockCore as any,
97
+ path: "/wecom"
98
+ });
99
+ });
100
+
101
+ afterEach(() => {
102
+ unregisterTarget?.();
103
+ unregisterTarget = null;
104
+ vi.restoreAllMocks();
105
+ });
106
+
107
+ // Mock media saving
108
+ const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ path: "/tmp/saved-image.jpg", contentType: "image/jpeg" });
109
+ (mockCore.channel as any).media = { saveMediaBuffer: mockSaveMediaBuffer };
110
+
111
+ it("should decrypt inbound image, save it, and inject into context", async () => {
112
+ // 1. Prepare Encrypted Media (The "File" on WeCom Server)
113
+ const fileContent = Buffer.from("fake-image-data");
114
+ const aesKey = Buffer.from(encodingAESKey + "=", "base64");
115
+ const iv = aesKey.subarray(0, 16);
116
+
117
+ // Encrypt content (WeCom does this)
118
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
119
+ cipher.setAutoPadding(false);
120
+ const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
121
+
122
+ // Mock Axios to return this encrypted media
123
+ (axios.get as any).mockResolvedValue({ data: encryptedMedia, headers: { "content-length": "100" } });
124
+
125
+ // 2. Prepare Inbound Message (The Webhook JSON)
126
+ const imageUrl = "http://wecom.server/media/123";
127
+ const inboundMsg = {
128
+ msgtype: "image",
129
+ image: { url: imageUrl },
130
+ from: { userid: "yanhaidao" }
131
+ };
132
+
133
+ // 3. Encrypt the *Inbound Message* Payload (The Envelope)
134
+ const timestamp = String(Math.floor(Date.now() / 1000));
135
+ const nonce = "123456";
136
+ const encrypt = encryptWecomPlaintext({
137
+ encodingAESKey,
138
+ receiveId,
139
+ plaintext: JSON.stringify(inboundMsg)
140
+ });
141
+ const msgSignature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
142
+
143
+ const query = new URLSearchParams({
144
+ msg_signature: msgSignature,
145
+ timestamp,
146
+ nonce
147
+ });
148
+
149
+ const bodyObj = {
150
+ touser: receiveId,
151
+ agentid: "10001",
152
+ encrypt, // Standard WeCom POST body structure
153
+ };
154
+
155
+ // 4. Send Request
156
+ const req = createMockRequest(bodyObj, query);
157
+ const res = createMockResponse();
158
+
159
+ await handleWecomWebhookRequest(req, res);
160
+
161
+ // Wait for debounce timer to trigger agent (DEFAULT_DEBOUNCE_MS = 500ms)
162
+ await new Promise(resolve => setTimeout(resolve, 600));
163
+
164
+ // 5. Verify
165
+ // Check recordInboundSession was called with correct RawBody and Media Context
166
+ expect(mockCore.channel.session.recordInboundSession).toHaveBeenCalled();
167
+ const recordCall = (mockCore.channel.session.recordInboundSession as any).mock.calls[0][0];
168
+ const ctx = recordCall.ctx;
169
+
170
+ // Expect: [image]
171
+ expect(ctx.RawBody).toBe("[image]");
172
+
173
+ // Expect media to be saved
174
+ expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
175
+ expect.any(Buffer), // The decrypted buffer
176
+ "image/jpeg",
177
+ "inbound",
178
+ expect.any(Number), // maxBytes
179
+ "image.jpg"
180
+ );
181
+ const savedBuffer = mockSaveMediaBuffer.mock.calls[0][0];
182
+ expect(savedBuffer.toString()).toBe("fake-image-data");
183
+
184
+ // Expect Context Injection
185
+ expect(ctx.MediaPath).toBe("/tmp/saved-image.jpg");
186
+ expect(ctx.MediaType).toBe("image/jpeg");
187
+
188
+ expect(axios.get).toHaveBeenCalledWith(imageUrl, expect.objectContaining({ responseType: "arraybuffer" }));
189
+ });
190
+ });