Files
root 36e95c2631 feat(goal): TG /goal 指令进入目标模式
- 复用宿主命令运行时(commands.execute,与 Web 端 /goal 同通道),不依赖
  GUI 连接:command-goal 在宿主侧操作 session-log 支撑的 goals 域
- 语法与 Web 完全一致:/goal <目标>、/goal(查看)、clear/edit/pause/resume
- 目标状态变更(blocked/complete)实时推送 TG 通知,手机可感知目标模式进展
- 配置侧启用 command-goal(plugin_toggle,已 live 生效)
2026-09-17 12:05:26 +08:00

2037 lines
88 KiB
JavaScript
Raw Permalink 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 { Buffer } from 'node:buffer';
import { randomUUID } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
import { SessionId } from '@deepseek-ai/dsh-session/types';
import { isAuthorized } from './auth.js';
import { resolveApiProxy } from './apiproxy.js';
import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForWorkspace, workspacesWithVisibleSessions, } from './catalog.js';
import { TelegramClient, } from './client.js';
import { MSG, LAST_CB, parseCommand } from './commands.js';
import { escapeHtml, markdownToHtml, splitMessage } from './format.js';
import { splitRichMarkdown } from './rich-format.js';
import { formatLastTurn, loadLastTurn } from './history.js';
import { describeAgent, displayLabel, workspaceName } from './label.js';
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
import { formatStatusText, } from './status.js';
/** Host-admitted raster media types (@deepseek-ai/dsh-attachment). */
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
/** Album debounce window: TG emits each photo of an album as its own update. */
const MEDIA_GROUP_WINDOW_MS = 1200;
/**
* 把 command-compact 返回的英文结果翻译成 TG 中文回复。
* 文案以宿主 @deepseek-ai/dsh-command-compact 的固定输出为准;无法识别时返回
* undefined,由调用方回退为「压缩失败 + 原文详情」。
*/
function translateCompactResult(result) {
const kind = result?.kind;
const text = result?.text ?? '';
if (kind === 'success') {
const m = /Compacted (\d+) history items \(~(\d+) tokens\)/.exec(text);
if (m)
return MSG.COMPACT_DONE(Number(m[1]), Number(m[2]));
if (/No compactable history yet/.test(text))
return MSG.COMPACT_NOTHING;
return undefined;
}
if (kind === 'error') {
if (/active compaction|not idle/.test(text))
return MSG.COMPACT_BUSY;
if (/^Compaction cancelled/.test(text))
return MSG.COMPACT_CANCELLED;
if (/history selected for compaction changed/.test(text))
return MSG.COMPACT_CHANGED;
if (/could not produce a useful summary/.test(text))
return MSG.COMPACT_SUMMARY_FAILED;
if (/did not finish cleanly/.test(text))
return MSG.COMPACT_COMMIT_FAILED;
if (/could not be saved/.test(text))
return MSG.COMPACT_PERSIST_FAILED;
return undefined;
}
return undefined;
}
const WS_CB = 'ws:';
const SID_CB = 'sid:';
const BACK_WS_CB = 'wb';
const MODEL_CB = 'mdl:';
/** Use eff: (not me:) — short prefix, no collision with other callbacks. */
const EFFORT_CB = 'eff:';
const BACK_MODEL_CB = 'mb';
/** ask:{questionIndex}:{optionIndex|ok|custom} — inline-keyboard taps for ask_user_question. */
const ASK_CB = 'ask:';
/** apr:allow / apr:deny — inline-keyboard taps for tool approvals. */
const APR_CB = 'apr:';
const MAX_BUTTONS = 40;
/** Marker stored on UserQuestionService.prototype for the ask interceptor. */
const kTgAskWrapped = Symbol.for('dsh-telegram-channel.askWrapped');
/** Marker stored on ApprovalService.prototype for the approval interceptor. */
const kTgApprovalWrapped = Symbol.for('dsh-telegram-channel.approvalWrapped');
function lastContextKeyboard() {
return {
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
};
}
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Cap a single reasoning block so a long chain of thought can't flood the phone. */
const REASONING_QUOTE_MAX_CHARS = 800;
/**
* Assistant message → markdown for delivery. Reasoning (thinking) blocks are
* kept, one `> ` quote line each — Telegram renders them as the accent-bar
* quote style the phone user expects for bash/thinking-style chatter.
*/
function contentToText(content) {
const parts = [];
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
parts.push(block.text);
}
else if (block.type === 'reasoning' && typeof block.text === 'string') {
let thinking = block.text.trim();
if (!thinking)
continue;
if (thinking.length > REASONING_QUOTE_MAX_CHARS)
thinking = `${thinking.slice(0, REASONING_QUOTE_MAX_CHARS)}…`;
parts.push(thinking.split('\n').map((line) => `> ${line}`).join('\n'));
}
}
return parts.join('\n\n');
}
function isRichUnsupportedError(err) {
const message = err instanceof Error ? err.message : String(err);
return /method not found|unknown method|not found|404|bad request|can't parse|rich message/i.test(message);
}
export class TelegramBridge {
ctx;
token;
allowedUserIds;
allowAllUsers;
client;
sleep;
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). */
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”. */
renderPrefs = new Map();
bindings = new Map();
pickers = new Map();
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
pendingModels = new Map();
polling = false;
offset;
pollPromise;
pollAbort;
disposeSessionListener;
// ── Stability & interactivity additions ──
/** sessionIds mid-turn (busy feedback, /stop state) */
busySessions = new Set();
/** chatId → typing heartbeat interval handle */
heartbeats = new Map();
/** chatId → serialized notice chain (prevents 429 storms) */
noticeQueue = new Map();
pendingAsks = new Map();
hookTimer;
pendingApprovalsTG = new Map();
/** Prototype-ask interceptor state (see installAskInterceptor). */
askInstalled = false;
askRestore;
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
approvalInstalled = false;
approvalRestore;
/** callId → tool name (tool/result failure notices) */
callNames = new Map();
/** sessionId → latest todo snapshot (/mission) */
lastTodos = new Map();
/** sessionId → in-flight manual compaction (dedup /compact) */
compacting = new Set();
/** sessionId → compaction abort controller (cancelled on bridge stop) */
compactAborts = new Map();
/** Album accumulation: chatId:media_group_id → parts buffered into one prompt. */
mediaGroups = new Map();
constructor(ctx, options) {
this.ctx = ctx;
this.token = options.token;
this.allowedUserIds = options.allowedUserIds;
this.allowAllUsers = options.allowAllUsers;
this.client = options.client ?? new TelegramClient(options.token, {
pollingTimeoutSec: options.pollingTimeoutSec ?? 30,
});
this.sleep = options.sleep ?? defaultSleep;
this.maxMessageLength = options.maxMessageLength ?? 4096;
// Compat-first default: only an explicit `rendering: 'rich'` opts into rich
// messages, because old Telegram clients (10.x) cannot render them at all.
this.renderingMode = options.rendering === 'rich' ? 'rich' : 'html';
}
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
modeFor(chatId) {
return this.renderPrefs.get(String(chatId)) ?? this.renderingMode;
}
start() {
this.disposeSessionListener?.();
this.disposeSessionListener = this.ctx.on('session/event', (session, event) => {
void this.onSessionEvent(session, event).catch((err) => {
this.ctx.logger.error(this.redact(err));
});
});
// Restore persisted bindings (survive hot reload / restart) and install
// the TG answering hooks (ask_user dual-path + approval dual-path).
this.loadBindings();
// ask_user interception: see installAskInterceptor — a prototype wrap of
// UserQuestionService.prototype.ask. Plain ctx.on('user-questions/request')
// registries never see the dispatch (the waterfall fires on a different
// per-fiber events registry in dsh 0.1.5-rc.1), so the prototype wrap —
// which sees EVERY ask regardless of fiber/instance — is the reliable seam.
this.installAskInterceptor();
this.installApprovalInterceptor();
void this.client.setMyCommands([
{ command: 'start', description: '欢迎与用法' },
{ command: 'sessions', description: '按工作区列出并附着会话' },
{ command: 'last', description: '查看上次问答(续接上下文)' },
{ command: 'model', description: '切换当前绑定会话的模型' },
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
{ command: 'goal', description: '目标模式:/goal <目标>(clear/edit/pause/resume 管理)' },
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
{ command: 'stop', description: '中止当前正在运行的任务' },
{ command: 'mission', description: '查看任务清单与完成情况' },
{ command: 'new', description: '在当前工作区新开对话并附着' },
{ command: 'cancel', description: '关闭 TG 端作答/审批(Web 端仍可答)' },
{ command: 'help', description: '显示帮助' },
]).then(() => {
this.ctx.logger.info('dsh-telegram-channel: bot commands registered');
}).catch((err) => {
this.ctx.logger.warn(`dsh-telegram-channel: setMyCommands failed: ${this.redact(err)}`);
});
if (!this.polling) {
this.polling = true;
this.pollAbort = new AbortController();
this.ctx.logger.info('dsh-telegram-channel: long-polling started');
this.pollPromise = this.pollLoop();
}
}
async stop() {
this.polling = false;
this.pollAbort?.abort();
this.pollAbort = undefined;
this.disposeSessionListener?.();
this.disposeSessionListener = undefined;
// Never dispose host agents — only clear remote bindings.
this.bindings.clear();
this.renderPrefs.clear();
this.pickers.clear();
this.pendingModels.clear();
this.stopAllHeartbeats();
this.busySessions.clear();
this.noticeQueue.clear();
this.pendingAsks.clear();
this.pendingApprovalsTG.clear();
this.callNames.clear();
this.lastTodos.clear();
for (const controller of this.compactAborts.values())
controller.abort();
this.compactAborts.clear();
this.compacting.clear();
for (const group of this.mediaGroups.values())
clearTimeout(group.timer);
this.mediaGroups.clear();
if (this.hookTimer) {
clearTimeout(this.hookTimer);
this.hookTimer = undefined;
}
this.askRestore?.();
this.askRestore = undefined;
this.askInstalled = false;
this.approvalRestore?.();
this.approvalRestore = undefined;
this.approvalInstalled = false;
if (this.pollPromise) {
await this.pollPromise.catch(() => { });
this.pollPromise = undefined;
}
}
async processUpdate(update) {
if (update.callback_query) {
await this.handleCallback(update);
return;
}
const message = update.message;
if (!message)
return;
const chatId = message.chat.id;
const userId = message.from?.id;
if (!isAuthorized({ allowAllUsers: this.allowAllUsers, allowedUserIds: this.allowedUserIds, userId })) {
await this.client.sendMessage(chatId, MSG.DENIED);
return;
}
// Media messages: photos / image documents (optionally with a caption) are
// admitted into the bound session; other media get an explicit notice.
// A caption is treated as accompanying text — never parsed as a command.
const mediaParts = await this.collectMediaParts(chatId, message);
if (mediaParts !== undefined) {
if (mediaParts.length > 0) {
if (message.media_group_id)
this.accumulateMediaGroup(chatId, message.media_group_id, mediaParts);
else
await this.sendImageFollowup(chatId, mediaParts);
}
return;
}
// Non-text messages without recognized media: tell the (authorized) user
// instead of dropping silently.
if (!message.text) {
await this.enqueueNotice(chatId, MSG.MEDIA_UNSUPPORTED);
return;
}
const parsed = parseCommand(message.text);
switch (parsed.type) {
case 'start':
await this.client.sendMessage(chatId, MSG.WELCOME);
return;
case 'help':
await this.client.sendMessage(chatId, MSG.HELP);
return;
case 'sessions':
await this.sendWorkspacePicker(chatId);
return;
case 'last':
await this.sendLastTurn(chatId);
return;
case 'model':
await this.sendModelPicker(chatId);
return;
case 'status':
await this.sendStatus(chatId);
return;
case 'compact':
await this.requestCompact(chatId);
return;
case 'goal':
await this.requestGoal(chatId, parsed.arg);
return;
case 'rich':
await this.handleRichCommand(chatId, parsed.arg);
return;
case 'unbind':
this.bindings.delete(String(chatId));
this.pickers.delete(String(chatId));
this.pendingAsks.delete(String(chatId));
this.saveBindings();
await this.client.sendMessage(chatId, MSG.UNBOUND);
return;
case 'stop':
await this.stopBound(chatId);
return;
case 'mission':
await this.sendMission(chatId);
return;
case 'new':
await this.newSessionHere(chatId);
return;
case 'cancel': {
// /cancel closes the TG-side answer/approval channel; the UI side stays open.
let cancelled = false;
if (this.pendingApprovalsTG.delete(String(chatId)))
cancelled = true;
if (this.pendingAsks.delete(String(chatId)))
cancelled = true;
if (cancelled)
await this.enqueueNotice(chatId, '已关闭 TG 端作答/审批(该问题仍可在 Web 端回答)');
else
await this.client.sendMessage(chatId, '当前没有待作答的提问或审批');
return;
}
case 'unknown':
await this.client.sendMessage(chatId, MSG.unknown(parsed.command));
return;
case 'plain': {
// A pending approval/ask on TG consumes the message as an answer.
const approval = this.pendingApprovalsTG.get(String(chatId));
if (approval) {
await this.handleTgApproval(chatId, approval, parsed.text);
return;
}
const pending = this.pendingAsks.get(String(chatId));
if (pending) {
await this.handleTgAnswer(chatId, pending, parsed.text);
return;
}
await this.followupBound(chatId, parsed.text);
return;
}
}
}
async handleCallback(update) {
const cq = update.callback_query;
const userId = cq.from.id;
const chatId = cq.message?.chat.id;
if (chatId === undefined) {
await this.client.answerCallbackQuery(cq.id);
return;
}
if (!isAuthorized({ allowAllUsers: this.allowAllUsers, allowedUserIds: this.allowedUserIds, userId })) {
await this.client.answerCallbackQuery(cq.id, MSG.DENIED);
await this.client.sendMessage(chatId, MSG.DENIED);
return;
}
const data = cq.data ?? '';
const picker = this.pickers.get(String(chatId));
if (data.startsWith(ASK_CB)) {
await this.handleAskCallback(chatId, cq);
return;
}
if (data.startsWith(APR_CB)) {
await this.handleApprovalCallback(chatId, cq, data);
return;
}
if (data === LAST_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendLastTurn(chatId);
return;
}
if (data === BACK_WS_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendWorkspacePicker(chatId, picker?.catalog);
return;
}
if (data === BACK_MODEL_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendModelPicker(chatId);
return;
}
if (data.startsWith(WS_CB)) {
const index = Number(data.slice(WS_CB.length));
await this.client.answerCallbackQuery(cq.id);
await this.sendSessionPicker(chatId, index);
return;
}
if (data.startsWith(SID_CB)) {
const index = Number(data.slice(SID_CB.length));
const row = picker?.sessions[index];
if (!row) {
await this.client.answerCallbackQuery(cq.id, '会话已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
await this.bindSession(chatId, cq.id, row);
return;
}
if (data.startsWith(MODEL_CB)) {
const index = Number(data.slice(MODEL_CB.length));
const option = picker?.models?.[index];
if (!option) {
await this.client.answerCallbackQuery(cq.id, '列表已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
const efforts = (option.efforts ?? []).filter((e) => e.id);
if (efforts.length === 1) {
// Single effort (often only "off") — apply immediately, no second tap.
await this.applyModel(chatId, cq.id, { ...option, efforts }, efforts[0].id);
return;
}
if (efforts.length > 1) {
const pending = { ...option, efforts };
this.pendingModels.set(String(chatId), pending);
if (picker)
picker.pendingModel = pending;
await this.client.answerCallbackQuery(cq.id);
await this.sendEffortPicker(chatId, pending);
return;
}
await this.applyModel(chatId, cq.id, option);
return;
}
if (data.startsWith(EFFORT_CB) || data.startsWith('me:')) {
// Accept legacy "me:" callbacks from older bot messages.
const rawIndex = data.startsWith(EFFORT_CB)
? data.slice(EFFORT_CB.length)
: data.slice('me:'.length);
const index = Number(rawIndex);
const pending = this.pendingModels.get(String(chatId)) ?? picker?.pendingModel;
const effort = pending?.efforts?.[index];
if (!pending || !effort?.id) {
await this.client.answerCallbackQuery(cq.id, '列表已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
await this.applyModel(chatId, cq.id, pending, effort.id);
return;
}
// Legacy bind:<sessionId> callbacks (older messages / tests)
if (data.startsWith('bind:')) {
const sessionId = data.slice('bind:'.length);
await this.bindSession(chatId, cq.id, {
sessionId,
title: sessionId,
blank: false,
running: true,
updatedAt: 0,
});
return;
}
await this.client.answerCallbackQuery(cq.id);
}
async resolveCatalog() {
try {
const fromApi = await loadCatalog(this.ctx);
if (fromApi)
return fromApi;
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: catalog via apiProxy failed: ${this.redact(err)}`);
}
return catalogFromLiveAgents(this.liveAgents(), this.ctx);
}
async sendWorkspacePicker(chatId, existing) {
const catalog = existing ?? await this.resolveCatalog();
const workspaces = workspacesWithVisibleSessions(catalog);
if (workspaces.length === 0) {
this.pickers.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.NO_SESSIONS);
return;
}
const shown = workspaces.slice(0, MAX_BUTTONS);
this.pickers.set(String(chatId), { workspaces: shown, sessions: [], catalog });
const keyboard = {
inline_keyboard: shown.map((ws, i) => ([{
text: truncateButton(`${i + 1}. ${ws.title}`),
callback_data: `${WS_CB}${i}`,
}])),
};
const body = [
catalog.complete
? `选择工作区(共 ${workspaces.length} 个,与 Web 对齐,已排除归档):`
: `选择工作区(共 ${workspaces.length} 个)⚠️ 仅运行中的会话(apiProxy 未就绪,完整列表需插件 ≥0.3.2 并重启 dsh web):`,
'',
...shown.map((ws, i) => {
const n = visibleSessionsForWorkspace(catalog, ws).length;
return `${i + 1}. ${ws.title}\n ${ws.path}\n 会话:${n}`;
}),
workspaces.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个工作区。` : '',
'',
'点下方按钮进入该工作区的会话列表。',
].filter(Boolean).join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
async sendSessionPicker(chatId, workspaceIndex) {
const picker = this.pickers.get(String(chatId));
const catalog = picker?.catalog ?? await this.resolveCatalog();
const workspaces = picker?.workspaces?.length
? picker.workspaces
: workspacesWithVisibleSessions(catalog);
const workspace = workspaces[workspaceIndex];
if (!workspace) {
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
const sessions = visibleSessionsForWorkspace(catalog, workspace)
.slice()
.sort((a, b) => b.updatedAt - a.updatedAt);
if (sessions.length === 0) {
await this.client.sendMessage(chatId, MSG.NO_SESSIONS_IN_WS(workspace.title));
return;
}
const shown = sessions.slice(0, MAX_BUTTONS);
this.pickers.set(String(chatId), { workspaces, sessions: shown, catalog });
const keyboard = {
inline_keyboard: [
...shown.map((row, i) => ([{
text: truncateButton(`${i + 1}. ${row.title}${row.running ? '' : ' · 冷'}`),
callback_data: `${SID_CB}${i}`,
}])),
[{ text: '← 返回工作区', callback_data: BACK_WS_CB }],
],
};
const body = [
`工作区:${workspace.title}`,
workspace.path,
'',
`选择会话(共 ${sessions.length} 个):`,
'',
...shown.map((row, i) => {
const mark = row.running ? '运行中' : '未附着';
return `${i + 1}. ${row.title}\n ${mark} · …${row.sessionId.slice(-12)}`;
}),
sessions.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个会话。` : '',
'',
'点下方按钮附着;冷会话会自动 resume(不关闭 Web)。',
].filter(Boolean).join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
async bindSession(chatId, callbackId, row) {
const agent = await this.ensureLiveAgent(row.sessionId);
if (!agent) {
await this.client.answerCallbackQuery(callbackId, '无法附着');
await this.client.sendMessage(chatId, MSG.RESUME_FAILED);
return;
}
const parts = describeAgent(agent, 0, this.ctx);
const label = row.title && row.title !== row.sessionId
? displayLabel({ ...parts, title: row.title })
: displayLabel(parts);
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label });
// Drop stale pending asks when rebinding to another session; persist bindings.
const stale = this.pendingAsks.get(String(chatId));
if (stale && stale.sessionId !== String(agent.id))
this.pendingAsks.delete(String(chatId));
this.saveBindings();
await this.client.answerCallbackQuery(callbackId, '已附着');
await this.client.sendMessage(chatId, MSG.BOUND(label), undefined, lastContextKeyboard());
}
async sendLastTurn(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
try {
const turn = await loadLastTurn(this.ctx, binding.sessionId, agent);
const text = formatLastTurn(turn);
await this.deliver(chatId, text);
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /last failed: ${this.redact(err)}`);
await this.client.sendMessage(chatId, MSG.LAST_FAILED);
}
}
async sendModelPicker(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
return;
}
try {
const snap = await loadSessionModels(this.ctx, binding.sessionId);
if (!snap.routable) {
await this.client.sendMessage(chatId, MSG.MODEL_UNROUTABLE(formatModel(snap.current)));
return;
}
if (snap.options.length === 0) {
await this.client.sendMessage(chatId, MSG.MODEL_EMPTY(formatModel(snap.current)));
return;
}
const shown = snap.options.slice(0, MAX_BUTTONS);
const prev = this.pickers.get(String(chatId));
this.pickers.set(String(chatId), {
workspaces: prev?.workspaces ?? [],
sessions: prev?.sessions ?? [],
catalog: prev?.catalog,
models: shown,
});
const keyboard = {
inline_keyboard: shown.map((opt, i) => ([{
text: truncateButton(`${opt.label}${opt.provider === snap.current.provider && opt.model === snap.current.model ? ' ✓' : ''}`),
callback_data: `${MODEL_CB}${i}`,
}])),
};
const body = [
`当前模型:${formatModel(snap.current)}`,
`会话:${binding.label}`,
'',
'选择新模型(下一回合生效):',
].join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`);
const detail = err instanceof Error ? err.message : String(err);
await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE(detail));
}
}
async sendEffortPicker(chatId, option) {
const efforts = option.efforts ?? [];
const keyboard = {
inline_keyboard: [
...efforts.map((e, i) => ([{
text: truncateButton(e.name || e.id),
callback_data: `${EFFORT_CB}${i}`,
}])),
[{ text: '← 返回模型列表', callback_data: BACK_MODEL_CB }],
],
};
await this.client.sendMessage(chatId, `已选 ${option.label}\n请选择 reasoning effort:`, undefined, keyboard);
}
async applyModel(chatId, callbackId, option, reasoningEffort) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.answerCallbackQuery(callbackId, '未绑定');
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
// Ensure session is live before selectModel (same as followup path).
await this.ensureLiveAgent(binding.sessionId);
try {
const selected = await selectSessionModel(this.ctx, binding.sessionId, {
provider: option.provider,
model: option.model,
reasoningEffort,
});
this.pendingModels.delete(String(chatId));
const picker = this.pickers.get(String(chatId));
if (picker)
picker.pendingModel = undefined;
await this.client.answerCallbackQuery(callbackId, '已切换');
await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected)));
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`);
const detail = err instanceof Error ? err.message : String(err);
await this.client.answerCallbackQuery(callbackId, '切换失败');
await this.client.sendMessage(chatId, MSG.MODEL_FAILED(detail));
}
}
liveAgents() {
const agents = this.ctx.agents;
if (typeof agents.roots === 'function') {
const roots = agents.roots();
if (roots.length > 0)
return roots;
}
if (typeof agents.list === 'function')
return agents.list();
return [];
}
findLiveAgent(sessionId) {
const agents = this.ctx.agents;
if (typeof agents.get === 'function') {
try {
const found = agents.get(SessionId(sessionId));
if (found)
return found;
}
catch {
// fall through to list scan
}
}
return this.liveAgents().find((a) => String(a.id) === sessionId);
}
/** Resume cold sessions when needed; never dispose the returned handle. */
async ensureLiveAgent(sessionId) {
const live = this.findLiveAgent(sessionId);
if (live)
return live;
const agents = this.ctx.agents;
if (typeof agents.resume !== 'function')
return undefined;
try {
const handle = await agents.resume({ resumeSessionId: SessionId(sessionId) });
return handle.agent;
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: resume failed for ${sessionId}: ${this.redact(err)}`);
return undefined;
}
}
async sendStatus(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.STATUS_NONE);
return;
}
const info = {
label: binding.label,
sessionId: binding.sessionId,
};
// 冷会话:不强制 resume(只读查询),给出可用的绑定/工作区信息即可。
const agent = this.findLiveAgent(binding.sessionId);
if (!agent) {
info.live = false;
info.workspace = await this.workspaceOf(binding.sessionId, undefined);
await this.client.sendMessage(chatId, formatStatusText(info));
return;
}
info.live = true;
info.workspace = await this.workspaceOf(binding.sessionId, agent);
try {
const snap = await loadSessionModels(this.ctx, binding.sessionId);
if (snap.current) {
info.model = {
provider: snap.current.provider,
model: snap.current.model,
reasoningEffort: snap.current.reasoningEffort,
};
}
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /status model read failed: ${this.redact(err)}`);
}
info.runtime = this.readRuntimeValues(agent);
await this.client.sendMessage(chatId, formatStatusText(info));
}
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
async workspaceOf(sessionId, agent) {
try {
const catalog = await this.resolveCatalog();
const ws = (catalog?.workspaces ?? []).find((w) => (w.sessionIds ?? []).includes(sessionId));
if (ws) {
return { title: ws.title || undefined, path: ws.path || undefined };
}
const row = catalog?.sessionsById?.get(sessionId);
if (row?.cwd) {
return { title: workspaceName(row.cwd) || undefined, path: row.cwd };
}
}
catch {
// 目录不可用 → 继续走 agent 侧兜底
}
if (agent) {
const parts = describeAgent(agent, 0, this.ctx);
if (parts.cwd) {
return { title: parts.workspace || undefined, path: parts.cwd };
}
}
return undefined;
}
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
readRuntimeValues(agent) {
const registry = this.serviceOf('sessionProjections');
let values;
if (registry?.snapshot) {
try {
values = registry.snapshot(agent.session)?.values;
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: projection snapshot failed: ${this.redact(err)}`);
}
}
if (!values)
return undefined;
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
const stats = (values['sessionStats'] ?? {});
const usage = (values['tokenUsage'] ?? {});
const pressure = (values['contextPressure'] ?? {});
const runtime = {
turns: num(stats.turns),
steps: num(stats.steps),
llmMs: num(stats.llmMs),
toolMs: num(stats.toolMs),
ttftMs: num(stats.ttftMs),
ttftSteps: num(stats.ttftSteps),
decodeMs: num(stats.decodeMs),
decodeTokens: num(stats.decodeTokens),
uncachedInputTokens: num(usage.uncachedInputTokens),
cacheReadTokens: num(usage.cacheReadTokens),
cacheWriteTokens: num(usage.cacheWriteTokens),
outputTokens: num(usage.outputTokens),
contextUsed: num(pressure.projectedTokens) ?? num(pressure.pressureTokens),
contextWindow: num(pressure.contextWindow),
};
// 完全无数据时视为无统计块。
const hasAny = Object.values(runtime).some((v) => v !== undefined);
return hasAny ? runtime : undefined;
}
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
serviceOf(name) {
const c = this.ctx;
if (typeof c.get === 'function') {
try {
const via = c.get(name, false);
if (via)
return via;
}
catch {
// 服务未装配或当前 ctx 不可解析
}
}
try {
if (Object.prototype.hasOwnProperty.call(this.ctx, name)) {
return this.ctx[name];
}
}
catch {
// proxy ctx:无 inject 直接读属性会抛错,忽略
}
return undefined;
}
// ── /compact:手动压缩当前绑定会话(需会话空闲)──
//
// 压缩引擎(compaction-basic)挂载在隔离作用域,插件 ctx 拿不到;但宿主的
// command-compact 已把 /compact 注册进命令运行时的按 agent 作用层(Web 端
// 同款通道),因此这里复用它:语义、busy/错误映射、命令生命周期日志与 Web
// 端 /compact 完全一致,且无需接触引擎内部。
async requestCompact(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
if (this.compacting.has(binding.sessionId)) {
await this.client.sendMessage(chatId, MSG.COMPACT_INFLIGHT);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
return;
}
const commands = this.serviceOf('commands');
if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'compact')) {
await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE);
return;
}
// 会话正在跑任务时压缩会被拒(runMaintenance 忙);提前提示避免「已开始→失败」两步。
const busy = this.busySessions.has(binding.sessionId)
|| Boolean(agent.phase?.kind
&& agent.phase?.kind !== 'idle');
if (busy) {
await this.client.sendMessage(chatId, MSG.COMPACT_BUSY);
return;
}
const controller = new AbortController();
this.compacting.add(binding.sessionId);
this.compactAborts.set(binding.sessionId, controller);
await this.client.sendMessage(chatId, MSG.COMPACT_STARTED);
const operation = commands.execute(agent, '/compact', [], controller.signal);
void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => {
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`);
});
}
/**
* TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条
* 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。
*/
async requestGoal(chatId, arg) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
return;
}
const commands = this.serviceOf('commands');
if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'goal')) {
await this.client.sendMessage(chatId, '当前环境不支持 /goal(command-goal 未装配)');
return;
}
const line = `/goal${arg ? ` ${arg}` : ''}`;
try {
const execution = await commands.execute(agent, line, [], new AbortController().signal);
if (execution === undefined) {
await this.client.sendMessage(chatId, '/goal 未执行:命令在运行时已注销');
return;
}
const text = execution.result?.text ?? (execution.result?.kind === 'success' ? '完成' : '失败');
await this.client.sendMessage(chatId, text.slice(0, 3500));
}
catch (err) {
await this.client.sendMessage(chatId, `/goal 执行失败:${this.redact(err).slice(0, 200)}`);
}
}
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
async runCompaction(chatId, sessionId, operation, controller) {
try {
const execution = await operation;
if (execution === undefined) {
// find() 通过但 execute 未命中:按未注册处理。
await this.enqueueNotice(chatId, MSG.COMPACT_UNAVAILABLE);
return;
}
const translated = translateCompactResult(execution.result);
if (translated !== undefined) {
await this.enqueueNotice(chatId, translated);
return;
}
// 无法识别的结果:回退为通用失败 + 宿主原文。
const raw = execution.result?.text;
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(raw));
}
catch (err) {
if (controller.signal.aborted) {
// 桥停止/热重载导致的取消:静默,不打扰(本实例正在退出)。
this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop');
return;
}
this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`);
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err)));
}
finally {
this.compacting.delete(sessionId);
this.compactAborts.delete(sessionId);
}
}
async followupBound(chatId, text) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
return;
}
const message = createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
});
// Busy feedback: mid-turn followups queue silently by design — tell the user.
const busy = this.busySessions.has(binding.sessionId)
|| Boolean(agent.phase?.kind
&& agent.phase?.kind !== 'idle');
agent.followup(message);
if (busy) {
await this.enqueueNotice(chatId, '已加入队列:当前任务完成后将处理这条消息');
}
}
/**
* 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.
*/
async collectMediaParts(chatId, message) {
const caption = message.caption?.trim();
const textPart = caption ? { type: 'text', text: caption } : undefined;
const withText = (part) => textPart ? [textPart, part] : [part];
if (message.photo && message.photo.length > 0) {
// TG photo sizes are ordered smallest → largest; the last is the best quality.
const best = message.photo[message.photo.length - 1];
const part = await this.downloadImagePart(chatId, best.file_id, 'image/jpeg');
return part ? withText(part) : [];
}
const doc = message.document;
if (doc) {
if (!doc.mime_type || !IMAGE_MEDIA_TYPES.includes(doc.mime_type)) {
await this.enqueueNotice(chatId, MSG.MEDIA_UNSUPPORTED);
return [];
}
const part = await this.downloadImagePart(chatId, doc.file_id, doc.mime_type, doc.file_name);
return part ? withText(part) : [];
}
return undefined;
}
/** Download one TG file and encode it as a canonical-base64 wire image part. */
async downloadImagePart(chatId, fileId, mediaType, name) {
try {
const file = await this.client.getFile(fileId);
if (!file.file_path)
throw new Error('TG 未返回文件路径');
const bytes = await this.client.downloadFile(file.file_path);
// Buffer base64 is canonical (standard alphabet, no whitespace) — the host
// attachment admission rejects non-canonical forms outright.
const data = Buffer.from(bytes).toString('base64');
return { type: 'image', mediaType, data, ...(name === undefined ? {} : { name }) };
}
catch (err) {
await this.enqueueNotice(chatId, MSG.IMAGE_FAILED(this.redact(err)));
return undefined;
}
}
/**
* 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.
*/
accumulateMediaGroup(chatId, groupId, parts) {
const key = `${String(chatId)}:${groupId}`;
const existing = this.mediaGroups.get(key);
if (existing) {
clearTimeout(existing.timer);
existing.parts.push(...parts);
}
const entry = existing ?? { chatId, parts: [...parts] };
entry.timer = setTimeout(() => {
this.mediaGroups.delete(key);
this.sendImageFollowup(entry.chatId, entry.parts).catch((err) => {
this.ctx.logger.error(this.redact(err));
});
}, MEDIA_GROUP_WINDOW_MS);
this.mediaGroups.set(key, entry);
}
/**
* 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).
*/
async sendImageFollowup(chatId, parts) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
return;
}
const proxy = resolveApiProxy(this.ctx);
if (!proxy?.sessions?.prompt) {
await this.enqueueNotice(chatId, '当前宿主不支持会话消息接口(sessions.prompt),无法发送图片。');
return;
}
const busy = this.busySessions.has(binding.sessionId)
|| Boolean(agent.phase?.kind
&& agent.phase?.kind !== 'idle');
let res;
try {
res = await proxy.sessions.prompt({
rpcId: randomUUID(),
payload: { sessionId: binding.sessionId, mode: 'queue', content: parts },
});
}
catch (err) {
await this.enqueueNotice(chatId, MSG.IMAGE_FAILED(this.redact(err)));
return;
}
const result = res?.result;
if (!result?.ok) {
const code = result?.error?.code;
const message = String(result?.error?.message ?? '宿主未确认接收');
if (code === 'attachment-error' && result?.error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES') {
await this.enqueueNotice(chatId, MSG.IMAGE_MODEL_UNSUPPORTED);
}
else {
const detail = `${code ? `[${code}] ` : ''}${message}`.trim();
await this.enqueueNotice(chatId, MSG.IMAGE_FAILED(detail));
}
return;
}
if (busy) {
await this.enqueueNotice(chatId, '已加入队列:当前任务完成后将处理这条消息');
}
}
async pollLoop() {
const signal = this.pollAbort?.signal;
let errorCount = 0;
while (this.polling) {
try {
const updates = await this.client.getUpdates(this.offset);
if (!this.polling)
break;
errorCount = 0;
if (updates.length === 0) {
await this.interruptibleDelay(50, signal);
continue;
}
for (const update of updates) {
if (!this.polling)
break;
await this.processUpdate(update);
this.offset = update.update_id + 1;
}
}
catch (err) {
if (!this.polling)
break;
errorCount += 1;
this.ctx.logger.error(this.redact(err));
await this.interruptibleSleep(Math.min(1000 * errorCount, 10_000), signal);
}
}
}
interruptibleDelay(ms, signal) {
if (signal?.aborted || !this.polling)
return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
}
async interruptibleSleep(ms, signal) {
if (signal?.aborted || !this.polling)
return;
await Promise.race([
this.sleep(ms),
new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
signal?.addEventListener('abort', () => resolve(), { once: true });
}),
]);
}
async onSessionEvent(session, event) {
if (!this.askInstalled)
this.installAskInterceptor();
if (!this.approvalInstalled)
this.installApprovalInterceptor();
const id = String(session.id);
const targets = [...this.bindings.values()].filter((b) => b.sessionId === id);
if (targets.length === 0)
return;
if (event.type === 'turn/start') {
// Typing heartbeat — one sendChatAction expires in ~5s, so refresh it
// for the whole turn instead of only at start.
this.busySessions.add(id);
for (const b of targets)
this.startTypingHeartbeat(b.chatId);
return;
}
if (event.type === 'turn/end') {
this.busySessions.delete(id);
for (const b of targets)
this.stopTypingHeartbeat(b.chatId);
// Abnormal endings (API failure / abort / crash-recovery) must be
// visible on the phone — previously they ended in silence.
const reason = event.data?.reason;
const kind = reason?.kind;
if (kind === 'error') {
const detail = String(reason?.error?.message ?? reason?.error?.code ?? '').replace(/\s+/g, ' ').slice(0, 300);
for (const b of targets)
this.enqueueNotice(b.chatId, `本轮异常结束(API/内部错误)${detail ? `:${detail}` : ''}`);
}
else if (kind === 'aborted') {
const manual = reason?.reason?.kind === 'user';
for (const b of targets)
this.enqueueNotice(b.chatId, manual ? '本轮已被手动中止' : '本轮已被中断');
}
else if (kind === 'interrupted') {
for (const b of targets)
this.enqueueNotice(b.chatId, '会话曾被异常退出(崩溃恢复),本轮被标记中断');
}
return;
}
if (event.type === 'tool/call') {
// Progress summary; ask_user is owned by the provider hook (avoids duplicates).
const name = event.data?.name ?? 'tool';
if (name === 'ask_user_question')
return;
if (event.data?.callId !== undefined) {
if (this.callNames.size > 400) {
const first = this.callNames.keys().next().value;
if (first !== undefined)
this.callNames.delete(first);
}
this.callNames.set(String(event.data.callId), String(name));
}
const args = typeof event.data?.arguments === 'string' ? event.data.arguments : '';
const brief = args.replace(/\s+/g, ' ').trim();
const shown = brief.length > 60 ? `${brief.slice(0, 60)}…` : brief;
for (const b of targets)
this.enqueueNotice(b.chatId, `> ${name}${shown ? ` ${shown}` : ''}`);
return;
}
if (event.type === 'assistant/message') {
const text = contentToText(event.data.message.content);
if (!text)
return;
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)));
return;
}
if (event.type === 'tool/result') {
// Only failures — successful results are too noisy for the phone.
const data = event.data;
if (data?.error) {
const callKey = String(data.message?.tool_use_id ?? data.message?.callId ?? '');
const name = this.callNames.get(callKey) ?? 'tool';
for (const b of targets)
this.enqueueNotice(b.chatId, `> 工具 ${name} 失败:${String(data.error.name ?? 'Error')}${data.error.code ? `/${String(data.error.code)}` : ''}`);
}
return;
}
// 'goal/changed' 不在本插件的 SessionEvent 类型联合内,宽松断言读取。
const eventType = event.type;
if (eventType === 'goal/changed') {
// Goal mode lives on the phone too: surface phase transitions (blocked/complete
// especially) without waiting for the next assistant text.
const data = event.data;
const phase = data?.phase;
if (phase === 'blocked' || phase === 'complete') {
const reason = data?.blockedReason;
const label = phase === 'blocked'
? `目标被阻塞${reason ? `:${reason.code ?? ''} ${reason.message ?? ''}`.slice(0, 120) : ''}`
: '目标已达成 ✓';
for (const b of targets)
this.enqueueNotice(b.chatId, `> ${label}`);
}
return;
}
if (event.type === 'todo/write') {
const todos = Array.isArray(event.data?.todos) ? event.data.todos : [];
this.lastTodos.set(id, todos);
if (todos.length === 0)
return;
const done = todos.filter((t) => t?.status === 'completed').length;
const current = todos.find((t) => t?.status === 'in_progress') ?? todos.find((t) => t?.status === 'pending');
const cur = current ? String(current.content ?? '').slice(0, 50) : '';
for (const b of targets)
this.enqueueNotice(b.chatId, `> 进度 ${done}/${todos.length}${cur ? `:${cur}` : ''}`);
return;
}
}
// ── /rich: per-chat rendering mode (persisted) ──
//
// Rich messages (Bot API sendRichMessage) only render on recent Telegram
// clients; old ones (10.x) show the message as “not supported”. The bot cannot
// detect the recipient's client version, so the choice is per-chat and manual,
// remembered forever. HTML mode is the compat default and works everywhere.
async handleRichCommand(chatId, arg) {
const key = String(chatId);
const setAndReply = async (mode) => {
this.renderPrefs.set(key, mode);
this.saveBindings();
await this.client.sendMessage(chatId, MSG.RICH_SET(mode));
};
switch (arg) {
case 'on':
case 'rich':
await setAndReply('rich');
return;
case 'off':
case 'html':
case 'compat':
await setAndReply('html');
return;
case 'auto':
case 'default':
case 'reset':
this.renderPrefs.delete(key);
this.saveBindings();
await this.client.sendMessage(chatId, MSG.RICH_STATE(this.renderingMode));
return;
case undefined:
await this.client.sendMessage(chatId, MSG.RICH_STATE(this.modeFor(chatId)));
return;
default:
await this.client.sendMessage(chatId, MSG.RICH_USAGE);
}
}
async deliver(chatId, markdown) {
if (this.modeFor(chatId) !== 'html') {
let sentAny = false;
try {
const chunks = splitRichMarkdown(markdown);
for (const chunk of chunks) {
try {
// Per-chunk retry: transient network blips must not drop a reply.
await this.withRetry(() => this.client.sendRichMessage(chatId, chunk));
sentAny = true;
}
catch (err) {
if (!sentAny && isRichUnsupportedError(err)) {
this.ctx.logger.warn('dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering');
this.renderingMode = 'html';
// Rich API being gone is a server-wide fact: per-chat rich overrides
// must not bypass the global downgrade (modeFor prefers the override).
for (const [key, mode] of this.renderPrefs) {
if (mode === 'rich')
this.renderPrefs.delete(key);
}
this.saveBindings();
await this.deliverHtml(chatId, markdown);
return;
}
throw err;
}
}
return;
}
catch (err) {
// Persistent rich failure → HTML fallback instead of dropping the message.
this.ctx.logger.warn(`dsh-telegram-channel: rich deliver failed (${this.redact(err)}), falling back to HTML`);
}
}
await this.deliverHtml(chatId, markdown);
}
async deliverHtml(chatId, markdown) {
const chunks = splitMessage(markdown, this.maxMessageLength);
for (const chunk of chunks) {
let html;
try {
html = markdownToHtml(chunk);
}
catch {
html = chunk; // Pathological markdown input: degrade to plain text instead of dropping.
}
try {
await this.withRetry(() => this.client.sendMessage(chatId, html, 'HTML'));
}
catch {
try {
await this.withRetry(() => this.client.sendMessage(chatId, chunk));
}
catch (err) {
this.ctx.logger.error(this.redact(err));
}
}
}
}
// ── Stability & interactivity helpers ──
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
async withRetry(fn, tries = 3) {
let lastErr;
for (let attempt = 1; attempt <= tries; attempt++) {
try {
return await fn();
}
catch (err) {
lastErr = err;
if (attempt < tries)
await this.sleep(Math.min(500 * 2 ** (attempt - 1), 4000));
}
}
throw lastErr;
}
/**
* Send a notice; a leading `> ` marks the body for quote rendering as a real
* `<blockquote>` (parse_mode HTML) — every current Telegram client draws the
* accent-colored left bar. Old-client compatibility is no longer required,
* but a failed HTML send still falls back to plain text — a notice is never lost.
*/
async deliverNotice(chatId, text) {
const quoted = text.startsWith('> ');
const body = quoted ? text.slice(2) : text;
if (quoted) {
try {
await this.withRetry(() => this.client.sendMessage(chatId, `<blockquote>${escapeHtml(body)}</blockquote>`, 'HTML'));
return;
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: blockquote notice failed (${this.redact(err)}), falling back to plain text`);
}
}
try {
await this.withRetry(() => this.client.sendMessage(chatId, body));
}
catch (err) {
this.ctx.logger.error(this.redact(err));
}
}
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
enqueueNotice(chatId, text) {
const key = String(chatId);
const prev = this.noticeQueue.get(key) ?? Promise.resolve();
const next = prev.then(() => this.deliverNotice(chatId, text)).catch(() => { });
this.noticeQueue.set(key, next);
return next;
}
startTypingHeartbeat(chatId) {
this.stopTypingHeartbeat(chatId);
const send = () => {
void this.client.sendChatAction(chatId, 'typing').catch(() => { });
};
send();
this.heartbeats.set(String(chatId), setInterval(send, 4000));
}
stopTypingHeartbeat(chatId) {
const h = this.heartbeats.get(String(chatId));
if (h) {
clearInterval(h);
this.heartbeats.delete(String(chatId));
}
}
stopAllHeartbeats() {
for (const h of this.heartbeats.values())
clearInterval(h);
this.heartbeats.clear();
}
// ── ask_user_question: TG answering via a prototype interceptor ──
//
// dsh 0.1.5-rc.1 routes the 'user-questions/request' waterfall through a
// per-fiber events registry: plain ctx.on listeners in other fibers (this
// plugin included) never see the dispatch, so the waterfall hook cannot
// mirror questions to TG. The class is shared across instances though, so
// wrapping UserQuestionService.prototype.ask intercepts EVERY ask with its
// agent + questions, regardless of fiber. The wrap races the TG answer
// against the original ask (GUI path), preserving the old dual-path
// semantics. Restore-on-stop keeps hot reloads from stacking wrappers.
installAskInterceptor() {
if (this.askInstalled)
return;
let svc;
try {
svc = this.ctx.get('userQuestions');
}
catch {
return; // service not ready yet — retried lazily from onSessionEvent
}
if (!svc)
return;
const proto = svc.constructor.prototype;
const prev = proto[kTgAskWrapped];
if (prev?.bridge === this) {
this.askInstalled = true;
this.askRestore = prev.restore;
return;
}
prev?.restore(); // stale wrapper from a disposed bridge instance
const orig = proto.ask;
const bridge = this;
const wrapped = function (request) {
return bridge.aroundUserQuestionAsk(this, request, orig);
};
proto.ask = wrapped;
const restore = () => {
if (proto.ask === wrapped)
proto.ask = orig;
};
proto[kTgAskWrapped] = { restore, bridge: this };
this.askRestore = restore;
this.askInstalled = true;
}
aroundUserQuestionAsk(service, request, orig) {
const tg = this.registerTgAsk(request);
let gui;
try {
gui = Promise.resolve().then(() => orig.call(service, request));
}
catch (err) {
tg.reject(err);
throw err;
}
void gui.then(() => this.settleGuiSide(request), () => this.settleGuiSide(request));
return Promise.race([tg.promise, gui]);
}
registerTgAsk(request) {
let resolveFn = () => { };
let rejectFn = () => { };
const promise = new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
const ask = { resolve: resolveFn, reject: rejectFn, answered: false };
const req = request;
const sessionId = req.agent?.id !== undefined ? String(req.agent.id) : undefined;
const questions = Array.isArray(req.questions) ? req.questions : [];
const onAbort = () => {
for (const [chatId, p] of this.pendingAsks) {
if (p.ask === ask)
this.pendingAsks.delete(chatId);
}
rejectFn(new Error('ask aborted'));
};
if (req.signal?.aborted) {
rejectFn(new Error('ask aborted'));
return { promise, reject: rejectFn };
}
req.signal?.addEventListener('abort', onAbort, { once: true });
if (sessionId !== undefined && questions.length > 0) {
let asked = false;
for (const [chatId, binding] of this.bindings) {
if (binding.sessionId !== sessionId)
continue;
const pending = {
req: request,
sessionId,
questions,
ask,
at: Date.now(),
kbSel: new Map(),
textAnswers: new Map(),
textWanted: new Set(),
};
this.pendingAsks.set(String(chatId), pending);
void this.deliverAskPrompt(Number(chatId), pending);
asked = true;
}
}
// No bound TG chat: never settle — the UI remains the only answer path.
return { promise, reject: rejectFn };
}
/** Announce the incoming question, then one message per question (options as native inline-keyboard buttons). */
async deliverAskPrompt(chatId, pending) {
const { questions } = pending;
try {
await this.withRetry(() => this.client.sendMessage(chatId, `❓ 即将向你提问(共 ${questions.length} 题)——点按钮或回复文本作答;/cancel 关闭 TG 作答`));
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const opts = Array.isArray(q.options) ? q.options : [];
const header = questions.length > 1 ? `Q${i + 1}. ` : '';
if (opts.length === 0) {
await this.withRetry(() => this.client.sendMessage(chatId, `${header}${q.question ?? ''}\n(开放问题,请直接回复文本)`));
continue;
}
const keyboard = {
inline_keyboard: [
...opts.map((o, j) => [{
text: `${j + 1}. ${o.label}`.slice(0, 48),
callback_data: `${ASK_CB}${i}:${j}`,
}]),
// Standing third choice: free-text answer, always available.
[{ text: '✏️ 输入自定义答案', callback_data: `${ASK_CB}${i}:custom` }],
...(q.multiSelect ? [[{ text: '✅ 提交所选', callback_data: `${ASK_CB}${i}:ok` }]] : []),
],
};
await this.withRetry(() => this.client.sendMessage(chatId, `${header}${q.question ?? ''}`, undefined, keyboard));
}
}
catch (err) {
this.ctx.logger.error(this.redact(err));
}
}
/** Resolve the pending ask once EVERY question has an answer (keyboard and/or text). */
finalizeAskIfComplete(chatId, pending) {
const { questions, ask } = pending;
const answers = [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const opts = Array.isArray(q.options) ? q.options : [];
const sel = pending.kbSel.get(i);
if (sel && sel.size > 0) {
answers.push({ id: q.id, selected: [...sel].sort((a, b) => a - b).map((j) => opts[j].label) });
continue;
}
const text = pending.textAnswers.get(i);
if (text !== undefined) {
answers.push(text);
continue;
}
return false;
}
ask.answered = true;
for (const [cid, p] of this.pendingAsks) {
if (p.ask === ask)
this.pendingAsks.delete(cid);
}
ask.resolve({ answers });
this.enqueueNotice(chatId, '已提交你的回答');
return true;
}
settleGuiSide(request) {
for (const [chatId, p] of [...this.pendingAsks]) {
if (p.req !== request)
continue;
this.pendingAsks.delete(chatId);
if (!p.ask.answered) {
this.enqueueNotice(Number(chatId), '该问题已在 Web 端作答,TG 作答通道关闭');
}
}
}
async handleTgAnswer(chatId, pending, text) {
const { questions } = pending;
let parts;
if (questions.length === 1) {
parts = [[0, text.trim()]];
}
else {
parts = [];
const lines = text.split(/\n|;|;/).map((s) => s.trim()).filter(Boolean);
for (const line of lines) {
const m = line.match(/^([0-9]+)\s*[::.、)]\s*(.+)$/);
if (m)
parts.push([Number(m[1]) - 1, m[2].trim()]);
else
parts.push([null, line]);
}
if (parts.some(([idx]) => idx === null)) {
// ✏️ free-text route: a question awaiting text absorbs the whole message.
const target = [...pending.textWanted].sort((a, b) => a - b)[0];
if (target !== undefined) {
parts = [[target, text.trim()]];
}
else {
await this.enqueueNotice(chatId, `共 ${questions.length} 个问题,请每行用 1:/2: 前缀分别作答`);
return;
}
}
}
for (const [idx, raw] of parts) {
if (idx === null || idx < 0 || idx >= questions.length) {
await this.enqueueNotice(chatId, `题号 ${(idx ?? 0) + 1} 不存在(共 ${questions.length} 题)`);
return;
}
const q = questions[idx];
const cleaned = raw.replace(/^\[+/, '').replace(/\]+$/, '').trim();
const letters = cleaned.split(/[,,、\s]+/).map((s) => s.trim()).filter(Boolean);
const opts = Array.isArray(q.options) ? q.options : [];
const isLetterChoice = letters.length > 0
&& letters.every((s) => /^[a-zA-Z]$/.test(s))
&& opts.length > 0;
if (isLetterChoice) {
const selected = [];
for (const s of letters) {
const i = s.toUpperCase().charCodeAt(0) - 65;
if (i < 0 || i >= opts.length) {
await this.enqueueNotice(chatId, `选项 ${s.toUpperCase()} 不存在(范围 A-${String.fromCharCode(64 + opts.length)}),请重答或 /cancel`);
return;
}
selected.push(opts[i].label);
}
pending.textAnswers.set(idx, { id: q.id, selected });
}
else {
pending.textAnswers.set(idx, { id: q.id, selected: [], custom: cleaned });
}
pending.textWanted.delete(idx);
}
// Keyboard selections take precedence per question; resolve when complete.
if (!this.finalizeAskIfComplete(chatId, pending)) {
const done = questions.filter((_, i) => pending.kbSel.get(i)?.size || pending.textAnswers.has(i)).length;
await this.enqueueNotice(chatId, `已记录 ${done}/${questions.length} 题,请继续作答(按钮或 1:/2: 文本)`);
}
}
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
async handleAskCallback(chatId, cq) {
const pending = this.pendingAsks.get(String(chatId));
if (!pending) {
await this.client.answerCallbackQuery(cq.id, '该提问已失效(可能已在 Web 端作答)');
return;
}
const payload = (cq.data ?? '').slice(ASK_CB.length);
const sep = payload.indexOf(':');
const qi = Number(payload.slice(0, sep));
const action = payload.slice(sep + 1);
const q = pending.questions[qi];
if (!q) {
await this.client.answerCallbackQuery(cq.id, '题目已失效');
return;
}
const opts = Array.isArray(q.options) ? q.options : [];
if (action === 'custom') {
// Free-text path: discard button selections for this question and wait
// for the user's next text message (routed via textWanted).
pending.kbSel.delete(qi);
pending.textWanted.add(qi);
await this.client.answerCallbackQuery(cq.id, '请直接发送你的答案文本');
return;
}
if (action === 'ok') {
if (!pending.kbSel.get(qi)?.size) {
await this.client.answerCallbackQuery(cq.id, '请先点选选项');
return;
}
}
else {
const j = Number(action);
if (!Number.isInteger(j) || j < 0 || j >= opts.length) {
await this.client.answerCallbackQuery(cq.id, '选项不存在');
return;
}
if (q.multiSelect) {
const set = pending.kbSel.get(qi) ?? new Set();
if (set.has(j))
set.delete(j);
else
set.add(j);
pending.kbSel.set(qi, set);
await this.client.answerCallbackQuery(cq.id, set.size > 0 ? `已选 ${set.size} 项,选完点"提交所选"` : '已取消该选项');
}
else {
pending.kbSel.set(qi, new Set([j]));
await this.client.answerCallbackQuery(cq.id, `已选:${opts[j].label}`);
}
}
if (!this.finalizeAskIfComplete(chatId, pending) && action === 'ok') {
await this.client.answerCallbackQuery(cq.id, '已提交本题选择,请继续作答其他问题');
}
}
// ── approval: TG answering via a prototype interceptor ──
//
// Same root cause and same cure as ask_user: the 'approval/request'
// waterfall dispatches on a per-fiber events registry that this plugin's
// ctx.on never sees (dsh 0.1.5-rc.1), so ApprovalService.prototype.request
// is wrapped instead — the class is shared across instances and fibers.
installApprovalInterceptor() {
if (this.approvalInstalled)
return;
let svc;
try {
svc = this.ctx.get('approval');
}
catch {
return; // service not ready yet — retried lazily from onSessionEvent
}
if (!svc)
return;
const proto = svc.constructor.prototype;
const prev = proto[kTgApprovalWrapped];
if (prev?.bridge === this) {
this.approvalInstalled = true;
this.approvalRestore = prev.restore;
return;
}
prev?.restore(); // stale wrapper from a disposed bridge instance
const orig = proto.request;
const bridge = this;
const wrapped = function (req) {
return bridge.aroundApprovalRequest(this, req, orig);
};
proto.request = wrapped;
const restore = () => {
if (proto.request === wrapped)
proto.request = orig;
};
proto[kTgApprovalWrapped] = { restore, bridge: this };
this.approvalRestore = restore;
this.approvalInstalled = true;
}
aroundApprovalRequest(service, req, orig) {
const sessionId = req?.agent?.id !== undefined ? String(req.agent.id) : undefined;
const targets = sessionId !== undefined
? [...this.bindings.values()].filter((b) => b.sessionId === sessionId).map((b) => String(b.chatId))
: [];
let resolveFn = () => { };
let rejectFn = () => { };
const promise = new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
const ask = { resolve: resolveFn, reject: rejectFn, answered: false };
const onAbort = () => {
for (const [chatId, p] of this.pendingApprovalsTG) {
if (p.ask === ask)
this.pendingApprovalsTG.delete(chatId);
}
rejectFn(new Error('approval withdrawn'));
};
if (req?.signal?.aborted) {
rejectFn(new Error('approval withdrawn'));
return orig.call(service, req);
}
req?.signal?.addEventListener('abort', onAbort, { once: true });
if (targets.length > 0) {
const toolName = String(req?.toolName ?? 'tool');
const reason = req?.reason ? String(req.reason).slice(0, 200) : '';
for (const chatId of targets) {
this.pendingApprovalsTG.set(chatId, { toolName, ask });
void this.deliverApprovalPrompt(Number(chatId), toolName, reason).catch((err) => {
this.ctx.logger.error(this.redact(err));
});
}
}
// No bound TG chat: promise never settles — the UI remains the only answer path.
const gui = Promise.resolve().then(() => orig.call(service, req));
void gui.then(() => {
for (const [chatId, p] of [...this.pendingApprovalsTG]) {
if (p.ask !== ask)
continue;
this.pendingApprovalsTG.delete(chatId);
if (!p.ask.answered) {
p.ask.reject(new Error('superseded-by-gui'));
this.enqueueNotice(Number(chatId), '该审批已在 Web 端处理');
}
}
}, () => {
for (const [chatId, p] of [...this.pendingApprovalsTG]) {
if (p.ask === ask)
this.pendingApprovalsTG.delete(chatId);
}
});
return Promise.race([promise, gui]);
}
/** Approval prompt as native inline-keyboard buttons; falls back to a plain-text prompt. */
async deliverApprovalPrompt(chatId, toolName, reason) {
const keyboard = {
inline_keyboard: [[
{ text: '✅ 允许一次', callback_data: `${APR_CB}allow` },
{ text: '❌ 拒绝', callback_data: `${APR_CB}deny` },
]],
};
try {
await this.withRetry(() => this.client.sendMessage(chatId, `权限审批:${toolName}${reason ? `\n(${reason})` : ''}`, undefined, keyboard));
}
catch (err) {
this.ctx.logger.error(this.redact(err));
// Never lose an approval: degrade to the text-answer prompt.
this.enqueueNotice(chatId, `权限审批:${toolName}${reason ? `\n(${reason})` : ''}\n回复 [A] 允许一次 / [B] 拒绝(/cancel 关闭 TG 审批)`);
}
}
async handleApprovalCallback(chatId, cq, data) {
const approval = this.pendingApprovalsTG.get(String(chatId));
if (!approval) {
await this.client.answerCallbackQuery(cq.id, '该审批已失效(可能已在 Web 端处理)');
return;
}
const allow = data === `${APR_CB}allow`;
approval.ask.answered = true;
for (const [cid, p] of this.pendingApprovalsTG) {
if (p.ask === approval.ask)
this.pendingApprovalsTG.delete(cid);
}
if (allow) {
approval.ask.resolve('allowed-once');
await this.client.answerCallbackQuery(cq.id, `已允许 ${approval.toolName} 执行一次`);
}
else {
approval.ask.resolve('rejected');
await this.client.answerCallbackQuery(cq.id, '已拒绝该工具执行');
}
}
async handleTgApproval(chatId, approval, text) {
const cleaned = text.trim().replace(/^\[+/, '').replace(/\]+$/, '').toUpperCase();
if (cleaned === 'A' || cleaned === 'B') {
approval.ask.answered = true;
for (const [cid, p] of this.pendingApprovalsTG) {
if (p.ask === approval.ask)
this.pendingApprovalsTG.delete(cid);
}
if (cleaned === 'A') {
approval.ask.resolve('allowed-once');
await this.enqueueNotice(chatId, `已允许 ${approval.toolName} 执行一次`);
}
else {
approval.ask.resolve('rejected');
await this.enqueueNotice(chatId, '已拒绝该工具执行');
}
return;
}
await this.enqueueNotice(chatId, '请回复 [A] 允许一次 或 [B] 拒绝(/cancel 关闭)');
}
// ── /stop /mission /new commands ──
async stopBound(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = this.findLiveAgent(binding.sessionId);
if (!agent) {
await this.client.sendMessage(chatId, '当前会话没有正在运行的任务');
return;
}
try {
agent.cancel({ kind: 'user' });
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: stop failed: ${this.redact(err)}`);
}
await this.enqueueNotice(chatId, '已请求中止当前轮…');
}
async sendMission(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const todos = this.lastTodos.get(binding.sessionId) ?? [];
const lines = ['任务清单'];
if (todos.length === 0) {
lines.push('(暂无任务记录,agent 使用 todo 工具后这里会显示)');
}
else {
const mark = { completed: '✓', in_progress: '⟳', pending: '·' };
todos.forEach((t, i) => lines.push(`${mark[t?.status ?? ''] ?? '·'} ${i + 1}. ${String(t?.content ?? '')}`));
const done = todos.filter((t) => t?.status === 'completed').length;
lines.push(`—— ${done}/${todos.length} 已完成`);
}
lines.push(this.busySessions.has(binding.sessionId) ? '(当前任务:运行中,/stop 可中止)' : '(当前空闲)');
await this.client.sendMessage(chatId, lines.join('\n'));
}
/** /new: create a session in the bound session's workspace and attach to it. */
async newSessionHere(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
let workspaceId;
let fallbackCwd;
try {
const catalog = await this.resolveCatalog();
for (const ws of catalog?.workspaces ?? []) {
if ((ws.sessionIds ?? []).includes(binding.sessionId)) {
workspaceId = ws.id;
break;
}
}
if (workspaceId === undefined)
fallbackCwd = catalog?.sessionsById?.get(binding.sessionId)?.cwd;
}
catch {
// Catalog unavailable → fall back to the host default workspace.
}
const proxy = resolveApiProxy(this.ctx);
if (!proxy?.sessions?.create) {
await this.client.sendMessage(chatId, '当前宿主不支持会话创建接口');
return;
}
let res;
try {
const payload = workspaceId !== undefined ? { workspaceId } : (fallbackCwd ? { cwd: fallbackCwd } : {});
res = await proxy.sessions.create({
rpcId: `tg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
payload,
});
}
catch (err) {
await this.client.sendMessage(chatId, `创建失败:${this.redact(err)}`);
return;
}
const newId = res?.result?.ok ? res.result.value?.sessionId : undefined;
if (!newId) {
const detail = res?.result?.error?.message ?? JSON.stringify(res?.result ?? res);
await this.client.sendMessage(chatId, `创建失败:${String(detail).slice(0, 200)}`);
return;
}
const stale = this.pendingAsks.get(String(chatId));
if (stale && stale.sessionId !== String(newId))
this.pendingAsks.delete(String(chatId));
this.bindings.set(String(chatId), { chatId, sessionId: String(newId), label: binding.label });
this.saveBindings();
const where = workspaceId !== undefined ? '当前工作区' : (fallbackCwd ? `目录 ${fallbackCwd}` : '默认工作区');
await this.client.sendMessage(chatId, `已在${where}新开对话并附着:\n${String(newId)}\n直接发消息即可开始`);
}
// ── Binding persistence (survives hot reload / restart) ──
bindingsPath() {
// DSH_TELEGRAM_BINDINGS_FILE overrides the location (tests must isolate
// from the real profile data — parallel test files share this file).
const override = process.env.DSH_TELEGRAM_BINDINGS_FILE;
if (override)
return override;
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
return join(home, 'telegram-channel-bindings.json');
}
saveBindings() {
try {
const obj = {
bindings: Object.fromEntries(this.bindings),
renderPrefs: Object.fromEntries(this.renderPrefs),
};
mkdirSync(dirname(this.bindingsPath()), { recursive: true });
writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2));
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: saveBindings failed: ${this.redact(err)}`);
}
}
loadBindings() {
try {
const raw = readFileSync(this.bindingsPath(), 'utf8');
const parsed = JSON.parse(raw);
const data = parsed;
// Legacy files (pre-renderPrefs) stored the bindings flat at the top level.
const flat = parsed;
const bindingEntries = data.bindings ?? flat;
let n = 0;
for (const [chatId, b] of Object.entries(bindingEntries ?? {})) {
if (b && b.sessionId) {
this.bindings.set(String(chatId), {
chatId: b.chatId ?? Number(chatId),
sessionId: String(b.sessionId),
label: b.label ?? String(b.sessionId),
});
n += 1;
}
}
for (const [chatId, mode] of Object.entries(data.renderPrefs ?? {})) {
if (mode === 'rich' || mode === 'html') {
this.renderPrefs.set(String(chatId), mode);
}
}
if (n > 0)
this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`);
}
catch {
// First run: no file yet.
}
}
redact(value) {
const message = value instanceof Error ? value.message : String(value);
return message.split(this.token).join('***');
}
}