/** * The signals a peer sees while their message waits: the typing indicator, the * one delayed acknowledgement, the "still working" line, and the "queued behind * another conversation" line. * * They are grouped here because they share the same per-peer timer bookkeeping: * every entry is a cancellable timer, every one is cleared when the turn settles, * and a reconnect drops all of them at once. WeChat notifies per bubble, so each * notice is bounded to one per wait episode. */ import type { BotCredentials } from "./types.ts"; import type { WechatClient } from "./client.ts"; import type { PeerState } from "./peer-worker.ts"; import type { PeerLanguage } from "./format.ts"; import { notice } from "./notices.ts"; import type { ArmTimer } from "./timer.ts"; /** How long a fetched typing ticket is reused. */ const TYPING_TTL_MS = 5 * 60 * 1000; /** How often the typing indicator is refreshed while a peer waits. */ const TYPING_KEEPALIVE_MS = 10_000; /** Stop refreshing the indicator after this long; a wedged turn must not tick forever. */ const TYPING_LIMIT_MS = 15 * 60 * 1000; /** Wait this long before telling a queued peer they are queued. */ const ACK_DELAY_MS = 4000; /** Suppress a second acknowledgement inside this window. */ const ACK_REPEAT_MS = 60_000; /** Say the running turn is still going after this long without a reply. */ const TURN_NOTICE_MS = 30_000; /** Say `busy` when a message is still queued behind a running turn after this. */ const BUSY_NOTICE_MS = 60_000; export interface PresenceDeps { typingIndicator: () => boolean; now: () => number; armTimer: ArmTimer; client: () => WechatClient | undefined; account: () => BotCredentials | undefined; signal: () => AbortSignal | undefined; /** The peer's conversation state, for the wait decisions. */ statusOf: (peer: string) => PeerState["status"]; languageOf: (peer: string) => PeerLanguage; /** Send one stock line to the peer (chunked by the bridge). */ send: (peer: string, contextToken: string | undefined, text: string) => Promise; /** A forwarded dialog is waiting; its own reminder covers the wait. */ promptPending: () => boolean; } /** One message a notice refers to. */ export interface PresenceTarget { peer: string; contextToken?: string; } export interface Presence { /** Start (or restart) the indicator and keep it alive while the peer waits. */ keepTyping(peer: string, contextToken: string | undefined): void; /** Turn the indicator off once a turn settles. */ typingOff(peer: string): Promise; /** Stop the keep-alive for peers with nothing left to answer. */ pruneTyping(): void; /** Stop one peer's keep-alive without touching the indicator. */ stopTyping(peer: string): void; /** Tell a queued peer once that their message is waiting. */ armAck(target: PresenceTarget): void; cancelAck(peer: string): void; /** Say once that this turn is taking a while. */ armSlowNotice(peer: string, contextToken: string | undefined): void; /** Say once why a message is still waiting behind another turn. */ armBusyNotice(peer: string, contextToken: string | undefined): void; stopSlowNotice(peer: string): void; stopBusyNotice(peer: string): void; /** Drop every timer and cached ticket (a new connection or a shutdown). */ reset(): void; } export function createPresence(deps: PresenceDeps): Presence { const typingCache = new Map(); /** Per-peer keep-alive cancellers for the typing indicator. */ const typing = new Map void>(); /** Per-peer pending acknowledgement cancellers. */ const acks = new Map void>(); /** When each peer was last acknowledged, so a chatty peer is not spammed. */ const ackedAt = new Map(); /** Peers told that a turn is still running, until that turn ends. */ const busyNotified = new Set(); /** Per-peer `busy` and `stillWorking` cancellers. */ const busyTimers = new Map void>(); const slowTimers = new Map void>(); const hasWork = (peer: string): boolean => deps.statusOf(peer) !== "idle"; const isRunning = (peer: string): boolean => deps.statusOf(peer) === "running"; /** The typing ticket for a peer, fetched on first use and cached. */ async function typingTicket(peer: string, contextToken: string | undefined): Promise { const cached = typingCache.get(peer); const now = deps.now(); if (cached && now - cached.checkedAt < TYPING_TTL_MS) return cached.ticket; const client = deps.client(); const account = deps.account(); if (!client || !account) return undefined; try { const response = await client.getConfig(account, { ilinkUserId: peer, contextToken }, deps.signal()); typingCache.set(peer, { ticket: response?.typingTicket, checkedAt: now }); return response?.typingTicket; } catch { typingCache.set(peer, { ticket: undefined, checkedAt: now }); return undefined; } } async function typingOn(peer: string, contextToken: string | undefined): Promise { const client = deps.client(); const account = deps.account(); if (!deps.typingIndicator() || !client || !account) return; const ticket = await typingTicket(peer, contextToken); if (!ticket) return; try { await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: true }, deps.signal()); // The peer's work may have finished while the ticket round-trip was in // flight; cancel immediately so the indicator cannot get stuck on. if (!hasWork(peer)) { await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: false }, deps.signal()); } } catch { // Typing is best-effort. } } async function typingOff(peer: string): Promise { const client = deps.client(); const account = deps.account(); if (!deps.typingIndicator() || !client || !account) return; const ticket = typingCache.get(peer)?.ticket; if (!ticket) return; try { await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: false }, deps.signal()); } catch { // Typing is best-effort. } } function stopTyping(peer: string): void { const cancel = typing.get(peer); if (!cancel) return; typing.delete(peer); cancel(); } /** * Start the typing indicator for a peer and keep it alive while they wait. * The chat window is the peer's only channel, and WeChat drops the indicator * after a few seconds, so a long turn needs the refresh to look alive at all. */ function keepTyping(peer: string, contextToken: string | undefined): void { if (!deps.typingIndicator()) return; stopTyping(peer); void typingOn(peer, contextToken); const started = deps.now(); const refresh = (): void => { if (!hasWork(peer) || deps.now() - started >= TYPING_LIMIT_MS) { typing.delete(peer); return; } void typingOn(peer, contextToken); typing.set(peer, deps.armTimer(TYPING_KEEPALIVE_MS, refresh)); }; typing.set(peer, deps.armTimer(TYPING_KEEPALIVE_MS, refresh)); } function stopSlowNotice(peer: string): void { const cancel = slowTimers.get(peer); if (cancel) cancel(); slowTimers.delete(peer); } function stopBusyNotice(peer: string): void { const cancel = busyTimers.get(peer); if (cancel) cancel(); busyTimers.delete(peer); busyNotified.delete(peer); } function armSlowNotice(peer: string, contextToken: string | undefined): void { stopSlowNotice(peer); slowTimers.set( peer, deps.armTimer(TURN_NOTICE_MS, () => { slowTimers.delete(peer); if (!isRunning(peer)) return; // A turn waiting on an unanswered dialog is not "still working": the // prompt itself nudges, and saying both reads as noise. if (deps.promptPending()) return; void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer))); }), ); } function armBusyNotice(peer: string, contextToken: string | undefined): void { if (busyNotified.has(peer) || busyTimers.has(peer)) return; busyTimers.set( peer, deps.armTimer(BUSY_NOTICE_MS, () => { busyTimers.delete(peer); if (!hasWork(peer) || busyNotified.has(peer)) return; busyNotified.add(peer); void deps.send(peer, contextToken, notice("busy", deps.languageOf(peer))); }), ); } function cancelAck(peer: string): void { const cancel = acks.get(peer); if (!cancel) return; acks.delete(peer); cancel(); } /** * Tell a queued peer, once, that their message is waiting. A turn that starts * within {@link ACK_DELAY_MS} cancels this, because the typing indicator says * the same thing without spending a bubble. */ function armAck(target: PresenceTarget): void { // A turn for this peer is already running: its own progress notice covers // this message, so a second acknowledgement would be noise. if (isRunning(target.peer)) return; if (acks.has(target.peer)) return; const last = ackedAt.get(target.peer); if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return; acks.set( target.peer, deps.armTimer(ACK_DELAY_MS, () => { acks.delete(target.peer); if (!hasWork(target.peer) || isRunning(target.peer)) return; ackedAt.set(target.peer, deps.now()); void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer))); // Still waiting after a minute behind another turn: say why. armBusyNotice(target.peer, target.contextToken); }), ); } return { keepTyping, typingOff, pruneTyping() { for (const peer of [...typing.keys()]) { if (!hasWork(peer)) stopTyping(peer); } }, armAck, cancelAck, armSlowNotice, armBusyNotice, stopSlowNotice, stopBusyNotice, stopTyping, reset() { for (const cancel of typing.values()) cancel(); typing.clear(); for (const cancel of acks.values()) cancel(); acks.clear(); for (const cancel of busyTimers.values()) cancel(); busyTimers.clear(); for (const cancel of slowTimers.values()) cancel(); slowTimers.clear(); typingCache.clear(); ackedAt.clear(); busyNotified.clear(); }, }; }