@yanhaidao/wecom 2.0.0 → 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 CHANGED
@@ -10,6 +10,22 @@
10
10
 
11
11
  ![企业微信交流群](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/link-me.jpg)
12
12
 
13
+ ## 文件与图片入模(说明)
14
+
15
+ 图片/文件 URL 下载内容为加密数据,需使用 `EncodingAESKey` 解密后再解析并入模。
16
+
17
+ ## 测试页截图(文件上传 / 解析)
18
+
19
+ > 图片过大可替换为压缩版(保持文件名不变即可)。
20
+
21
+ ![WeCom 测试页截图(文件上传 / 解析)](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg)
22
+
23
+ ## A2UI 交互卡片(template_card)
24
+
25
+ - Agent 输出 `{"template_card": ...}`(JSON)时:单聊且有 `response_url` 会发送交互卡片;群聊或无 `response_url` 自动降级为文本说明(不透出原始 JSON)。
26
+ - 收到 `template_card_event` 时:会转换为伪文本消息触发 Agent,并基于 `msgid` 去重避免重复处理。
27
+ - 卡片相关的示例/skill:加群获取(见上方交流群二维码)。
28
+
13
29
  ## 安装
14
30
 
15
31
  ### 从 npm 安装
@@ -19,21 +35,19 @@ openclaw plugins enable wecom
19
35
  openclaw gateway restart
