与 ask_user 同一根因:'approval/request' waterfall 分发在 per-fiber events 注册表,本插件 ctx.on 监听收不到。改为包装 ApprovalService.prototype. request(类跨实例/fiber 共享),TG 审批答案与原请求竞速,保留 dual-path 语义;stop 时恢复原型。已验证运行态拦截器标记就位。
2111 lines
83 KiB
TypeScript
2111 lines
83 KiB
TypeScript
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 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 } 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 TelegramMessage,
|
||
type TelegramUpdate,
|
||
} 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,
|
||
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
|
||
/** question index → option indices chosen via inline-keyboard taps. */
|
||
kbSel: Map<number, Set<number>>
|
||
/** question index → answer parsed from a text reply (letters or custom). */
|
||
textAnswers: Map<number, { id?: string; selected: string[]; custom?: string }>
|
||
/** questions the user chose to answer with free text (✏️ button): the next text reply fills them. */
|
||
textWanted: Set<number>
|
||
}
|
||
|
||
/** chatId → pending approval request mirrored to Telegram. */
|
||
interface TgPendingApproval {
|
||
toolName: string
|
||
ask: TgAnswerHandle
|
||
}
|
||
|
||
/** 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.commands.execute)。
|
||
*
|
||
* /compact 不在插件可见作用域内直接提供 compaction 服务(compaction-basic 挂载在
|
||
* 隔离作用域,ctx.get 取不到),但宿主命令运行时可见,且 command-compact 已把
|
||
* /compact 注册进该运行时的按 agent 作用层 —— Web 端正是用 execute() 派发它的。
|
||
* TG 端复用同一条通道:语义、busy/错误映射、命令生命周期日志与 Web 完全一致。
|
||
*/
|
||
interface CommandsRuntimeLike {
|
||
find?: (agent: unknown, name: string) => unknown
|
||
execute?: (
|
||
agent: unknown,
|
||
line: string,
|
||
images: readonly unknown[],
|
||
signal: AbortSignal,
|
||
) => Promise<{ commandId: string; result: { kind: string; text?: string } } | undefined>
|
||
}
|
||
|
||
/** command-compact 的 CommandResult.kind 值。 */
|
||
type CompactCommandKind = 'success' | 'error'
|
||
|
||
/** Wire prompt content part (mirrors host PromptContentPart): text or base64 image. */
|
||
type WireContentPart =
|
||
| { type: 'text'; text: string }
|
||
| { type: 'image'; mediaType: string; data: string; name?: string }
|
||
|
||
/** Host-admitted raster media types (@deepseek-ai/dsh-attachment). */
|
||
const IMAGE_MEDIA_TYPES: readonly string[] = ['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: { kind: string; text?: string } | undefined): string | undefined {
|
||
const kind = result?.kind as CompactCommandKind | undefined
|
||
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} — inline-keyboard taps for ask_user_question. */
|
||
const ASK_CB = 'ask:'
|
||
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(): InlineKeyboardMarkup {
|
||
return {
|
||
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
|
||
}
|
||
}
|
||
|
||
const defaultSleep = (ms: number) => new Promise<void>((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: readonly ContentBlock[]): string {
|
||
const parts: string[] = []
|
||
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: 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>()
|
||
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||
private askInstalled = false
|
||
private askRestore: (() => void) | undefined
|
||
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
|
||
private approvalInstalled = false
|
||
private approvalRestore: (() => void) | undefined
|
||
/** 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>()
|
||
/** Album accumulation: chatId:media_group_id → parts buffered into one prompt. */
|
||
private readonly mediaGroups = new Map<string, { chatId: number; parts: WireContentPart[]; timer?: ReturnType<typeof setTimeout> }>()
|
||
|
||
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()
|
||
// 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: '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.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: 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
|
||
}
|
||
|
||
// 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 '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.startsWith(ASK_CB)) {
|
||
await this.handleAskCallback(chatId, cq)
|
||
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)
|
||
}
|
||
|
||
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:手动压缩当前绑定会话(需会话空闲)──
|
||
//
|
||
// 压缩引擎(compaction-basic)挂载在隔离作用域,插件 ctx 拿不到;但宿主的
|
||
// command-compact 已把 /compact 注册进命令运行时的按 agent 作用层(Web 端
|
||
// 同款通道),因此这里复用它:语义、busy/错误映射、命令生命周期日志与 Web
|
||
// 端 /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
|
||
}
|
||
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<CommandsRuntimeLike>('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 as { phase?: { kind?: string } }).phase?.kind
|
||
&& (agent as { phase?: { kind?: string } }).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 as never, '/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)}`)
|
||
})
|
||
}
|
||
|
||
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||
private async runCompaction(
|
||
chatId: number,
|
||
sessionId: string,
|
||
operation: Promise<{ commandId: string; result: { kind: string; text?: string } } | undefined>,
|
||
controller: AbortController,
|
||
): Promise<void> {
|
||
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)
|
||
}
|
||
}
|
||
|
||
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, '已加入队列:当前任务完成后将处理这条消息')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract wire prompt parts from a media message.
|
||
* Returns undefined when the message carries no media at all; returns [] for
|
||
* an unsupported media type (the user was already notified); otherwise the
|
||
* caption text part (when present) followed by the image part.
|
||
*/
|
||
private async collectMediaParts(chatId: number, message: TelegramMessage): Promise<WireContentPart[] | undefined> {
|
||
const caption = message.caption?.trim()
|
||
const textPart: WireContentPart | undefined = caption ? { type: 'text', text: caption } : undefined
|
||
const withText = (part: WireContentPart): WireContentPart[] =>
|
||
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. */
|
||
private async downloadImagePart(
|
||
chatId: number,
|
||
fileId: string,
|
||
mediaType: string,
|
||
name?: string,
|
||
): Promise<WireContentPart | undefined> {
|
||
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.
|
||
*/
|
||
private accumulateMediaGroup(chatId: number, groupId: string, parts: WireContentPart[]): void {
|
||
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).
|
||
*/
|
||
private async sendImageFollowup(chatId: number, parts: WireContentPart[]): 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 proxy = resolveApiProxy(this.ctx)
|
||
if (!proxy?.sessions?.prompt) {
|
||
await this.enqueueNotice(chatId, '当前宿主不支持会话消息接口(sessions.prompt),无法发送图片。')
|
||
return
|
||
}
|
||
const busy = this.busySessions.has(binding.sessionId)
|
||
|| Boolean((agent as { phase?: { kind?: string } }).phase?.kind
|
||
&& (agent as { phase?: { kind?: string } }).phase?.kind !== 'idle')
|
||
type PromptResponse = { result?: { ok?: boolean; value?: { accepted?: boolean }; error?: { code?: string; message?: string; details?: { reason?: string } } } }
|
||
let res: PromptResponse | undefined
|
||
try {
|
||
res = await proxy.sessions.prompt({
|
||
rpcId: randomUUID(),
|
||
payload: { sessionId: binding.sessionId, mode: 'queue', content: parts },
|
||
}) as PromptResponse | undefined
|
||
} 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, '已加入队列:当前任务完成后将处理这条消息')
|
||
}
|
||
}
|
||
|
||
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> {
|
||
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 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
|
||
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 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 `> ` 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.
|
||
*/
|
||
private async deliverNotice(chatId: number, text: string): Promise<void> {
|
||
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. */
|
||
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 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.
|
||
|
||
private installAskInterceptor(): void {
|
||
if (this.askInstalled) return
|
||
let svc: { constructor: { prototype: Record<string | symbol, unknown> } } | undefined
|
||
try {
|
||
svc = (this.ctx as unknown as { get: (name: string) => unknown }).get('userQuestions') as
|
||
| { constructor: { prototype: Record<string | symbol, unknown> } }
|
||
| undefined
|
||
} catch {
|
||
return // service not ready yet — retried lazily from onSessionEvent
|
||
}
|
||
if (!svc) return
|
||
const proto = svc.constructor.prototype
|
||
const prev = proto[kTgAskWrapped] as { restore: () => void; bridge: unknown } | undefined
|
||
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 as (this: unknown, request: unknown) => Promise<unknown>
|
||
const bridge = this
|
||
const wrapped = function (this: unknown, request: unknown): Promise<unknown> {
|
||
return bridge.aroundUserQuestionAsk(this, request, orig)
|
||
}
|
||
proto.ask = wrapped
|
||
const restore = (): void => {
|
||
if (proto.ask === wrapped) proto.ask = orig
|
||
}
|
||
proto[kTgAskWrapped] = { restore, bridge: this }
|
||
this.askRestore = restore
|
||
this.askInstalled = true
|
||
}
|
||
|
||
private aroundUserQuestionAsk(
|
||
service: unknown,
|
||
request: unknown,
|
||
orig: (this: unknown, request: unknown) => Promise<unknown>,
|
||
): Promise<unknown> {
|
||
const tg = this.registerTgAsk(request)
|
||
let gui: Promise<unknown>
|
||
try {
|
||
gui = Promise.resolve().then(() => orig.call(service, request))
|
||
} catch (err) {
|
||
tg.reject(err as Error)
|
||
throw err
|
||
}
|
||
void gui.then(() => this.settleGuiSide(request), () => this.settleGuiSide(request))
|
||
return Promise.race([tg.promise, gui])
|
||
}
|
||
|
||
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
|
||
const pending: TgPendingAsk = {
|
||
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). */
|
||
private async deliverAskPrompt(chatId: number, pending: TgPendingAsk): Promise<void> {
|
||
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: InlineKeyboardMarkup = {
|
||
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). */
|
||
private finalizeAskIfComplete(chatId: number, pending: TgPendingAsk): boolean {
|
||
const { questions, ask } = pending
|
||
const answers: Array<{ id?: string; selected: string[]; custom?: string }> = []
|
||
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
|
||
}
|
||
|
||
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 async handleTgAnswer(chatId: number, pending: TgPendingAsk, text: string): Promise<void> {
|
||
const { questions } = 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)) {
|
||
// ✏️ 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: 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)
|
||
}
|
||
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. */
|
||
private async handleAskCallback(chatId: number, cq: { id: string; data?: string }): Promise<void> {
|
||
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<number>()
|
||
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.
|
||
|
||
private installApprovalInterceptor(): void {
|
||
if (this.approvalInstalled) return
|
||
let svc: { constructor: { prototype: Record<string | symbol, unknown> } } | undefined
|
||
try {
|
||
svc = (this.ctx as unknown as { get: (name: string) => unknown }).get('approval') as
|
||
| { constructor: { prototype: Record<string | symbol, unknown> } }
|
||
| undefined
|
||
} catch {
|
||
return // service not ready yet — retried lazily from onSessionEvent
|
||
}
|
||
if (!svc) return
|
||
const proto = svc.constructor.prototype
|
||
const prev = proto[kTgApprovalWrapped] as { restore: () => void; bridge: unknown } | undefined
|
||
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 as (this: unknown, req: unknown) => Promise<string>
|
||
const bridge = this
|
||
const wrapped = function (this: unknown, req: unknown): Promise<string> {
|
||
return bridge.aroundApprovalRequest(this, req as ApprovalRequestLike, orig as (this: unknown, req: ApprovalRequestLike) => Promise<string>)
|
||
}
|
||
proto.request = wrapped
|
||
const restore = (): void => {
|
||
if (proto.request === wrapped) proto.request = orig
|
||
}
|
||
proto[kTgApprovalWrapped] = { restore, bridge: this }
|
||
this.approvalRestore = restore
|
||
this.approvalInstalled = true
|
||
}
|
||
|
||
private aroundApprovalRequest(
|
||
service: unknown,
|
||
req: ApprovalRequestLike,
|
||
orig: (this: unknown, req: ApprovalRequestLike) => 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 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 })
|
||
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 = 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]) 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('***')
|
||
}
|
||
}
|