@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.
- package/extensions/wechat/README.md +33 -11
- package/extensions/wechat/admission.ts +104 -0
- package/extensions/wechat/bridge.ts +186 -626
- package/extensions/wechat/commands.ts +121 -9
- package/extensions/wechat/deliver.ts +207 -0
- package/extensions/wechat/doctor.ts +121 -0
- package/extensions/wechat/errors.ts +11 -0
- package/extensions/wechat/format.ts +1 -72
- package/extensions/wechat/inbound.ts +152 -0
- package/extensions/wechat/index.ts +6 -2
- package/extensions/wechat/interaction.ts +40 -2
- package/extensions/wechat/lock.ts +11 -2
- package/extensions/wechat/login.ts +2 -20
- package/extensions/wechat/notices.ts +76 -0
- package/extensions/wechat/peer-worker.ts +13 -17
- package/extensions/wechat/poll.ts +146 -0
- package/extensions/wechat/presence.ts +274 -0
- package/extensions/wechat/slash.ts +2 -1
- package/extensions/wechat/status-screen.ts +192 -31
- package/extensions/wechat/timer.ts +25 -5
- package/package.json +1 -1
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The WeChat bridge: one
|
|
2
|
+
* The WeChat bridge: one connection per account, one session per peer.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
|
22
|
-
import {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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):
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
392
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
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 (!
|
|
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) +
|
|
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
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
944
|
-
|
|
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) +
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|