20
36
  ```
21
37
 
22
-
23
38
  ## 配置结构参考
24
39
 
25
- ```json5
40
+ ```json
26
41
  {
27
- channels: {
28
- wecom: {
29
- enabled: true,
30
- webhookPath: "/wecom",
31
- token: "YOUR_TOKEN",
32
- encodingAESKey: "YOUR_ENCODING_AES_KEY",
33
- receiveId: "",
34
- // stream 模式第一次回包占位符(默认 "正在思考..."
35
- streamPlaceholderContent: "正在思考...",
36
- dm: { policy: "pairing" }
42
+ "channels": {
43
+ "wecom": {
44
+ "enabled": true,
45
+ "webhookPath": "/wecom",
46
+ "token": "YOUR_TOKEN",
47
+ "encodingAESKey": "YOUR_ENCODING_AES_KEY",
48
+ "receiveId": "",
49
+ "streamPlaceholderContent": "正在思考...",
50
+ "dm": { "policy": "pairing" }
37
51
  }
38
52
  }
39
53
  }
@@ -98,16 +112,10 @@ openclaw channels status
98
112
 
99
113
  # 更新日志
100
114
 
101
- ## 2026.1.30
115
+ ## 2026.1.31
102
116
 
103
- ### 重大变更
117
+ - 文档:补充入模与测试截图说明。
104
118
 
105
- 1. **项目更名**:Clawdbot 正式更名为 **OpenClaw**。CLI 命令由 `clawdbot` 变更为 `openclaw`。请更新您的安装脚本和文档引用。
106
-
107
- ### 企业微信插件改进计划
119
+ ## 2026.1.30
108
120
 
109
- 1. 引用回复纳入上下文:AI 将同时理解你引用的那条消息;文本原文直传,图片/文件/语音等以 `[引用: 类型] URL` 形式提供上下文线索。
110
- 2. `<think>...</think>` 原样透传:不做过滤、转义或重排,确保支持该特性的企业微信客户端可稳定展示思考态 UI。
111
- 3. 流式回复稳定性加固:减少空刷新、超时与“卡住不回”;异常时返回可见错误摘要而非无期限等待。
112
- 4. 交付可验收:围绕入站解析、stream 状态与回包链路增强可观测性,方便客户侧定位问题并验证效果。
113
- 5. 下一阶段(可选):补齐图片闭环(加密图片解密入模 + 原生 stream `msg_item` 图片回传),实现图文对话体验。
121
+ - 项目更名:Clawdbot OpenClaw(CLI:`clawdbot` `openclaw`)。
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "author": "YanHaidao (VX: YanHaidao)",
@@ -38,4 +38,4 @@
38
38
  "peerDependencies": {
39
39
  "openclaw": ">=2026.1.26"
40
40
  }
41
- }
41
+ }
package/src/media.ts CHANGED
@@ -9,11 +9,13 @@ import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./cryp
9
9
  * The IV is the first 16 bytes of the AES Key.
10
10
  * The content is PKCS#7 padded.
11
11
  */
12
- export async function decryptWecomMedia(url: string, encodingAESKey: string): Promise<Buffer> {
12
+ export async function decryptWecomMedia(url: string, encodingAESKey: string, maxBytes?: number): Promise<Buffer> {
13
13
  // 1. Download encrypted content
14
14
  const response = await axios.get(url, {
15
15
  responseType: "arraybuffer", // Important: get raw buffer
16
16
  timeout: 15000,
17
+ maxContentLength: maxBytes || undefined, // Limit download size
18
+ maxBodyLength: maxBytes || undefined,
17
19
  });
18
20
  const encryptedData = Buffer.from(response.data);
19
21
 
@@ -39,11 +39,11 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
39
39
  return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
40
40
  }
41
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;
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
47
 
48
48
  // Mock Core Runtime
49
49
  const mockDeliver = vi.fn();
@@ -76,15 +76,15 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
76
76
  logging: { shouldLogVerbose: () => true },
77
77
  };
78
78
 
79
- beforeEach(() => {
80
- vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
79
+ beforeEach(() => {
80
+ vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
81
81
 
82
- unregisterTarget?.();
83
- unregisterTarget = registerWecomWebhookTarget({
84
- account: {
85
- accountId: "test-acc",
86
- name: "Test",
87
- enabled: true,
82
+ unregisterTarget?.();
83
+ unregisterTarget = registerWecomWebhookTarget({
84
+ account: {
85
+ accountId: "test-acc",
86
+ name: "Test",
87
+ enabled: true,
88
88
  configured: true,
89
89
  token,
90
90
  encodingAESKey,
@@ -92,21 +92,24 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
92
92
  config: {} as any
93
93
  },
94
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 () => {
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 () => {
108
112
  // 1. Prepare Encrypted Media (The "File" on WeCom Server)
109
- // We pretend this is the media data returned by axios
110
113
  const fileContent = Buffer.from("fake-image-data");
111
114
  const aesKey = Buffer.from(encodingAESKey + "=", "base64");
112
115
  const iv = aesKey.subarray(0, 16);
@@ -117,7 +120,7 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
117
120
  const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
118
121
 
119
122
  // Mock Axios to return this encrypted media
120
- (axios.get as any).mockResolvedValue({ data: encryptedMedia });
123
+ (axios.get as any).mockResolvedValue({ data: encryptedMedia, headers: { "content-length": "100" } });
121
124
 
122
125
  // 2. Prepare Inbound Message (The Webhook JSON)
123
126
  const imageUrl = "http://wecom.server/media/123";
@@ -155,17 +158,33 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
155
158
 
156
159
  await handleWecomWebhookRequest(req, res);
157
160
 
161
+ // Wait for debounce timer to trigger agent (DEFAULT_DEBOUNCE_MS = 500ms)
162
+ await new Promise(resolve => setTimeout(resolve, 600));
163
+
158
164
  // 5. Verify
159
- // Check recordInboundSession was called with correct RawBody
165
+ // Check recordInboundSession was called with correct RawBody and Media Context
166
+ expect(mockCore.channel.session.recordInboundSession).toHaveBeenCalled();
160
167
  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());
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" }));
170
189
  });
171
190
  });
package/src/monitor.ts CHANGED
@@ -33,15 +33,31 @@ type StreamState = {
33
33
  finished: boolean;
34
34
  error?: string;
35
35
  content: string;
36
- image?: { base64: string; md5: string };
36
+ images?: { base64: string; md5: string }[];
37
37
  };
38
38
 
39
39
  const webhookTargets = new Map<string, WecomWebhookTarget[]>();
40
40
  const streams = new Map<string, StreamState>();
41
41
  const msgidToStreamId = new Map<string, string>();
42
42
 
43
+ // Pending inbound messages for debouncing rapid consecutive messages
44
+ type PendingInbound = {
45
+ streamId: string;
46
+ target: WecomWebhookTarget;
47
+ msg: WecomInboundMessage;
48
+ contents: string[];
49
+ media?: { buffer: Buffer; contentType: string; filename: string };
50
+ msgids: string[];
51
+ nonce: string;
52
+ timestamp: string;
53
+ timeout: ReturnType<typeof setTimeout> | null;
54
+ createdAt: number;
55
+ };
56
+ const pendingInbounds = new Map<string, PendingInbound>();
57
+
43
58
  const STREAM_TTL_MS = 10 * 60 * 1000;
44
59
  const STREAM_MAX_BYTES = 20_480;
60
+ const DEFAULT_DEBOUNCE_MS = 500;
45
61
 
46
62
  function normalizeWebhookPath(raw: string): string {
47
63
  const trimmed = raw.trim();
@@ -179,11 +195,11 @@ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; str
179
195
  id: state.streamId,
180
196
  finish: state.finished,
181
197
  content,
182
- ...(state.finished && state.image ? {
183
- msg_item: [{
198
+ ...(state.finished && state.images?.length ? {
199
+ msg_item: state.images.map(img => ({
184
200
  msgtype: "image",
185
- image: { base64: state.image.base64, md5: state.image.md5 }
186
- }]
201
+ image: { base64: img.base64, md5: img.md5 }
202
+ }))
187
203
  } : {})
188
204
  },
189
205
  };
@@ -194,11 +210,17 @@ function createStreamId(): string {
194
210
  }
195
211
 
196
212
  function logVerbose(target: WecomWebhookTarget, message: string): void {
197
- const core = target.core;
198
- const should = core.logging?.shouldLogVerbose?.() ?? false;
199
- if (should) {
200
- target.runtime.log?.(`[wecom] ${message}`);
201
- }
213
+ const should =
214
+ target.core.logging?.shouldLogVerbose?.() ??
215
+ (() => {
216
+ try {
217
+ return getWecomRuntime().logging.shouldLogVerbose();
218
+ } catch {
219
+ return false;
220
+ }
221
+ })();
222
+ if (!should) return;
223
+ target.runtime.log?.(`[wecom] ${message}`);
202
224
  }
203
225
 
204
226
  function parseWecomPlainMessage(raw: string): WecomInboundMessage {
@@ -209,24 +231,164 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
209
231
  return parsed as WecomInboundMessage;
210
232
  }
211
233
 
212
- async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<string> {
234
+ type InboundResult = {
235
+ body: string;
236
+ media?: {
237
+ buffer: Buffer;
238
+ contentType: string;
239
+ filename: string;
240
+ };
241
+ };
242
+
243
+ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
213
244
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
245
+ const aesKey = target.account.encodingAESKey;
246
+ const mediaMaxMb = target.config.mediaMaxMb ?? 5; // Default 5MB
247
+ const maxBytes = mediaMaxMb * 1024 * 1024;
214
248
 
215
249
  if (msgtype === "image") {
216
250
  const url = String((msg as any).image?.url ?? "").trim();
217
- const aesKey = target.account.encodingAESKey;
218
251
  if (url && aesKey) {
219
252
  try {
220
- const buf = await decryptWecomMedia(url, aesKey);
221
- const base64 = buf.toString("base64");
222
- return `[image] data:image/jpeg;base64,${base64}`;
253
+ const buf = await decryptWecomMedia(url, aesKey, maxBytes);
254
+ return {
255
+ body: "[image]",
256
+ media: {
257
+ buffer: buf,
258
+ contentType: "image/jpeg", // WeCom images are usually generic; safest assumption or could act as generic
259
+ filename: "image.jpg",
260
+ }
261
+ };
223
262
  } catch (err) {
224
263
  target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
225
- return `[image] (decryption failed)`;
264
+ return { body: `[image] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
265
+ }
266
+ }
267
+ }
268
+
269
+ if (msgtype === "file") {
270
+ const url = String((msg as any).file?.url ?? "").trim();
271
+ if (url && aesKey) {
272
+ try {
273
+ const buf = await decryptWecomMedia(url, aesKey, maxBytes);
274
+ return {
275
+ body: "[file]",
276
+ media: {
277
+ buffer: buf,
278
+ contentType: "application/octet-stream",
279
+ filename: "file.bin", // WeCom doesn't guarantee filename in webhook payload always, defaulting
280
+ }
281
+ };
282
+ } catch (err) {
283
+ target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}`);
284
+ return { body: `[file] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
226
285
  }
227
286
  }
