@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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Inbound media and the hand-off to a peer's session.
3
+ *
4
+ * An image is downloaded, decrypted, and attached as model image content; a file
5
+ * is saved under the media directory and referenced by path. Every failure is
6
+ * counted so the peer can be told, because an attachment the bridge could not
7
+ * read must not look like the model ignoring a photo.
8
+ */
9
+
10
+ import type { ImageContent } from "@earendil-works/pi-ai";
11
+ import type { BotCredentials, InboundItem } from "./types.ts";
12
+ import type { WechatClient } from "./client.ts";
13
+ import type { PeerItem } from "./peer-worker.ts";
14
+ import { messageOf } from "./errors.ts";
15
+ import { decodeMediaKey, decryptEcb, saveMedia, sniffImageMime } from "./media.ts";
16
+ import { notice } from "./notices.ts";
17
+
18
+ /** What the inbound side needs from the bridge; every value is read at call time. */
19
+ export interface InboundDeps {
20
+ client: () => WechatClient | undefined;
21
+ account: () => BotCredentials | undefined;
22
+ signal: () => AbortSignal | undefined;
23
+ maxMediaBytes: () => number;
24
+ mediaDir: () => string;
25
+ now: () => number;
26
+ notify: (message: string, kind?: "info" | "warning" | "error") => void;
27
+ /** Answer a message that has nothing readable. */
28
+ reply: (item: InboundItem, text: string) => Promise<unknown>;
29
+ /** Hand a resolved message to the peer's own session. */
30
+ enqueue: (item: PeerItem) => void;
31
+ pruneTyping: () => void;
32
+ isRunning: () => boolean;
33
+ }
34
+
35
+ /** Resolve inbound media and admit the message to the peer's queue. */
36
+ export interface InboundAdmission {
37
+ admit(item: InboundItem): Promise<void>;
38
+ /** Media the bridge dropped since this connection opened. */
39
+ dropped(): number;
40
+ /** Reset the dropped counter (a new connection). */
41
+ reset(): void;
42
+ }
43
+
44
+ export function createInboundAdmission(deps: InboundDeps): InboundAdmission {
45
+ let mediaDropped = 0;
46
+ let mediaCounter = 0;
47
+
48
+ /**
49
+ * Download, decrypt, and split what the peer sent into a prompt text and image
50
+ * blocks. A file is saved and referenced by path; an image becomes model image
51
+ * content. Every failure is counted so the peer can be told.
52
+ */
53
+ async function resolveMedia(item: InboundItem): Promise<{
54
+ images: ImageContent[];
55
+ files: string[];
56
+ dropped: number;
57
+ }> {
58
+ const images: ImageContent[] = [];
59
+ const files: string[] = [];
60
+ let dropped = 0;
61
+ const client = deps.client();
62
+ for (const ref of item.media) {
63
+ if (!client) break;
64
+ try {
65
+ const cipher = await client.downloadCdn(ref.media, deps.signal());
66
+ if (cipher.byteLength > deps.maxMediaBytes()) {
67
+ mediaDropped += 1;
68
+ dropped += 1;
69
+ deps.notify(`WeChat media dropped: over ${deps.maxMediaBytes()} bytes.`, "warning");
70
+ continue;
71
+ }
72
+ if (ref.kind === "file") {
73
+ if (!ref.media.aes_key) throw new Error("missing AES key");
74
+ const plain = decryptEcb(cipher, decodeMediaKey(ref.media.aes_key));
75
+ mediaCounter += 1;
76
+ const path = saveMedia(deps.mediaDir(), `${deps.now()}-${mediaCounter}-${ref.fileName}`, plain);
77
+ files.push(`[WeChat file: ${ref.fileName} saved to ${path}]`);
78
+ } else {
79
+ // Without a key the bytes are not the image the peer sent; attaching them
80
+ // anyway would hand the model a corrupt JPEG.
81
+ const key = ref.aeskey ?? ref.media.aes_key;
82
+ if (!key) throw new Error("missing AES key");
83
+ const plain = decryptEcb(cipher, decodeMediaKey(key));
84
+ images.push({
85
+ type: "image",
86
+ data: Buffer.from(plain).toString("base64"),
87
+ mimeType: sniffImageMime(plain),
88
+ });
89
+ }
90
+ } catch (error) {
91
+ mediaDropped += 1;
92
+ dropped += 1;
93
+ deps.notify(`WeChat media dropped: ${messageOf(error)}`, "warning");
94
+ }
95
+ }
96
+ return { images, files, dropped };
97
+ }
98
+
99
+ return {
100
+ /**
101
+ * Resolve media, then hand the message to the peer's own session.
102
+ *
103
+ * An attachment the bridge could not read is never silent: the model gets a
104
+ * `[WeChat media skipped]` block and the peer's reply opens with one line, so a
105
+ * failed download cannot look like the model ignoring a photo. A message with
106
+ * nothing readable is answered here instead of starting an empty turn.
107
+ */
108
+ async admit(item: InboundItem): Promise<void> {
109
+ let text = item.text;
110
+ let images: ImageContent[] | undefined;
111
+ let prefix: string | undefined;
112
+ if (item.media.length > 0) {
113
+ const resolved = await resolveMedia(item);
114
+ if (!deps.isRunning()) return;
115
+ if (!text && resolved.images.length === 0 && resolved.files.length === 0) {
116
+ if (resolved.dropped > 0) prefix = notice("media", item.language, resolved.dropped);
117
+ await deps.reply(item, prefix ?? notice("unsupported", item.language));
118
+ deps.pruneTyping();
119
+ return;
120
+ }
121
+ if (resolved.dropped > 0) {
122
+ prefix = notice("media", item.language, resolved.dropped);
123
+ text = [text, `[WeChat media skipped: ${resolved.dropped}]`].filter(Boolean).join("\n");
124
+ }
125
+ if (resolved.files.length > 0) text = [text, ...resolved.files].filter(Boolean).join("\n");
126
+ images = resolved.images.length > 0 ? resolved.images : undefined;
127
+ }
128
+ if (!text && !images) {
129
+ await deps.reply(item, notice("unsupported", item.language));
130
+ deps.pruneTyping();
131
+ return;
132
+ }
133
+ const queued: PeerItem = {
134
+ peer: item.peer,
135
+ text,
136
+ images,
137
+ prefix,
138
+ language: item.language,
139
+ contextToken: item.contextToken,
140
+ };
141
+ deps.enqueue(queued);
142
+ },
143
+
144
+ dropped(): number {
145
+ return mediaDropped;
146
+ },
147
+
148
+ reset(): void {
149
+ mediaDropped = 0;
150
+ },
151
+ };
152
+ }
@@ -20,9 +20,10 @@ import { paint } from "../../lib/tui/tokens.ts";
20
20
  import { createBridge, type Bridge, type BridgeState } from "./bridge.ts";
