@marshulll/wecom-dual 0.1.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.
@@ -0,0 +1,645 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import crypto from "node:crypto";
3
+
4
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
5
+
6
+ import type { WecomWebhookTarget } from "./monitor.js";
7
+ import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
8
+ import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature } from "./crypto.js";
9
+ import { fetchMediaFromUrl, sendWecomFile, sendWecomImage, sendWecomVideo, sendWecomVoice, uploadWecomMedia } from "./wecom-api.js";
10
+ import { getWecomRuntime } from "./runtime.js";
11
+
12
+ const STREAM_TTL_MS = 10 * 60 * 1000;
13
+ const STREAM_MAX_BYTES = 20_480;
14
+
15
+ type StreamState = {
16
+ streamId: string;
17
+ msgid?: string;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ started: boolean;
21
+ finished: boolean;
22
+ error?: string;
23
+ content: string;
24
+ };
25
+
26
+ const streams = new Map<string, StreamState>();
27
+ const msgidToStreamId = new Map<string, string>();
28
+
29
+ function pruneStreams(): void {
30
+ const cutoff = Date.now() - STREAM_TTL_MS;
31
+ for (const [id, state] of streams.entries()) {
32
+ if (state.updatedAt < cutoff) {
33
+ streams.delete(id);
34
+ }
35
+ }
36
+ for (const [msgid, id] of msgidToStreamId.entries()) {
37
+ if (!streams.has(id)) {
38
+ msgidToStreamId.delete(msgid);
39
+ }
40
+ }
41
+ }
42
+
43
+ function truncateUtf8Bytes(text: string, maxBytes: number): string {
44
+ const buf = Buffer.from(text, "utf8");
45
+ if (buf.length <= maxBytes) return text;
46
+ const slice = buf.subarray(buf.length - maxBytes);
47
+ return slice.toString("utf8");
48
+ }
49
+
50
+ function jsonOk(res: ServerResponse, body: unknown): void {
51
+ res.statusCode = 200;
52
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
53
+ res.end(JSON.stringify(body));
54
+ }
55
+
56
+ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
57
+ const chunks: Buffer[] = [];
58
+ let total = 0;
59
+ return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
60
+ req.on("data", (chunk: Buffer) => {
61
+ total += chunk.length;
62
+ if (total > maxBytes) {
63
+ resolve({ ok: false, error: "payload too large" });
64
+ req.destroy();
65
+ return;
66
+ }
67
+ chunks.push(chunk);
68
+ });
69
+ req.on("end", () => {
70
+ try {
71
+ const raw = Buffer.concat(chunks).toString("utf8");
72
+ if (!raw.trim()) {
73
+ resolve({ ok: false, error: "empty payload" });
74
+ return;
75
+ }
76
+ resolve({ ok: true, value: JSON.parse(raw) as unknown });
77
+ } catch (err) {
78
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
79
+ }
80
+ });
81
+ req.on("error", (err) => {
82
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
83
+ });
84
+ });
85
+ }
86
+
87
+ function buildEncryptedJsonReply(params: {
88
+ account: ResolvedWecomAccount;
89
+ plaintextJson: unknown;
90
+ nonce: string;
91
+ timestamp: string;
92
+ }): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } {
93
+ const plaintext = JSON.stringify(params.plaintextJson ?? {});
94
+ const encrypt = encryptWecomPlaintext({
95
+ encodingAESKey: params.account.encodingAESKey ?? "",
96
+ receiveId: params.account.receiveId ?? "",
97
+ plaintext,
98
+ });
99
+ const msgsignature = computeWecomMsgSignature({
100
+ token: params.account.token ?? "",
101
+ timestamp: params.timestamp,
102
+ nonce: params.nonce,
103
+ encrypt,
104
+ });
105
+ return {
106
+ encrypt,
107
+ msgsignature,
108
+ timestamp: params.timestamp,
109
+ nonce: params.nonce,
110
+ };
111
+ }
112
+
113
+ function resolveQueryParams(req: IncomingMessage): URLSearchParams {
114
+ const url = new URL(req.url ?? "/", "http://localhost");
115
+ return url.searchParams;
116
+ }
117
+
118
+ function resolveSignatureParam(params: URLSearchParams): string {
119
+ return (
120
+ params.get("msg_signature") ??
121
+ params.get("msgsignature") ??
122
+ params.get("signature") ??
123
+ ""
124
+ );
125
+ }
126
+
127
+ function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
128
+ return {
129
+ msgtype: "stream",
130
+ stream: {
131
+ id: streamId,
132
+ finish: false,
133
+ content: "1",
134
+ },
135
+ };
136
+ }
137
+
138
+ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
139
+ const content = truncateUtf8Bytes(state.content, STREAM_MAX_BYTES);
140
+ return {
141
+ msgtype: "stream",
142
+ stream: {
143
+ id: state.streamId,
144
+ finish: state.finished,
145
+ content,
146
+ },
147
+ };
148
+ }
149
+
150
+ function createStreamId(): string {
151
+ return crypto.randomBytes(16).toString("hex");
152
+ }
153
+
154
+ function logVerbose(target: WecomWebhookTarget, message: string): void {
155
+ try {
156
+ const core = getWecomRuntime();
157
+ const should = core.logging?.shouldLogVerbose?.() ?? false;
158
+ if (should) {
159
+ target.runtime.log?.(`[wecom] ${message}`);
160
+ }
161
+ } catch {
162
+ // runtime not ready; skip verbose logging
163
+ }
164
+ }
165
+
166
+ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
167
+ const parsed = JSON.parse(raw) as unknown;
168
+ if (!parsed || typeof parsed !== "object") {
169
+ return {};
170
+ }
171
+ return parsed as WecomInboundMessage;
172
+ }
173
+
174
+ async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
175
+ if (maxWaitMs <= 0) return;
176
+ const startedAt = Date.now();
177
+ await new Promise<void>((resolve) => {
178
+ const tick = () => {
179
+ const state = streams.get(streamId);
180
+ if (!state) return resolve();
181
+ if (state.error || state.finished || state.content.trim()) return resolve();
182
+ if (Date.now() - startedAt >= maxWaitMs) return resolve();
183
+ setTimeout(tick, 25);
184
+ };
185
+ tick();
186
+ });
187
+ }
188
+
189
+ async function startAgentForStream(params: {
190
+ target: WecomWebhookTarget;
191
+ accountId: string;
192
+ msg: WecomInboundMessage;
193
+ streamId: string;
194
+ }): Promise<void> {
195
+ const { target, msg, streamId } = params;
196
+ const core = getWecomRuntime();
197
+ const config = target.config;
198
+ const account = target.account;
199
+
200
+ const userid = msg.from?.userid?.trim() || "unknown";
201
+ const chatType = msg.chattype === "group" ? "group" : "direct";
202
+ const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
203
+ const rawBody = buildInboundBody(msg);
204
+
205
+ const route = core.channel.routing.resolveAgentRoute({
206
+ cfg: config,
207
+ channel: "wecom",
208
+ accountId: account.accountId,
209
+ peer: { kind: chatType === "group" ? "group" : "dm", id: chatId },
210
+ });
211
+
212
+ logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`);
213
+
214
+ const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`;
215
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
216
+ agentId: route.agentId,
217
+ });
218
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
219
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
220
+ storePath,
221
+ sessionKey: route.sessionKey,
222
+ });
223
+ const body = core.channel.reply.formatAgentEnvelope({
224
+ channel: "WeCom",
225
+ from: fromLabel,
226
+ previousTimestamp,
227
+ envelope: envelopeOptions,
228
+ body: rawBody,
229
+ });
230
+
231
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
232
+ Body: body,
233
+ RawBody: rawBody,
234
+ CommandBody: rawBody,
235
+ From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`,
236
+ To: `wecom:${chatId}`,
237
+ SessionKey: route.sessionKey,
238
+ AccountId: route.accountId,
239
+ ChatType: chatType,
240
+ ConversationLabel: fromLabel,
241
+ SenderName: userid,
242
+ SenderId: userid,
243
+ Provider: "wecom",
244
+ Surface: "wecom",
245
+ MessageSid: msg.msgid,
246
+ OriginatingChannel: "wecom",
247
+ OriginatingTo: `wecom:${chatId}`,
248
+ });
249
+
250
+ await core.channel.session.recordInboundSession({
251
+ storePath,
252
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
253
+ ctx: ctxPayload,
254
+ onRecordError: (err) => {
255
+ target.runtime.error?.(`wecom: failed updating session meta: ${String(err)}`);
256
+ },
257
+ });
258
+
259
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
260
+ cfg: config,
261
+ channel: "wecom",
262
+ accountId: account.accountId,
263
+ });
264
+
265
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
266
+ ctx: ctxPayload,
267
+ cfg: config,
268
+ dispatcherOptions: {
269
+ deliver: async (payload) => {
270
+ const maybeMediaUrl = (payload as any).mediaUrl as string | undefined;
271
+ const maybeMediaType = (payload as any).mediaType as string | undefined;
272
+ const canBridgeMedia = account.config.botMediaBridge !== false
273
+ && Boolean(account.corpId && account.corpSecret && account.agentId);
274
+ const toChatId = chatType === "group" ? chatId : undefined;
275
+
276
+ if (maybeMediaUrl && canBridgeMedia) {
277
+ try {
278
+ const media = await fetchMediaFromUrl(maybeMediaUrl, account);
279
+ const type = normalizeMediaType(maybeMediaType) ?? "file";
280
+ const ext = media.contentType.includes("png") ? "png"
281
+ : media.contentType.includes("gif") ? "gif"
282
+ : media.contentType.includes("jpeg") || media.contentType.includes("jpg") ? "jpg"
283
+ : media.contentType.includes("mp4") ? "mp4"
284
+ : media.contentType.includes("amr") ? "amr"
285
+ : media.contentType.includes("wav") ? "wav"
286
+ : media.contentType.includes("mp3") ? "mp3"
287
+ : "bin";
288
+ const mediaId = await uploadWecomMedia({
289
+ account,
290
+ type: type as "image" | "voice" | "video" | "file",
291
+ buffer: media.buffer,
292
+ filename: `${type}.${ext}`,
293
+ });
294
+ if (type === "image") {
295
+ await sendWecomImage({ account, toUser: userid, chatId: toChatId, mediaId });
296
+ } else if (type === "voice") {
297
+ await sendWecomVoice({ account, toUser: userid, chatId: toChatId, mediaId });
298
+ } else if (type === "video") {
299
+ const title = (payload as any).title as string | undefined;
300
+ const description = (payload as any).description as string | undefined;
301
+ await sendWecomVideo({ account, toUser: userid, chatId: toChatId, mediaId, title, description });
302
+ } else if (type === "file") {
303
+ await sendWecomFile({ account, toUser: userid, chatId: toChatId, mediaId });
304
+ }
305
+ const current = streams.get(streamId);
306
+ if (current) {
307
+ const note = mediaSentLabel(type);
308
+ const nextText = current.content ? `${current.content}\n\n${note}` : note;
309
+ current.content = truncateUtf8Bytes(nextText.trim(), STREAM_MAX_BYTES);
310
+ current.updatedAt = Date.now();
311
+ }
312
+ target.statusSink?.({ lastOutboundAt: Date.now() });
313
+ } catch (err) {
314
+ target.runtime.error?.(`[${account.accountId}] wecom bot media bridge failed: ${String(err)}`);
315
+ }
316
+ }
317
+
318
+ const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
319
+ const current = streams.get(streamId);
320
+ if (!current) return;
321
+ const nextText = current.content
322
+ ? `${current.content}\n\n${text}`.trim()
323
+ : text.trim();
324
+ current.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
325
+ current.updatedAt = Date.now();
326
+ target.statusSink?.({ lastOutboundAt: Date.now() });
327
+ },
328
+ onError: (err, info) => {
329
+ target.runtime.error?.(`[${account.accountId}] wecom ${info.kind} reply failed: ${String(err)}`);
330
+ },
331
+ },
332
+ });
333
+
334
+ const current = streams.get(streamId);
335
+ if (current) {
336
+ current.finished = true;
337
+ current.updatedAt = Date.now();
338
+ }
339
+ }
340
+
341
+ function buildInboundBody(msg: WecomInboundMessage): string {
342
+ const msgtype = String(msg.msgtype ?? "").toLowerCase();
343
+ if (msgtype === "text") {
344
+ const content = (msg as any).text?.content;
345
+ return typeof content === "string" ? content : "";
346
+ }
347
+ if (msgtype === "voice") {
348
+ const content = (msg as any).voice?.content;
349
+ return typeof content === "string" ? content : "[voice]";
350
+ }
351
+ if (msgtype === "mixed") {
352
+ const items = (msg as any).mixed?.msg_item;
353
+ if (Array.isArray(items)) {
354
+ return items
355
+ .map((item: any) => {
356
+ const t = String(item?.msgtype ?? "").toLowerCase();
357
+ if (t === "text") return String(item?.text?.content ?? "");
358
+ if (t === "image") return `[image] ${String(item?.image?.url ?? "").trim()}`.trim();
359
+ return `[${t || "item"}]`;
360
+ })
361
+ .filter((part: string) => Boolean(part && part.trim()))
362
+ .join("\n");
363
+ }
364
+ return "[mixed]";
365
+ }
366
+ if (msgtype === "image") {
367
+ const url = String((msg as any).image?.url ?? "").trim();
368
+ return url ? `[image] ${url}` : "[image]";
369
+ }
370
+ if (msgtype === "file") {
371
+ const url = String((msg as any).file?.url ?? "").trim();
372
+ return url ? `[file] ${url}` : "[file]";
373
+ }
374
+ if (msgtype === "video") {
375
+ const url = String((msg as any).video?.url ?? "").trim();
376
+ return url ? `[video] ${url}` : "[video]";
377
+ }
378
+ if (msgtype === "event") {
379
+ const eventtype = String((msg as any).event?.eventtype ?? "").trim();
380
+ return eventtype ? `[event] ${eventtype}` : "[event]";
381
+ }
382
+ if (msgtype === "stream") {
383
+ const id = String((msg as any).stream?.id ?? "").trim();
384
+ return id ? `[stream_refresh] ${id}` : "[stream_refresh]";
385
+ }
386
+ return msgtype ? `[${msgtype}]` : "";
387
+ }
388
+
389
+ function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
390
+ if (!raw) return null;
391
+ const value = raw.toLowerCase();
392
+ if (value === "image" || value === "voice" || value === "video" || value === "file") return value;
393
+ return null;
394
+ }
395
+
396
+ function mediaSentLabel(type: string): string {
397
+ if (type === "image") return "[已发送图片]";
398
+ if (type === "voice") return "[已发送语音]";
399
+ if (type === "video") return "[已发送视频]";
400
+ if (type === "file") return "[已发送文件]";
401
+ return "[已发送媒体]";
402
+ }
403
+
404
+ function shouldHandleBot(account: ResolvedWecomAccount): boolean {
405
+ return account.mode === "bot" || account.mode === "both";
406
+ }
407
+
408
+ export async function handleWecomBotWebhook(params: {
409
+ req: IncomingMessage;
410
+ res: ServerResponse;
411
+ targets: WecomWebhookTarget[];
412
+ }): Promise<boolean> {
413
+ pruneStreams();
414
+
415
+ const { req, res, targets } = params;
416
+ const query = resolveQueryParams(req);
417
+ const timestamp = query.get("timestamp") ?? "";
418
+ const nonce = query.get("nonce") ?? "";
419
+ const signature = resolveSignatureParam(query);
420
+
421
+ const firstTarget = targets[0]!;
422
+ logVerbose(firstTarget, `incoming ${req.method} request (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`);
423
+
424
+ if (req.method === "GET") {
425
+ const echostr = query.get("echostr") ?? "";
426
+ if (!timestamp || !nonce || !signature || !echostr) {
427
+ return false;
428
+ }
429
+
430
+ const target = targets.find((candidate) => {
431
+ if (!shouldHandleBot(candidate.account)) return false;
432
+ if (!candidate.account.configured || !candidate.account.token) return false;
433
+ const ok = verifyWecomSignature({
434
+ token: candidate.account.token,
435
+ timestamp,
436
+ nonce,
437
+ encrypt: echostr,
438
+ signature,
439
+ });
440
+ return ok;
441
+ });
442
+ if (!target || !target.account.encodingAESKey) {
443
+ return false;
444
+ }
445
+ try {
446
+ const plain = decryptWecomEncrypted({
447
+ encodingAESKey: target.account.encodingAESKey,
448
+ receiveId: target.account.receiveId,
449
+ encrypt: echostr,
450
+ });
451
+ res.statusCode = 200;
452
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
453
+ res.end(plain);
454
+ return true;
455
+ } catch (err) {
456
+ const msg = err instanceof Error ? err.message : String(err);
457
+ res.statusCode = 400;
458
+ res.end(msg || "decrypt failed");
459
+ return true;
460
+ }
461
+ }
462
+
463
+ if (req.method !== "POST") {
464
+ return false;
465
+ }
466
+
467
+ const contentType = req.headers["content-type"] ?? "";
468
+ if (!String(contentType).toLowerCase().includes("json")) {
469
+ return false;
470
+ }
471
+
472
+ if (!timestamp || !nonce || !signature) {
473
+ return false;
474
+ }
475
+
476
+ const body = await readJsonBody(req, 1024 * 1024);
477
+ if (!body.ok) {
478
+ res.statusCode = body.error === "payload too large" ? 413 : 400;
479
+ res.end(body.error ?? "invalid payload");
480
+ return true;
481
+ }
482
+ const record = body.value && typeof body.value === "object" ? (body.value as Record<string, unknown>) : null;
483
+ const encrypt = record ? String(record.encrypt ?? record.Encrypt ?? "") : "";
484
+ if (!encrypt) {
485
+ res.statusCode = 400;
486
+ res.end("missing encrypt");
487
+ return true;
488
+ }
489
+
490
+ const target = targets.find((candidate) => {
491
+ if (!shouldHandleBot(candidate.account)) return false;
492
+ if (!candidate.account.token) return false;
493
+ const ok = verifyWecomSignature({
494
+ token: candidate.account.token,
495
+ timestamp,
496
+ nonce,
497
+ encrypt,
498
+ signature,
499
+ });
500
+ return ok;
501
+ });
502
+ if (!target) {
503
+ return false;
504
+ }
505
+
506
+ if (!target.account.configured || !target.account.token || !target.account.encodingAESKey) {
507
+ res.statusCode = 500;
508
+ res.end("wecom not configured");
509
+ return true;
510
+ }
511
+
512
+ let plain: string;
513
+ try {
514
+ plain = decryptWecomEncrypted({
515
+ encodingAESKey: target.account.encodingAESKey,
516
+ receiveId: target.account.receiveId,
517
+ encrypt,
518
+ });
519
+ } catch (err) {
520
+ const msg = err instanceof Error ? err.message : String(err);
521
+ res.statusCode = 400;
522
+ res.end(msg || "decrypt failed");
523
+ return true;
524
+ }
525
+
526
+ const msg = parseWecomPlainMessage(plain);
527
+ target.statusSink?.({ lastInboundAt: Date.now() });
528
+
529
+ const msgtype = String(msg.msgtype ?? "").toLowerCase();
530
+ const msgid = msg.msgid ? String(msg.msgid) : undefined;
531
+
532
+ if (msgtype === "stream") {
533
+ const streamId = String((msg as any).stream?.id ?? "").trim();
534
+ const state = streamId ? streams.get(streamId) : undefined;
535
+ const reply = state
536
+ ? buildStreamReplyFromState(state)
537
+ : buildStreamReplyFromState({
538
+ streamId: streamId || "unknown",
539
+ createdAt: Date.now(),
540
+ updatedAt: Date.now(),
541
+ started: true,
542
+ finished: true,
543
+ content: "",
544
+ });
545
+ jsonOk(res, buildEncryptedJsonReply({
546
+ account: target.account,
547
+ plaintextJson: reply,
548
+ nonce,
549
+ timestamp,
550
+ }));
551
+ return true;
552
+ }
553
+
554
+ if (msgid && msgidToStreamId.has(msgid)) {
555
+ const streamId = msgidToStreamId.get(msgid) ?? "";
556
+ const reply = buildStreamPlaceholderReply(streamId);
557
+ jsonOk(res, buildEncryptedJsonReply({
558
+ account: target.account,
559
+ plaintextJson: reply,
560
+ nonce,
561
+ timestamp,
562
+ }));
563
+ return true;
564
+ }
565
+
566
+ if (msgtype === "event") {
567
+ const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
568
+ if (eventtype === "enter_chat") {
569
+ const welcome = target.account.config.welcomeText?.trim();
570
+ const reply = welcome
571
+ ? { msgtype: "text", text: { content: welcome } }
572
+ : {};
573
+ jsonOk(res, buildEncryptedJsonReply({
574
+ account: target.account,
575
+ plaintextJson: reply,
576
+ nonce,
577
+ timestamp,
578
+ }));
579
+ return true;
580
+ }
581
+
582
+ jsonOk(res, buildEncryptedJsonReply({
583
+ account: target.account,
584
+ plaintextJson: {},
585
+ nonce,
586
+ timestamp,
587
+ }));
588
+ return true;
589
+ }
590
+
591
+ const streamId = createStreamId();
592
+ if (msgid) msgidToStreamId.set(msgid, streamId);
593
+ streams.set(streamId, {
594
+ streamId,
595
+ msgid,
596
+ createdAt: Date.now(),
597
+ updatedAt: Date.now(),
598
+ started: false,
599
+ finished: false,
600
+ content: "",
601
+ });
602
+
603
+ let core: PluginRuntime | null = null;
604
+ try {
605
+ core = getWecomRuntime();
606
+ } catch (err) {
607
+ logVerbose(target, `runtime not ready, skipping agent processing: ${String(err)}`);
608
+ }
609
+
610
+ if (core) {
611
+ streams.get(streamId)!.started = true;
612
+ const enrichedTarget: WecomWebhookTarget = { ...target, core };
613
+ startAgentForStream({ target: enrichedTarget, accountId: target.account.accountId, msg, streamId }).catch((err) => {
614
+ const state = streams.get(streamId);
615
+ if (state) {
616
+ state.error = err instanceof Error ? err.message : String(err);
617
+ state.content = state.content || `Error: ${state.error}`;
618
+ state.finished = true;
619
+ state.updatedAt = Date.now();
620
+ }
621
+ target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
622
+ });
623
+ } else {
624
+ const state = streams.get(streamId);
625
+ if (state) {
626
+ state.finished = true;
627
+ state.updatedAt = Date.now();
628
+ }
629
+ }
630
+
631
+ await waitForStreamContent(streamId, 800);
632
+ const state = streams.get(streamId);
633
+ const initialReply = state && (state.content.trim() || state.error)
634
+ ? buildStreamReplyFromState(state)
635
+ : buildStreamPlaceholderReply(streamId);
636
+
637
+ jsonOk(res, buildEncryptedJsonReply({
638
+ account: target.account,
639
+ plaintextJson: initialReply,
640
+ nonce,
641
+ timestamp,
642
+ }));
643
+
644
+ return true;
645
+ }