@marshulll/openclaw-wecom 0.1.4 → 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/README.en.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/INSTALL.md +1 -1
- package/package.json +1 -1
- package/wecom/package.json +1 -1
- package/wecom/src/monitor.ts +5 -0
- package/wecom/src/wecom-app.ts +24 -2
- package/wecom/src/wecom-bot.ts +117 -20
package/README.en.md
CHANGED
package/README.md
CHANGED
package/README.zh.md
CHANGED
package/docs/INSTALL.md
CHANGED
package/package.json
CHANGED
package/wecom/package.json
CHANGED
package/wecom/src/monitor.ts
CHANGED
|
@@ -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.
|
package/wecom/src/wecom-app.ts
CHANGED
|
@@ -26,8 +26,30 @@ function parseIncomingXml(xml: string): Record<string, any> {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
|
|
29
|
-
const
|
|
30
|
-
|
|
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 {
|
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -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", "
|
|
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;
|
|
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
|
|
115
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|