21
21
  import { createFetchRunner, WechatClient, type WechatClientOptions } from "./client.ts";
22
22
  import { registerCommands, type CommandDeps } from "./commands.ts";
23
+ import { messageOf } from "./errors.ts";
23
24
  import { loadWechatConfig } from "./config.ts";
24
25
  import { clearCredentials, primaryAccount, saveAccount, wechatDir } from "./credentials.ts";
25
- import { acquireLock, lockPath, refreshLock, releaseLock } from "./lock.ts";
26
+ import { acquireLock, lockPath, readLock, refreshLock, releaseLock } from "./lock.ts";
26
27
  import { openPeerSession, type PeerSession, type PeerSessionOptions } from "./peer-session.ts";
27
28
  import { createPeerUIContext } from "./remote-ui.ts";
28
29
  import { peerKey } from "./format.ts";
@@ -126,6 +127,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
126
127
  acquireLock: () => acquireLock(lockPath(), lockDeps()),
127
128
  refreshLock: () => refreshLock(lockPath(), lockDeps()),
128
129
  releaseLock: () => releaseLock(lockPath(), process.pid),
130
+ loadLock: () => readLock(lockPath()),
129
131
  notify: (message, kind) => {
130
132
  ctxRef?.ui.notify(message, kind);
131
133
  },
