@yanhaidao/wecom 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/assets/link-me.jpg +0 -0
- package/clawdbot.plugin.json +9 -0
- package/index.ts +23 -0
- package/package.json +41 -0
- package/src/accounts.ts +73 -0
- package/src/channel.ts +212 -0
- package/src/config-schema.ts +36 -0
- package/src/crypto.test.ts +32 -0
- package/src/crypto.ts +133 -0
- package/src/monitor.ts +646 -0
- package/src/monitor.webhook.test.ts +161 -0
- package/src/runtime.ts +15 -0
- package/src/types.ts +77 -0
- package/tsconfig.json +22 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
|
5
|
+
|
|
6
|
+
import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
|
|
7
|
+
import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
|
|
8
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
9
|
+
|
|
10
|
+
export type WecomRuntimeEnv = {
|
|
11
|
+
log?: (message: string) => void;
|
|
12
|
+
error?: (message: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type WecomWebhookTarget = {
|
|
16
|
+
account: ResolvedWecomAccount;
|
|
17
|
+
config: ClawdbotConfig;
|
|
18
|
+
runtime: WecomRuntimeEnv;
|
|
19
|
+
core: PluginRuntime;
|
|
20
|
+
path: string;
|
|
21
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type StreamState = {
|
|
25
|
+
streamId: string;
|
|
26
|
+
msgid?: string;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
updatedAt: number;
|
|
29
|
+
started: boolean;
|
|
30
|
+
finished: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
content: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const webhookTargets = new Map<string, WecomWebhookTarget[]>();
|
|
36
|
+
const streams = new Map<string, StreamState>();
|
|
37
|
+
const msgidToStreamId = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
const STREAM_TTL_MS = 10 * 60 * 1000;
|
|
40
|
+
const STREAM_MAX_BYTES = 20_480;
|
|
41
|
+
|
|
42
|
+
function normalizeWebhookPath(raw: string): string {
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (!trimmed) return "/";
|
|
45
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
46
|
+
if (withSlash.length > 1 && withSlash.endsWith("/")) return withSlash.slice(0, -1);
|
|
47
|
+
return withSlash;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pruneStreams(): void {
|
|
51
|
+
const cutoff = Date.now() - STREAM_TTL_MS;
|
|
52
|
+
for (const [id, state] of streams.entries()) {
|
|
53
|
+
if (state.updatedAt < cutoff) {
|
|
54
|
+
streams.delete(id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const [msgid, id] of msgidToStreamId.entries()) {
|
|
58
|
+
if (!streams.has(id)) {
|
|
59
|
+
msgidToStreamId.delete(msgid);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
|
65
|
+
const buf = Buffer.from(text, "utf8");
|
|
66
|
+
if (buf.length <= maxBytes) return text;
|
|
67
|
+
const slice = buf.subarray(buf.length - maxBytes);
|
|
68
|
+
return slice.toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function jsonOk(res: ServerResponse, body: unknown): void {
|
|
72
|
+
res.statusCode = 200;
|
|
73
|
+
// WeCom's reference implementation returns the encrypted JSON as text/plain.
|
|
74
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
75
|
+
res.end(JSON.stringify(body));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
79
|
+
const chunks: Buffer[] = [];
|
|
80
|
+
let total = 0;
|
|
81
|
+
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
|
82
|
+
req.on("data", (chunk: Buffer) => {
|
|
83
|
+
total += chunk.length;
|
|
84
|
+
if (total > maxBytes) {
|
|
85
|
+
resolve({ ok: false, error: "payload too large" });
|
|
86
|
+
req.destroy();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
});
|
|
91
|
+
req.on("end", () => {
|
|
92
|
+
try {
|
|
93
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
94
|
+
if (!raw.trim()) {
|
|
95
|
+
resolve({ ok: false, error: "empty payload" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
resolve({ ok: true, value: JSON.parse(raw) as unknown });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
req.on("error", (err) => {
|
|
104
|
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildEncryptedJsonReply(params: {
|
|
110
|
+
account: ResolvedWecomAccount;
|
|
111
|
+
plaintextJson: unknown;
|
|
112
|
+
nonce: string;
|
|
113
|
+
timestamp: string;
|
|
114
|
+
}): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } {
|
|
115
|
+
const plaintext = JSON.stringify(params.plaintextJson ?? {});
|
|
116
|
+
const encrypt = encryptWecomPlaintext({
|
|
117
|
+
encodingAESKey: params.account.encodingAESKey ?? "",
|
|
118
|
+
receiveId: params.account.receiveId ?? "",
|
|
119
|
+
plaintext,
|
|
120
|
+
});
|
|
121
|
+
const msgsignature = computeWecomMsgSignature({
|
|
122
|
+
token: params.account.token ?? "",
|
|
123
|
+
timestamp: params.timestamp,
|
|
124
|
+
nonce: params.nonce,
|
|
125
|
+
encrypt,
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
encrypt,
|
|
129
|
+
msgsignature,
|
|
130
|
+
timestamp: params.timestamp,
|
|
131
|
+
nonce: params.nonce,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
|
|
136
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
137
|
+
return url.searchParams;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolvePath(req: IncomingMessage): string {
|
|
141
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
142
|
+
return normalizeWebhookPath(url.pathname || "/");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveSignatureParam(params: URLSearchParams): string {
|
|
146
|
+
return (
|
|
147
|
+
params.get("msg_signature") ??
|
|
148
|
+
params.get("msgsignature") ??
|
|
149
|
+
params.get("signature") ??
|
|
150
|
+
""
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
|
|
155
|
+
return {
|
|
156
|
+
msgtype: "stream",
|
|
157
|
+
stream: {
|
|
158
|
+
id: streamId,
|
|
159
|
+
finish: false,
|
|
160
|
+
// Spec: "第一次回复内容为 1" works as a minimal placeholder.
|
|
161
|
+
content: "1",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
|
|
167
|
+
const content = truncateUtf8Bytes(state.content, STREAM_MAX_BYTES);
|
|
168
|
+
return {
|
|
169
|
+
msgtype: "stream",
|
|
170
|
+
stream: {
|
|
171
|
+
id: state.streamId,
|
|
172
|
+
finish: state.finished,
|
|
173
|
+
content,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function createStreamId(): string {
|
|
179
|
+
return crypto.randomBytes(16).toString("hex");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
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
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseWecomPlainMessage(raw: string): WecomInboundMessage {
|
|
191
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
192
|
+
if (!parsed || typeof parsed !== "object") {
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
return parsed as WecomInboundMessage;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
|
|
199
|
+
if (maxWaitMs <= 0) return;
|
|
200
|
+
const startedAt = Date.now();
|
|
201
|
+
await new Promise<void>((resolve) => {
|
|
202
|
+
const tick = () => {
|
|
203
|
+
const state = streams.get(streamId);
|
|
204
|
+
if (!state) return resolve();
|
|
205
|
+
if (state.error || state.finished || state.content.trim()) return resolve();
|
|
206
|
+
if (Date.now() - startedAt >= maxWaitMs) return resolve();
|
|
207
|
+
setTimeout(tick, 25);
|
|
208
|
+
};
|
|
209
|
+
tick();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function startAgentForStream(params: {
|
|
214
|
+
target: WecomWebhookTarget;
|
|
215
|
+
accountId: string;
|
|
216
|
+
msg: WecomInboundMessage;
|
|
217
|
+
streamId: string;
|
|
218
|
+
}): Promise<void> {
|
|
219
|
+
const { target, msg, streamId } = params;
|
|
220
|
+
const core = target.core;
|
|
221
|
+
const config = target.config;
|
|
222
|
+
const account = target.account;
|
|
223
|
+
|
|
224
|
+
const userid = msg.from?.userid?.trim() || "unknown";
|
|
225
|
+
const chatType = msg.chattype === "group" ? "group" : "direct";
|
|
226
|
+
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
227
|
+
const rawBody = buildInboundBody(msg);
|
|
228
|
+
|
|
229
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
230
|
+
cfg: config,
|
|
231
|
+
channel: "wecom",
|
|
232
|
+
accountId: account.accountId,
|
|
233
|
+
peer: { kind: chatType === "group" ? "group" : "dm", id: chatId },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`);
|
|
237
|
+
|
|
238
|
+
const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`;
|
|
239
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
240
|
+
agentId: route.agentId,
|
|
241
|
+
});
|
|
242
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
243
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
244
|
+
storePath,
|
|
245
|
+
sessionKey: route.sessionKey,
|
|
246
|
+
});
|
|
247
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
248
|
+
channel: "WeCom",
|
|
249
|
+
from: fromLabel,
|
|
250
|
+
previousTimestamp,
|
|
251
|
+
envelope: envelopeOptions,
|
|
252
|
+
body: rawBody,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
256
|
+
Body: body,
|
|
257
|
+
RawBody: rawBody,
|
|
258
|
+
CommandBody: rawBody,
|
|
259
|
+
From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`,
|
|
260
|
+
To: `wecom:${chatId}`,
|
|
261
|
+
SessionKey: route.sessionKey,
|
|
262
|
+
AccountId: route.accountId,
|
|
263
|
+
ChatType: chatType,
|
|
264
|
+
ConversationLabel: fromLabel,
|
|
265
|
+
SenderName: userid,
|
|
266
|
+
SenderId: userid,
|
|
267
|
+
Provider: "wecom",
|
|
268
|
+
Surface: "wecom",
|
|
269
|
+
MessageSid: msg.msgid,
|
|
270
|
+
OriginatingChannel: "wecom",
|
|
271
|
+
OriginatingTo: `wecom:${chatId}`,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await core.channel.session.recordInboundSession({
|
|
275
|
+
storePath,
|
|
276
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
277
|
+
ctx: ctxPayload,
|
|
278
|
+
onRecordError: (err) => {
|
|
279
|
+
target.runtime.error?.(`wecom: failed updating session meta: ${String(err)}`);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
284
|
+
cfg: config,
|
|
285
|
+
channel: "wecom",
|
|
286
|
+
accountId: account.accountId,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
290
|
+
ctx: ctxPayload,
|
|
291
|
+
cfg: config,
|
|
292
|
+
dispatcherOptions: {
|
|
293
|
+
deliver: async (payload) => {
|
|
294
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
295
|
+
const current = streams.get(streamId);
|
|
296
|
+
if (!current) return;
|
|
297
|
+
const nextText = current.content
|
|
298
|
+
? `${current.content}\n\n${text}`.trim()
|
|
299
|
+
: text.trim();
|
|
300
|
+
current.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
|
|
301
|
+
current.updatedAt = Date.now();
|
|
302
|
+
target.statusSink?.({ lastOutboundAt: Date.now() });
|
|
303
|
+
},
|
|
304
|
+
onError: (err, info) => {
|
|
305
|
+
target.runtime.error?.(`[${account.accountId}] wecom ${info.kind} reply failed: ${String(err)}`);
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const current = streams.get(streamId);
|
|
311
|
+
if (current) {
|
|
312
|
+
current.finished = true;
|
|
313
|
+
current.updatedAt = Date.now();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function buildInboundBody(msg: WecomInboundMessage): string {
|
|
318
|
+
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") {
|
|
328
|
+
const items = (msg as any).mixed?.msg_item;
|
|
329
|
+
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]";
|
|
353
|
+
}
|
|
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}]` : "";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
|
|
362
|
+
const key = normalizeWebhookPath(target.path);
|
|
363
|
+
const normalizedTarget = { ...target, path: key };
|
|
364
|
+
const existing = webhookTargets.get(key) ?? [];
|
|
365
|
+
const next = [...existing, normalizedTarget];
|
|
366
|
+
webhookTargets.set(key, next);
|
|
367
|
+
return () => {
|
|
368
|
+
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
|
369
|
+
if (updated.length > 0) webhookTargets.set(key, updated);
|
|
370
|
+
else webhookTargets.delete(key);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function handleWecomWebhookRequest(
|
|
375
|
+
req: IncomingMessage,
|
|
376
|
+
res: ServerResponse,
|
|
377
|
+
): Promise<boolean> {
|
|
378
|
+
pruneStreams();
|
|
379
|
+
|
|
380
|
+
const path = resolvePath(req);
|
|
381
|
+
const targets = webhookTargets.get(path);
|
|
382
|
+
if (!targets || targets.length === 0) return false;
|
|
383
|
+
|
|
384
|
+
const query = resolveQueryParams(req);
|
|
385
|
+
const timestamp = query.get("timestamp") ?? "";
|
|
386
|
+
const nonce = query.get("nonce") ?? "";
|
|
387
|
+
const signature = resolveSignatureParam(query);
|
|
388
|
+
|
|
389
|
+
const firstTarget = targets[0]!;
|
|
390
|
+
logVerbose(firstTarget, `incoming ${req.method} request on ${path} (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`);
|
|
391
|
+
|
|
392
|
+
if (req.method === "GET") {
|
|
393
|
+
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
|
+
});
|
|
414
|
+
if (!target || !target.account.encodingAESKey) {
|
|
415
|
+
logVerbose(firstTarget, "no matching target for GET signature");
|
|
416
|
+
res.statusCode = 401;
|
|
417
|
+
res.end("unauthorized");
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
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");
|
|
427
|
+
res.statusCode = 200;
|
|
428
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
429
|
+
res.end(plain);
|
|
430
|
+
return true;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
433
|
+
logVerbose(target, `GET decrypt failed: ${msg}`);
|
|
434
|
+
res.statusCode = 400;
|
|
435
|
+
res.end(msg || "decrypt failed");
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
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
|
+
}
|
|
453
|
+
|
|
454
|
+
const body = await readJsonBody(req, 1024 * 1024);
|
|
455
|
+
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
|
+
res.statusCode = 400;
|
|
466
|
+
res.end("missing encrypt");
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
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");
|
|
487
|
+
res.statusCode = 401;
|
|
488
|
+
res.end("unauthorized");
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
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
|
+
let plain: string;
|
|
500
|
+
try {
|
|
501
|
+
plain = decryptWecomEncrypted({
|
|
502
|
+
encodingAESKey: target.account.encodingAESKey,
|
|
503
|
+
receiveId: target.account.receiveId,
|
|
504
|
+
encrypt,
|
|
505
|
+
});
|
|
506
|
+
logVerbose(target, `decrypted POST message: ${plain}`);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
509
|
+
logVerbose(target, `POST decrypt failed: ${msg}`);
|
|
510
|
+
res.statusCode = 400;
|
|
511
|
+
res.end(msg || "decrypt failed");
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const msg = parseWecomPlainMessage(plain);
|
|
516
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
517
|
+
|
|
518
|
+
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
|
+
|
|
556
|
+
// enter_chat welcome event: optionally reply with text (allowed by spec).
|
|
557
|
+
if (msgtype === "event") {
|
|
558
|
+
const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
|
|
559
|
+
if (eventtype === "enter_chat") {
|
|
560
|
+
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
|
+
}));
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// For other events, reply empty to avoid timeouts.
|
|
574
|
+
jsonOk(res, buildEncryptedJsonReply({
|
|
575
|
+
account: target.account,
|
|
576
|
+
plaintextJson: {},
|
|
577
|
+
nonce,
|
|
578
|
+
timestamp,
|
|
579
|
+
}));
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
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)}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
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
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
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}`);
|
|
645
|
+
return true;
|
|
646
|
+
}
|