Files
dsh-telegram-channel/lib/bridge.d.ts
T
RRRosmontis f3f6a3ebaf feat: admit photos and image documents into the bound session
Telegram 图片消息此前一律回复「暂只支持文本消息」。本改动:

- photo 取最大尺寸(TG 压成 jpeg),document 仅接受 image/png|jpeg|webp|gif
- caption 作为伴随文本块进入同一条消息(不解析为命令)
- 相册(同一 media_group_id)去抖 1.2s 合并为一条多图消息,避免 N 连回合
- 派发走宿主 sessions.prompt RPC(Web 端同通道):模型图像能力校验 +
  附件服务持久化 + mode queue 排队,与纯文本路径的 agent.followup 语义一致
- 错误映射:MODEL_DOES_NOT_SUPPORT_IMAGES → 提示换多模态模型;其余回退
  图片发送失败 + [code] 详情;下载失败即时提示
- 客户端新增 getFile/downloadFile(GET file 端点,复用代理感知 fetch)

tests +7(58/58):photo+caption、png document、pdf 拒绝、相册合并、
模型不支持、下载失败、未绑定。无 emoji 文案。
2026-09-05 17:51:21 +08:00

154 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/** Global rendering mode: follows config; flipped to 'html' permanently when the
* Rich Message API itself is unavailable (a server-wide fact, not per-chat). */
private renderingMode;
/** Per-chat rendering override (persisted): rich messages need a recent Telegram
* client — old ones (e.g. 10.x) cannot render them and show “unsupported”. */
private readonly renderPrefs;
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;
/** sessionId → in-flight manual compaction (dedup /compact) */
private readonly compacting;
/** sessionId → compaction abort controller (cancelled on bridge stop) */
private readonly compactAborts;
/** Album accumulation: chatId:media_group_id → parts buffered into one prompt. */
private readonly mediaGroups;
constructor(ctx: Context, options: TelegramBridgeOptions);
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
private modeFor;
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;
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
private workspaceOf;
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
private readRuntimeValues;
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
private serviceOf;
private requestCompact;
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
private runCompaction;
private followupBound;
/**
* Extract wire prompt parts from a media message.
* Returns undefined when the message carries no media at all; returns [] for
* an unsupported media type (the user was already notified); otherwise the
* caption text part (when present) followed by the image part.
*/
private collectMediaParts;
/** Download one TG file and encode it as a canonical-base64 wire image part. */
private downloadImagePart;
/**
* Album photos arrive as separate updates sharing a media_group_id — debounce
* them into ONE prompt so an album does not spawn N sequential turns.
*/
private accumulateMediaGroup;
/**
* Dispatch an image-bearing message to the bound session through the host
* prompt RPC — the exact channel the Web composer uses. The host checks
* whether the current model accepts image input, admits the bytes into the
* durable attachment store, then queues the message on the agent (mode
* 'queue' — same semantics as the text path's agent.followup).
*/
private sendImageFollowup;
private pollLoop;
private interruptibleDelay;
private interruptibleSleep;
private onSessionEvent;
private handleRichCommand;
private deliver;
private deliverHtml;
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
private withRetry;
/**
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
* the blockquote entity at all and show the whole message as “not supported”.
* Falls back to plain text when the 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;
}