@@ -157,7 +159,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
157
159
  bridge = bridge ?? build(ctx);
158
160
  bridge.open();
159
161
  } catch (error) {
160
- ctx.ui.notify(`WeChat did not connect: ${error instanceof Error ? error.message : String(error)}`, "warning");
162
+ ctx.ui.notify(`WeChat did not connect: ${messageOf(error)}`, "warning");
161
163
  }
162
164
  };
163
165
 
@@ -179,6 +181,8 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
179
181
  },
180
182
  saveAccount,
181
183
  primaryAccount,
184
+ readHistory: (peer, count) =>
185
+ readPeerHistory(ctxRef?.cwd ?? process.cwd(), join(wechatDir(), "sessions"), peer, count),
182
186
  logout: () => {
183
187
  clearCredentials();
184
188
  // The account is the on/off switch: without one there is nothing to report,
@@ -19,7 +19,8 @@ import {
19
19
  type InteractionChannel,
20
20
  type InteractionRequest,
21
21
  } from "../../lib/interaction.ts";
22
- import { notice, type PeerLanguage } from "./format.ts";
22
+ import { type PeerLanguage } from "./format.ts";
23
+ import { notice } from "./notices.ts";
23
24
  import { formatPrompt, parsePromptAnswer } from "./prompt.ts";
24
25
  import { defaultTimer, type ArmTimer } from "./timer.ts";
25
26
 
@@ -37,6 +38,8 @@ export interface WechatInteractionDeps {
37
38
  language: () => PeerLanguage;
38
39
  /** Arm a cancellable one-shot timer. Defaults to a real, unref'd `setTimeout`. */
39
40
  armTimer?: ArmTimer;
41
+ /** Clock for the prompt's start time; defaults to `Date.now`. */
42
+ now?: () => number;
40
43
  }
41
44
 
42
45
  export interface WechatInteractionChannel extends InteractionChannel {
@@ -44,13 +47,27 @@ export interface WechatInteractionChannel extends InteractionChannel {
44
47
  handleInbound(peer: string, text: string): boolean;
45
48
  /** Whether a prompt is currently waiting for a reply. */
46
49
  hasPending(): boolean;
50
+ /** The waiting prompt, for the control screen; undefined when none waits. */
51
+ pendingInfo(): PendingPromptInfo | undefined;
52
+ /** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
53
+ cancel(): void;
47
54
  /** Drop any pending prompt (turn settled or the bridge stopped). */
48
55
  reset(): void;
49
56
  }
50
57
 
58
+ /** The waiting prompt, as the control screen reads it. */
59
+ export interface PendingPromptInfo {
60
+ peer: string;
61
+ kind: InteractionRequest["kind"];
62
+ title: string;
63
+ /** When the prompt was registered, in epoch milliseconds. */
64
+ startedAt: number;
65
+ }
66
+
51
67
  interface Pending {
52
68
  request: InteractionRequest;
53
69
  peer: string;
70
+ startedAt: number;
54
71
  resolve: (answer: InteractionAnswer) => void;
55
72
  signal?: AbortSignal;
56
73
  onAbort: () => void;
@@ -60,6 +77,7 @@ interface Pending {
60
77
 
61
78
  export function createWechatInteractionChannel(deps: WechatInteractionDeps): WechatInteractionChannel {
62
79
  const arm = deps.armTimer ?? defaultTimer;
80
+ const now = deps.now ?? Date.now;
63
81
  let pending: Pending | undefined;
64
82
  /**
65
83
  * Free text captured from a `select` "Other" reply, per peer: the immediately
@@ -87,6 +105,26 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
87
105
  return pending !== undefined;
88
106
  },
89
107
 
108
+ pendingInfo(): PendingPromptInfo | undefined {
109
+ const current = pending;
110
+ return current === undefined
111
+ ? undefined
112
+ : {
113
+ peer: current.peer,
114
+ kind: current.request.kind,
115
+ title: current.request.title,
116
+ startedAt: current.startedAt,
117
+ };
118
+ },
119
+
120
+ cancel(): void {
121
+ if (!pending) return;
122
+ // The peer saw the prompt; one line says it was withdrawn. `reset` is the
123
+ // silent cleanup path and deliberately does not send this.
124
+ void deps.send(notice("timeout", deps.language()));
125
+ settle({ kind: "cancelled" });
126
+ },
127
+
90
128
  async post(text: string, signal?: AbortSignal): Promise<void> {
91
129
  await deps.send(text, signal);
92
130
  },
@@ -116,7 +154,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
116
154
  const cancelTimers: Array<() => void> = [];
117
155
  const answer = new Promise<InteractionAnswer>((resolve) => {
118
156
  const onAbort = () => settle({ kind: "cancelled" });
119
- pending = { request, peer, resolve, signal, onAbort, cancelTimers };
157
+ pending = { request, peer, startedAt: now(), resolve, signal, onAbort, cancelTimers };
120
158
  signal?.addEventListener("abort", onAbort, { once: true });
121
159
  // An ignored question must not freeze the session: nudge once, then
122
160
  // cancel so the model gets a clean answer instead of a hang.
@@ -48,10 +48,19 @@ function parseLock(text: string): LockInfo | undefined {
48
48
  }
49
49
  }
50
50
 
51
+ /** The raw lock record, or undefined when the file is missing or corrupt. */
52
+ export function readLock(path: string): LockInfo | undefined {
53
+ try {
54
+ if (!existsSync(path)) return undefined;
55
+ return parseLock(readFileSync(path, "utf-8"));
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
51
61
  /** True when the existing lock blocks a new owner. */
52
62
  export function isLocked(path: string, deps: LockDeps): boolean {
53
- if (!existsSync(path)) return false;
54
- const info = parseLock(readFileSync(path, "utf-8"));
63
+ const info = readLock(path);
55
64
  if (!info) return false;
56
65
  if (info.pid === deps.pid) return false;
57
66
  const stale = deps.now() - info.at > (deps.staleMs ?? DEFAULT_STALE_MS);
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { DEFAULT_BASE_URL, type WechatClient } from "./client.ts";
10
+ import { messageOf } from "./errors.ts";
10
11
  import type { BotCredentials, QrResponse, QrStatus } from "./types.ts";
11
12
 
12
13
  /** Categorical meaning of a `get_qrcode_status` value. */
@@ -62,25 +63,6 @@ export interface LoginOptions {
62
63
  requestVerifyCode?: () => Promise<string | undefined>;
63
64
  }
64
65
 
65
- /** Default sleep, aborted by `signal`. */
66
- export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
67
- return new Promise((resolve, reject) => {
68
- if (signal?.aborted) {
69
- reject(new Error("aborted"));
70
- return;
71
- }
72
- const timer = setTimeout(() => {
73
- signal?.removeEventListener("abort", onAbort);
74
- resolve();
75
- }, ms);
76
- const onAbort = () => {
77
- clearTimeout(timer);
78
- reject(new Error("aborted"));
79
- };
80
- signal?.addEventListener("abort", onAbort, { once: true });
81
- });
82
- }
83
-
84
66
  /** Poll a started login until it settles. */
85
67
  export async function waitForLogin(client: WechatClient, qr: QrResponse, options: LoginOptions): Promise<LoginOutcome> {
86
68
  const interval = options.intervalMs ?? 2000;
@@ -91,7 +73,7 @@ export async function waitForLogin(client: WechatClient, qr: QrResponse, options
91
73
  status = await client.pollQrStatus(qr.qrcode, verifyCode, options.signal);
92
74
  } catch (error) {
93
75
  if (options.signal?.aborted) return { kind: "cancelled" };
94
- return { kind: "error", message: error instanceof Error ? error.message : String(error) };
76
+ return { kind: "error", message: messageOf(error) };
95
77
  }
96
78
  options.onStatus?.(status.status);
97
79
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The short peer-facing notices the bridge sends on its own.
3
+ *
4
+ * Every one is a single line in the peer's language: WeChat pings per message,
5
+ * so a notice has to earn its bubble. Kept apart from `format.ts` so the text
6
+ * helpers stay text helpers and the notice catalog can be reviewed in one place.
7
+ */
8
+
9
+ import type { PeerLanguage } from "./format.ts";
10
+
11
+ /** One stock line the bridge can send outside a reply. */
12
+ export type NoticeKind =
13
+ | "ack"
14
+ | "busy"
15
+ | "stillWorking"
16
+ | "overflow"
17
+ | "media"
18
+ | "unsupported"
19
+ | "stopped"
20
+ | "noText"
21
+ | "compacted"
22
+ | "budget"
23
+ | "promptWaiting"
24
+ | "timeout"
25
+ | "failed"
26
+ | "newBusy";
27
+
28
+ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
29
+ ack: { zh: "收到,正在处理…", en: "Got it — working on it." },
30
+ busy: {
31
+ zh: "前面还有一条正在处理,你的消息排在后面,轮到你就回你。",
32
+ en: "Your message is queued behind another conversation; I'll answer as soon as it's free.",
33
+ },
34
+ stillWorking: {
35
+ zh: "还在处理这条,稍等一下…",
36
+ en: "Still working on this one — a moment…",
37
+ },
38
+ overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
39
+ media: {
40
+ zh: "有 {n} 个附件没能读取,可以重发或直接说明。",
41
+ en: "Couldn't open {n} attachment(s) — please resend or describe it.",
42
+ },
43
+ unsupported: {
44
+ zh: "语音(无转写)和视频暂不支持,请发文字或图片。",
45
+ en: "Voice without a transcript and video aren't supported — please send text or an image.",
46
+ },
47
+ stopped: { zh: "已停止。", en: "Stopped." },
48
+ noText: {
49
+ zh: "这条处理完了,但没有文字输出。",
50
+ en: "That turn finished without any text to send.",
51
+ },
52
+ compacted: {
53
+ zh: "对话较长,上下文已压缩;更早的细节可能记不全。",
54
+ en: "The conversation was compacted to stay within context; earlier details may be fuzzy.",
55
+ },
56
+ budget: {
57
+ zh: "这一小时的消息有点多,稍后再试。",
58
+ en: "You've reached this hour's message limit — please try again later.",
59
+ },
60
+ promptWaiting: {
61
+ zh: "还在等你的回复,约 1 分钟后超时。",
62
+ en: "Still waiting for your reply — this expires in about a minute.",
63
+ },
64
+ timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
65
+ failed: { zh: "处理失败,请稍后重试。", en: "Something went wrong — please try again." },
66
+ newBusy: {
67
+ zh: "正在处理当前消息,稍后再发送 /new。",
68
+ en: "Still working on your last message — send /new again after it finishes.",
69
+ },
70
+ };
71
+
72
+ /** Render one notice, with `{n}` replaced when a count applies. */
73
+ export function notice(kind: NoticeKind, lang: PeerLanguage, count?: number): string {
74
+ const text = NOTICES[kind][lang];
75
+ return count === undefined ? text : text.replace("{n}", String(count));
76
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import type { ImageContent } from "@earendil-works/pi-ai";
27
+ import { messageOf } from "./errors.ts";
27
28
  import type { PeerSession } from "./peer-session.ts";
28
29
  import { createReplyPipeline } from "./reply-pipeline.ts";
29
30
 
@@ -50,8 +51,6 @@ export interface PeerWorkerDeps {
50
51
  openSession: (peer: string, options: { fresh: boolean }) => Promise<PeerSession>;
51
52
  /** Deliver one turn's text to the peer; false stops the rest of a stream. */
52
53
  send: (item: PeerItem, text: string) => Promise<boolean>;
53
- /** Read the peer's own recent messages, for `/history`; read-only. */
54
- readHistory?: (peer: string, count: number) => Promise<string>;
55
54
  /** The fallback line for a turn that produced no text. */
56
55
  fallback: (item: PeerItem, reason: TurnFallback) => string;
57
56
  /** A turn started / finished for this item, for typing and notices. */
@@ -76,6 +75,8 @@ export interface PeerState {
76
75
  queued: number;
77
76
  /** How long the running turn has been going, in milliseconds. */
78
77
  runningMs?: number;
78
+ /** How long the peer's session has been open, when one is open. */
79
+ sessionAgeMs?: number;
79
80
  }
80
81
 
81
82
  export interface PeerSnapshot {
@@ -96,8 +97,6 @@ export interface PeerWorker {
96
97
  /** The turn running right now, if any (one with the default concurrency). */
97
98
  current(): PeerItem | undefined;
98
99
  state(peer: string): PeerState;
99
- /** The peer's own recent messages, for `/history`. */
100
- history(peer: string, count: number): Promise<string>;
101
100
  snapshot(): PeerSnapshot[];
102
101
  /** Drop every session; queued messages are dropped with them. */
103
102
  close(): Promise<void>;
@@ -113,6 +112,8 @@ interface PeerEntry {
113
112
  /** Monotonic start time of the running turn, for `runningMs`. */
114
113
  startedAt?: number;
115
114
  session?: PeerSession;
115
+ /** When the current session opened, for the doctor's session age. */
116
+ sessionSince?: number;
116
117
  opening?: Promise<PeerSession>;
117
118
  disposeTimer?: () => void;
118
119
  }
@@ -167,15 +168,18 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
167
168
  function stateOf(peer: string): PeerState {
168
169
  const target = peers.get(peer);
169
170
  if (!target) return { status: "idle", queued: 0 };
171
+ const sessionAge =
172
+ target.sessionSince === undefined ? undefined : { sessionAgeMs: Math.max(0, now() - target.sessionSince) };
170
173
  if (target.running) {
171
174
  return {
172
175
  status: "running",
173
176
  queued: target.queue.length + target.merged,
174
177
  runningMs: target.startedAt === undefined ? 0 : now() - target.startedAt,
178
+ ...sessionAge,
175
179
  };
176
180
  }
177
181
  const queued = target.queue.length + target.merged;
178
- return { status: queued > 0 ? "queued" : "idle", queued };
182
+ return { status: queued > 0 ? "queued" : "idle", queued, ...sessionAge };
179
183
  }
180
184
 
181
185
  /** Drop the peer's session and queue. */
@@ -185,6 +189,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
185
189
  cancelIdleSweep(target);
186
190
  target.session?.dispose();
187
191
  target.session = undefined;
192
+ target.sessionSince = undefined;
188
193
  target.opening = undefined;
189
194
  }
190
195
 
@@ -215,6 +220,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
215
220
  throw new Error("The WeChat bridge closed while opening the session.");
216
221
  }
217
222
  target.session = opened;
223
+ target.sessionSince = now();
218
224
  return opened;
219
225
  })
220
226
  .finally(() => {
@@ -284,10 +290,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
284
290
  const wasAborted = aborted.delete(item.peer);
285
291
  reason = wasAborted ? "stopped" : "failed";
286
292
  if (!wasAborted) {
287
- deps.notify?.(
288
- `WeChat turn failed for ${item.peer}: ${error instanceof Error ? error.message : String(error)}`,
289
- "warning",
290
- );
293
+ deps.notify?.(`WeChat turn failed for ${item.peer}: ${messageOf(error)}`, "warning");
291
294
  }
292
295
  } finally {
293
296
  unsubscribe();
@@ -323,10 +326,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
323
326
  const opened = await sessionFor(peer, target);
324
327
  await runTurn(item, opened);
325
328
  } catch (error) {
326
- deps.notify?.(
327
- `WeChat session failed for ${item.peer}: ${error instanceof Error ? error.message : String(error)}`,
328
- "error",
329
- );
329
+ deps.notify?.(`WeChat session failed for ${item.peer}: ${messageOf(error)}`, "error");
330
330
  await deps.send(item, deps.fallback(item, "failed"));
331
331
  } finally {
332
332
  target.running = undefined;
@@ -387,10 +387,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
387
387
  return stateOf(peer);
388
388
  },
389
389
 
390
- history(peer: string, count: number): Promise<string> {
391
- return deps.readHistory?.(peer, count) ?? Promise.resolve("");
392
- },
393
-
394
390
  snapshot(): PeerSnapshot[] {
395
391
  return [...peers.keys()].map((peer) => ({ peer, state: stateOf(peer) }));
396
392
  },
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The long-poll loop for one account.
3
+ *
4
+ * Weixin holds a `getupdates` request for a server-chosen duration; the loop
5
+ * feeds every returned message to the bridge, advances the stored cursor, and
6
+ * keeps the poller lock fresh. A wedged request is retried at once with the same
7
+ * cursor (nothing is skipped), a transport error backs off, and an expired token
8
+ * stops the loop.
9
+ */
10
+
11
+ import type { BotCredentials, WeixinMessage } from "./types.ts";
12
+ import { isSessionExpired, type WechatClient } from "./client.ts";
13
+ import { messageOf } from "./errors.ts";
14
+
15
+ const BACKOFF_MS = 2000;
16
+ /** Poll watchdog when the server has not told us its long-poll hold yet. */
17
+ const POLL_TIMEOUT_FALLBACK_MS = 90_000;
18
+ /** Never trust a server hint below this, so a busy loop cannot be papered over. */
19
+ const POLL_TIMEOUT_FLOOR_MS = 30_000;
20
+ /** Slack above the server's hold before a poll counts as wedged. */
21
+ const POLL_TIMEOUT_MARGIN_MS = 15_000;
22
+
23
+ /**
24
+ * How long one `getupdates` request may hang before it is treated as wedged: the
25
+ * server's own hold time plus a margin, so a half-open connection cannot stop
26
+ * inbound traffic without a word.
27
+ */
28
+ export function defaultPollTimeout(serverHintMs: number | undefined): number {
29
+ return Math.max(serverHintMs ?? POLL_TIMEOUT_FALLBACK_MS, POLL_TIMEOUT_FLOOR_MS) + POLL_TIMEOUT_MARGIN_MS;
30
+ }
31
+
32
+ /** What the loop needs from the bridge; every value is read at call time. */
33
+ export interface PollDeps {
34
+ client: () => WechatClient | undefined;
35
+ account: () => BotCredentials | undefined;
36
+ /** The server's requested hold, when the operator configured one. */
37
+ holdMs: () => number | undefined;
38
+ cursor: () => string;
39
+ setCursor: (cursor: string) => void;
40
+ saveState: () => void;
41
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
42
+ notify: (message: string, kind?: "info" | "warning" | "error") => void;
43
+ /** The token expired; the bridge clears its running state and reports it. */
44
+ onExpired: () => void;
45
+ onMessages: (messages: WeixinMessage[]) => void;
46
+ refreshLock: () => void;
47
+ isRunning: () => boolean;
48
+ now: () => number;
49
+ /** Report the last poll attempt, for the doctor/status. */
50
+ onPoll?: (result: { at: number; error?: string; holdMs?: number }) => void;
51
+ pollTimeout: (serverHintMs: number | undefined) => number;
52
+ }
53
+
54
+ /** The poll loop for one connection. */
55
+ export interface PollLoop {
56
+ /** Run until `signal` aborts, the connection closes, or the token expires. */
57
+ run(signal: AbortSignal): Promise<void>;
58
+ }
59
+
60
+ export function createPollLoop(deps: PollDeps): PollLoop {
61
+ return {
62
+ async run(signal: AbortSignal): Promise<void> {
63
+ // The server tells us how long it holds a poll; remember it so the watchdog
64
+ // can be tight instead of the generic fallback.
65
+ let serverHint: number | undefined;
66
+ /** One notice per failure episode, so a flapping link is reported once. */
67
+ let timeoutNotified = false;
68
+ let rejectedNotified = false;
69
+ while (deps.isRunning() && !signal.aborted) {
70
+ const client = deps.client();
71
+ const account = deps.account();
72
+ if (!client || !account) break;
73
+ const watchdogMs = deps.pollTimeout(serverHint ?? deps.holdMs());
74
+ // A cancellable watchdog: `AbortSignal.timeout` cannot be cleared, and a
75
+ // pending timer per poll would keep the process awake long after the request
76
+ // settled. Unref'd, so a wedged poll never holds a shutdown open either.
77
+ const watchdog = new AbortController();
78
+ const watchdogTimer = setTimeout(() => watchdog.abort(), watchdogMs);
79
+ (watchdogTimer as { unref?: () => void }).unref?.();
80
+ let response;
81
+ try {
82
+ response = await client.getUpdates(account, deps.cursor(), AbortSignal.any([signal, watchdog.signal]), {
83
+ holdMs: deps.holdMs(),
84
+ });
85
+ } catch (error) {
86
+ if (signal.aborted || !deps.isRunning()) break;
87
+ if (watchdog.signal.aborted) {
88
+ deps.onPoll?.({
89
+ at: deps.now(),
90
+ error: `timed out after ${Math.round(watchdogMs / 1000)}s`,
91
+ });
92
+ // A wedged request, not a server answer: retry at once with the same
93
+ // cursor, so nothing is skipped, and say so once.
94
+ if (!timeoutNotified) {
95
+ timeoutNotified = true;
96
+ deps.notify(`WeChat poll timed out after ${Math.round(watchdogMs / 1000)}s; retrying.`, "warning");
97
+ }
98
+ continue;
99
+ }
100
+ deps.onPoll?.({ at: deps.now(), error: messageOf(error) });
101
+ deps.notify(`WeChat poll failed: ${messageOf(error)}`, "warning");
102
+ try {
103
+ await deps.sleep(BACKOFF_MS, signal);
104
+ } catch {
105
+ break;
106
+ }
107
+ continue;
108
+ } finally {
109
+ clearTimeout(watchdogTimer);
110
+ }
111
+ if (!response) break;
112
+ if (response.timeoutMs !== undefined && response.timeoutMs > 0) serverHint = response.timeoutMs;
113
+ if (isSessionExpired(response)) {
114
+ deps.onExpired();
115
+ return;
116
+ }
117
+ if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
118
+ // A body-level rejection (bad cursor, rate limit, revoked token) is not an
119
+ // empty poll: report it once and back off instead of hammering the API.
120
+ if (!rejectedNotified) {
121
+ rejectedNotified = true;
122
+ const code = (response.ret ?? 0) !== 0 ? response.ret : (response.errcode ?? 0);
123
+ deps.onPoll?.({ at: deps.now(), error: `rejected ret ${code}` });
124
+ deps.notify(`WeChat poll rejected (ret ${code}); retrying.`, "warning");
125
+ }
126
+ try {
127
+ await deps.sleep(BACKOFF_MS, signal);
128
+ } catch {
129
+ break;
130
+ }
131
+ continue;
132
+ }
133
+ timeoutNotified = false;
134
+ rejectedNotified = false;
135
+ const next = response.buf;
136
+ if (next && next !== deps.cursor()) {
137
+ deps.setCursor(next);
138
+ deps.saveState();
139
+ }
140
+ deps.onMessages(response.msgs);
141
+ deps.refreshLock();
142
+ deps.onPoll?.({ at: deps.now(), holdMs: serverHint ?? deps.holdMs() });
143
+ }
144
+ },
145
+ };
146
+ }