@gavin-hu/my-pi-agent 0.11.1 → 0.11.3

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.
@@ -1,41 +1,37 @@
1
1
  /**
2
- * The WeChat bridge: one poll loop per account, one session per peer.
2
+ * The WeChat bridge: one connection per account, one session per peer.
3
3
  *
4
- * The bridge owns what is genuinely shared — the poll loop, admission, the media
5
- * pipeline, the typing indicator, the peer-facing notices, and the reply
6
- * transport. Each admitted message is handed to the sender's own session through
7
- * the peer worker, so a WeChat turn never runs in the terminal's session: no idle
8
- * gate, no reply capture from a run that happens to finish, and no shared
9
- * context.
4
+ * This is the composition root. The poll loop, outbound delivery, the typing and
5
+ * wait notices, admission (dedupe/cap/budget), and inbound media each live in
6
+ * their own module; the bridge wires them to the live client, account, cursor,
7
+ * and state, and owns the connection lifecycle and the status snapshot. Each
8
+ * admitted message is handed to the sender's own session through the peer
9
+ * worker, so a WeChat turn never runs in the terminal's session.
10
10
  *
11
11
  * The peer only has the chat window, so the bridge carries the signals the phone
12
12
  * needs: typing starts as soon as a message is picked up, a message waiting on
13
13
  * another conversation is acknowledged once and told why, a slow turn says so,
14
14
  * and a burst that arrives before its turn starts is answered once, in order.
15
15
  *
16
- * Inbound images are downloaded, decrypted, and attached as model image content;
17
- * inbound files are saved under the media directory and referenced by path. The
18
- * model may message the owner proactively through {@link Bridge.sendToOwner}.
16
+ * The model may message the owner proactively through {@link Bridge.sendToOwner}.
19
17
  */
20
18
 
21
- import type { ImageContent } from "@earendil-works/pi-ai";
22
- import { isSessionExpired, WechatError, type WechatClient } from "./client.ts";
19
+ import { WechatError, type WechatClient } from "./client.ts";
20
+ import { createAdmissionQueue, createTurnBudget } from "./admission.ts";
21
+ import { createDeliverer } from "./deliver.ts";
22
+ import { createInboundAdmission } from "./inbound.ts";
23
+ import { createPollLoop, defaultPollTimeout } from "./poll.ts";
24
+ import { messageOf } from "./errors.ts";
25
+ import { parseInbound, peerKey, peerLabel, peerLanguage, sanitizeInbound, type PeerLanguage } from "./format.ts";
26
+ import { notice } from "./notices.ts";
27
+ import { createPresence } from "./presence.ts";
23
28
  import {
24
- chunkText,
25
- notice,
26
- parseInbound,
27
- peerKey,
28
- peerLabel,
29
- peerLanguage,
30
- sanitizeInbound,
31
- toWechatText,
32
- type PeerLanguage,
33
- } from "./format.ts";
34
- import { createWechatInteractionChannel, type WechatInteractionChannel } from "./interaction.ts";
35
- import { defaultSleep } from "./login.ts";
36
- import { sendLocalFile } from "./outbound.ts";
37
- import { decodeMediaKey, decryptEcb, saveMedia, sniffImageMime } from "./media.ts";
38
- import { createPeerWorker, type PeerItem, type PeerState, type PeerWorker } from "./peer-worker.ts";
29
+ createWechatInteractionChannel,
30
+ type PendingPromptInfo,
31
+ type WechatInteractionChannel,
32
+ } from "./interaction.ts";
33
+ import { defaultSleep, defaultTimer, type ArmTimer } from "./timer.ts";
34
+ import { createPeerWorker, type PeerState, type PeerWorker } from "./peer-worker.ts";
39
35
  import type { PeerSession } from "./peer-session.ts";
40
36
  import { emptyState, findPeer, recordInbound, rememberMessage, seenMessage } from "./state.ts";
