@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/README.md +54 -26
- package/assets/01.image.jpg +0 -0
- package/assets/link-me.jpg +0 -0
- package/index.ts +4 -4
- package/{clawdbot.plugin.json → openclaw.plugin.json} +1 -0
- package/package.json +5 -5
- package/src/accounts.ts +9 -10
- package/src/channel.ts +12 -12
- package/src/config-schema.ts +3 -0
- package/src/crypto.ts +3 -3
- package/src/media.test.ts +49 -0
- package/src/media.ts +39 -0
- package/src/monitor.active.test.ts +137 -0
- package/src/monitor.integration.test.ts +190 -0
- package/src/monitor.ts +452 -261
- package/src/monitor.webhook.test.ts +162 -94
- package/src/runtime.ts +1 -2
- package/src/types.ts +84 -2
- package/tsconfig.json +1 -1
- package/vitest.config.ts +15 -0
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 {
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
728
|
+
res.end(body.error || "invalid payload");
|
|
467
729
|
return true;
|
|
468
730
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const target = targets.find(
|
|
472
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
streamId,
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
});
|
|
619
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
+
}
|