@marshulll/openclaw-wecom 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -55,6 +55,11 @@ export async function handleWecomWebhookRequest(
55
55
  const path = resolvePath(req);
56
56
  const targets = webhookTargets.get(path);
57
57
  if (!targets || targets.length === 0) return false;
58
+ const firstTarget = targets[0];
59
+ const ua = req.headers["user-agent"] ?? "";
60
+ const fwd = req.headers["x-forwarded-for"] ?? "";
61
+ const ct = req.headers["content-type"] ?? "";
62
+ firstTarget?.runtime?.log?.(`[wecom] webhook ${req.method ?? "UNKNOWN"} ${req.url ?? ""} ct=${ct} ua=${ua} fwd=${fwd}`);
58
63
 
59
64
  // Prefer account-level mode. If both, we attempt bot first (JSON) then app (XML).
60
65
  // Concrete routing is implemented in handlers.
@@ -26,8 +26,30 @@ function parseIncomingXml(xml: string): Record<string, any> {
26
26
  }
27
27
 
28
28
  function resolveQueryParams(req: IncomingMessage): URLSearchParams {
29
- const url = new URL(req.url ?? "/", "http://localhost");
30
- return url.searchParams;
29
+ const rawUrl = req.url ?? "";
30
+ const queryIndex = rawUrl.indexOf("?");
31
+ if (queryIndex < 0) return new URLSearchParams();
32
+ const queryString = rawUrl.slice(queryIndex + 1);
33
+ const params = new URLSearchParams();
34
+ if (!queryString) return params;
35
+ for (const part of queryString.split("&")) {
36
+ if (!part) continue;
37
+ const eqIndex = part.indexOf("=");
38
+ const keyRaw = eqIndex >= 0 ? part.slice(0, eqIndex) : part;
39
+ const valueRaw = eqIndex >= 0 ? part.slice(eqIndex + 1) : "";
40
+ const key = safeDecodeURIComponent(keyRaw);
41
+ const value = safeDecodeURIComponent(valueRaw);
42
+ params.append(key, value);
43
+ }
44
+ return params;
45
+ }
46
+
47
+ function safeDecodeURIComponent(value: string): string {
48
+ try {
49
+ return decodeURIComponent(value);
50
+ } catch {
51
+ return value;
52
+ }
31
53
  }
32
54
 
33
55
  function resolveSignatureParam(params: URLSearchParams): string {
@@ -11,6 +11,9 @@ import { getWecomRuntime } from "./runtime.js";
11
11
 
12
12
  const STREAM_TTL_MS = 10 * 60 * 1000;
13
13
  const STREAM_MAX_BYTES = 20_480;
14
+ const STREAM_MAX_ENTRIES = 500;
15
+ const DEDUPE_TTL_MS = 2 * 60 * 1000;
16
+ const DEDUPE_MAX_ENTRIES = 2_000;
14
17
 
15
18
  type StreamState = {
16
19
  streamId: string;
@@ -25,6 +28,7 @@ type StreamState = {
25
28
 
26
29
  const streams = new Map<string, StreamState>();
27
30
  const msgidToStreamId = new Map<string, string>();
31
+ const recentEncrypts = new Map<string, { ts: number; streamId?: string }>();
28
32
 
29
33
  function pruneStreams(): void {
30
34
  const cutoff = Date.now() - STREAM_TTL_MS;
@@ -38,6 +42,30 @@ function pruneStreams(): void {
38
42
  msgidToStreamId.delete(msgid);
39
43
  }
40
44
  }
45
+
46
+ const dedupeCutoff = Date.now() - DEDUPE_TTL_MS;
47
+ for (const [hash, entry] of recentEncrypts.entries()) {
48
+ if (entry.ts < dedupeCutoff) {
49
+ recentEncrypts.delete(hash);
50
+ }
51
+ }
52
+
53
+ if (streams.size > STREAM_MAX_ENTRIES) {
54
+ const sorted = Array.from(streams.entries()).sort((a, b) => a[1].updatedAt - b[1].updatedAt);
55
+ const overflow = sorted.length - STREAM_MAX_ENTRIES;
56
+ for (let i = 0; i < overflow; i += 1) {
57
+ const [streamId] = sorted[i]!;
58
+ streams.delete(streamId);
59
+ }
60
+ }
61
+
62
+ if (recentEncrypts.size > DEDUPE_MAX_ENTRIES) {
63
+ const sorted = Array.from(recentEncrypts.entries()).sort((a, b) => a[1].ts - b[1].ts);
64
+ const overflow = sorted.length - DEDUPE_MAX_ENTRIES;
65
+ for (let i = 0; i < overflow; i += 1) {
66
+ recentEncrypts.delete(sorted[i]![0]);
67
+ }
68
+ }
41
69
  }
42
70
 
43
71
  function truncateUtf8Bytes(text: string, maxBytes: number): string {
@@ -49,7 +77,7 @@ function truncateUtf8Bytes(text: string, maxBytes: number): string {
49
77
 
50
78
  function jsonOk(res: ServerResponse, body: unknown): void {
51
79
  res.statusCode = 200;
52
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
80
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
53
81
  res.end(JSON.stringify(body));
54
82
  }
55
83
 
@@ -89,7 +117,7 @@ function buildEncryptedJsonReply(params: {
89
117
  plaintextJson: unknown;
90
118
  nonce: string;
91
119
  timestamp: string;
92
- }): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } {
120
+ }): { encrypt: string; msg_signature: string; timestamp: string; nonce: string } {
93
121
  const plaintext = JSON.stringify(params.plaintextJson ?? {});
94
122
  const encrypt = encryptWecomPlaintext({
95
123
  encodingAESKey: params.account.encodingAESKey ?? "",
@@ -104,15 +132,37 @@ function buildEncryptedJsonReply(params: {
104
132
  });
105
133
  return {
106
134
  encrypt,
107
- msgsignature,
135
+ msg_signature: msgsignature,
108
136
  timestamp: params.timestamp,
109
137
  nonce: params.nonce,
110
138
  };
111
139
  }
112
140
 
113
141
  function resolveQueryParams(req: IncomingMessage): URLSearchParams {
114
- const url = new URL(req.url ?? "/", "http://localhost");
115
- return url.searchParams;
142
+ const rawUrl = req.url ?? "";
143
+ const queryIndex = rawUrl.indexOf("?");
144
+ if (queryIndex < 0) return new URLSearchParams();
145
+ const queryString = rawUrl.slice(queryIndex + 1);
146
+ const params = new URLSearchParams();
147
+ if (!queryString) return params;
148
+ for (const part of queryString.split("&")) {
149
+ if (!part) continue;
150
+ const eqIndex = part.indexOf("=");
151
+ const keyRaw = eqIndex >= 0 ? part.slice(0, eqIndex) : part;
152
+ const valueRaw = eqIndex >= 0 ? part.slice(eqIndex + 1) : "";
153
+ const key = safeDecodeURIComponent(keyRaw);
154
+ const value = safeDecodeURIComponent(valueRaw);
155
+ params.append(key, value);
156
+ }
157
+ return params;
158
+ }
159
+
160
+ function safeDecodeURIComponent(value: string): string {
161
+ try {
162
+ return decodeURIComponent(value);
163
+ } catch {
164
+ return value;
165
+ }
116
166
  }
117
167
 
118
168
  function resolveSignatureParam(params: URLSearchParams): string {
@@ -151,6 +201,10 @@ function createStreamId(): string {
151
201
  return crypto.randomBytes(16).toString("hex");
152
202
  }
153
203
 
204
+ function hashEncryptPayload(encrypt: string): string {
205
+ return crypto.createHash("sha256").update(encrypt).digest("hex");
206
+ }
207
+
154
208
  function logVerbose(target: WecomWebhookTarget, message: string): void {
155
209
  try {
156
210
  const core = getWecomRuntime();
@@ -424,6 +478,9 @@ export async function handleWecomBotWebhook(params: {
424
478
  if (req.method === "GET") {
425
479
  const echostr = query.get("echostr") ?? "";
426
480
  if (!timestamp || !nonce || !signature || !echostr) {
481
+ targets[0]?.runtime?.log?.(
482
+ `[wecom] bot GET missing params (timestamp=${Boolean(timestamp)} nonce=${Boolean(nonce)} signature=${Boolean(signature)} echostr=${Boolean(echostr)})`,
483
+ );
427
484
  return false;
428
485
  }
429
486
 
@@ -440,20 +497,18 @@ export async function handleWecomBotWebhook(params: {
440
497
  return ok;
441
498
  });
442
499
  if (!target || !target.account.encodingAESKey) {
500
+ targets[0]?.runtime?.log?.("[wecom] bot GET signature verify failed");
443
501
  return false;
444
502
  }
445
503
  try {
446
- const plain = decryptWecomEncrypted({
447
- encodingAESKey: target.account.encodingAESKey,
448
- receiveId: target.account.receiveId,
449
- encrypt: echostr,
450
- });
504
+ const plain = decryptBotEncrypted(target.account, echostr);
451
505
  res.statusCode = 200;
452
506
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
453
507
  res.end(plain);
454
508
  return true;
455
509
  } catch (err) {
456
510
  const msg = err instanceof Error ? err.message : String(err);
511
+ targets[0]?.runtime?.error?.(`[wecom] bot GET decrypt failed: ${msg}`);
457
512
  res.statusCode = 400;
458
513
  res.end(msg || "decrypt failed");
459
514
  return true;
@@ -464,11 +519,6 @@ export async function handleWecomBotWebhook(params: {
464
519
  return false;
465
520
  }
466
521
 
467
- const contentType = req.headers["content-type"] ?? "";
468
- if (!String(contentType).toLowerCase().includes("json")) {
469
- return false;
470
- }
471
-
472
522
  if (!timestamp || !nonce || !signature) {
473
523
  return false;
474
524
  }
@@ -509,13 +559,31 @@ export async function handleWecomBotWebhook(params: {
509
559
  return true;
510
560
  }
511
561
 
562
+ const encryptHash = hashEncryptPayload(encrypt);
563
+ const dedupeEntry = recentEncrypts.get(encryptHash);
564
+ if (dedupeEntry && Date.now() - dedupeEntry.ts <= DEDUPE_TTL_MS) {
565
+ const streamId = dedupeEntry.streamId ?? "";
566
+ const state = streamId ? streams.get(streamId) : undefined;
567
+ if (streamId && state) {
568
+ const reply = state.error || state.content.trim()
569
+ ? buildStreamReplyFromState(state)
570
+ : buildStreamPlaceholderReply(streamId);
571
+ logVerbose(target, `bot dedupe hit streamId=${streamId}`);
572
+ jsonOk(res, buildEncryptedJsonReply({
573
+ account: target.account,
574
+ plaintextJson: reply,
575
+ nonce,
576
+ timestamp,
577
+ }));
578
+ dedupeEntry.ts = Date.now();
579
+ return true;
580
+ }
581
+ recentEncrypts.delete(encryptHash);
582
+ }
583
+
512
584
  let plain: string;
513
585
  try {
514
- plain = decryptWecomEncrypted({
515
- encodingAESKey: target.account.encodingAESKey,
516
- receiveId: target.account.receiveId,
517
- encrypt,
518
- });
586
+ plain = decryptBotEncrypted(target.account, encrypt);
519
587
  } catch (err) {
520
588
  const msg = err instanceof Error ? err.message : String(err);
521
589
  res.statusCode = 400;
@@ -528,6 +596,7 @@ export async function handleWecomBotWebhook(params: {
528
596
 
529
597
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
530
598
  const msgid = msg.msgid ? String(msg.msgid) : undefined;
599
+ logVerbose(target, `bot inbound msgtype=${msgtype || "unknown"} msgid=${msgid || "n/a"}`);
531
600
 
532
601
  if (msgtype === "stream") {
533
602
  const streamId = String((msg as any).stream?.id ?? "").trim();
@@ -542,6 +611,7 @@ export async function handleWecomBotWebhook(params: {
542
611
  finished: true,
543
612
  content: "",
544
613
  });
614
+ logVerbose(target, `bot stream refresh reply streamId=${streamId || "unknown"} finished=${Boolean(state?.finished)}`);
545
615
  jsonOk(res, buildEncryptedJsonReply({
546
616
  account: target.account,
547
617
  plaintextJson: reply,
@@ -554,6 +624,7 @@ export async function handleWecomBotWebhook(params: {
554
624
  if (msgid && msgidToStreamId.has(msgid)) {
555
625
  const streamId = msgidToStreamId.get(msgid) ?? "";
556
626
  const reply = buildStreamPlaceholderReply(streamId);
627
+ logVerbose(target, `bot stream placeholder reply streamId=${streamId || "unknown"}`);
557
628
  jsonOk(res, buildEncryptedJsonReply({
558
629
  account: target.account,
559
630
  plaintextJson: reply,
@@ -570,6 +641,7 @@ export async function handleWecomBotWebhook(params: {
570
641
  const reply = welcome
571
642
  ? { msgtype: "text", text: { content: welcome } }
572
643
  : {};
644
+ logVerbose(target, "bot event enter_chat reply");
573
645
  jsonOk(res, buildEncryptedJsonReply({
574
646
  account: target.account,
575
647
  plaintextJson: reply,
@@ -579,6 +651,7 @@ export async function handleWecomBotWebhook(params: {
579
651
  return true;
580
652
  }
581
653
 
654
+ logVerbose(target, "bot event reply empty");
582
655
  jsonOk(res, buildEncryptedJsonReply({
583
656
  account: target.account,
584
657
  plaintextJson: {},
@@ -599,6 +672,7 @@ export async function handleWecomBotWebhook(params: {
599
672
  finished: false,
600
673
  content: "",
601
674
  });
675
+ recentEncrypts.set(encryptHash, { ts: Date.now(), streamId });
602
676
 
603
677
  let core: PluginRuntime | null = null;
604
678
  try {
@@ -634,6 +708,11 @@ export async function handleWecomBotWebhook(params: {
634
708
  ? buildStreamReplyFromState(state)
635
709
  : buildStreamPlaceholderReply(streamId);
636
710
 
711
+ logVerbose(
712
+ target,
713
+ `bot initial reply streamId=${streamId} mode=${state && (state.content.trim() || state.error) ? "stream" : "placeholder"}`,
714
+ );
715
+ target.runtime.log?.(`[wecom] bot reply acked streamId=${streamId} msgid=${msgid || "n/a"}`);
637
716
  jsonOk(res, buildEncryptedJsonReply({
638
717
  account: target.account,
639
718
  plaintextJson: initialReply,
@@ -643,3 +722,21 @@ export async function handleWecomBotWebhook(params: {
643
722
 
644
723
  return true;
645
724
  }
725
+
726
+ function decryptBotEncrypted(account: ResolvedWecomAccount, encrypt: string): string {
727
+ const encodingAESKey = account.encodingAESKey ?? "";
728
+ if (!encodingAESKey) {
729
+ throw new Error("encodingAESKey missing");
730
+ }
731
+ const receiveId = account.receiveId || "";
732
+ try {
733
+ return decryptWecomEncrypted({ encodingAESKey, receiveId, encrypt });
734
+ } catch (err) {
735
+ const msg = err instanceof Error ? err.message : String(err);
736
+ if (!msg.includes("receiveId mismatch")) {
737
+ throw err;
738
+ }
739
+ // Some WeCom bot callbacks omit receiveId in the encrypted payload.
740
+ return decryptWecomEncrypted({ encodingAESKey, encrypt });
741
+ }
742
+ }