228
287
  }
229
- return buildInboundBody(msg);
288
+
289
+ // Mixed message handling: extract first media if available
290
+ if (msgtype === "mixed") {
291
+ const items = (msg as any).mixed?.msg_item;
292
+ if (Array.isArray(items)) {
293
+ let foundMedia: InboundResult["media"] | undefined = undefined;
294
+ let bodyParts: string[] = [];
295
+
296
+ for (const item of items) {
297
+ const t = String(item.msgtype ?? "").toLowerCase();
298
+ if (t === "text") {
299
+ const content = String(item.text?.content ?? "").trim();
300
+ if (content) bodyParts.push(content);
301
+ } else if ((t === "image" || t === "file") && !foundMedia && aesKey) {
302
+ // Found first media, try to download
303
+ const url = String(item[t]?.url ?? "").trim();
304
+ if (url) {
305
+ try {
306
+ const buf = await decryptWecomMedia(url, aesKey, maxBytes);
307
+ foundMedia = {
308
+ buffer: buf,
309
+ contentType: t === "image" ? "image/jpeg" : "application/octet-stream",
310
+ filename: t === "image" ? "image.jpg" : "file.bin"
311
+ };
312
+ bodyParts.push(`[${t}]`);
313
+ } catch (err) {
314
+ target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}`);
315
+ bodyParts.push(`[${t}] (decryption failed)`);
316
+ }
317
+ } else {
318
+ bodyParts.push(`[${t}]`);
319
+ }
320
+ } else {
321
+ // Other items or already found media -> just placeholder
322
+ bodyParts.push(`[${t}]`);
323
+ }
324
+ }
325
+ return {
326
+ body: bodyParts.join("\n"),
327
+ media: foundMedia
328
+ };
329
+ }
330
+ }
331
+
332
+ return { body: buildInboundBody(msg) };
333
+ }
334
+
335
+ /**
336
+ * Flush pending inbound messages after debounce timeout.
337
+ * Merges all buffered message contents and starts agent processing.
338
+ */
339
+ async function flushPending(pendingKey: string): Promise<void> {
340
+ const pending = pendingInbounds.get(pendingKey);
341
+ if (!pending) return;
342
+ pendingInbounds.delete(pendingKey);
343
+
344
+ if (pending.timeout) {
345
+ clearTimeout(pending.timeout);
346
+ pending.timeout = null;
347
+ }
348
+
349
+ const { streamId, target, msg, contents, media, msgids } = pending;
350
+
351
+ // Merge all message contents (each is already formatted by buildInboundBody)
352
+ const mergedContents = contents.filter(c => c.trim()).join("\n").trim();
353
+
354
+ let core: PluginRuntime | null = null;
355
+ try {
356
+ core = getWecomRuntime();
357
+ } catch (err) {
358
+ logVerbose(target, `flush pending: runtime not ready: ${String(err)}`);
359
+ const state = streams.get(streamId);
360
+ if (state) {
361
+ state.finished = true;
362
+ state.updatedAt = Date.now();
363
+ }
364
+ return;
365
+ }
366
+
367
+ if (core) {
368
+ const state = streams.get(streamId);
369
+ if (state) state.started = true;
370
+ const enrichedTarget: WecomWebhookTarget = { ...target, core };
371
+ logVerbose(target, `flush pending: starting agent for ${contents.length} merged messages`);
372
+
373
+ // Pass the first msg (with its media structure), and mergedContents for multi-message context
374
+ startAgentForStream({
375
+ target: enrichedTarget,
376
+ accountId: target.account.accountId,
377
+ msg,
378
+ streamId,
379
+ mergedContents: contents.length > 1 ? mergedContents : undefined,
380
+ mergedMsgids: msgids.length > 1 ? msgids : undefined,
381
+ }).catch((err) => {
382
+ const state = streams.get(streamId);
383
+ if (state) {
384
+ state.error = err instanceof Error ? err.message : String(err);
385
+ state.content = state.content || `Error: ${state.error}`;
386
+ state.finished = true;
387
+ state.updatedAt = Date.now();
388
+ }
389
+ target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
390
+ });
391
+ }
230
392
  }
231
393
 
232
394
  async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
@@ -250,6 +412,8 @@ async function startAgentForStream(params: {
250
412
  accountId: string;
251
413
  msg: WecomInboundMessage;
252
414
  streamId: string;
415
+ mergedContents?: string; // Combined content from debounced messages
416
+ mergedMsgids?: string[];
253
417
  }): Promise<void> {
254
418
  const { target, msg, streamId } = params;
255
419
  const core = target.core;
@@ -259,7 +423,29 @@ async function startAgentForStream(params: {
259
423
  const userid = msg.from?.userid?.trim() || "unknown";
260
424
  const chatType = msg.chattype === "group" ? "group" : "direct";
261
425
  const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
262
- const rawBody = await processInboundMessage(target, msg);
426
+ // 1. Process inbound message (decrypt media if any)
427
+ const { body: rawBody, media } = await processInboundMessage(target, msg);
428
+
429
+ // 2. Save media if present
430
+ let mediaPath: string | undefined;
431
+ let mediaType: string | undefined;
432
+ if (media) {
433
+ try {
434
+ const maxBytes = (target.config.mediaMaxMb ?? 5) * 1024 * 1024;
435
+ const saved = await core.channel.media.saveMediaBuffer(
436
+ media.buffer,
437
+ media.contentType,
438
+ "inbound",
439
+ maxBytes,
440
+ media.filename
441
+ );
442
+ mediaPath = saved.path;
443
+ mediaType = saved.contentType;
444
+ logVerbose(target, `saved inbound media to ${mediaPath} (${mediaType})`);
445
+ } catch (err) {
446
+ target.runtime.error?.(`Failed to save inbound media: ${String(err)}`);
447
+ }
448
+ }
263
449
 
264
450
  const route = core.channel.routing.resolveAgentRoute({
265
451
  cfg: config,
@@ -304,6 +490,9 @@ async function startAgentForStream(params: {
304
490
  MessageSid: msg.msgid,
305
491
  OriginatingChannel: "wecom",
306
492
  OriginatingTo: `wecom:${chatId}`,
493
+ MediaPath: mediaPath,
494
+ MediaType: mediaType,
495
+ MediaUrl: mediaPath, // Local path for now
307
496
  });
308
497
 
309
498
  await core.channel.session.recordInboundSession({
@@ -336,6 +525,40 @@ async function startAgentForStream(params: {
336
525
  return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
337
526
  });
338
527
 
528
+ // [A2UI] Detect template_card JSON output from Agent
529
+ const trimmedText = text.trim();
530
+ if (trimmedText.startsWith("{") && trimmedText.includes('"template_card"')) {
531
+ try {
532
+ const parsed = JSON.parse(trimmedText);
533
+ if (parsed.template_card) {
534
+ const current = streams.get(streamId);
535
+ const isSingleChat = msg.chattype !== "group";
536
+ const hasResponseUrl = current?.response_url;
537
+
538
+ if (hasResponseUrl && isSingleChat) {
539
+ // 单聊且有 response_url:发送卡片
540
+ await axios.post(current!.response_url!, {
541
+ msgtype: "template_card",
542
+ template_card: parsed.template_card,
543
+ });
544
+ logVerbose(target, `sent template_card: task_id=${parsed.template_card.task_id}`);
545
+ current.finished = true;
546
+ current.content = "[已发送交互卡片]";
547
+ current.updatedAt = Date.now();
548
+ target.statusSink?.({ lastOutboundAt: Date.now() });
549
+ return;
550
+ } else {
551
+ // 群聊 或 无 response_url:降级为文本描述
552
+ logVerbose(target, `template_card fallback to text (group=${!isSingleChat}, hasUrl=${!!hasResponseUrl})`);
553
+ const cardTitle = parsed.template_card.main_title?.title || "交互卡片";
554
+ const cardDesc = parsed.template_card.main_title?.desc || "";
555
+ const buttons = parsed.template_card.button_list?.map((b: any) => b.text).join(" / ") || "";
556
+ text = `📋 **${cardTitle}**${cardDesc ? `\n${cardDesc}` : ""}${buttons ? `\n\n选项: ${buttons}` : ""}`;
557
+ }
558
+ }
559
+ } catch { /* parse fail, use normal text */ }
560
+ }
561
+
339
562
  text = core.channel.text.convertMarkdownTables(text, tableMode);
340
563
 
341
564
  // Restore <think> tags
@@ -346,18 +569,43 @@ async function startAgentForStream(params: {
346
569
  const current = streams.get(streamId);
347
570
  if (!current) return;
348
571
 
349
- // Detect Markdown image: ![alt](url)
350
- const imgMatch = text.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
351
- if (imgMatch && imgMatch[1]) {
572
+ if (!current.images) current.images = [];
573
+
574
+ const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
575
+ for (const mediaPath of mediaUrls) {
352
576
  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 };
577
+ let buf: Buffer;
578
+ let contentType: string | undefined;
579
+ let filename: string;
580
+
581
+ const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
582
+
583
+ if (looksLikeUrl) {
584
+ const loaded = await core.channel.media.fetchRemoteMedia(mediaPath, {
585
+ maxBytes: 10 * 1024 * 1024,
586
+ });
587
+ buf = loaded.buffer;
588
+ contentType = loaded.contentType;
589
+ filename = loaded.filename ?? "attachment";
590
+ } else {
591
+ const fs = await import("node:fs/promises");
592
+ const pathModule = await import("node:path");
593
+ buf = await fs.readFile(mediaPath);
594
+ filename = pathModule.basename(mediaPath);
595
+ const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
596
+ const imageExts: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
597
+ contentType = imageExts[ext] ?? "application/octet-stream";
598
+ }
599
+
600
+ if (contentType?.startsWith("image/")) {
601
+ const base64 = buf.toString("base64");
602
+ const md5 = crypto.createHash("md5").update(buf).digest("hex");
603
+ current.images.push({ base64, md5 });
604
+ } else {
605
+ text += `\n\n[File: ${filename}]`;
606
+ }
359
607
  } catch (err) {
360
- target.runtime.error?.(`Failed to download outbound image: ${String(err)}`);
608
+ target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
361
609
  }
362
610
  }
363
611
 
@@ -383,12 +631,8 @@ async function startAgentForStream(params: {
383
631
 
384
632
  function formatQuote(quote: WecomInboundQuote): string {
385
633
  const type = quote.msgtype ?? "";
386
- if (type === "text") {
387
- return quote.text?.content || "";
388
- }
389
- if (type === "image") {
390
- return `[引用: 图片] ${quote.image?.url || ""}`;
391
- }
634
+ if (type === "text") return quote.text?.content || "";
635
+ if (type === "image") return `[引用: 图片] ${quote.image?.url || ""}`;
392
636
  if (type === "mixed" && quote.mixed?.msg_item) {
393
637
  const items = quote.mixed.msg_item.map((item) => {
394
638
  if (item.msgtype === "text") return item.text?.content;
@@ -397,12 +641,8 @@ function formatQuote(quote: WecomInboundQuote): string {
397
641
  }).filter(Boolean).join(" ");
398
642
  return `[引用: 图文] ${items}`;
399
643
  }
400
- if (type === "voice") {
401
- return `[引用: 语音] ${quote.voice?.content || ""}`;
402
- }
403
- if (type === "file") {
404
- return `[引用: 文件] ${quote.file?.url || ""}`;
405
- }
644
+ if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
645
+ if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
406
646
  return "";
407
647
  }
408
648
 
@@ -410,50 +650,28 @@ function buildInboundBody(msg: WecomInboundMessage): string {
410
650
  let body = "";
411
651
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
412
652
 
413
- if (msgtype === "text") {
414
- const content = (msg as any).text?.content;
415
- body = typeof content === "string" ? content : "";
416
- } else if (msgtype === "voice") {
417
- const content = (msg as any).voice?.content;
418
- body = typeof content === "string" ? content : "[voice]";
419
- } else if (msgtype === "mixed") {
653
+ if (msgtype === "text") body = (msg as any).text?.content || "";
654
+ else if (msgtype === "voice") body = (msg as any).voice?.content || "[voice]";
655
+ else if (msgtype === "mixed") {
420
656
  const items = (msg as any).mixed?.msg_item;
421
657
  if (Array.isArray(items)) {
422
- body = items
423
- .map((item: any) => {
424
- const t = String(item?.msgtype ?? "").toLowerCase();
425
- if (t === "text") return String(item?.text?.content ?? "");
426
- if (t === "image") return `[image] ${String(item?.image?.url ?? "").trim()}`.trim();
427
- return `[${t || "item"}]`;
428
- })
429
- .filter((part: string) => Boolean(part && part.trim()))
430
- .join("\n");
431
- } else {
432
- body = "[mixed]";
433
- }
434
- } else if (msgtype === "image") {
435
- const url = String((msg as any).image?.url ?? "").trim();
436
- body = url ? `[image] ${url}` : "[image]";
437
- } else if (msgtype === "file") {
438
- const url = String((msg as any).file?.url ?? "").trim();
439
- body = url ? `[file] ${url}` : "[file]";
440
- } else if (msgtype === "event") {
441
- const eventtype = String((msg as any).event?.eventtype ?? "").trim();
442
- body = eventtype ? `[event] ${eventtype}` : "[event]";
443
- } else if (msgtype === "stream") {
444
- const id = String((msg as any).stream?.id ?? "").trim();
445
- body = id ? `[stream_refresh] ${id}` : "[stream_refresh]";
446
- } else {
447
- body = msgtype ? `[${msgtype}]` : "";
448
- }
658
+ body = items.map((item: any) => {
659
+ const t = String(item?.msgtype ?? "").toLowerCase();
660
+ if (t === "text") return item?.text?.content || "";
661
+ if (t === "image") return `[image] ${item?.image?.url || ""}`;
662
+ return `[${t || "item"}]`;
663
+ }).filter(Boolean).join("\n");
664
+ } else body = "[mixed]";
665
+ } else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
666
+ else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
667
+ else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
668
+ else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
669
+ else body = msgtype ? `[${msgtype}]` : "";
449
670
 
450
- // Append quote if available
451
671
  const quote = (msg as any).quote;
452
672
  if (quote) {
453
673
  const quoteText = formatQuote(quote).trim();
454
- if (quoteText) {
455
- body += `\n\n> ${quoteText}`;
456
- }
674
+ if (quoteText) body += `\n\n> ${quoteText}`;
457
675
  }
458
676
  return body;
459
677
  }
@@ -462,8 +680,7 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
462
680
  const key = normalizeWebhookPath(target.path);
463
681
  const normalizedTarget = { ...target, path: key };
464
682
  const existing = webhookTargets.get(key) ?? [];
465
- const next = [...existing, normalizedTarget];
466
- webhookTargets.set(key, next);
683
+ webhookTargets.set(key, [...existing, normalizedTarget]);
467
684
  return () => {
468
685
  const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
469
686
  if (updated.length > 0) webhookTargets.set(key, updated);
@@ -471,12 +688,8 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
471
688
  };
472
689
  }
473
690
 
474
- export async function handleWecomWebhookRequest(
475
- req: IncomingMessage,
476
- res: ServerResponse,
477
- ): Promise<boolean> {
691
+ export async function handleWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
478
692
  pruneStreams();
479
-
480
693
  const path = resolvePath(req);
481
694
  const targets = webhookTargets.get(path);
482
695
  if (!targets || targets.length === 0) return false;
@@ -486,282 +699,139 @@ export async function handleWecomWebhookRequest(
486
699
  const nonce = query.get("nonce") ?? "";
487
700
  const signature = resolveSignatureParam(query);
488
701
 
489
- const firstTarget = targets[0]!;
490
- logVerbose(firstTarget, `incoming ${req.method} request on ${path} (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`);
491
-
492
702
  if (req.method === "GET") {
493
703
  const echostr = query.get("echostr") ?? "";
494
- if (!timestamp || !nonce || !signature || !echostr) {
495
- logVerbose(firstTarget, "GET request missing query params");
496
- res.statusCode = 400;
497
- res.end("missing query params");
498
- return true;
499
- }
500
- const target = targets.find((candidate) => {
501
- if (!candidate.account.configured || !candidate.account.token) return false;
502
- const ok = verifyWecomSignature({
503
- token: candidate.account.token,
504
- timestamp,
505
- nonce,
506
- encrypt: echostr,
507
- signature,
508
- });
509
- if (!ok) {
510
- logVerbose(candidate, `signature verification failed for echostr (token=${candidate.account.token?.slice(0, 4)}...)`);
511
- }
512
- return ok;
513
- });
704
+ const target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt: echostr, signature }));
514
705
  if (!target || !target.account.encodingAESKey) {
515
- logVerbose(firstTarget, "no matching target for GET signature");
516
706
  res.statusCode = 401;
517
707
  res.end("unauthorized");
518
708
  return true;
519
709
  }
520
710
  try {
521
- const plain = decryptWecomEncrypted({
522
- encodingAESKey: target.account.encodingAESKey,
523
- receiveId: target.account.receiveId,
524
- encrypt: echostr,
525
- });
526
- logVerbose(target, "GET echostr decrypted successfully");
711
+ const plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt: echostr });
527
712
  res.statusCode = 200;
528
713
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
529
714
  res.end(plain);
530
715
  return true;
531
716
  } catch (err) {
532
- const msg = err instanceof Error ? err.message : String(err);
533
- logVerbose(target, `GET decrypt failed: ${msg}`);
534
717
  res.statusCode = 400;
535
- res.end(msg || "decrypt failed");
718
+ res.end("decrypt failed");
536
719
  return true;
537
720
  }
538
721
  }
539
722
 
540
- if (req.method !== "POST") {
541
- res.statusCode = 405;
542
- res.setHeader("Allow", "GET, POST");
543
- res.end("Method Not Allowed");
544
- return true;
545
- }
546
-
547
- if (!timestamp || !nonce || !signature) {
548
- logVerbose(firstTarget, "POST request missing query params");
549
- res.statusCode = 400;
550
- res.end("missing query params");
551
- return true;
552
- }
723
+ if (req.method !== "POST") return false;
553
724
 
554
725
  const body = await readJsonBody(req, 1024 * 1024);
555
726
  if (!body.ok) {
556
- logVerbose(firstTarget, `POST body read failed: ${body.error}`);
557
- res.statusCode = body.error === "payload too large" ? 413 : 400;
558
- res.end(body.error ?? "invalid payload");
559
- return true;
560
- }
561
- const record = body.value && typeof body.value === "object" ? (body.value as Record<string, unknown>) : null;
562
- const encrypt = record ? String(record.encrypt ?? record.Encrypt ?? "") : "";
563
- if (!encrypt) {
564
- logVerbose(firstTarget, "POST request missing encrypt field in body");
565
727
  res.statusCode = 400;
566
- res.end("missing encrypt");
728
+ res.end(body.error || "invalid payload");
567
729
  return true;
568
730
  }
569
-
570
- // Find the first target that validates the signature.
571
- const target = targets.find((candidate) => {
572
- if (!candidate.account.token) return false;
573
- const ok = verifyWecomSignature({
574
- token: candidate.account.token,
575
- timestamp,
576
- nonce,
577
- encrypt,
578
- signature,
579
- });
580
- if (!ok) {
581
- logVerbose(candidate, `signature verification failed for POST (token=${candidate.account.token?.slice(0, 4)}...)`);
582
- }
583
- return ok;
584
- });
585
- if (!target) {
586
- logVerbose(firstTarget, "no matching target for POST signature");
731
+ const record = body.value as any;
732
+ const encrypt = String(record?.encrypt ?? record?.Encrypt ?? "");
733
+ const target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt, signature }));
734
+ if (!target || !target.account.configured || !target.account.encodingAESKey) {
587
735
  res.statusCode = 401;
588
736
  res.end("unauthorized");
589
737
  return true;
590
738
  }
591
739
 
592
- if (!target.account.configured || !target.account.token || !target.account.encodingAESKey) {
593
- logVerbose(target, "target found but not fully configured");
594
- res.statusCode = 500;
595
- res.end("wecom not configured");
596
- return true;
597
- }
598
-
599
740
  let plain: string;
600
741
  try {
601
- plain = decryptWecomEncrypted({
602
- encodingAESKey: target.account.encodingAESKey,
603
- receiveId: target.account.receiveId,
604
- encrypt,
605
- });
606
- logVerbose(target, `decrypted POST message: ${plain}`);
742
+ plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt });
607
743
  } catch (err) {
608
- const msg = err instanceof Error ? err.message : String(err);
609
- logVerbose(target, `POST decrypt failed: ${msg}`);
610
744
  res.statusCode = 400;
611
- res.end(msg || "decrypt failed");
745
+ res.end("decrypt failed");
612
746
  return true;
613
747
  }
614
748
 
615
749
  const msg = parseWecomPlainMessage(plain);
616
- target.statusSink?.({ lastInboundAt: Date.now() });
617
-
618
750
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
619
- const msgid = msg.msgid ? String(msg.msgid) : undefined;
620
751
 
621
- // Stream refresh callback: reply with current state (if any).
622
- if (msgtype === "stream") {
623
- const streamId = String((msg as any).stream?.id ?? "").trim();
624
- const state = streamId ? streams.get(streamId) : undefined;
625
- if (state) logVerbose(target, `stream refresh streamId=${streamId} started=${state.started} finished=${state.finished}`);
626
- const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({
627
- streamId: streamId || "unknown",
628
- createdAt: Date.now(),
629
- updatedAt: Date.now(),
630
- started: true,
631
- finished: true,
632
- content: "",
633
- });
634
- jsonOk(res, buildEncryptedJsonReply({
635
- account: target.account,
636
- plaintextJson: reply,
637
- nonce,
638
- timestamp,
639
- }));
640
- return true;
641
- }
642
-
643
- // Dedupe: if we already created a stream for this msgid, return placeholder again.
644
- if (msgid && msgidToStreamId.has(msgid)) {
645
- const streamId = msgidToStreamId.get(msgid) ?? "";
646
- const reply = buildStreamPlaceholderReply({
647
- streamId,
648
- placeholderContent: target.account.config.streamPlaceholderContent,
649
- });
650
- jsonOk(res, buildEncryptedJsonReply({
651
- account: target.account,
652
- plaintextJson: reply,
653
- nonce,
654
- timestamp,
655
- }));
656
- return true;
657
- }
658
-
659
- // enter_chat welcome event: optionally reply with text (allowed by spec).
752
+ // Handle Event
660
753
  if (msgtype === "event") {
661
754
  const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
755
+
756
+ if (eventtype === "template_card_event") {
757
+ const msgid = msg.msgid ? String(msg.msgid) : undefined;
758
+
759
+ // Dedupe: skip if already processed this event
760
+ if (msgid && msgidToStreamId.has(msgid)) {
761
+ logVerbose(target, `template_card_event: already processed msgid=${msgid}, skipping`);
762
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
763
+ return true;
764
+ }
765
+
766
+ const cardEvent = (msg as any).event?.template_card_event;
767
+ let interactionDesc = `[卡片交互] 按钮: ${cardEvent?.event_key || "unknown"}`;
768
+ if (cardEvent?.selected_items?.selected_item?.length) {
769
+ const selects = cardEvent.selected_items.selected_item.map((i: any) => `${i.question_key}=${i.option_ids?.option_id?.join(",")}`);
770
+ interactionDesc += ` 选择: ${selects.join("; ")}`;
771
+ }
772
+ if (cardEvent?.task_id) interactionDesc += ` (任务ID: ${cardEvent.task_id})`;
773
+
774
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
775
+
776
+ const streamId = createStreamId();
777
+ if (msgid) msgidToStreamId.set(msgid, streamId); // Mark as processed
778
+ streams.set(streamId, { streamId, response_url: msg.response_url, createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: false, content: "" });
779
+ const core = getWecomRuntime();
780
+ startAgentForStream({
781
+ target: { ...target, core },
782
+ accountId: target.account.accountId,
783
+ msg: { ...msg, msgtype: "text", text: { content: interactionDesc } } as any,
784
+ streamId,
785
+ }).catch(err => target.runtime.error?.(`interaction failed: ${String(err)}`));
786
+ return true;
787
+ }
788
+
662
789
  if (eventtype === "enter_chat") {
663
790
  const welcome = target.account.config.welcomeText?.trim();
664
- const reply = welcome
665
- ? { msgtype: "text", text: { content: welcome } }
666
- : {};
667
- jsonOk(res, buildEncryptedJsonReply({
668
- account: target.account,
669
- plaintextJson: reply,
670
- nonce,
671
- timestamp,
672
- }));
791
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: welcome ? { msgtype: "text", text: { content: welcome } } : {}, nonce, timestamp }));
673
792
  return true;
674
793
  }
675
794
 
676
- // For other events, reply empty to avoid timeouts.
677
- jsonOk(res, buildEncryptedJsonReply({
678
- account: target.account,
679
- plaintextJson: {},
680
- nonce,
681
- timestamp,
682
- }));
795
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
683
796
  return true;
684
797
  }
685
798
 
686
- // Default: respond with a stream placeholder and compute the actual reply async.
687
- const streamId = createStreamId();
688
- if (msgid) msgidToStreamId.set(msgid, streamId);
689
- streams.set(streamId, {
690
- streamId,
691
- msgid,
692
- response_url: msg.response_url,
693
- createdAt: Date.now(),
694
- updatedAt: Date.now(),
695
- started: false,
696
- finished: false,
697
- content: "",
698
- });
699
-
700
- // Kick off agent processing in the background.
701
- let core: PluginRuntime | null = null;
702
- try {
703
- core = getWecomRuntime();
704
- } catch (err) {
705
- // If runtime is not ready, we can't process the agent, but we should still
706
- // return the placeholder if possible, or handle it as a background error.
707
- logVerbose(target, `runtime not ready, skipping agent processing: ${String(err)}`);
799
+ // Handle Stream Refresh
800
+ if (msgtype === "stream") {
801
+ const streamId = String((msg as any).stream?.id ?? "").trim();
802
+ const state = streams.get(streamId);
803
+ const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({ streamId: streamId || "unknown", createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: true, content: "" });
804
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: reply, nonce, timestamp }));
805
+ return true;
708
806
  }
709
807
 
710
- if (core) {
711
- streams.get(streamId)!.started = true;
712
- const enrichedTarget: WecomWebhookTarget = { ...target, core };
713
- startAgentForStream({ target: enrichedTarget, accountId: target.account.accountId, msg, streamId }).catch((err) => {
714
- const state = streams.get(streamId);
715
- if (state) {
716
- state.error = err instanceof Error ? err.message : String(err);
717
- state.content = state.content || `Error: ${state.error}`;
718
- state.finished = true;
719
- state.updatedAt = Date.now();
720
- }
721
- target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
722
- });
723
- } else {
724
- // In tests or uninitialized state, we might not have a core.
725
- // We mark it as finished to avoid hanging, but don't set an error content
726
- // immediately if we want to return the placeholder "1".
727
- const state = streams.get(streamId);
728
- if (state) {
729
- state.finished = true;
730
- state.updatedAt = Date.now();
731
- }
808
+ // Handle Message (with Debounce)
809
+ const userid = msg.from?.userid?.trim() || "unknown";
810
+ const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
811
+ const pendingKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
812
+ const msgContent = buildInboundBody(msg);
813
+
814
+ const existingPending = pendingInbounds.get(pendingKey);
815
+ if (existingPending) {
816
+ existingPending.contents.push(msgContent);
817
+ if (msg.msgid) existingPending.msgids.push(msg.msgid);
818
+ if (existingPending.timeout) clearTimeout(existingPending.timeout);
819
+ existingPending.timeout = setTimeout(() => void flushPending(pendingKey), DEFAULT_DEBOUNCE_MS);
820
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: buildStreamPlaceholderReply({ streamId: existingPending.streamId, placeholderContent: target.account.config.streamPlaceholderContent }), nonce, timestamp }));
821
+ return true;
732
822
  }
733
823
 
734
- // Try to include a first chunk in the initial response (matches WeCom demo behavior).
735
- // If nothing is ready quickly, fall back to the placeholder "1".
736
- await waitForStreamContent(streamId, 800);
737
- const state = streams.get(streamId);
738
- const initialReply = state && (state.content.trim() || state.error)
739
- ? buildStreamReplyFromState(state)
740
- : buildStreamPlaceholderReply({
741
- streamId,
742
- placeholderContent: target.account.config.streamPlaceholderContent,
743
- });
744
- jsonOk(res, buildEncryptedJsonReply({
745
- account: target.account,
746
- plaintextJson: initialReply,
747
- nonce,
748
- timestamp,
749
- }));
750
-
751
- logVerbose(target, `accepted msgtype=${msgtype || "unknown"} msgid=${msgid || "none"} streamId=${streamId}`);
824
+ const streamId = createStreamId();
825
+ if (msg.msgid) msgidToStreamId.set(msg.msgid, streamId);
826
+ streams.set(streamId, { streamId, msgid: msg.msgid, response_url: msg.response_url, createdAt: Date.now(), updatedAt: Date.now(), started: false, finished: false, content: "" });
827
+ pendingInbounds.set(pendingKey, { streamId, target, msg, contents: [msgContent], msgids: msg.msgid ? [msg.msgid] : [], nonce, timestamp, createdAt: Date.now(), timeout: setTimeout(() => void flushPending(pendingKey), DEFAULT_DEBOUNCE_MS) });
828
+
829
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: buildStreamPlaceholderReply({ streamId, placeholderContent: target.account.config.streamPlaceholderContent }), nonce, timestamp }));
752
830
  return true;
753
831
  }
754
832
 
755
833
  export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
756
834
  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
- }
835
+ if (!state || !state.response_url) throw new Error(`No response_url for stream ${streamId}`);
836
+ await axios.post(state.response_url, { msgtype: "text", text: { content } });
837
+ }
package/src/types.ts CHANGED
@@ -91,3 +91,69 @@ export type WecomInboundMessage =
91
91
  | WecomInboundStreamRefresh
92
92
  | WecomInboundEvent
93
93
  | (WecomInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
94
+
95
+ export type WecomTemplateCard = {
96
+ card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
97
+ source?: { icon_url?: string; desc?: string; desc_color?: number };
98
+ main_title?: { title?: string; desc?: string };
99
+ task_id?: string;
100
+ button_list?: Array<{ text: string; style?: number; key: string }>;
101
+ sub_title_text?: string;
102
+ horizontal_content_list?: Array<{ keyname: string; value?: string; type?: number; url?: string; userid?: string }>;
103
+ card_action?: { type: number; url?: string; appid?: string; pagepath?: string };
104
+ action_menu?: { desc: string; action_list: Array<{ text: string; key: string }> };
105
+ select_list?: Array<{
106
+ question_key: string;
107
+ title?: string;
108
+ selected_id?: string;
109
+ option_list: Array<{ id: string; text: string }>;
110
+ }>;
111
+ submit_button?: { text: string; key: string };
112
+ checkbox?: {
113
+ question_key: string;
114
+ option_list: Array<{ id: string; text: string; is_checked?: boolean }>;
115
+ mode?: number;
116
+ };
117
+ };
118
+
119
+ export type WecomInboundTemplateCardEvent = WecomInboundBase & {
120
+ msgtype: "event";
121
+ event: {
122
+ eventtype: "template_card_event";
123
+ template_card_event: {
124
+ card_type: string;
125
+ event_key: string;
126
+ task_id: string;
127
+ selected_items?: {
128
+ selected_item: Array<{
129
+ question_key: string;
130
+ option_ids: { option_id: string[] };
131
+ }>;
132
+ };
133
+ };
134
+ };
135
+ };
136
+
137
+
138
+ /**
139
+ * Template card event payload (button click, checkbox, select)
140
+ */
141
+ export type WecomTemplateCardEventPayload = {
142
+ card_type: string;
143
+ event_key: string;
144
+ task_id: string;
145
+ response_code?: string;
146
+ selected_items?: {
147
+ question_key?: string;
148
+ option_ids?: string[];
149
+ };
150
+ };
151
+
152
+ /**
153
+ * Outbound message types that can be sent via response_url
154
+ */
155
+ export type WecomOutboundMessage =
156
+ | { msgtype: "text"; text: { content: string } }
157
+ | { msgtype: "markdown"; markdown: { content: string } }
158
+ | { msgtype: "template_card"; template_card: WecomTemplateCard };
159
+