@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/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,20 +26,38 @@ 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
+ images?: { base64: string; md5: string }[];
33
37
  };
34
38
 
35
39
  const webhookTargets = new Map<string, WecomWebhookTarget[]>();
36
40
  const streams = new Map<string, StreamState>();
37
41
  const msgidToStreamId = new Map<string, string>();
38
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
+
39
58
  const STREAM_TTL_MS = 10 * 60 * 1000;
40
59
  const STREAM_MAX_BYTES = 20_480;
60
+ const DEFAULT_DEBOUNCE_MS = 500;
41
61
 
42
62
  function normalizeWebhookPath(raw: string): string {
43
63
  const trimmed = raw.trim();
@@ -151,14 +171,18 @@ function resolveSignatureParam(params: URLSearchParams): string {
151
171
  );
152
172
  }
153
173
 
154
- function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
174
+ function buildStreamPlaceholderReply(params: {
175
+ streamId: string;
176
+ placeholderContent?: string;
177
+ }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
178
+ const content = params.placeholderContent?.trim() || "1";
155
179
  return {
156
180
  msgtype: "stream",
157
181
  stream: {
158
- id: streamId,
182
+ id: params.streamId,
159
183
  finish: false,
160
184
  // Spec: "第一次回复内容为 1" works as a minimal placeholder.
161
- content: "1",
185
+ content,
162
186
  },
163
187
  };
164
188
  }
@@ -171,6 +195,12 @@ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; str
171
195
  id: state.streamId,
172
196
  finish: state.finished,
173
197
  content,
198
+ ...(state.finished && state.images?.length ? {
199
+ msg_item: state.images.map(img => ({
200
+ msgtype: "image",
201
+ image: { base64: img.base64, md5: img.md5 }
202
+ }))
203
+ } : {})
174
204
  },
175
205
  };
176
206
  }
@@ -180,11 +210,17 @@ function createStreamId(): string {
180
210
  }
181
211
 
182
212
  function logVerbose(target: WecomWebhookTarget, message: string): void {
183
- const core = target.core;
184
- const should = core.logging?.shouldLogVerbose?.() ?? false;
185
- if (should) {
186
- target.runtime.log?.(`[wecom] ${message}`);
187
- }
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}`);
188
224
  }
189
225
 
190
226
  function parseWecomPlainMessage(raw: string): WecomInboundMessage {
@@ -195,6 +231,166 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
195
231
  return parsed as WecomInboundMessage;
196
232
  }
197
233
 
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> {
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;
248
+
249
+ if (msgtype === "image") {
250
+ const url = String((msg as any).image?.url ?? "").trim();
251
+ if (url && aesKey) {
252
+ try {
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
+ };
262
+ } catch (err) {
263
+ target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
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)})` };
285
+ }
286
+ }
287
+ }
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
+ }
392
+ }
393
+
198
394
  async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
199
395
  if (maxWaitMs <= 0) return;
200
396
  const startedAt = Date.now();
@@ -202,7 +398,8 @@ async function waitForStreamContent(streamId: string, maxWaitMs: number): Promis
202
398
  const tick = () => {
203
399
  const state = streams.get(streamId);
204
400
  if (!state) return resolve();
205
- if (state.error || state.finished || state.content.trim()) return resolve();
401
+ if (state.error || state.finished) return resolve();
402
+ if (state.content.trim()) return resolve();
206
403
  if (Date.now() - startedAt >= maxWaitMs) return resolve();
207
404
  setTimeout(tick, 25);
208
405
  };