41
37
  import {
@@ -48,41 +44,10 @@ import {
48
44
  } from "./slash.ts";
49
45
  import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
50
46
  import type { WechatConfig } from "./config.ts";
51
- import { defaultTimer, type ArmTimer } from "./timer.ts";
52
47
 
53
- const BACKOFF_MS = 2000;
54
- /** How long a fetched typing ticket is reused. */
55
- const TYPING_TTL_MS = 5 * 60 * 1000;
56
- /** How often the typing indicator is refreshed while a peer waits. */
57
- const TYPING_KEEPALIVE_MS = 10_000;
58
- /** Stop refreshing the indicator after this long; a wedged turn must not tick forever. */
59
- const TYPING_LIMIT_MS = 15 * 60 * 1000;
60
- /** Wait this long before telling a queued peer they are queued. */
61
- const ACK_DELAY_MS = 4000;
62
- /** Suppress a second acknowledgement inside this window. */
63
- const ACK_REPEAT_MS = 60_000;
64
- /** Say the running turn is still going after this long without a reply. */
65
- const TURN_NOTICE_MS = 30_000;
66
- /** Say `busy` when a message is still queued behind a running turn after this. */
67
- const BUSY_NOTICE_MS = 60_000;
68
- /** Poll watchdog when the server has not told us its long-poll hold yet. */
69
- const POLL_TIMEOUT_FALLBACK_MS = 90_000;
70
- /** Never trust a server hint below this, so a busy loop cannot be papered over. */
71
- const POLL_TIMEOUT_FLOOR_MS = 30_000;
72
- /** Slack above the server's hold before a poll counts as wedged. */
73
- const POLL_TIMEOUT_MARGIN_MS = 15_000;
74
48
  /** Inbound messages held while a turn runs; beyond this the peer is told to wait. */
75
49
  const MAX_QUEUE = 8;
76
50
 
77
- /**
78
- * How long one `getupdates` request may hang before it is treated as wedged: the
79
- * server's own hold time plus a margin, so a half-open connection cannot stop
80
- * inbound traffic without a word.
81
- */
82
- export function defaultPollTimeout(serverHintMs: number | undefined): number {
83
- return Math.max(serverHintMs ?? POLL_TIMEOUT_FALLBACK_MS, POLL_TIMEOUT_FLOOR_MS) + POLL_TIMEOUT_MARGIN_MS;
84
- }
85
-
86
51
  /**
87
52
  * Whether `peer` may drive the agent: the owner, or an explicit extra. Either id
88
53
  * form is accepted (with or without `@im.wechat`), so a label copied from the
@@ -95,7 +60,7 @@ export function isAllowedPeer(peer: string, owner: string | undefined, allowedPe
95
60
  }
96
61
 
97
62
  /** True when a message is inbound from a human (not the bot's own echo). */
98
- export function isInboundMessage(message: WeixinMessage): boolean {
63
+ export function isInboundMessage(message: WeixinMessage): message is WeixinMessage & { from_user_id: string } {
99
64
  return message.message_type !== 2 && typeof message.from_user_id === "string" && message.from_user_id !== "";
100
65
  }
101
66
 
@@ -132,10 +97,10 @@ export interface BridgeDeps {
132
97
  */
133
98
  pollTimeout?: (serverHintMs: number | undefined) => number;
134
99
  notify?: (message: string, kind?: "info" | "warning" | "error") => void;
135
- /** Called when the server reports an expired token. */
136
- onExpired?: () => void;
137
100
  /** Called whenever the bridge's connection state changes, for a status chip. */
138
101
  onStateChange?: (state: BridgeState) => void;
102
+ /** Read the poller lock, for the doctor. */
103
+ loadLock?: () => { pid: number; at: number } | undefined;
139
104
  }
140
105
 
141
106
  export interface BridgeStatus {
@@ -155,6 +120,22 @@ export interface BridgeStatus {
155
120
  mediaDropped: number;
156
121
  /** Whether a remote dialog prompt is waiting for an answer. */
157
122
  promptPending: boolean;
123
+ /** The waiting prompt's peer, kind, title, and age. */
124
+ prompt?: PendingPromptInfo;
125
+ /** When this connection opened. */
126
+ startedAt?: number;
127
+ /** When the last poll settled, successful or not. */
128
+ lastPollAt?: number;
129
+ /** The last poll failure, cleared by a successful poll. */
130
+ lastPollError?: string;
131
+ /** The last outbound send failure, cleared by a successful send. */
132
+ lastSendError?: string;
133
+ /** The long-poll hold currently in use (server hint or configured). */
134
+ pollHoldMs?: number;
135
+ /** The stored account's login time. */
136
+ tokenCreatedAt?: number;
137
+ /** The poller lock on disk, for the doctor. */
138
+ lock?: { pid: number; at: number };
158
139
  }
159
140
 
160
141
  export interface SendResult {
@@ -179,16 +160,17 @@ export interface Bridge {
179
160
  sendToOwner(text: string): Promise<SendResult>;
180
161
  }
181
162
 
182
- function messageOf(error: unknown): string {
183
- return error instanceof Error ? error.message : String(error);
184
- }
185
-
186
163
  /** Create the bridge over an injected environment. */
187
164
  export function createBridge(deps: BridgeDeps): Bridge {
188
165
  const sleep = deps.sleep ?? defaultSleep;
189
166
  const armTimer = deps.armTimer ?? defaultTimer;
190
167
  let running = false;
191
168
  let expired = false;
169
+ let startedAt: number | undefined;
170
+ let lastPollAt: number | undefined;
171
+ let lastPollError: string | undefined;
172
+ let lastSendError: string | undefined;
173
+ let pollHoldMs: number | undefined;
192
174
  let abort: AbortController | undefined;
193
175
  let loopPromise: Promise<void> | undefined;
194
176
  let client: WechatClient | undefined;
@@ -197,30 +179,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
197
179
  let refused = 0;
198
180
  /** Labels of refused senders, so the operator can allowlist one by name. */
199
181
  const refusedPeers = new Set<string>();
200
- let mediaDropped = 0;
201
- let mediaCounter = 0;
202
- const typingCache = new Map<string, { ticket?: string; checkedAt: number }>();
203
- /** Per-peer keep-alive cancellers for the typing indicator. */
204
- const typing = new Map<string, () => void>();
205
- /** Per-peer pending acknowledgement cancellers. */
206
- const acks = new Map<string, () => void>();
207
- /** When each peer was last acknowledged, so a chatty peer is not spammed. */
208
- const ackedAt = new Map<string, number>();
209
- /** Peers told that a turn is still running, until that turn ends. */
210
- const busyNotified = new Set<string>();
211
- /** Per-peer `busy` and `stillWorking` cancellers. */
212
- const busyTimers = new Map<string, () => void>();
213
- const slowTimers = new Map<string, () => void>();
214
182
  /** The language each peer last wrote in, so a media-only message can answer. */
215
183
  const languages = new Map<string, PeerLanguage>();
216
184
  /** Peers already told that the queue is full, until it drains. */
217
185
  const overflow = new Set<string>();
218
- /** When each peer's recent turns started, for the hourly cap. */
219
- const turnTimes = new Map<string, number[]>();
220
- /** Messages being resolved (media download) before they reach the queue. */
221
- const admitting = new Map<string, number>();
222
- /** Per-peer admission chain, so two media messages keep their arrival order. */
223
- const admitChains = new Map<string, Promise<void>>();
224
186
  const slashCommands = deps.slashCommands ?? SLASH_COMMANDS;
225
187
 
226
188
  /** One worker per open connection: it owns the queues and the peer sessions. */
@@ -243,86 +205,9 @@ export function createBridge(deps: BridgeDeps): Bridge {
243
205
  // `getUpdates`; abort it so it cannot wake up beside a later `open()`.
244
206
  abort?.abort();
245
207
  publishState();
246
- deps.onExpired?.();
247
208
  notify("WeChat session expired. Run /wechat login to reconnect.", "warning");
248
209
  }
249
210
 
250
- /**
251
- * Send `text` to one peer in `maxReplyChars` chunks, stopping at the first
252
- * failed chunk. A session expiry is handled here; a thrown transport error is
253
- * handed to `onError` with how much of the reply went out, so each caller can
254
- * report it its own way without claiming delivery.
255
- */
256
- async function sendChunked(
257
- to: string,
258
- contextToken: string | undefined,
259
- text: string,
260
- signal: AbortSignal | undefined,
261
- onError: (error: unknown, progress: { sent: number; total: number }) => void,
262
- ): Promise<boolean> {
263
- if (!client || !account) return false;
264
- const chunks = chunkText(toWechatText(text), deps.config.maxReplyChars).filter((chunk) => chunk !== "");
265
- if (chunks.length === 0) {
266
- // Nothing survived formatting: report it rather than claim a delivery.
267
- notify("WeChat reply had nothing to send after formatting.", "warning");
268
- return false;
269
- }
270
- for (const [index, chunk] of chunks.entries()) {
271
- // A split answer says so, so the peer knows more is coming and can tell a
272
- // truncated reply from a complete one.
273
- const body = chunks.length > 1 ? `[${index + 1}/${chunks.length}] ${chunk}` : chunk;
274
- try {
275
- const response = await client.sendMessage(account, { to, text: body, contextToken }, signal);
276
- if (isSessionExpired(response)) {
277
- handleExpired();
278
- return false;
279
- }
280
- // A body-level rejection is not a delivery: report it with the part count
281
- // instead of letting the answer vanish.
282
- if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
283
- const code = response.ret ?? response.errcode;
284
- // The server's own description is the only way to tell a bad token
285
- // from a rate limit, so it is part of the error.
286
- const detail = response.errmsg ? `: ${response.errmsg}` : "";
287
- onError(new WechatError(`Weixin rejected the message (ret ${code}${detail}).`), {
288
- sent: index,
289
- total: chunks.length,
290
- });
291
- return false;
292
- }
293
- } catch (error) {
294
- onError(error, { sent: index, total: chunks.length });
295
- return false;
296
- }
297
- }
298
- return true;
299
- }
300
-
301
- /** Peers already reported as unable to receive, until a send gets through. */
302
- const sendFailureNotified = new Set<string>();
303
-
304
- /**
305
- * Send one bubble to a peer and report whether it was delivered.
306
- *
307
- * A rejection is reported once per outage rather than once per streamed part,
308
- * and the peer's stored reply token is forgotten: a token the server rejects is
309
- * the likely cause, and the next inbound message brings a fresh one.
310
- */
311
- async function reply(item: { peer: string; contextToken?: string }, text: string): Promise<boolean> {
312
- let failed = false;
313
- const delivered = await sendChunked(item.peer, item.contextToken, text, abort?.signal, (error, progress) => {
314
- failed = true;
315
- if (abort?.signal.aborted) return;
316
- forgetToken(item.peer);
317
- if (sendFailureNotified.has(item.peer)) return;
318
- sendFailureNotified.add(item.peer);
319
- const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
320
- notify(`WeChat reply failed${partial}: ${messageOf(error)}`, "warning");
321
- });
322
- if (!failed && delivered) sendFailureNotified.delete(item.peer);
323
- return failed ? false : delivered;
324
- }
325
-
326
211
  /** Drop a peer's cached reply token, so the next inbound message replaces it. */
327
212
  function forgetToken(peer: string): void {
328
213
  const entry = findPeer(state, peer);
@@ -331,6 +216,29 @@ export function createBridge(deps: BridgeDeps): Bridge {
331
216
  deps.saveState(state);
332
217
  }
333
218
 
219
+ /** Send one bubble to a peer and report whether it was delivered. */
220
+ async function reply(item: { peer: string; contextToken?: string }, text: string): Promise<boolean> {
221
+ return deliverer.reply(item, text, abort?.signal);
222
+ }
223
+
224
+ /** The outbound surface, reading the live client and account at call time. */
225
+ const deliverer = createDeliverer({
226
+ client: () => client,
227
+ account: () => account,
228
+ maxReplyChars: () => deps.config.maxReplyChars,
229
+ cdnBaseUrl: deps.config.cdnBaseUrl,
230
+ maxMediaBytes: deps.config.maxMediaBytes,
231
+ notify,
232
+ onExpired: handleExpired,
233
+ forgetToken,
234
+ onSendError: (message) => {
235
+ lastSendError = message;
236
+ },
237
+ onSent: () => {
238
+ lastSendError = undefined;
239
+ },
240
+ });
241
+
334
242
  /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
335
243
  async function runSlashAction(command: SlashCommand, item: InboundItem, args: string): Promise<void> {
336
244
  if (!command.run) return;
@@ -348,7 +256,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
348
256
  },
349
257
  stop: () => worker?.abort(item.peer) ?? false,
350
258
  status: () => worker?.state(item.peer) ?? { status: "idle", queued: 0 },
351
- history: (count) => worker?.history(item.peer, count) ?? Promise.resolve(""),
259
+ history: (count) => deps.readHistory(item.peer, count),
352
260
  },
353
261
  };
354
262
  try {
@@ -360,51 +268,23 @@ export function createBridge(deps: BridgeDeps): Bridge {
360
268
  }
361
269
 
362
270
  /**
363
- * Send a text message to the peer owning the in-flight turn, chunked.
271
+ * Send a dialog preface (or plan digest) to the peer owning the in-flight turn.
364
272
  *
365
- * A failure is reported on the terminal once per peer, the same way a reply
366
- * failure is: this carries a dialog preface (and the plan digest), and a silent
273
+ * A failure is reported on the terminal once per peer by the deliverer; a silent
367
274
  * failure would leave the peer with only the choices, as if nothing preceded
368
275
  * them.
369
276
  */
370
277
  async function sendPrompt(text: string, signal?: AbortSignal): Promise<boolean> {
371
278
  const current = worker?.current();
372
279
  if (!current) return false;
373
- const activeSignal = signal ?? abort?.signal;
374
- let failed = false;
375
- const delivered = await sendChunked(current.peer, current.contextToken, text, activeSignal, (error, progress) => {
376
- failed = true;
377
- if (activeSignal?.aborted) return;
378
- forgetToken(current.peer);
379
- if (sendFailureNotified.has(current.peer)) return;
380
- sendFailureNotified.add(current.peer);
381
- const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
382
- notify(`WeChat message delivery failed${partial}: ${messageOf(error)}`, "warning");
383
- });
384
- if (!failed && delivered) sendFailureNotified.delete(current.peer);
385
- return failed ? false : delivered;
280
+ return deliverer.prompt(current, text, signal ?? abort?.signal);
386
281
  }
387
282
 
388
283
  /** Upload and send a local file to the peer owning the in-flight turn. */
389
284
  async function sendFileToPeer(filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean> {
390
285
  const current = worker?.current();
391
- if (!client || !account || !current) return false;
392
- const activeSignal = signal ?? abort?.signal;
393
- const sent = await sendLocalFile({
394
- client,
395
- account,
396
- cdnBaseUrl: deps.config.cdnBaseUrl,
397
- maxBytes: deps.config.maxMediaBytes,
398
- to: current.peer,
399
- contextToken: current.contextToken,
400
- filePath,
401
- fileName,
402
- signal: activeSignal,
403
- });
404
- // `sendLocalFile` reports only success, and the caller may fall back to text;
405
- // without this line the operator would never know the attachment was dropped.
406
- if (!sent && !activeSignal?.aborted) notify(`WeChat file delivery failed for ${fileName}.`, "warning");
407
- return sent;
286
+ if (!current) return false;
287
+ return deliverer.file(current, filePath, fileName, signal ?? abort?.signal);
408
288
  }
409
289
 
410
290
  const interaction = createWechatInteractionChannel({
@@ -415,71 +295,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
415
295
  armTimer: deps.armTimer,
416
296
  });
417
297
 
418
- /** The typing ticket for a peer, fetched on first use and cached. */
419
- async function typingTicket(peer: string, contextToken: string | undefined): Promise<string | undefined> {
420
- const cached = typingCache.get(peer);
421
- const now = deps.now();
422
- if (cached && now - cached.checkedAt < TYPING_TTL_MS) return cached.ticket;
423
- if (!client || !account) return undefined;
424
- try {
425
- const response = await client.getConfig(account, { ilinkUserId: peer, contextToken }, abort?.signal);
426
- typingCache.set(peer, { ticket: response?.typingTicket, checkedAt: now });
427
- return response?.typingTicket;
428
- } catch {
429
- typingCache.set(peer, { ticket: undefined, checkedAt: now });
430
- return undefined;
431
- }
432
- }
433
-
434
- async function typingOn(peer: string, contextToken: string | undefined): Promise<void> {
435
- if (!deps.config.typingIndicator || !client || !account) return;
436
- const ticket = await typingTicket(peer, contextToken);
437
- if (!ticket) return;
438
- try {
439
- await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: true }, abort?.signal);
440
- // The peer's work may have finished while the ticket round-trip was in
441
- // flight; cancel immediately so the indicator cannot get stuck on.
442
- if (!hasWork(peer)) {
443
- await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: false }, abort?.signal);
444
- }
445
- } catch {
446
- // Typing is best-effort.
447
- }
448
- }
449
-
450
- async function typingOff(peer: string): Promise<void> {
451
- if (!deps.config.typingIndicator || !client || !account) return;
452
- const ticket = typingCache.get(peer)?.ticket;
453
- if (!ticket) return;
454
- try {
455
- await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: false }, abort?.signal);
456
- } catch {
457
- // Typing is best-effort.
458
- }
459
- }
460
-
461
- /** Whether a peer has a turn running or a message waiting. */
462
- function hasWork(peer: string): boolean {
463
- return (worker?.state(peer).status ?? "idle") !== "idle";
464
- }
465
-
466
- /** Reserve a turn in the peer's rolling hour; false when the cap is reached. */
467
- function allowTurn(peer: string): boolean {
468
- const limit = deps.config.maxTurnsPerHour;
469
- if (limit <= 0) return true;
470
- const key = peerKey(peer);
471
- const now = deps.now();
472
- const cutoff = now - 60 * 60 * 1000;
473
- const times = (turnTimes.get(key) ?? []).filter((at) => at >= cutoff);
474
- if (times.length >= limit) {
475
- turnTimes.set(key, times);
476
- return false;
477
- }
478
- times.push(now);
479
- turnTimes.set(key, times);
480
- return true;
481
- }
482
-
483
298
  /** The peer's language: from this text, else the last one they wrote. */
