Files
dsh-telegram-channel/src/bridge.ts
T
RRRosmontis d712fb3494 feat: /status 通用状态显示 + /compact 手动压缩;HTML 兼容渲染默认化
- /status:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/
  首 token 平均/输出速率/输入输出 tokens —— 与 Web 底部统计条同源
  同算法(sessionProjections: sessionStats/tokenUsage/contextPressure),
  投影插件缺失时逐组优雅降级;冷会话不强制 resume
- /compact:复用 ctx.compaction.compactNow 手动压缩绑定会话历史,
  异步执行不阻塞轮询,完成/失败均中文通知,同会话去重,
  桥停止/热重载自动中止进行中的压缩
- 全部用户可见文案不使用 emoji
- rendering 默认改 html(兼容优先)+ userQuestions/approval 双通道等
  既有修改一并合入(此前已上线确认)
- tests:+6 覆盖 /status 全字段与 /compact 各分支(51 全绿)
2026-09-05 17:24:41 +08:00

1763 lines
67 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 { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import { isAuthorized } from './auth.js'
import { resolveApiProxy } from './apiproxy.js'
import {
catalogFromLiveAgents,
loadCatalog,
truncateButton,
visibleSessionsForWorkspace,
workspacesWithVisibleSessions,
type CatalogSnapshot,
type SessionRow,
type WorkspaceRow,
} from './catalog.js'
import {
TelegramClient,
type InlineKeyboardMarkup,
type TelegramClientLike,
type TelegramUpdate,
} from './client.js'
import { MSG, LAST_CB, parseCommand } from './commands.js'
import { 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,
type ModelOption,
} from './models.js'
import {
formatStatusText,
type StatusInfo,
type StatusRuntime,
type StatusWorkspace,
} from './status.js'
export interface TelegramBridgeOptions {
token: string
allowedUserIds: number[]
allowAllUsers: boolean
client?: TelegramClientLike
sleep?: (ms: number) => Promise<void>
maxMessageLength?: number
pollingTimeoutSec?: number
rendering?: 'rich' | 'html'
}
/** chatId → bound live session id (string form of SessionId) */
interface Binding {
chatId: number
sessionId: string
label: string
}
interface PickerState {
workspaces: WorkspaceRow[]
sessions: SessionRow[]
catalog?: CatalogSnapshot
models?: ModelOption[]
pendingModel?: ModelOption
}
interface SessionLike {
id: ReturnType<typeof SessionId>
}
/** ── Stability & interactivity additions: local types (host seam shapes mirrored structurally) ── */
interface TgQuestionItem {
id?: string
question?: string
options?: Array<{ label: string; description?: string }>
multiSelect?: boolean
}
interface TgAnswerHandle {
resolve: (answer: unknown) => void
reject: (err: Error) => void
answered: boolean
}
/** chatId → pending ask_user_question mirrored to Telegram. */
interface TgPendingAsk {
req: unknown
sessionId: string
questions: TgQuestionItem[]
ask: TgAnswerHandle
at: number
}
/** chatId → pending approval request mirrored to Telegram. */
interface TgPendingApproval {
toolName: string
ask: TgAnswerHandle
}
/** Structural view of the user-questions seam (types live in the host's dsh-user-questions package). */
interface UserQuestionsServiceLike {
provider?: {
ask: (request: unknown) => Promise<unknown>
__tgHooked?: boolean
__tgRealAsk?: (request: unknown) => Promise<unknown>
}
}
/** Loose view of the approval request passed through the `approval/request` waterfall hook. */
interface ApprovalRequestLike {
agent?: Agent
toolName?: string
reason?: string
signal?: AbortSignal
}
/** ── /status 与 /compact 需要的宿主缝隙(结构镜像,避免引入额外依赖)── */
/** 会话投影注册表(只读使用 snapshot):对齐 Web 底部统计条的数据源。 */
interface ProjectionRegistryLike {
snapshot?: (session: unknown) => { asOfSeq: number; values: Record<string, unknown> } | undefined
}
/** 手动压缩引擎(结构镜像 ctx.compaction.compactNow)。 */
interface CompactionEngineLike {
compactNow?: (
agent: unknown,
signal: AbortSignal,
sourceCommandId?: string,
) => Promise<{
shadowedSeqs: number[]
shadowedTokenCount: number
summarySeq: number
} | null>
}
/** 手动压缩的预期失败类(ManualCompactionError.code 的运行时字符串)。 */
type CompactFailureCode = 'busy' | 'cancelled' | 'changed' | 'summary' | 'commit' | 'persistence'
function compactErrorCode(err: unknown): CompactFailureCode | undefined {
const code = (err as { code?: unknown } | undefined)?.code
if (code === 'busy' || code === 'cancelled' || code === 'changed'
|| code === 'summary' || code === 'commit' || code === 'persistence') {
return code
}
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'
const MAX_BUTTONS = 40
function lastContextKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
}
}
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
function contentToText(content: readonly ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('')
}
function isRichUnsupportedError(err: unknown): boolean {
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 {
private readonly ctx: Context
private readonly token: string
private readonly allowedUserIds: number[]
private readonly allowAllUsers: boolean
private readonly client: TelegramClientLike
private readonly sleep: (ms: number) => Promise<void>
private readonly maxMessageLength: number
/** 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: 'rich' | 'html'
/** 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 = new Map<string, 'rich' | 'html'>()
private readonly bindings = new Map<string, Binding>()
private readonly pickers = new Map<string, PickerState>()
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
private readonly pendingModels = new Map<string, ModelOption>()
private polling = false
private offset: number | undefined
private pollPromise: Promise<void> | undefined
private pollAbort: AbortController | undefined
private disposeSessionListener: (() => void) | undefined
// ── Stability & interactivity additions ──
/** sessionIds mid-turn (busy feedback, /stop state) */
private readonly busySessions = new Set<string>()
/** chatId → typing heartbeat interval handle */
private readonly heartbeats = new Map<string, ReturnType<typeof setInterval>>()
/** chatId → serialized notice chain (prevents 429 storms) */
private readonly noticeQueue = new Map<string, Promise<unknown>>()
private readonly pendingAsks = new Map<string, TgPendingAsk>()
private hookTimer: ReturnType<typeof setTimeout> | undefined
private readonly pendingApprovalsTG = new Map<string, TgPendingApproval>()
private disposeApprovalHook: (() => void) | undefined
/** sessionId → thinking indicator state (one notice per reasoning phase) */
private readonly thinkingSessions = new Map<string, boolean>()
/** callId → tool name (tool/result failure notices) */
private readonly callNames = new Map<string, string>()
/** sessionId → latest todo snapshot (/mission) */
private readonly lastTodos = new Map<string, Array<{ content?: string; status?: string }>>()
/** sessionId → in-flight manual compaction (dedup /compact) */
private readonly compacting = new Set<string>()
/** sessionId → compaction abort controller (cancelled on bridge stop) */
private readonly compactAborts = new Map<string, AbortController>()
constructor(ctx: Context, options: TelegramBridgeOptions) {
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. */
private modeFor(chatId: number): 'rich' | 'html' {
return this.renderPrefs.get(String(chatId)) ?? this.renderingMode
}
start(): void {
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()
this.hookUserQuestions()
this.disposeApprovalHook?.()
this.disposeApprovalHook = (this.ctx as unknown as {
on: (event: string, handler: (req: ApprovalRequestLike, next: () => Promise<string>) => Promise<string>) => () => void
}).on('approval/request', (req, next) => this.onApprovalRequest(req, next))
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: '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(): Promise<void> {
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.thinkingSessions.clear()
this.callNames.clear()
this.lastTodos.clear()
for (const controller of this.compactAborts.values()) controller.abort()
this.compactAborts.clear()
this.compacting.clear()
if (this.hookTimer) {
clearTimeout(this.hookTimer)
this.hookTimer = undefined
}
this.disposeApprovalHook?.()
this.disposeApprovalHook = undefined
if (this.pollPromise) {
await this.pollPromise.catch(() => {})
this.pollPromise = undefined
}
}
async processUpdate(update: TelegramUpdate): Promise<void> {
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
}
// Non-text messages: tell the (authorized) user instead of dropping silently.
if (!message.text) {
await this.enqueueNotice(chatId, '暂只支持文本消息(图片/语音/文件等媒体暂不处理)')
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 '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
}
}
}
private async handleCallback(update: TelegramUpdate): Promise<void> {
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 === 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)
}
private async resolveCatalog(): Promise<CatalogSnapshot> {
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)
}
private async sendWorkspacePicker(chatId: number, existing?: CatalogSnapshot): Promise<void> {
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: InlineKeyboardMarkup = {
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)
}
private async sendSessionPicker(chatId: number, workspaceIndex: number): Promise<void> {
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: InlineKeyboardMarkup = {
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)
}
private async bindSession(chatId: number, callbackId: string, row: SessionRow): Promise<void> {
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())
}
private async sendLastTurn(chatId: number): Promise<void> {
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)
}
}
private async sendModelPicker(chatId: number): Promise<void> {
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: InlineKeyboardMarkup = {
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))
}
}
private async sendEffortPicker(chatId: number, option: ModelOption): Promise<void> {
const efforts = option.efforts ?? []
const keyboard: InlineKeyboardMarkup = {
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,
)
}
private async applyModel(
chatId: number,
callbackId: string,
option: ModelOption,
reasoningEffort?: string,
): Promise<void> {
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))
}
}
private liveAgents(): Agent[] {
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 []
}
private findLiveAgent(sessionId: string): Agent | undefined {
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. */
private async ensureLiveAgent(sessionId: string): Promise<Agent | undefined> {
const live = this.findLiveAgent(sessionId)
if (live) return live
const agents = this.ctx.agents as { resume?: (opts: { resumeSessionId: ReturnType<typeof SessionId> }) => Promise<{ agent: Agent }> }
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
}
}
private async sendStatus(chatId: number): Promise<void> {
const binding = this.bindings.get(String(chatId))
if (!binding) {
await this.client.sendMessage(chatId, MSG.STATUS_NONE)
return
}
const info: StatusInfo = {
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。 */
private async workspaceOf(sessionId: string, agent: Agent | undefined): Promise<StatusWorkspace | undefined> {
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)。 */
private readRuntimeValues(agent: Agent): StatusRuntime | undefined {
const registry = this.serviceOf<ProjectionRegistryLike>('sessionProjections')
let values: Record<string, unknown> | undefined
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: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
const stats = (values['sessionStats'] ?? {}) as Record<string, unknown>
const usage = (values['tokenUsage'] ?? {}) as Record<string, unknown>
const pressure = (values['contextPressure'] ?? {}) as Record<string, unknown>
const runtime: StatusRuntime = {
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 走自有属性兜底)。 */
private serviceOf<T>(name: string): T | undefined {
const c = this.ctx as { get?: (name: string, strict?: boolean) => unknown }
if (typeof c.get === 'function') {
try {
const via = c.get(name, false)
if (via) return via as T
} catch {
// 服务未装配或当前 ctx 不可解析
}
}
try {
if (Object.prototype.hasOwnProperty.call(this.ctx, name)) {
return (this.ctx as unknown as Record<string, unknown>)[name] as T
}
} catch {
// proxy ctx:无 inject 直接读属性会抛错,忽略
}
return undefined
}
// ── /compact:手动压缩当前绑定会话(需会话空闲)──
private async requestCompact(chatId: number): Promise<void> {
const binding = this.bindings.get(String(chatId))
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND)
return
}
const engine = this.serviceOf<CompactionEngineLike>('compaction')
if (!engine?.compactNow) {
await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE)
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 controller = new AbortController()
let operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>
try {
// compactNow 在会话忙时同步抛 busy(runMaintenance 同步判定)。
operation = engine.compactNow(agent as never, controller.signal)
} catch (err) {
const code = compactErrorCode(err)
if (code === 'busy') {
await this.client.sendMessage(chatId, MSG.COMPACT_BUSY)
return
}
this.ctx.logger.warn(`dsh-telegram-channel: /compact rejected: ${this.redact(err)}`)
await this.client.sendMessage(chatId, MSG.COMPACT_FAILED(this.redact(err)))
return
}
this.compacting.add(binding.sessionId)
this.compactAborts.set(binding.sessionId, controller)
await this.client.sendMessage(chatId, MSG.COMPACT_STARTED)
void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => {
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`)
})
}
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
private async runCompaction(
chatId: number,
sessionId: string,
operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>,
controller: AbortController,
): Promise<void> {
try {
const result = await operation
if (result === null) {
await this.enqueueNotice(chatId, MSG.COMPACT_NOTHING)
} else {
await this.enqueueNotice(chatId, MSG.COMPACT_DONE(result.shadowedSeqs.length, result.shadowedTokenCount))
}
} catch (err) {
if (controller.signal.aborted) {
// 桥停止/热重载导致的取消:静默,不打扰(本实例正在退出)。
this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop')
return
}
const code = compactErrorCode(err)
switch (code) {
case 'cancelled':
await this.enqueueNotice(chatId, MSG.COMPACT_CANCELLED)
return
case 'changed':
await this.enqueueNotice(chatId, MSG.COMPACT_CHANGED)
return
case 'summary':
await this.enqueueNotice(chatId, MSG.COMPACT_SUMMARY_FAILED)
return
case 'commit':
await this.enqueueNotice(chatId, MSG.COMPACT_COMMIT_FAILED)
return
case 'persistence':
await this.enqueueNotice(chatId, MSG.COMPACT_PERSIST_FAILED)
return
case 'busy':
await this.enqueueNotice(chatId, MSG.COMPACT_BUSY)
return
default:
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)
}
}
private async followupBound(chatId: number, text: string): Promise<void> {
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 as { phase?: { kind?: string } }).phase?.kind
&& (agent as { phase?: { kind?: string } }).phase?.kind !== 'idle')
agent.followup(message)
if (busy) {
await this.enqueueNotice(chatId, '已加入队列:当前任务完成后将处理这条消息')
}
}
private async pollLoop(): Promise<void> {
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)
}
}
}
private interruptibleDelay(ms: number, signal?: AbortSignal): Promise<void> {
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 })
})
}
private async interruptibleSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted || !this.polling) return
await Promise.race([
this.sleep(ms),
new Promise<void>((resolve) => {
if (signal?.aborted) {
resolve()
return
}
signal?.addEventListener('abort', () => resolve(), { once: true })
}),
])
}
private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> {
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)
this.thinkingSessions.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 as { kind?: string; error?: { message?: string; code?: string }; reason?: { kind?: string } } | undefined
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
this.thinkingSessions.set(id, false)
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)
this.thinkingSessions.set(id, false)
if (!text) return
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)))
return
}
if (event.type === 'assistant/chunk') {
// Thinking indicator: once per reasoning phase, status only (no content).
const chunk = event.data?.chunk as { type?: string; block?: { type?: string } } | undefined
const ctype = String(chunk?.type ?? '')
const btype = String(chunk?.block?.type ?? '')
const isReasoning = ctype.includes('reasoning') || btype.includes('reasoning') || btype.includes('think')
if (isReasoning && this.thinkingSessions.get(id) !== true) {
this.thinkingSessions.set(id, true)
for (const b of targets)
this.enqueueNotice(b.chatId, '> Thinking…')
}
return
}
if (event.type === 'tool/result') {
// Only failures — successful results are too noisy for the phone.
const data = event.data as { error?: { name?: string; code?: string }; message?: { tool_use_id?: string; callId?: string } }
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
}
if (event.type === 'todo/write') {
const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
this.lastTodos.set(id, todos as Array<{ content?: string; status?: string }>)
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.
private async handleRichCommand(chatId: number, arg?: string): Promise<void> {
const key = String(chatId)
const setAndReply = async (mode: 'rich' | 'html'): Promise<void> => {
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)
}
}
private async deliver(chatId: number, markdown: string): Promise<void> {
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)
}
private async deliverHtml(chatId: number, markdown: string): Promise<void> {
const chunks = splitMessage(markdown, this.maxMessageLength)
for (const chunk of chunks) {
let html: string
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). */
private async withRetry<T>(fn: () => Promise<T>, tries = 3): Promise<T> {
let lastErr: unknown
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 `> ` 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 async deliverNotice(chatId: number, text: string): Promise<void> {
const quoted = text.startsWith('> ')
const body = quoted ? text.slice(2) : text
try {
await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body))
return
} catch (err) {
this.ctx.logger.error(this.redact(err))
}
}
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
private enqueueNotice(chatId: number, text: string): Promise<void> {
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
}
private startTypingHeartbeat(chatId: number): void {
this.stopTypingHeartbeat(chatId)
const send = (): void => {
void this.client.sendChatAction(chatId, 'typing').catch(() => {})
}
send()
this.heartbeats.set(String(chatId), setInterval(send, 4000))
}
private stopTypingHeartbeat(chatId: number): void {
const h = this.heartbeats.get(String(chatId))
if (h) {
clearInterval(h)
this.heartbeats.delete(String(chatId))
}
}
private stopAllHeartbeats(): void {
for (const h of this.heartbeats.values()) clearInterval(h)
this.heartbeats.clear()
}
// ── ask_user_question: TG answering via dual-path race with the UI ──
private userQuestions(): UserQuestionsServiceLike | undefined {
return (this.ctx as unknown as { userQuestions?: UserQuestionsServiceLike }).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(attempt = 0): void {
try {
const provider = this.userQuestions()?.provider
if (provider) {
if (provider.__tgHooked && provider.__tgRealAsk) {
// Hot reload: re-point the wrapper at THIS bridge instance.
const realAsk = provider.__tgRealAsk
const self = this
provider.ask = function (request: unknown): Promise<unknown> {
const tg = self.registerTgAsk(request)
let gui: Promise<unknown>
try {
gui = realAsk(request)
} catch (err) {
tg.reject(err as Error)
throw err
}
void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request))
return Promise.race([tg.promise, gui])
}
return
}
const realAsk = provider.ask.bind(provider) as (request: unknown) => Promise<unknown>
provider.__tgRealAsk = realAsk
const self = this
provider.ask = function (request: unknown): Promise<unknown> {
const tg = self.registerTgAsk(request)
let gui: Promise<unknown>
try {
gui = realAsk(request)
} catch (err) {
tg.reject(err as Error)
throw err
}
void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request))
return Promise.race([tg.promise, gui])
}
provider.__tgHooked = true
this.ctx.logger.info('dsh-telegram-channel: ask_user TG answering hook installed')
return
}
} catch (err) {
if (attempt === 0)
this.ctx.logger.warn(`dsh-telegram-channel: userQuestions hook deferred: ${this.redact(err)}`)
}
if (attempt >= 30) {
this.ctx.logger.warn('dsh-telegram-channel: user-questions provider never appeared; TG answering disabled')
return
}
this.hookTimer = setTimeout(() => this.hookUserQuestions(attempt + 1), 2000)
}
private registerTgAsk(request: unknown): { promise: Promise<unknown>; reject: (err: Error) => void } {
let resolveFn: (v: unknown) => void = () => {}
let rejectFn: (err: Error) => void = () => {}
const promise = new Promise<unknown>((resolve, reject) => {
resolveFn = resolve
rejectFn = reject
})
const ask: TgAnswerHandle = { resolve: resolveFn, reject: rejectFn, answered: false }
const req = request as { agent?: Agent; questions?: TgQuestionItem[]; signal?: AbortSignal }
const sessionId = req.agent?.id !== undefined ? String(req.agent.id) : undefined
const questions = Array.isArray(req.questions) ? req.questions : []
const onAbort = (): void => {
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
this.pendingAsks.set(String(chatId), { req: request, sessionId, questions, ask, at: Date.now() })
this.enqueueNotice(Number(chatId), this.formatAskPending(questions))
asked = true
}
if (asked) return { promise, reject: rejectFn }
}
// No bound TG chat: never settle — the UI remains the only answer path.
return { promise, reject: rejectFn }
}
private settleGuiSide(request: unknown): void {
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 作答通道关闭')
}
}
}
private formatAskPending(questions: TgQuestionItem[]): string {
const lines = ['待回答(回复字母选项,或直接输入自定义答案;/cancel 关闭 TG 作答)']
questions.forEach((q, i) => {
const prefix = questions.length > 1 ? `Q${i + 1}. ` : ''
lines.push(`${prefix}${q.question ?? ''}`)
const opts = Array.isArray(q.options) ? q.options : []
if (opts.length === 0) {
lines.push(' (开放问题,直接输入答案)')
} else {
opts.forEach((o, j) => lines.push(` [${String.fromCharCode(65 + j)}] ${o.label}${o.description ? ` — ${o.description}` : ''}`))
if (q.multiSelect) lines.push(' (可多选,如:A,B)')
}
})
return lines.join('\n')
}
private async handleTgAnswer(chatId: number, pending: TgPendingAsk, text: string): Promise<void> {
const { questions, ask } = pending
let parts: Array<[number | null, string]>
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)) {
await this.enqueueNotice(chatId, `共 ${questions.length} 个问题,请每行用 1:/2: 前缀分别作答`)
return
}
}
const answers: Array<{ id?: string; selected: string[]; custom?: string }> = []
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: string[] = []
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)
}
answers.push({ id: q.id, selected })
} else {
answers.push({ id: q.id, selected: [], custom: cleaned })
}
}
ask.answered = true
for (const [cid, p] of this.pendingAsks) {
if (p.ask === ask) this.pendingAsks.delete(cid)
}
ask.resolve({ answers })
this.enqueueNotice(chatId, '已提交你的回答')
}
// ── approval: TG answering via dual-path race on the request waterfall ──
private async onApprovalRequest(
req: ApprovalRequestLike,
next: () => Promise<string>,
): Promise<string> {
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: (v: unknown) => void = () => {}
let rejectFn: (err: Error) => void = () => {}
const promise = new Promise<unknown>((resolve, reject) => {
resolveFn = resolve
rejectFn = reject
})
const ask: TgAnswerHandle = { resolve: resolveFn, reject: rejectFn, answered: false }
const onAbort = (): void => {
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 next()
}
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 })
this.enqueueNotice(Number(chatId), `权限审批:${toolName}${reason ? `\n(${reason})` : ''}\n回复 [A] 允许一次 / [B] 拒绝(/cancel 关闭 TG 审批)`)
}
}
// No bound TG chat: promise never settles — the UI remains the only answer path.
const gui = next()
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]) as Promise<string>
}
private async handleTgApproval(chatId: number, approval: TgPendingApproval, text: string): Promise<void> {
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 ──
private async stopBound(chatId: number): Promise<void> {
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, '已请求中止当前轮…')
}
private async sendMission(chatId: number): Promise<void> {
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: Record<string, string> = { 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. */
private async newSessionHere(chatId: number): Promise<void> {
const binding = this.bindings.get(String(chatId))
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND)
return
}
let workspaceId: string | undefined
let fallbackCwd: string | undefined
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: { result?: { ok?: boolean; value?: { sessionId?: string }; error?: { message?: string } } } | undefined
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,
}) as typeof res
} 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) ──
private bindingsPath(): string {
// 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')
}
private saveBindings(): void {
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)}`)
}
}
private loadBindings(): void {
try {
const raw = readFileSync(this.bindingsPath(), 'utf8')
const parsed = JSON.parse(raw) as unknown
const data = parsed as {
bindings?: Record<string, { chatId?: number; sessionId?: string; label?: string }>
renderPrefs?: Record<string, string>
}
// Legacy files (pre-renderPrefs) stored the bindings flat at the top level.
const flat = parsed as Record<string, { chatId?: number; sessionId?: string; label?: string }>
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.
}
}
private redact(value: unknown): string {
const message = value instanceof Error ? value.message : String(value)
return message.split(this.token).join('***')
}
}