@@ -215,6 +412,8 @@ async function startAgentForStream(params: {
215
412
  accountId: string;
216
413
  msg: WecomInboundMessage;
217
414
  streamId: string;
415
+ mergedContents?: string; // Combined content from debounced messages
416
+ mergedMsgids?: string[];
218
417
  }): Promise<void> {
219
418
  const { target, msg, streamId } = params;
220
419
  const core = target.core;
@@ -224,7 +423,29 @@ async function startAgentForStream(params: {
224
423
  const userid = msg.from?.userid?.trim() || "unknown";
225
424
  const chatType = msg.chattype === "group" ? "group" : "direct";
226
425
  const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
227
- const rawBody = buildInboundBody(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
+ }
228
449
 
229
450
  const route = core.channel.routing.resolveAgentRoute({
230
451
  cfg: config,
@@ -269,6 +490,9 @@ async function startAgentForStream(params: {
269
490
  MessageSid: msg.msgid,
270
491
  OriginatingChannel: "wecom",
271
492
  OriginatingTo: `wecom:${chatId}`,
493
+ MediaPath: mediaPath,
494
+ MediaType: mediaType,
495
+ MediaUrl: mediaPath, // Local path for now
272
496
  });
273
497
 
274
498
  await core.channel.session.recordInboundSession({
@@ -291,9 +515,100 @@ async function startAgentForStream(params: {
291
515
  cfg: config,
292
516
  dispatcherOptions: {
293
517
  deliver: async (payload) => {
294
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
518
+ let text = payload.text ?? "";
519
+
520
+ // Protect <think> tags from table conversion
521
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
522
+ const thinks: string[] = [];
523
+ text = text.replace(thinkRegex, (match: string) => {
524
+ thinks.push(match);
525
+ return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
526
+ });
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
+
562
+ text = core.channel.text.convertMarkdownTables(text, tableMode);
563
+
564
+ // Restore <think> tags
565
+ thinks.forEach((think, i) => {
566
+ text = text.replace(`__THINK_PLACEHOLDER_${i}__`, think);
567
+ });
568
+
295
569
  const current = streams.get(streamId);
296
570
  if (!current) return;
571
+
572
+ if (!current.images) current.images = [];
573
+
574
+ const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
575
+ for (const mediaPath of mediaUrls) {
576
+ try {
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
+ }
607
+ } catch (err) {
608
+ target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
609
+ }
610
+ }
611
+
297
612
  const nextText = current.content
298
613
  ? `${current.content}\n\n${text}`.trim()
299
614
  : text.trim();
@@ -314,56 +629,58 @@ async function startAgentForStream(params: {
314
629
  }
315
630
  }
316
631
 
632
+ function formatQuote(quote: WecomInboundQuote): string {
633
+ const type = quote.msgtype ?? "";
634
+ if (type === "text") return quote.text?.content || "";
635
+ if (type === "image") return `[引用: 图片] ${quote.image?.url || ""}`;
636
+ if (type === "mixed" && quote.mixed?.msg_item) {
637
+ const items = quote.mixed.msg_item.map((item) => {
638
+ if (item.msgtype === "text") return item.text?.content;
639
+ if (item.msgtype === "image") return `[图片] ${item.image?.url || ""}`;
640
+ return "";
641
+ }).filter(Boolean).join(" ");
642
+ return `[引用: 图文] ${items}`;
643
+ }
644
+ if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
645
+ if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
646
+ return "";
647
+ }
648
+
317
649
  function buildInboundBody(msg: WecomInboundMessage): string {
650
+ let body = "";
318
651
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
319
- if (msgtype === "text") {
320
- const content = (msg as any).text?.content;
321
- return typeof content === "string" ? content : "";
322
- }
323
- if (msgtype === "voice") {
324
- const content = (msg as any).voice?.content;
325
- return typeof content === "string" ? content : "[voice]";
326
- }
327
- if (msgtype === "mixed") {
652
+
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") {
328
656
  const items = (msg as any).mixed?.msg_item;
329
657
  if (Array.isArray(items)) {
330
- return items
331
- .map((item: any) => {
332
- const t = String(item?.msgtype ?? "").toLowerCase();
333
- if (t === "text") return String(item?.text?.content ?? "");
334
- if (t === "image") return `[image] ${String(item?.image?.url ?? "").trim()}`.trim();
335
- return `[${t || "item"}]`;
336
- })
337
- .filter((part: string) => Boolean(part && part.trim()))
338
- .join("\n");
339
- }
340
- return "[mixed]";
341
- }
342
- if (msgtype === "image") {
343
- const url = String((msg as any).image?.url ?? "").trim();
344
- return url ? `[image] ${url}` : "[image]";
345
- }
346
- if (msgtype === "file") {
347
- const url = String((msg as any).file?.url ?? "").trim();
348
- return url ? `[file] ${url}` : "[file]";
349
- }
350
- if (msgtype === "event") {
351
- const eventtype = String((msg as any).event?.eventtype ?? "").trim();
352
- return eventtype ? `[event] ${eventtype}` : "[event]";
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}]` : "";
670
+
671
+ const quote = (msg as any).quote;
672
+ if (quote) {
673
+ const quoteText = formatQuote(quote).trim();
674
+ if (quoteText) body += `\n\n> ${quoteText}`;
353
675
  }
354
- if (msgtype === "stream") {
355
- const id = String((msg as any).stream?.id ?? "").trim();
356
- return id ? `[stream_refresh] ${id}` : "[stream_refresh]";
357
- }
358
- return msgtype ? `[${msgtype}]` : "";
676
+ return body;
359
677
  }
360
678
 
361
679
  export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
362
680
  const key = normalizeWebhookPath(target.path);
363
681
  const normalizedTarget = { ...target, path: key };
364
682
  const existing = webhookTargets.get(key) ?? [];
365
- const next = [...existing, normalizedTarget];
366
- webhookTargets.set(key, next);
683
+ webhookTargets.set(key, [...existing, normalizedTarget]);
367
684
  return () => {
368
685
  const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
369
686
  if (updated.length > 0) webhookTargets.set(key, updated);
@@ -371,12 +688,8 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
371
688
  };
372
689
  }
373
690
 
374
- export async function handleWecomWebhookRequest(
375
- req: IncomingMessage,
376
- res: ServerResponse,
377
- ): Promise<boolean> {
691
+ export async function handleWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
378
692
  pruneStreams();
379
-
380
693
  const path = resolvePath(req);
381
694
  const targets = webhookTargets.get(path);
382
695
  if (!targets || targets.length === 0) return false;
@@ -386,261 +699,139 @@ export async function handleWecomWebhookRequest(
386
699
  const nonce = query.get("nonce") ?? "";
387
700
  const signature = resolveSignatureParam(query);
388
701
 
389
- const firstTarget = targets[0]!;
390
- logVerbose(firstTarget, `incoming ${req.method} request on ${path} (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`);
391
-
392
702
  if (req.method === "GET") {
393
703
  const echostr = query.get("echostr") ?? "";
394
- if (!timestamp || !nonce || !signature || !echostr) {
395
- logVerbose(firstTarget, "GET request missing query params");
396
- res.statusCode = 400;
397
- res.end("missing query params");
398
- return true;
399
- }
400
- const target = targets.find((candidate) => {
401
- if (!candidate.account.configured || !candidate.account.token) return false;
402
- const ok = verifyWecomSignature({
403
- token: candidate.account.token,
404
- timestamp,
405
- nonce,
406
- encrypt: echostr,
407
- signature,
408
- });
409
- if (!ok) {
410
- logVerbose(candidate, `signature verification failed for echostr (token=${candidate.account.token?.slice(0, 4)}...)`);
411
- }
412
- return ok;
413
- });
704
+ const target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt: echostr, signature }));
414
705
  if (!target || !target.account.encodingAESKey) {
415
- logVerbose(firstTarget, "no matching target for GET signature");
416
706
  res.statusCode = 401;
417
707
  res.end("unauthorized");
418
708
  return true;
419
709
  }
420
710
  try {
421
- const plain = decryptWecomEncrypted({
422
- encodingAESKey: target.account.encodingAESKey,
423
- receiveId: target.account.receiveId,
424
- encrypt: echostr,
425
- });
426
- logVerbose(target, "GET echostr decrypted successfully");
711
+ const plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt: echostr });
427
712
  res.statusCode = 200;
428
713
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
429
714
  res.end(plain);
430
715
  return true;
431
716
  } catch (err) {
432
- const msg = err instanceof Error ? err.message : String(err);
433
- logVerbose(target, `GET decrypt failed: ${msg}`);
434
717
  res.statusCode = 400;
435
- res.end(msg || "decrypt failed");
718
+ res.end("decrypt failed");
436
719
  return true;
437
720
  }
438
721
  }
439
722
 
440
- if (req.method !== "POST") {
441
- res.statusCode = 405;
442
- res.setHeader("Allow", "GET, POST");
443
- res.end("Method Not Allowed");
444
- return true;
445
- }
446
-
447
- if (!timestamp || !nonce || !signature) {
448
- logVerbose(firstTarget, "POST request missing query params");
449
- res.statusCode = 400;
450
- res.end("missing query params");
451
- return true;
452
- }
723
+ if (req.method !== "POST") return false;
453
724
 
454
725
  const body = await readJsonBody(req, 1024 * 1024);
455
726
  if (!body.ok) {
456
- logVerbose(firstTarget, `POST body read failed: ${body.error}`);
457
- res.statusCode = body.error === "payload too large" ? 413 : 400;
458
- res.end(body.error ?? "invalid payload");
459
- return true;
460
- }
461
- const record = body.value && typeof body.value === "object" ? (body.value as Record<string, unknown>) : null;
462
- const encrypt = record ? String(record.encrypt ?? record.Encrypt ?? "") : "";
463
- if (!encrypt) {
464
- logVerbose(firstTarget, "POST request missing encrypt field in body");
465
727
  res.statusCode = 400;
466
- res.end("missing encrypt");
728
+ res.end(body.error || "invalid payload");
467
729
  return true;
468
730
  }
469
-
470
- // Find the first target that validates the signature.
471
- const target = targets.find((candidate) => {
472
- if (!candidate.account.token) return false;
473
- const ok = verifyWecomSignature({
474
- token: candidate.account.token,
475
- timestamp,
476
- nonce,
477
- encrypt,
478
- signature,
479
- });
480
- if (!ok) {
481
- logVerbose(candidate, `signature verification failed for POST (token=${candidate.account.token?.slice(0, 4)}...)`);
482
- }
483
- return ok;
484
- });
485
- if (!target) {
486
- 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) {
487
735
  res.statusCode = 401;
488
736
  res.end("unauthorized");
489
737
  return true;
490
738
  }
491
739
 
492
- if (!target.account.configured || !target.account.token || !target.account.encodingAESKey) {
493
- logVerbose(target, "target found but not fully configured");
494
- res.statusCode = 500;
495
- res.end("wecom not configured");
496
- return true;
497
- }
498
-
499
740
  let plain: string;
500
741
  try {
501
- plain = decryptWecomEncrypted({
502
- encodingAESKey: target.account.encodingAESKey,
503
- receiveId: target.account.receiveId,
504
- encrypt,
505
- });
506
- logVerbose(target, `decrypted POST message: ${plain}`);
742
+ plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt });
507
743
  } catch (err) {
508
- const msg = err instanceof Error ? err.message : String(err);
509
- logVerbose(target, `POST decrypt failed: ${msg}`);
510
744
  res.statusCode = 400;
511
- res.end(msg || "decrypt failed");
745
+ res.end("decrypt failed");
512
746
  return true;
513
747
  }
514
748
 
515
749
  const msg = parseWecomPlainMessage(plain);
516
- target.statusSink?.({ lastInboundAt: Date.now() });
517
-
518
750
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
519
- const msgid = msg.msgid ? String(msg.msgid) : undefined;
520
-
521
- // Stream refresh callback: reply with current state (if any).
522
- if (msgtype === "stream") {
523
- const streamId = String((msg as any).stream?.id ?? "").trim();
524
- const state = streamId ? streams.get(streamId) : undefined;
525
- if (state) logVerbose(target, `stream refresh streamId=${streamId} started=${state.started} finished=${state.finished}`);
526
- const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({
527
- streamId: streamId || "unknown",
528
- createdAt: Date.now(),
529
- updatedAt: Date.now(),
530
- started: true,
531
- finished: true,
532
- content: "",
533
- });
534
- jsonOk(res, buildEncryptedJsonReply({
535
- account: target.account,
536
- plaintextJson: reply,
537
- nonce,
538
- timestamp,
539
- }));
540
- return true;
541
- }
542
-
543
- // Dedupe: if we already created a stream for this msgid, return placeholder again.
544
- if (msgid && msgidToStreamId.has(msgid)) {
545
- const streamId = msgidToStreamId.get(msgid) ?? "";
546
- const reply = buildStreamPlaceholderReply(streamId);
547
- jsonOk(res, buildEncryptedJsonReply({
548
- account: target.account,
549
- plaintextJson: reply,
550
- nonce,
551
- timestamp,
552
- }));
553
- return true;
554
- }
555
751
 
556
- // enter_chat welcome event: optionally reply with text (allowed by spec).
752
+ // Handle Event
557
753
  if (msgtype === "event") {
558
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
+
559
789
  if (eventtype === "enter_chat") {
560
790
  const welcome = target.account.config.welcomeText?.trim();
561
- const reply = welcome
562
- ? { msgtype: "text", text: { content: welcome } }
563
- : {};
564
- jsonOk(res, buildEncryptedJsonReply({
565
- account: target.account,
566
- plaintextJson: reply,
567
- nonce,
568
- timestamp,
569
- }));
791
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: welcome ? { msgtype: "text", text: { content: welcome } } : {}, nonce, timestamp }));
570
792
  return true;
571
793
  }
572
794
 
573
- // For other events, reply empty to avoid timeouts.
574
- jsonOk(res, buildEncryptedJsonReply({
575
- account: target.account,
576
- plaintextJson: {},
577
- nonce,
578
- timestamp,
579
- }));
795
+ jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
580
796
  return true;
581
797
  }
582
798
 
583
- // Default: respond with a stream placeholder and compute the actual reply async.
584
- const streamId = createStreamId();
585
- if (msgid) msgidToStreamId.set(msgid, streamId);
586
- streams.set(streamId, {
587
- streamId,
588
- msgid,
589
- createdAt: Date.now(),
590
- updatedAt: Date.now(),
591
- started: false,
592
- finished: false,
593
- content: "",
594
- });
595
-
596
- // Kick off agent processing in the background.
597
- let core: PluginRuntime | null = null;
598
- try {
599
- core = getWecomRuntime();
600
- } catch (err) {
601
- // If runtime is not ready, we can't process the agent, but we should still
602
- // return the placeholder if possible, or handle it as a background error.
603
- 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;
604
806
  }
605
807
 
606
- if (core) {
607
- streams.get(streamId)!.started = true;
608
- const enrichedTarget: WecomWebhookTarget = { ...target, core };
609
- startAgentForStream({ target: enrichedTarget, accountId: target.account.accountId, msg, streamId }).catch((err) => {
610
- const state = streams.get(streamId);
611
- if (state) {
612
- state.error = err instanceof Error ? err.message : String(err);
613
- state.content = state.content || `Error: ${state.error}`;
614
- state.finished = true;
615
- state.updatedAt = Date.now();
616
- }
617
- target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
618
- });
619
- } else {
620
- // In tests or uninitialized state, we might not have a core.
621
- // We mark it as finished to avoid hanging, but don't set an error content
622
- // immediately if we want to return the placeholder "1".
623
- const state = streams.get(streamId);
624
- if (state) {
625
- state.finished = true;
626
- state.updatedAt = Date.now();
627
- }
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;
628
822
  }
629
823
 
630
- // Try to include a first chunk in the initial response (matches WeCom demo behavior).
631
- // If nothing is ready quickly, fall back to the placeholder "1".
632
- await waitForStreamContent(streamId, 800);
633
- const state = streams.get(streamId);
634
- const initialReply = state && (state.content.trim() || state.error)
635
- ? buildStreamReplyFromState(state)
636
- : buildStreamPlaceholderReply(streamId);
637
- jsonOk(res, buildEncryptedJsonReply({
638
- account: target.account,
639
- plaintextJson: initialReply,
640
- nonce,
641
- timestamp,
642
- }));
643
-
644
- 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 }));
645
830
  return true;
646
831
  }
832
+
833
+ export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
834
+ const state = streams.get(streamId);
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
+ }