@yanhaidao/wecom 1.0.1 → 2.0.0

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,171 @@
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
+ it("should decrypt inbound image and pass base64 to agent", async () => {
108
+ // 1. Prepare Encrypted Media (The "File" on WeCom Server)
109
+ // We pretend this is the media data returned by axios
110
+ const fileContent = Buffer.from("fake-image-data");
111
+ const aesKey = Buffer.from(encodingAESKey + "=", "base64");
112
+ const iv = aesKey.subarray(0, 16);
113
+
114
+ // Encrypt content (WeCom does this)
115
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
116
+ cipher.setAutoPadding(false);
117
+ const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
118
+
119
+ // Mock Axios to return this encrypted media
120
+ (axios.get as any).mockResolvedValue({ data: encryptedMedia });
121
+
122
+ // 2. Prepare Inbound Message (The Webhook JSON)
123
+ const imageUrl = "http://wecom.server/media/123";
124
+ const inboundMsg = {
125
+ msgtype: "image",
126
+ image: { url: imageUrl },
127
+ from: { userid: "yanhaidao" }
128
+ };
129
+
130
+ // 3. Encrypt the *Inbound Message* Payload (The Envelope)
131
+ const timestamp = String(Math.floor(Date.now() / 1000));
132
+ const nonce = "123456";
133
+ const encrypt = encryptWecomPlaintext({
134
+ encodingAESKey,
135
+ receiveId,
136
+ plaintext: JSON.stringify(inboundMsg)
137
+ });
138
+ const msgSignature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
139
+
140
+ const query = new URLSearchParams({
141
+ msg_signature: msgSignature,
142
+ timestamp,
143
+ nonce
144
+ });
145
+
146
+ const bodyObj = {
147
+ touser: receiveId,
148
+ agentid: "10001",
149
+ encrypt, // Standard WeCom POST body structure
150
+ };
151
+
152
+ // 4. Send Request
153
+ const req = createMockRequest(bodyObj, query);
154
+ const res = createMockResponse();
155
+
156
+ await handleWecomWebhookRequest(req, res);
157
+
158
+ // 5. Verify
159
+ // Check recordInboundSession was called with correct RawBody
160
+ const recordCall = (mockCore.channel.session.recordInboundSession as any).mock.calls[0][0];
161
+ const rawBody = recordCall.ctx.RawBody;
162
+
163
+ // Expect: [image] data:image/jpeg;base64,...
164
+ expect(rawBody).toContain("[image] data:image/jpeg;base64,");
165
+ const base64Part = rawBody.split("base64,")[1];
166
+ const decoded = Buffer.from(base64Part, "base64");
167
+
168
+ expect(decoded.toString()).toBe("fake-image-data");
169
+ expect(axios.get).toHaveBeenCalledWith(imageUrl, expect.anything());
170
+ });
171
+ });
package/src/monitor.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import crypto from "node:crypto";
3
3
 
4
- import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
4
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
5
5
 
6
- import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
6
+ import type { ResolvedWecomAccount, WecomInboundMessage, WecomInboundQuote } from "./types.js";
7
7
  import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
8
8
  import { getWecomRuntime } from "./runtime.js";
9
+ import { decryptWecomMedia } from "./media.js";
10
+ import axios from "axios";
9
11
 
