Delivery & visibility: - per-chunk retry with capped backoff on rich/HTML sends; rich failure now falls back to HTML instead of dropping the reply - typing heartbeat: refresh sendChatAction every 4s for the whole turn (a single action expires in ~5s, long turns looked dead) - turn/end error / abort / crash-recovery notices reach Telegram (API failures were previously invisible) - tool/call progress summaries, tool/result failure notices, todo progress - thinking indicator (one notice per reasoning phase, status only) - notices prefixed '> ' render as Telegram blockquotes; plain-text fallback - non-text messages get a hint instead of being silently dropped - busy feedback: a followup queued mid-turn gets an explicit ack TG-side answering (dual-path race; UI path untouched, first answer wins): - ask_user_question questions render with letter options on TG; reply A / [A] / A,B (multi) / free text / '1: A' per-line for multi-question - approval requests (approval/request waterfall) answerable from TG: [A] allow once / [B] reject; /cancel closes only the TG side Commands: /stop (cancel current turn), /mission (todo snapshot + busy state), /new (create a session in the current workspace and attach) Persistence: chat→session bindings stored at $DSH_HOME/ telegram-channel-bindings.json (override: DSH_TELEGRAM_BINDINGS_FILE) and restored on start — hot reloads and restarts no longer drop attachments. Host seams without public surface yet are reached with documented structural casts (userQuestions provider wrap, approval/request waterfall). Tests: 41/41 pass. Two pre-existing environment failures in bridge.test.ts (/last history mirror, assistant mirror) now pass: the former was fixed by the rich→HTML fallback, the latter needed an event-loop flush after the (void) listener call since resilient delivery lengthens the chain.
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
import type { Context } from '@deepseek-ai/cordis';
|
|
import { type TelegramClientLike, type TelegramUpdate } from './client.js';
|
|
export interface TelegramBridgeOptions {
|
|
token: string;
|
|
allowedUserIds: number[];
|
|
allowAllUsers: boolean;
|
|
client?: TelegramClientLike;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
maxMessageLength?: number;
|
|
pollingTimeoutSec?: number;
|
|
rendering?: 'rich' | 'html';
|
|
}
|
|
export declare class TelegramBridge {
|
|
private readonly ctx;
|
|
private readonly token;
|
|
private readonly allowedUserIds;
|
|
private readonly allowAllUsers;
|
|
private readonly client;
|
|
private readonly sleep;
|
|
private readonly maxMessageLength;
|
|
private renderingMode;
|
|
private readonly bindings;
|
|
private readonly pickers;
|
|
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
|
private readonly pendingModels;
|
|
private polling;
|
|
private offset;
|
|
private pollPromise;
|
|
private pollAbort;
|
|
private disposeSessionListener;
|
|
/** sessionIds mid-turn (busy feedback, /stop state) */
|
|
private readonly busySessions;
|
|
/** chatId → typing heartbeat interval handle */
|
|
private readonly heartbeats;
|
|
/** chatId → serialized notice chain (prevents 429 storms) */
|
|
private readonly noticeQueue;
|
|
private readonly pendingAsks;
|
|
private hookTimer;
|
|
private readonly pendingApprovalsTG;
|
|
private disposeApprovalHook;
|
|
/** sessionId → thinking indicator state (one notice per reasoning phase) */
|
|
private readonly thinkingSessions;
|
|
/** callId → tool name (tool/result failure notices) */
|
|
private readonly callNames;
|
|
/** sessionId → latest todo snapshot (/mission) */
|
|
private readonly lastTodos;
|
|
constructor(ctx: Context, options: TelegramBridgeOptions);
|
|
start(): void;
|
|
stop(): Promise<void>;
|
|
processUpdate(update: TelegramUpdate): Promise<void>;
|
|
private handleCallback;
|
|
private resolveCatalog;
|
|
private sendWorkspacePicker;
|
|
private sendSessionPicker;
|
|
private bindSession;
|
|
private sendLastTurn;
|
|
private sendModelPicker;
|
|
private sendEffortPicker;
|
|
private applyModel;
|
|
private liveAgents;
|
|
private findLiveAgent;
|
|
/** Resume cold sessions when needed; never dispose the returned handle. */
|
|
private ensureLiveAgent;
|
|
private sendStatus;
|
|
private followupBound;
|
|
private pollLoop;
|
|
private interruptibleDelay;
|
|
private interruptibleSleep;
|
|
private onSessionEvent;
|
|
private deliver;
|
|
private deliverHtml;
|
|
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
|
private withRetry;
|
|
private escHtml;
|
|
/**
|
|
* Send a notice; a leading `> ` renders as a Telegram blockquote (HTML).
|
|
* Falls back to plain text when the HTML send fails — a notice is never lost.
|
|
*/
|
|
private deliverNotice;
|
|
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
|
private enqueueNotice;
|
|
private startTypingHeartbeat;
|
|
private stopTypingHeartbeat;
|
|
private stopAllHeartbeats;
|
|
private userQuestions;
|
|
/**
|
|
* Wrap the UI provider's ask() so Telegram gets a parallel answer path.
|
|
* `Promise.race` decides; the UI path is untouched. The TG promise NEVER
|
|
* settles when there is no bound chat — race would kill the UI's window
|
|
* with that early rejection.
|
|
*/
|
|
private hookUserQuestions;
|
|
private registerTgAsk;
|
|
private settleGuiSide;
|
|
private formatAskPending;
|
|
private handleTgAnswer;
|
|
private onApprovalRequest;
|
|
private handleTgApproval;
|
|
private stopBound;
|
|
private sendMission;
|
|
/** /new: create a session in the bound session's workspace and attach to it. */
|
|
private newSessionHere;
|
|
private bindingsPath;
|
|
private saveBindings;
|
|
private loadBindings;
|
|
private redact;
|
|
}
|