/** * Every outbound WeChat message goes through here: a reply, a dialog preface, a * file, and the owner-only `send_wechat` tool. * * The path is shared so Markdown flattening, part numbering, session expiry, * body-level rejection, and partial progress are decided once. A failure is * reported once per outage until a send gets through, and the chat's stored reply * token is forgotten so the next inbound message can replace it. */ import type { BotCredentials } from "../types.ts"; import { isSessionExpired, rejectionOf, WechatError, type WechatClient } from "./client.ts"; import { messageOf } from "../../../lib/errors.ts"; import { chunkText, toWechatText } from "../text/format.ts"; import { basename } from "node:path"; import { sendLocalFile, sendLocalImage } from "./outbound.ts"; /** The widest marker `parts` parts need: `[n/n] ` is four characters plus both counts. */ /** `[i/n] ` in code points, for a part count of `digits` digits. */ function markerWidth(digits: number): number { return 4 + 2 * digits; } /** * `text` split so that every numbered part — marker included — fits `max` code * points. One chunk needs no marker, so it is cut at `max`; a split reserves the * marker's width first, because a part that then overshoots is a part the peer * receives over the limit. * * The final count `n` is the fixpoint of "cut with room for a `digits(n)`-digit * marker, then count again": reserving room can add a part, and enough parts widen * the marker (9 → 10). `digits(count)` only grows as the reserved width grows and * never exceeds `digits(text.length)`, so a fixed point exists in that range and * the loop reaches it by walking the widths. * * The room never drops below one code point, so a limit too small for the marker * its own part count needs cannot be honoured: the part is one code point and the * marker overflows it. That is the floor — the limit wins wherever it can. */ export function numberedChunks(text: string, max: number): string[] { const whole = chunkText(text, max); if (whole.length <= 1) return whole; let chunks = whole; for (let digits = 1; digits <= String(text.length).length; digits += 1) { chunks = chunkText(text, Math.max(1, max - markerWidth(digits))); if (String(chunks.length).length === digits) break; } return chunks; } /** One addressed text destination. */ export interface MessageTarget { peer: string; contextToken?: string; } /** The outcome of one outbound text delivery attempt. */ export interface Delivery { ok: boolean; /** Parts the transport accepted before stopping. */ sent: number; total: number; /** The server reported an expired token; the caller has been notified. */ expired?: boolean; /** Formatting left nothing to send. */ empty?: boolean; } /** The owner-send result shape `send_wechat` returns. */ export interface DeliveryResult { ok: boolean; error?: string; } /** What the deliverer needs from the bridge; all state is read at call time. */ export interface DeliveryDeps { client: () => WechatClient | undefined; account: () => BotCredentials | undefined; maxReplyChars: () => number; cdnBaseUrl: string; maxMediaBytes: number; notify: (message: string, kind?: "info" | "warning" | "error") => void; onExpired: () => void; /** Drop a peer's cached reply token. */ forgetToken: (peer: string) => void; /** The last send failure, for the doctor/status. */ onSendError?: (message: string) => void; /** A message got through; clear the last failure. */ onSent?: () => void; } /** The bridge's outbound surface. */ export interface Deliverer { /** Send a reply to a peer; false when the transport rejected it. */ reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise; /** Send a prompt to a peer. */ prompt(target: MessageTarget, text: string, signal?: AbortSignal): Promise; /** Upload and send a local file to a peer. */ file(target: MessageTarget, filePath: string, fileName: string, signal?: AbortSignal): Promise; /** Upload and send a local image to a peer, as a picture. */ image(target: MessageTarget, filePath: string, signal?: AbortSignal): Promise; /** Message the owner; returns the tool's result instead of logging. */ toOwner(owner: string, contextToken: string | undefined, text: string, signal?: AbortSignal): Promise; /** Forget the one-notice-per-outage state (a new connection). */ reset(): void; } export function createDeliverer(deps: DeliveryDeps): Deliverer { /** Whether the chat was already reported unable to receive, until a send gets through. */ let failureNotified = false; /** The FIFO: a reply, prompt, or file cannot start before the previous one. */ let queue: Promise = Promise.resolve(); function serialize(work: () => Promise): Promise { const previous = queue; const run = previous.then(work, work); queue = run.then( () => undefined, () => undefined, ); return run; } /** * Send `text` in `maxReplyChars` chunks, stopping at the first failed chunk. * A session expiry is handled here; a transport or rejection error is handed to * `onFailure` with how much went out, so each caller reports it its own way. */ async function deliverText( to: string, contextToken: string | undefined, text: string, signal: AbortSignal | undefined, onFailure: (error: unknown, progress: { sent: number; total: number }) => void, ): Promise { const client = deps.client(); const account = deps.account(); if (!client || !account) return { ok: false, sent: 0, total: 0 }; const chunks = numberedChunks(toWechatText(text), deps.maxReplyChars()).filter((chunk) => chunk !== ""); if (chunks.length === 0) { deps.notify("WeChat message had nothing to send after formatting.", "warning"); return { ok: false, sent: 0, total: 0, empty: true }; } for (const [index, chunk] of chunks.entries()) { // A split answer says so, so the peer knows more is coming and can tell a // truncated reply from a complete one. const body = chunks.length > 1 ? `[${index + 1}/${chunks.length}] ${chunk}` : chunk; try { const response = await client.sendMessage(account, { to, text: body, contextToken }, signal); if (isSessionExpired(response)) { deps.onExpired(); return { ok: false, sent: index, total: chunks.length, expired: true }; } // A body-level rejection is not a delivery: report it with the part count // instead of letting the answer vanish. const rejection = rejectionOf(response); if (rejection) { const detail = rejection.detail ? `: ${rejection.detail}` : ""; onFailure(new WechatError(`Weixin rejected the message (ret ${rejection.code}${detail}).`), { sent: index, total: chunks.length, }); return { ok: false, sent: index, total: chunks.length }; } } catch (error) { onFailure(error, { sent: index, total: chunks.length }); return { ok: false, sent: index, total: chunks.length }; } } return { ok: true, sent: chunks.length, total: chunks.length }; } /** Report a failure once per outage, forget the token, and return the outcome. */ async function deliverWithNotice( target: MessageTarget, text: string, label: "reply" | "message delivery", signal: AbortSignal | undefined, ): Promise { let failed = false; const delivery = await deliverText(target.peer, target.contextToken, text, signal, (error, progress) => { failed = true; if (signal?.aborted) return; deps.forgetToken(target.peer); if (failureNotified) return; failureNotified = true; const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : ""; deps.onSendError?.(messageOf(error)); deps.notify(`WeChat ${label} failed${partial}: ${messageOf(error)}`, "warning"); }); if (!failed && delivery.ok) { failureNotified = false; deps.onSent?.(); } return delivery.ok; } /** * Send one attachment, reporting a failure the model cannot see otherwise. * * The sender reports only success, and a caller may fall back to text; without * this report the operator would never know the attachment was dropped. */ function media( _target: MessageTarget, label: "file" | "image", name: string, send: (client: WechatClient, account: BotCredentials, signal?: AbortSignal) => Promise, signal?: AbortSignal, ): Promise { return serialize(async () => { const client = deps.client(); const account = deps.account(); if (!client || !account) return false; const sent = await send(client, account, signal); if (!sent && !signal?.aborted) { deps.onSendError?.(`${label} delivery failed: ${name}`); deps.notify(`WeChat ${label} delivery failed for ${name}.`, "warning"); } if (sent) { // An attachment that arrived is delivery as much as a text is, so the peer's // next failure is news again. failureNotified = false; deps.onSent?.(); } return sent; }); } return { reply(target, text, signal) { return serialize(() => deliverWithNotice(target, text, "reply", signal)); }, prompt(target, text, signal) { return serialize(() => deliverWithNotice(target, text, "message delivery", signal)); }, file(target, filePath, fileName, signal) { return media( target, "file", fileName, (client, account, abortSignal) => sendLocalFile({ client, account, cdnBaseUrl: deps.cdnBaseUrl, maxBytes: deps.maxMediaBytes, to: target.peer, contextToken: target.contextToken, filePath, fileName, signal: abortSignal, }), signal, ); }, image(target, filePath, signal) { return media( target, "image", basename(filePath), (client, account, abortSignal) => sendLocalImage({ client, account, cdnBaseUrl: deps.cdnBaseUrl, maxBytes: deps.maxMediaBytes, to: target.peer, contextToken: target.contextToken, filePath, signal: abortSignal, }), signal, ); }, toOwner(owner, contextToken, text, signal) { return serialize(async () => { let failure: string | undefined; const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => { const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : ""; failure = `${messageOf(error)}${partial}`; // A close aborts the send; nothing was refused, so the owner's token stays // and the abort is not recorded as the last failure. if (signal?.aborted) return; deps.forgetToken(owner); deps.onSendError?.(messageOf(error)); }); if (delivery.expired) return { ok: false, error: "WeChat session expired." }; if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." }; if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." }; deps.onSent?.(); return { ok: true }; }); }, reset() { failureNotified = false; }, }; }