10
12
  export type WecomRuntimeEnv = {
11
13
  log?: (message: string) => void;
@@ -14,7 +16,7 @@ export type WecomRuntimeEnv = {
14
16
 
15
17
  type WecomWebhookTarget = {
16
18
  account: ResolvedWecomAccount;
17
- config: ClawdbotConfig;
19
+ config: OpenClawConfig;
18
20
  runtime: WecomRuntimeEnv;
19
21
  core: PluginRuntime;
20
22
  path: string;
@@ -24,12 +26,14 @@ type WecomWebhookTarget = {
24
26
  type StreamState = {
25
27
  streamId: string;
26
28
  msgid?: string;
29
+ response_url?: string;
27
30
  createdAt: number;
28
31
  updatedAt: number;
29
32
  started: boolean;
30
33
  finished: boolean;
31
34
  error?: string;
32
35
  content: string;
36
+ image?: { base64: string; md5: string };
33
37
  };
34
38
 
35
39
  const webhookTargets = new Map<string, WecomWebhookTarget[]>();
@@ -151,14 +155,18 @@ function resolveSignatureParam(params: URLSearchParams): string {
151
155
  );
152
156
  }
153
157
 
154
- function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
158
+ function buildStreamPlaceholderReply(params: {
159
+ streamId: string;
160
+ placeholderContent?: string;
161
+ }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
162
+ const content = params.placeholderContent?.trim() || "1";
155
163
  return {
156
164
  msgtype: "stream",
157
165
  stream: {
158
- id: streamId,
166
+ id: params.streamId,
159
167
  finish: false,
160
168
  // Spec: "第一次回复内容为 1" works as a minimal placeholder.
161
- content: "1",
169
+ content,
162
170
  },
163
171
  };
164
172
  }
@@ -171,6 +179,12 @@ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; str
171
179
  id: state.streamId,
172
180
  finish: state.finished,
173
181
  content,
182
+ ...(state.finished && state.image ? {
183
+ msg_item: [{
184
+ msgtype: "image",
185
+ image: { base64: state.image.base64, md5: state.image.md5 }
186
+ }]
187
+ } : {})
174
188
  },
175
189
  };
176
190
  }
@@ -195,6 +209,26 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
195
209
  return parsed as WecomInboundMessage;
196
210
  }
197
211
 
212
+ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<string> {
213
+ const msgtype = String(msg.msgtype ?? "").toLowerCase();
214
+
215
+ if (msgtype === "image") {
216
+ const url = String((msg as any).image?.url ?? "").trim();
217
+ const aesKey = target.account.encodingAESKey;
218
+ if (url && aesKey) {
219
+ try {
220
+ const buf = await decryptWecomMedia(url, aesKey);
221
+ const base64 = buf.toString("base64");
222
+ return `[image] data:image/jpeg;base64,${base64}`;
223
+ } catch (err) {
224
+ target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
225
+ return `[image] (decryption failed)`;
226
+ }
227
+ }
228
+ }
229
+ return buildInboundBody(msg);
230
+ }
231
+
198
232
  async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
199
233
  if (maxWaitMs <= 0) return;
200
234
  const startedAt = Date.now();
@@ -202,7 +236,8 @@ async function waitForStreamContent(streamId: string, maxWaitMs: number): Promis
202
236
  const tick = () => {
203
237
  const state = streams.get(streamId);
204
238
  if (!state) return resolve();
205
- if (state.error || state.finished || state.content.trim()) return resolve();
239
+ if (state.error || state.finished) return resolve();
240
+ if (state.content.trim()) return resolve();
206
241
  if (Date.now() - startedAt >= maxWaitMs) return resolve();
207
242
  setTimeout(tick, 25);
208
243
  };