484
299
  function languageOf(peer: string, text?: string): PeerLanguage {
485
300
  if (text) {
@@ -490,235 +305,46 @@ export function createBridge(deps: BridgeDeps): Bridge {
490
305
  return languages.get(peer) ?? "en";
491
306
  }
492
307
 
493
- function stopTyping(peer: string): void {
494
- const cancel = typing.get(peer);
495
- if (!cancel) return;
496
- typing.delete(peer);
497
- cancel();
498
- }
499
-
500
- function stopAllTyping(): void {
501
- for (const cancel of typing.values()) cancel();
502
- typing.clear();
503
- }
504
-
505
- /**
506
- * Start the typing indicator for a peer and keep it alive while they wait.
507
- * The chat window is the peer's only channel, and WeChat drops the indicator
508
- * after a few seconds, so a long turn needs the refresh to look alive at all.
509
- */
510
- function keepTyping(peer: string, contextToken: string | undefined): void {
511
- if (!deps.config.typingIndicator) return;
512
- stopTyping(peer);
513
- void typingOn(peer, contextToken);
514
- const started = deps.now();
515
- const refresh = (): void => {
516
- if (!hasWork(peer) || deps.now() - started >= TYPING_LIMIT_MS) {
517
- typing.delete(peer);
518
- return;
519
- }
520
- void typingOn(peer, contextToken);
521
- typing.set(peer, armTimer(TYPING_KEEPALIVE_MS, refresh));
522
- };
523
- typing.set(peer, armTimer(TYPING_KEEPALIVE_MS, refresh));
524
- }
525
-
526
- /** Stop refreshing the indicator for peers with nothing left to answer. */
527
- function pruneTyping(): void {
528
- for (const peer of [...typing.keys()]) {
529
- if (!hasWork(peer)) stopTyping(peer);
530
- }
531
- }
532
-
533
- /**
534
- * Stop the `busy` line for a peer: the wait episode is over, so the next one
535
- * is allowed to speak again.
536
- */
537
- function stopBusyNotice(peer: string): void {
538
- const cancel = busyTimers.get(peer);
539
- if (cancel) cancel();
540
- busyTimers.delete(peer);
541
- busyNotified.delete(peer);
542
- }
543
-
544
- /** Stop the turn-progress notice for `peer`. */
545
- function stopSlowNotice(peer: string): void {
546
- const cancel = slowTimers.get(peer);
547
- if (cancel) cancel();
548
- slowTimers.delete(peer);
549
- }
550
-
551
- /**
552
- * Say once that this turn is taking a while. The typing indicator carries the
553
- * same signal, but it is best-effort and invisible in some clients, so a slow
554
- * turn gets one plain line instead of silence.
555
- */
556
- function armSlowNotice(peer: string, contextToken: string | undefined): void {
557
- stopSlowNotice(peer);
558
- slowTimers.set(
559
- peer,
560
- armTimer(TURN_NOTICE_MS, () => {
561
- slowTimers.delete(peer);
562
- if (worker?.state(peer).status !== "running") return;
563
- // A turn waiting on an unanswered dialog is not "still working": the
564
- // prompt itself nudges at 90 seconds, and saying both reads as noise.
565
- if (interaction.hasPending()) return;
566
- void reply({ peer, contextToken }, notice("stillWorking", languageOf(peer)));
567
- }),
568
- );
569
- }
570
-
571
- /**
572
- * Say once why a message is still waiting: this peer's turn is still running,
573
- * so theirs has not started. Bounded to one line per wait episode.
574
- */
575
- function armBusyNotice(peer: string, contextToken: string | undefined): void {
576
- if (busyNotified.has(peer) || busyTimers.has(peer)) return;
577
- busyTimers.set(
578
- peer,
579
- armTimer(BUSY_NOTICE_MS, () => {
580
- busyTimers.delete(peer);
581
- if (!hasWork(peer) || busyNotified.has(peer)) return;
582
- busyNotified.add(peer);
583
- void reply({ peer, contextToken }, notice("busy", languageOf(peer)));
584
- }),
585
- );
586
- }
587
-
588
- function cancelAck(peer: string): void {
589
- const cancel = acks.get(peer);
590
- if (!cancel) return;
591
- acks.delete(peer);
592
- cancel();
593
- }
594
-
595
- function cancelAllAcks(): void {
596
- for (const cancel of acks.values()) cancel();
597
- acks.clear();
598
- }
599
-
600
- /**
601
- * Tell a queued peer, once, that their message is waiting. A turn that starts
602
- * within {@link ACK_DELAY_MS} cancels this, because the typing indicator says
603
- * the same thing without spending a bubble.
604
- */
605
- function armAck(item: InboundItem): void {
606
- // A turn for this peer is already running: its own progress notice covers
607
- // this message, so a second acknowledgement would be noise.
608
- if (worker?.state(item.peer).status === "running") return;
609
- if (acks.has(item.peer)) return;
610
- const last = ackedAt.get(item.peer);
611
- if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
612
- acks.set(
613
- item.peer,
614
- armTimer(ACK_DELAY_MS, () => {
615
- acks.delete(item.peer);
616
- if (!hasWork(item.peer) || worker?.state(item.peer).status === "running") return;
617
- ackedAt.set(item.peer, deps.now());
618
- void reply(item, notice("ack", languageOf(item.peer)));
619
- // Still waiting after a minute behind another turn: say why.
620
- armBusyNotice(item.peer, item.contextToken);
621
- }),
622
- );
308
+ /** The peer's conversation state, for the wait decisions in presence. */
309
+ function peerStatus(peer: string): PeerState["status"] {
310
+ return worker?.state(peer).status ?? "idle";
623
311
  }
624
312
 
625
- /**
626
- * Download, decrypt, and split what the peer sent into a prompt text and image
627
- * blocks. A file is saved and referenced by path; an image becomes model image
628
- * content. Every failure is counted so the peer can be told.
629
- */
630
- async function resolveMedia(item: InboundItem): Promise<{
631
- images: ImageContent[];
632
- files: string[];
633
- dropped: number;
634
- }> {
635
- const images: ImageContent[] = [];
636
- const files: string[] = [];
637
- let dropped = 0;
638
- for (const ref of item.media) {
639
- if (!client) break;
640
- try {
641
- const cipher = await client.downloadCdn(ref.media, abort?.signal);
642
- if (cipher.byteLength > deps.config.maxMediaBytes) {
643
- mediaDropped += 1;
644
- dropped += 1;
645
- notify(`WeChat media dropped: over ${deps.config.maxMediaBytes} bytes.`, "warning");
646
- continue;
647
- }
648
- if (ref.kind === "file") {
649
- if (!ref.media.aes_key) throw new Error("missing AES key");
650
- const plain = decryptEcb(cipher, decodeMediaKey(ref.media.aes_key));
651
- mediaCounter += 1;
652
- const path = saveMedia(deps.mediaDir(), `${deps.now()}-${mediaCounter}-${ref.fileName}`, plain);
653
- files.push(`[WeChat file: ${ref.fileName} saved to ${path}]`);
654
- } else {
655
- // Without a key the bytes are not the image the peer sent; attaching them
656
- // anyway would hand the model a corrupt JPEG.
657
- const key = ref.aeskey ?? ref.media.aes_key;
658
- if (!key) throw new Error("missing AES key");
659
- const plain = decryptEcb(cipher, decodeMediaKey(key));
660
- images.push({
661
- type: "image",
662
- data: Buffer.from(plain).toString("base64"),
663
- mimeType: sniffImageMime(plain),
664
- });
665
- }
666
- } catch (error) {
667
- mediaDropped += 1;
668
- dropped += 1;
669
- notify(`WeChat media dropped: ${messageOf(error)}`, "warning");
670
- }
671
- }
672
- return { images, files, dropped };
673
- }
313
+ /** Typing, acknowledgement, and the slow/busy lines; one timer registry each. */
314
+ const presence = createPresence({
315
+ typingIndicator: () => deps.config.typingIndicator,
316
+ now: deps.now,
317
+ armTimer,
318
+ client: () => client,
319
+ account: () => account,
320
+ signal: () => abort?.signal,
321
+ statusOf: peerStatus,
322
+ languageOf,
323
+ send: (peer, contextToken, text) => reply({ peer, contextToken }, text),
324
+ promptPending: () => interaction.hasPending(),
325
+ });
674
326
 
675
- /**
676
- * Resolve media, then hand the message to the peer's own session.
677
- *
678
- * An attachment the bridge could not read is never silent: the model gets a
679
- * `[WeChat media skipped]` block and the peer's reply opens with one line, so a
680
- * failed download cannot look like the model ignoring a photo. A message with
681
- * nothing readable is answered here instead of starting an empty turn.
682
- */
683
- async function admit(item: InboundItem): Promise<void> {
684
- let text = item.text;
685
- let images: ImageContent[] | undefined;
686
- let prefix: string | undefined;
687
- if (item.media.length > 0) {
688
- const resolved = await resolveMedia(item);
689
- if (!running) return;
690
- if (!text && resolved.images.length === 0 && resolved.files.length === 0) {
691
- if (resolved.dropped > 0) prefix = notice("media", item.language, resolved.dropped);
692
- await reply(item, prefix ?? notice("unsupported", item.language));
693
- pruneTyping();
694
- return;
695
- }
696
- if (resolved.dropped > 0) {
697
- prefix = notice("media", item.language, resolved.dropped);
698
- text = [text, `[WeChat media skipped: ${resolved.dropped}]`].filter(Boolean).join("\n");
699
- }
700
- if (resolved.files.length > 0) text = [text, ...resolved.files].filter(Boolean).join("\n");
701
- images = resolved.images.length > 0 ? resolved.images : undefined;
702
- }
703
- if (!text && !images) {
704
- await reply(item, notice("unsupported", item.language));
705
- pruneTyping();
706
- return;
707
- }
708
- const queued: PeerItem = {
709
- peer: item.peer,
710
- text,
711
- images,
712
- prefix,
713
- language: item.language,
714
- contextToken: item.contextToken,
715
- };
716
- worker?.enqueue(queued);
717
- }
327
+ /** Inbound media resolution and the hand-off to a peer session. */
328
+ const inbound = createInboundAdmission({
329
+ client: () => client,
330
+ account: () => account,
331
+ signal: () => abort?.signal,
332
+ maxMediaBytes: () => deps.config.maxMediaBytes,
333
+ mediaDir: deps.mediaDir,
334
+ now: deps.now,
335
+ notify,
336
+ reply: (target, text) => reply(target, text),
337
+ enqueue: (item) => worker?.enqueue(item),
338
+ pruneTyping: () => presence.pruneTyping(),
339
+ isRunning: () => running,
340
+ });
341
+ /** The hourly turn budget and the serial admission chain. */
342
+ const budget = createTurnBudget(deps.config.maxTurnsPerHour, deps.now);
343
+ const admission = createAdmissionQueue({ admit: inbound.admit, notify });
718
344
 
719
345
  function handleMessage(message: WeixinMessage): void {
720
346
  if (!isInboundMessage(message)) return;
721
- const peer = message.from_user_id as string;
347
+ const peer = message.from_user_id;
722
348
  const { text: rawText, media, unsupported } = parseInbound(message.item_list);
723
349
  const owner = state.ownerId ?? account?.ilinkUserId;
724
350
  // Admission comes first: a refused sender never reaches the state file, a
@@ -789,11 +415,11 @@ export function createBridge(deps: BridgeDeps): Bridge {
789
415
  }
790
416
  // A slot is reserved here, before the download, so a burst of media cannot
791
417
  // all pass the check and then enqueue together.
792
- if (!allowTurn(peer)) {
418
+ if (!budget.allow(peer)) {
793
419
  void reply(item, notice("budget", language));
794
420
  return;
795
421
  }
796
- const busy = (worker?.state(peer).queued ?? 0) + (admitting.get(peer) ?? 0);
422
+ const busy = (worker?.state(peer).queued ?? 0) + admission.pending(peer);
797
423
  if (busy >= MAX_QUEUE) {
798
424
  // Refuse rather than grow without bound; one notice per episode, not per
799
425
  // dropped message.
@@ -803,100 +429,58 @@ export function createBridge(deps: BridgeDeps): Bridge {
803
429
  }
804
430
  return;
805
431
  }
806
- keepTyping(peer, item.contextToken);
807
- armAck(item);
808
- admitting.set(peer, (admitting.get(peer) ?? 0) + 1);
809
- const previous = admitChains.get(peer) ?? Promise.resolve();
810
- const next = previous
811
- .then(async () => {
812
- await admit(item);
813
- })
814
- .catch((error) => {
815
- // One bad admission must not stall the peer's chain forever.
816
- notify(`WeChat could not accept a message: ${messageOf(error)}`, "warning");
817
- })
818
- .finally(() => {
819
- const left = (admitting.get(peer) ?? 1) - 1;
820
- if (left > 0) admitting.set(peer, left);
821
- else admitting.delete(peer);
822
- });
823
- admitChains.set(peer, next);
824
- void next.finally(() => {
825
- if (admitChains.get(peer) === next) admitChains.delete(peer);
826
- });
432
+ presence.keepTyping(peer, item.contextToken);
433
+ presence.armAck(item);
434
+ admission.enqueue(item);
827
435
  }
828
436
 
829
- async function loop(signal: AbortSignal): Promise<void> {
830
- // The server tells us how long it holds a poll; remember it so the watchdog
831
- // can be tight instead of the generic fallback.
832
- let serverHint: number | undefined;
833
- /** One notice per failure episode, so a flapping link is reported once. */
834
- let timeoutNotified = false;
835
- let rejectedNotified = false;
836
- while (running && !signal.aborted) {
837
- if (!client || !account) break;
838
- const watchdogMs = pollTimeout(serverHint ?? deps.config.pollHoldMs);
839
- // A cancellable watchdog: `AbortSignal.timeout` cannot be cleared, and a
840
- // pending timer per poll would keep the process awake long after the request
841
- // settled. Unref'd, so a wedged poll never holds a shutdown open either.
842
- const watchdog = new AbortController();
843
- const watchdogTimer = setTimeout(() => watchdog.abort(), watchdogMs);
844
- (watchdogTimer as { unref?: () => void }).unref?.();
845
- let response;
846
- try {
847
- response = await client.getUpdates(account, state.cursor, AbortSignal.any([signal, watchdog.signal]), {
848
- holdMs: deps.config.pollHoldMs,
849
- });
850
- } catch (error) {
851
- if (signal.aborted || !running) break;
852
- if (watchdog.signal.aborted) {
853
- // A wedged request, not a server answer: retry at once with the same
854
- // cursor, so nothing is skipped, and say so once.
855
- if (!timeoutNotified) {
856
- timeoutNotified = true;
857
- notify(`WeChat poll timed out after ${Math.round(watchdogMs / 1000)}s; retrying.`, "warning");
858
- }
859
- continue;
860
- }
861
- notify(`WeChat poll failed: ${messageOf(error)}`, "warning");
862
- try {
863
- await sleep(BACKOFF_MS, signal);
864
- } catch {
865
- break;
866
- }
867
- continue;
868
- } finally {
869
- clearTimeout(watchdogTimer);
870
- }
871
- if (!response) break;
872
- if (response.timeoutMs !== undefined && response.timeoutMs > 0) serverHint = response.timeoutMs;
873
- if (isSessionExpired(response)) {
874
- handleExpired();
875
- return;
876
- }
877
- if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
878
- // A body-level rejection (bad cursor, rate limit, revoked token) is not an
879
- // empty poll: report it once and back off instead of hammering the API.
880
- if (!rejectedNotified) {
881
- rejectedNotified = true;
882
- notify(`WeChat poll rejected (ret ${response.ret ?? response.errcode}); retrying.`, "warning");
883
- }
884
- try {
885
- await sleep(BACKOFF_MS, signal);
886
- } catch {
887
- break;
888
- }
889
- continue;
890
- }
891
- timeoutNotified = false;
892
- rejectedNotified = false;
893
- if (response.buf && response.buf !== state.cursor) {
894
- state.cursor = response.buf;
895
- deps.saveState(state);
896
- }
897
- for (const message of response.msgs) handleMessage(message);
898
- deps.refreshLock();
899
- }
437
+ /** The long-poll loop; it reads the live client, cursor, and state. */
438
+ const poll = createPollLoop({
439
+ client: () => client,
440
+ account: () => account,
441
+ holdMs: () => deps.config.pollHoldMs,
442
+ cursor: () => state.cursor,
443
+ setCursor: (cursor) => {
444
+ state.cursor = cursor;
445
+ },
446
+ saveState: () => deps.saveState(state),
447
+ sleep,
448
+ notify,
449
+ onExpired: handleExpired,
450
+ onMessages: (messages) => {
451
+ for (const message of messages) handleMessage(message);
452
+ },
453
+ refreshLock: deps.refreshLock,
454
+ isRunning: () => running,
455
+ pollTimeout,
456
+ now: deps.now,
457
+ onPoll: (result) => {
458
+ lastPollAt = result.at;
459
+ lastPollError = result.error;
460
+ if (result.holdMs !== undefined) pollHoldMs = result.holdMs;
461
+ },
462
+ });
463
+
464
+ /**
465
+ * Drop everything one connection accumulated. Called when a connection opens
466
+ * and when it closes, so a counter from the previous connection can never leak
467
+ * into the next one's status or hourly window.
468
+ */
469
+ function resetEphemeral(): void {
470
+ languages.clear();
471
+ overflow.clear();
472
+ refused = 0;
473
+ refusedPeers.clear();
474
+ interaction.reset();
475
+ deliverer.reset();
476
+ presence.reset();
477
+ budget.reset();
478
+ admission.reset();
479
+ inbound.reset();
480
+ lastPollAt = undefined;
481
+ lastPollError = undefined;
482
+ lastSendError = undefined;
483
+ pollHoldMs = undefined;
900
484
  }
901
485
 
902
486
  function releaseSafe(): void {
@@ -940,14 +524,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
940
524
  }
941
525
  running = true;
942
526
  expired = false;
943
- typingCache.clear();
944
- languages.clear();
945
- ackedAt.clear();
946
- overflow.clear();
947
- turnTimes.clear();
948
- stopAllTyping();
949
- cancelAllAcks();
950
- interaction.reset();
527
+ resetEphemeral();
528
+ startedAt = deps.now();
951
529
  // One worker per connection: it owns the per-peer queues and sessions, so
952
530
  // a login (or a reconnect after an expiry) starts from a clean slate while
953
531
  // each peer's history stays on disk.
@@ -957,22 +535,21 @@ export function createBridge(deps: BridgeDeps): Bridge {
957
535
  // attachment line on the first one. A false result stops the stream.
958
536
  send: (item, text) => reply(item, text),
959
537
  fallback: (item, reason) => notice(reason, item.language),
960
- readHistory: deps.readHistory,
961
538
  onStart: (item) => {
962
- cancelAck(item.peer);
963
- stopBusyNotice(item.peer);
964
- keepTyping(item.peer, item.contextToken);
965
- armSlowNotice(item.peer, item.contextToken);
539
+ presence.cancelAck(item.peer);
540
+ presence.stopBusyNotice(item.peer);
541
+ presence.keepTyping(item.peer, item.contextToken);
542
+ presence.armSlowNotice(item.peer, item.contextToken);
966
543
  },
967
544
  onSettled: (item) => {
968
- stopSlowNotice(item.peer);
969
- stopBusyNotice(item.peer);
970
- stopTyping(item.peer);
971
- void typingOff(item.peer);
972
- pruneTyping();
545
+ presence.stopSlowNotice(item.peer);
546
+ presence.stopBusyNotice(item.peer);
547
+ presence.stopTyping(item.peer);
548
+ void presence.typingOff(item.peer);
549
+ presence.pruneTyping();
973
550
  languages.set(item.peer, item.language);
974
551
  // The flood notice is per episode: clear it once the queue drains.
975
- const left = (worker?.state(item.peer).queued ?? 0) + (admitting.get(item.peer) ?? 0);
552
+ const left = (worker?.state(item.peer).queued ?? 0) + admission.pending(item.peer);
976
553
  if (left === 0) overflow.delete(item.peer);
977
554
  },
978
555
  notify,
@@ -981,7 +558,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
981
558
  });
982
559
  publishState();
983
560
  abort = new AbortController();
984
- loopPromise = loop(abort.signal).catch((error) => {
561
+ loopPromise = poll.run(abort.signal).catch((error) => {
985
562
  notify(`WeChat bridge stopped: ${messageOf(error)}`, "error");
986
563
  });
987
564
  },
@@ -1005,16 +582,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
1005
582
  const closing = worker;
1006
583
  worker = undefined;
1007
584
  await closing?.close();
1008
- for (const cancel of busyTimers.values()) cancel();
1009
- busyTimers.clear();
1010
- busyNotified.clear();
1011
- for (const cancel of slowTimers.values()) cancel();
1012
- slowTimers.clear();
1013
- stopAllTyping();
1014
- cancelAllAcks();
1015
- overflow.clear();
1016
- turnTimes.clear();
1017
- interaction.reset();
585
+ resetEphemeral();
1018
586
  publishState();
1019
587
  releaseSafe();
1020
588
  },
@@ -1047,8 +615,16 @@ export function createBridge(deps: BridgeDeps): Bridge {
1047
615
  running: states.some((entry) => entry.state.status === "running"),
1048
616
  refused,
1049
617
  refusedPeers: [...refusedPeers],
1050
- mediaDropped,
618
+ mediaDropped: inbound.dropped(),
1051
619
  promptPending: interaction.hasPending(),
620
+ prompt: interaction.pendingInfo(),
621
+ startedAt,
622
+ lastPollAt,
623
+ lastPollError,
624
+ lastSendError,
625
+ pollHoldMs: pollHoldMs ?? deps.config.pollHoldMs,
626
+ tokenCreatedAt: account?.createdAt,
627
+ lock: deps.loadLock?.(),
1052
628
  };
1053
629
  },
1054
630
 
@@ -1072,23 +648,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
1072
648
  // Either id form may be stored: the owner id comes from the login response
1073
649
  // and the token from an inbound `from_user_id`, and they need not agree.
1074
650
  const contextToken = findPeer(state, owner)?.lastContextToken;
1075
- for (const chunk of chunkText(trimmed, deps.config.maxReplyChars)) {
1076
- try {
1077
- const response = await client.sendMessage(account, { to: owner, text: chunk, contextToken }, abort?.signal);
1078
- if (isSessionExpired(response)) {
1079
- handleExpired();
1080
- return { ok: false, error: "WeChat session expired." };
1081
- }
1082
- if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
1083
- // A body-level rejection is not a delivery; the tool must say so.
1084
- const code = response.ret ?? response.errcode;
1085
- return { ok: false, error: `Weixin rejected the message (ret ${code}).` };
1086
- }
1087
- } catch (error) {
1088
- return { ok: false, error: messageOf(error) };
1089
- }
1090
- }
1091
- return { ok: true };
651
+ return deliverer.toOwner(owner, contextToken, trimmed, abort?.signal);
1092
652
  },
1093
653
  };
1094
654
  }