@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.
- package/README.md +54 -26
- package/assets/01.image.jpg +0 -0
- package/assets/link-me.jpg +0 -0
- package/index.ts +4 -4
- package/{clawdbot.plugin.json → openclaw.plugin.json} +1 -0
- package/package.json +5 -5
- package/src/accounts.ts +9 -10
- package/src/channel.ts +12 -12
- package/src/config-schema.ts +3 -0
- package/src/crypto.ts +3 -3
- package/src/media.test.ts +49 -0
- package/src/media.ts +39 -0
- package/src/monitor.active.test.ts +137 -0
- package/src/monitor.integration.test.ts +190 -0
- package/src/monitor.ts +452 -261
- package/src/monitor.webhook.test.ts +162 -94
- package/src/runtime.ts +1 -2
- package/src/types.ts +84 -2
- package/tsconfig.json +1 -1
- package/vitest.config.ts +15 -0
|
@@ -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
|
+
});
|