@@ -224,7 +259,7 @@ async function startAgentForStream(params: {
224
259
  const userid = msg.from?.userid?.trim() || "unknown";
225
260
  const chatType = msg.chattype === "group" ? "group" : "direct";
226
261
  const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
227
- const rawBody = buildInboundBody(msg);
262
+ const rawBody = await processInboundMessage(target, msg);
228
263
 
229
264
  const route = core.channel.routing.resolveAgentRoute({
230
265
  cfg: config,
@@ -291,9 +326,41 @@ async function startAgentForStream(params: {
291
326
  cfg: config,
292
327
  dispatcherOptions: {
293
328
  deliver: async (payload) => {
294
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
329
+ let text = payload.text ?? "";
330
+
331
+ // Protect <think> tags from table conversion
332
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
333
+ const thinks: string[] = [];
334
+ text = text.replace(thinkRegex, (match: string) => {
335
+ thinks.push(match);
336
+ return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
337
+ });
338
+
339
+ text = core.channel.text.convertMarkdownTables(text, tableMode);
340
+
341
+ // Restore <think> tags
342
+ thinks.forEach((think, i) => {
343
+ text = text.replace(`__THINK_PLACEHOLDER_${i}__`, think);
344
+ });
345
+
295
346
  const current = streams.get(streamId);
296
347
  if (!current) return;
348
+
349
+ // Detect Markdown image: ![alt](url)
350
+ const imgMatch = text.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
351
+ if (imgMatch && imgMatch[1]) {
352
+ try {
353
+ const imgUrl = imgMatch[1];
354
+ const resp = await axios.get(imgUrl, { responseType: "arraybuffer", timeout: 10000 });
355
+ const buf = Buffer.from(resp.data);
356
+ const base64 = buf.toString("base64");
357
+ const md5 = crypto.createHash("md5").update(buf).digest("hex");
358
+ current.image = { base64, md5 };
359
+ } catch (err) {
360
+ target.runtime.error?.(`Failed to download outbound image: ${String(err)}`);
361
+ }
362
+ }
363
+
297
364
  const nextText = current.content
298
365
  ? `${current.content}\n\n${text}`.trim()
299
366
  : text.trim();
@@ -314,20 +381,45 @@ async function startAgentForStream(params: {
314
381
  }
315
382
  }
316
383
 
384
+ function formatQuote(quote: WecomInboundQuote): string {
385
+ const type = quote.msgtype ?? "";
386
+ if (type === "text") {
387
+ return quote.text?.content || "";
388
+ }
389
+ if (type === "image") {
390
+ return `[引用: 图片] ${quote.image?.url || ""}`;
391
+ }
392
+ if (type === "mixed" && quote.mixed?.msg_item) {
393
+ const items = quote.mixed.msg_item.map((item) => {
394
+ if (item.msgtype === "text") return item.text?.content;
395
+ if (item.msgtype === "image") return `[图片] ${item.image?.url || ""}`;
396
+ return "";
397
+ }).filter(Boolean).join(" ");
398
+ return `[引用: 图文] ${items}`;
399
+ }
400
+ if (type === "voice") {
401
+ return `[引用: 语音] ${quote.voice?.content || ""}`;
402
+ }
403
+ if (type === "file") {
404
+ return `[引用: 文件] ${quote.file?.url || ""}`;
405
+ }
406
+ return "";
407
+ }
408
+
317
409
  function buildInboundBody(msg: WecomInboundMessage): string {
410
+ let body = "";
318
411
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
412
+
319
413
  if (msgtype === "text") {
320
414
  const content = (msg as any).text?.content;
321
- return typeof content === "string" ? content : "";
322
- }
323
- if (msgtype === "voice") {
415
+ body = typeof content === "string" ? content : "";
416
+ } else if (msgtype === "voice") {
324
417
  const content = (msg as any).voice?.content;
325
- return typeof content === "string" ? content : "[voice]";
326
- }
327
- if (msgtype === "mixed") {
418
+ body = typeof content === "string" ? content : "[voice]";
419
+ } else if (msgtype === "mixed") {
328
420
  const items = (msg as any).mixed?.msg_item;
329
421
  if (Array.isArray(items)) {
330
- return items
422
+ body = items
331
423
  .map((item: any) => {
332
424
  const t = String(item?.msgtype ?? "").toLowerCase();
333
425
  if (t === "text") return String(item?.text?.content ?? "");
@@ -336,26 +428,34 @@ function buildInboundBody(msg: WecomInboundMessage): string {
336
428
  })
337
429
  .filter((part: string) => Boolean(part && part.trim()))
338
430
  .join("\n");
431
+ } else {
432
+ body = "[mixed]";
339
433
  }
340
- return "[mixed]";
341
- }
342
- if (msgtype === "image") {
434
+ } else if (msgtype === "image") {
343
435
  const url = String((msg as any).image?.url ?? "").trim();
344
- return url ? `[image] ${url}` : "[image]";
345
- }
346
- if (msgtype === "file") {
436
+ body = url ? `[image] ${url}` : "[image]";
437
+ } else if (msgtype === "file") {
347
438
  const url = String((msg as any).file?.url ?? "").trim();
348
- return url ? `[file] ${url}` : "[file]";
349
- }
350
- if (msgtype === "event") {
439
+ body = url ? `[file] ${url}` : "[file]";
440
+ } else if (msgtype === "event") {
351
441
  const eventtype = String((msg as any).event?.eventtype ?? "").trim();
352
- return eventtype ? `[event] ${eventtype}` : "[event]";
353
- }
354
- if (msgtype === "stream") {
442
+ body = eventtype ? `[event] ${eventtype}` : "[event]";
443
+ } else if (msgtype === "stream") {
355
444
  const id = String((msg as any).stream?.id ?? "").trim();
356
- return id ? `[stream_refresh] ${id}` : "[stream_refresh]";
445
+ body = id ? `[stream_refresh] ${id}` : "[stream_refresh]";
446
+ } else {
447
+ body = msgtype ? `[${msgtype}]` : "";
448
+ }
449
+
450
+ // Append quote if available
451
+ const quote = (msg as any).quote;
452
+ if (quote) {
453
+ const quoteText = formatQuote(quote).trim();
454
+ if (quoteText) {
455
+ body += `\n\n> ${quoteText}`;
456
+ }
357
457
  }
358
- return msgtype ? `[${msgtype}]` : "";
458
+ return body;
359
459
  }
360
460
 
361
461
  export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
@@ -543,7 +643,10 @@ export async function handleWecomWebhookRequest(
543
643
  // Dedupe: if we already created a stream for this msgid, return placeholder again.
544
644
  if (msgid && msgidToStreamId.has(msgid)) {
545
645
  const streamId = msgidToStreamId.get(msgid) ?? "";
546
- const reply = buildStreamPlaceholderReply(streamId);
646
+ const reply = buildStreamPlaceholderReply({
647
+ streamId,
648
+ placeholderContent: target.account.config.streamPlaceholderContent,
649
+ });
547
650
  jsonOk(res, buildEncryptedJsonReply({
548
651
  account: target.account,
549
652
  plaintextJson: reply,
@@ -586,6 +689,7 @@ export async function handleWecomWebhookRequest(
586
689
  streams.set(streamId, {
587
690
  streamId,
588
691
  msgid,
692
+ response_url: msg.response_url,
589
693
  createdAt: Date.now(),
590
694
  updatedAt: Date.now(),
591
695
  started: false,
@@ -633,7 +737,10 @@ export async function handleWecomWebhookRequest(
633
737
  const state = streams.get(streamId);
634
738
  const initialReply = state && (state.content.trim() || state.error)
635
739
  ? buildStreamReplyFromState(state)
636
- : buildStreamPlaceholderReply(streamId);
740
+ : buildStreamPlaceholderReply({
741
+ streamId,
742
+ placeholderContent: target.account.config.streamPlaceholderContent,
743
+ });
637
744
  jsonOk(res, buildEncryptedJsonReply({
638
745
  account: target.account,
639
746
  plaintextJson: initialReply,
@@ -644,3 +751,17 @@ export async function handleWecomWebhookRequest(
644
751
  logVerbose(target, `accepted msgtype=${msgtype || "unknown"} msgid=${msgid || "none"} streamId=${streamId}`);
645
752
  return true;
646
753
  }
754
+
755
+ export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
756
+ const state = streams.get(streamId);
757
+ if (!state || !state.response_url) {
758
+ throw new Error(`Active message failed: No response_url for stream ${streamId}`);
759
+ }
760
+
761
+ // WeCom Webhook Reply Format
762
+ // Note: Only works if response_url is valid and within time limit.
763
+ await axios.post(state.response_url, {
764
+ msgtype: "text",
765
+ text: { content },
766
+ });
767
+ }