Compare commits

...
23 Commits
Author SHA1 Message Date
root 36e95c2631 feat(goal): TG /goal 指令进入目标模式
- 复用宿主命令运行时(commands.execute,与 Web 端 /goal 同通道),不依赖
  GUI 连接:command-goal 在宿主侧操作 session-log 支撑的 goals 域
- 语法与 Web 完全一致:/goal <目标>、/goal(查看)、clear/edit/pause/resume
- 目标状态变更(blocked/complete)实时推送 TG 通知,手机可感知目标模式进展
- 配置侧启用 command-goal(plugin_toggle,已 live 生效)
2026-09-17 12:05:26 +08:00
root 85a42f28a6 feat(approval): 审批改用 TG 原生 inline keyboard 按钮
- 审批请求以「✅ 允许一次 / ❌ 拒绝」双按钮消息呈现,点按钮即裁决并 toast 确认
- 按钮发送失败自动降级为原文本提示(回复 A/B),审批绝不丢失
- 文本作答通道保留(handleTgApproval 的 A/B 解析不变),/cancel 语义不变
2026-09-17 11:53:45 +08:00
root f49ac5d3c0 fix(approval): prototype 拦截器替代 approval/request waterfall hook
与 ask_user 同一根因:'approval/request' waterfall 分发在 per-fiber events
注册表,本插件 ctx.on 监听收不到。改为包装 ApprovalService.prototype.
request(类跨实例/fiber 共享),TG 审批答案与原请求竞速,保留 dual-path
语义;stop 时恢复原型。已验证运行态拦截器标记就位。
2026-09-17 11:47:48 +08:00
RRRosmontis 566932e0ae Revert "feat(tg): 思考块改为可折叠引用块(与工具调用区分)"
This reverts commit 6d00225cc4.
2026-09-17 11:39:18 +08:00
root 6d00225cc4 feat(tg): 思考块改为可折叠引用块(与工具调用区分)
TG 不支持按消息自定义引用块颜色(竖线颜色跟随客户端主题),按用户
选择采用官方可折叠引用块:默认收起为「💭 思考过程 ▾」,点击展开,
与工具调用的普通蓝色竖线引用块在交互上明确区分。

- rich 模式:Telegram 原生 markdown 可折叠引用语法(**> 开头 + 行尾 ||)
- HTML 模式:markdownToHtml 转换为 <blockquote expandable>
- 思考文本内的 || 全角化,避免被解析成 spoiler 实体
2026-09-17 11:36:15 +08:00
root 0b946ffaa4 Merge remote-tracking branch 'gitea/master'
# Conflicts:
#	lib/bridge.js
#	src/bridge.ts
2026-09-17 11:24:47 +08:00
root 2c50a35795 feat(ask): 键盘常态提供 ✏️ 输入自定义答案 按钮
- 每个带选项的提问键盘固定追加第三行 ✏️ 输入自定义答案
- 点击后该题进入待文本状态(textWanted),丢弃该题已点选的按钮项
- 下一条文本消息自动填充最先待文本的题;带 1:/2: 前缀的作答同样可清除待文本状态
- 已实测:✏️ → 发送 111 → custom 答案正确回流
2026-09-17 11:21:30 +08:00
root 373abf586d chore: 移除误加的杂物文件 2026-09-17 11:15:25 +08:00
rootandShirobana Agent Bot <tg-bridge> da06485d64 fix(ask): prototype 拦截器替代 waterfall hook + TG 原生按钮作答
根因:dsh 0.1.5-rc.1 把 'user-questions/request' waterfall 分发到
per-fiber events 注册表,其他 fiber 的 ctx.on 监听(含本插件)永远收不到
分发(实测 root/global/agent 作用域监听全部落空)。

修复:包装 UserQuestionService.prototype.ask(类跨实例共享),无论哪个
fiber/实例发起的 ask 都能拦截到 agent+questions;TG 答案与原 ask 竞速,
保留 dual-path 语义;stop 时恢复原型,热重载不叠加。

功能(应用户要求):
- 提问前显式发送预告文本消息
- 选项改用 TG 原生 inline keyboard 按钮(单选即点即答,多选带提交键)
- 文本作答保留并与按钮作答合并,逐题累计、齐题自动提交
- 附带修复:Telegram API 调用加 15s 客户端超时(代理挂死会卡死通知队列)

Co-authored-by: Shirobana Agent Bot <tg-bridge>
2026-09-17 11:15:16 +08:00
dsh-agent 4039dec03c feat(config): 渲染默认 rich(用户要求 2026-09-14)
Config.rendering 默认 'html'→'rich';显式 'html'/'compat' 仍可强制兼容模式。
profile cordis.patch.yml 的 rendering 同步改为 rich(patch 会覆盖插件默认值,
两处都改才生效)。旧客户端显示'不支持'时用 /rich off 按聊天切回。
2026-09-16 10:42:45 +08:00
owo d7c74e88ba Merge origin/master into PR7 2026-09-15 21:38:50 +08:00
owo e748132541 fix: make optional services non-blocking 2026-09-15 21:37:16 +08:00
徐跃恒andGitHub 72e1d399a7 fix: make apiProxy injection optional
Resolves the optional apiProxy injection, keeps HTML as the compatibility default, synchronizes generated entrypoints, and adds a regression test.
2026-09-15 21:36:14 +08:00
dsh-agent 5fa2827ed6 feat(tg): 去掉 '> Thinking…' 思考提示流水
用户要求:不再发送思考指示(assistant/chunk 的 reasoning 分支整段移除,
连带 thinkingSessions 状态与相关清理)。tool/call、tool/result、todo/write、
turn/end 的流水保持不变。
2026-09-14 19:16:28 +08:00
dsh-agent 9a45bde25a test: 兜底 DSH_HOME,杜绝用例把 fixture 写进真实绑定文件
tests/bridge.test.ts 有若干用例直接构造 bridge 并 bind,未设
DSH_TELEGRAM_BINDINGS_FILE,绑定落盘走 DSH_HOME 默认值 → 覆盖
/root/.dsh/telegram-channel-bindings.json(2026-09-14 实际发生,覆盖掉用户
真实绑定)。模块顶部把 DSH_HOME 指向 /tmp 兜底,显式 override 的用例不受影响。
2026-09-14 19:07:08 +08:00
dsh-agent 9f03c6c748 fix(ask_user): dsh 0.1.5 走 user-questions/request waterfall
0.1.5 移除了 userQuestions.provider,改为 waterfall 事件注册(与 approval/request 同形);
同时把 2026-09-12 只在编译产物上做过的修复回植到 src,避免源码/产物漂移。
2026-09-14 19:03:08 +08:00
RRRosmontis f3f6a3ebaf feat: admit photos and image documents into the bound session
Telegram 图片消息此前一律回复「暂只支持文本消息」。本改动:

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

tests +7(58/58):photo+caption、png document、pdf 拒绝、相册合并、
模型不支持、下载失败、未绑定。无 emoji 文案。
2026-09-05 17:51:21 +08:00
RRRosmontis 8600835436 fix: /compact 改走宿主命令通道(commands.execute),绕开隔离作用域
根因:compaction-basic 挂载在隔离作用域,插件 ctx 的 ctx.get('compaction')
解析不到(实测 undefined;agent.ctx 亦不可达)。但宿主命令运行时的按 agent
作用层已注册 /compact(command-compact,Web 端同款通道),
commands.find(agent,'compact') 可命中。

因此 /compact 改为 ctx.commands.execute(agent,'/compact',[],signal) 派发:
- 与 Web 端 /compact 完全同语义(busy/错误映射、command/run 生命周期日志)
- 结果文本按 command-compact 固定文案翻译为中文回复;无法识别时回退原文详情
- 忙会话提前提示(busySessions/phase 预检);异步执行不阻塞轮询;同会话去重
- 测试改 mock commands.find/execute(51 全绿)
2026-09-05 17:34:37 +08:00
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
RRRosmontis 20898d5d21 fix: guard markdownToHtml in the HTML fallback path
A throw from the converter used to escape the try/catch and drop the
message; degrade to plain text for that chunk instead.
2026-09-02 16:10:48 +08:00
RRRosmontis cecb655a4b fix: register /cancel as a proper command
Previously /cancel was intercepted inside the 'unknown' branch, and the
comparison used the command WITHOUT the leading slash while parseCommand
returns it WITH one ('/cancel') — the check never matched, so /cancel
always fell through to the unknown-command reply, even with a pending
ask/approval.

Now a first-class command (menu included): closes the TG-side
answer/approval channel when one is pending, otherwise reports that
nothing is awaiting a reply.
2026-09-02 16:07:53 +08:00
RRRosmontis cde6979f2d feat: resilient delivery, live progress visibility, TG-side answering, /stop /mission /new
Delivery & visibility:
- per-chunk retry with capped backoff on rich/HTML sends; rich failure now
  falls back to HTML instead of dropping the reply
- typing heartbeat: refresh sendChatAction every 4s for the whole turn
  (a single action expires in ~5s, long turns looked dead)
- turn/end error / abort / crash-recovery notices reach Telegram
  (API failures were previously invisible)
- tool/call progress summaries, tool/result failure notices, todo progress
- thinking indicator (one notice per reasoning phase, status only)
- notices prefixed '> ' render as Telegram blockquotes; plain-text fallback
- non-text messages get a hint instead of being silently dropped
- busy feedback: a followup queued mid-turn gets an explicit ack

TG-side answering (dual-path race; UI path untouched, first answer wins):
- ask_user_question questions render with letter options on TG; reply A /
  [A] / A,B (multi) / free text / '1: A' per-line for multi-question
- approval requests (approval/request waterfall) answerable from TG:
  [A] allow once / [B] reject; /cancel closes only the TG side

Commands: /stop (cancel current turn), /mission (todo snapshot + busy state),
/new (create a session in the current workspace and attach)

Persistence: chat→session bindings stored at $DSH_HOME/
telegram-channel-bindings.json (override: DSH_TELEGRAM_BINDINGS_FILE) and
restored on start — hot reloads and restarts no longer drop attachments.

Host seams without public surface yet are reached with documented structural
casts (userQuestions provider wrap, approval/request waterfall).

Tests: 41/41 pass. Two pre-existing environment failures in bridge.test.ts
(/last history mirror, assistant mirror) now pass: the former was fixed by
the rich→HTML fallback, the latter needed an event-loop flush after the
(void) listener call since resilient delivery lengthens the chain.
2026-09-01 16:06:00 +08:00
93f3bec975 feat: use Telegram native Rich Message for assistant replies
Co-authored-by: MuziIsabel <MuziIsabel@users.noreply.github.com>
2026-08-18 18:27:32 +08:00
28 changed files with 5226 additions and 51 deletions
+6 -2
View File
@@ -34,6 +34,8 @@ Telegram **手机遥控器** for DeepSeek Harness:附着本机正在跑的 Web
2. 手机 Bot:`/sessions` → **工作区** → **会话** → 附着
3. 之后手机 ↔ Web 走**同一条**轨迹;可用 `/model` 切换模型(下一回合生效)
支持的消息:文本、图片(照片或以文件形式发送的 png/jpeg/webp/gif,可带文字说明;多张相册照片合并为一条消息进入会话)。语音/视频/其他文件暂不支持(会收到提示)。图片经宿主附件服务持久化,与 Web 端上传走同一通道;当前模型不支持图像输入时会收到明确提示。
### 效果截图
手机选择会话并发问:
@@ -120,14 +122,15 @@ curl -fsSL https://raw.githubusercontent.com/hi-wenw/dsh-telegram-channel/master
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
5. 续接上下文:附着后点 **查看上次对话**,或发 `/last`
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `unbind` `help`。
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `compact` `unbind` `help`。
| 命令 | 作用 |
|---|---|
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
| `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) |
| `/model` | 切换当前绑定会话的模型 |
| `/status` | 当前绑定 |
| `/status` | 通用状态显示:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/首 token 平均/输出速率/输入输出 tokens(与 Web 底部统计条同源) |
| `/compact` | 手动压缩当前绑定会话的历史(缩短上下文;会话需空闲,压缩期间新消息排队) |
| `/unbind` | 只断开手机,**不关**电脑会话 |
| `/help` | 帮助 |
@@ -174,6 +177,7 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel
| `allowAllUsers` | `true` 仅调试 |
| `maxMessageLength` | 默认 4096 |
| `pollingTimeoutSec` | 默认 30 |
| `rendering` | `html`(默认,兼容所有客户端)或 `rich`(原生 Rich Message) |
若本机用了 HTTP(S)_PROXY 访问 Telegram,插件会自动走代理(无需再设 `NODE_USE_ENV_PROXY`)。
+13
View File
@@ -17,6 +17,19 @@ export type ApiProxySessions = {
rpcId: string;
payload: unknown;
}) => Promise<unknown>;
/** Create a session (used by /new; payload: workspaceId | cwd). */
create?: (req: {
rpcId: string;
payload: unknown;
}) => Promise<unknown>;
/**
* Prompt dispatch (Web 端同通道):文本 + 图片字节(base64)经宿主附件服务持久化后
* 进入会话;含图片时会校验当前模型是否支持图像输入。
*/
prompt?: (req: {
rpcId: string;
payload: unknown;
}) => Promise<unknown>;
};
export type ApiProxyLike = {
workspace?: {
+110
View File
@@ -8,6 +8,7 @@ export interface TelegramBridgeOptions {
sleep?: (ms: number) => Promise<void>;
maxMessageLength?: number;
pollingTimeoutSec?: number;
rendering?: 'rich' | 'html';
}
export declare class TelegramBridge {
private readonly ctx;
@@ -17,6 +18,12 @@ export declare class TelegramBridge {
private readonly client;
private readonly sleep;
private readonly maxMessageLength;
/** Global rendering mode: follows config; flipped to 'html' permanently when the
* Rich Message API itself is unavailable (a server-wide fact, not per-chat). */
private renderingMode;
/** Per-chat rendering override (persisted): rich messages need a recent Telegram
* client — old ones (e.g. 10.x) cannot render them and show “unsupported”. */
private readonly renderPrefs;
private readonly bindings;
private readonly pickers;
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
@@ -26,7 +33,34 @@ export declare class TelegramBridge {
private pollPromise;
private pollAbort;
private disposeSessionListener;
/** sessionIds mid-turn (busy feedback, /stop state) */
private readonly busySessions;
/** chatId → typing heartbeat interval handle */
private readonly heartbeats;
/** chatId → serialized notice chain (prevents 429 storms) */
private readonly noticeQueue;
private readonly pendingAsks;
private hookTimer;
private readonly pendingApprovalsTG;
/** Prototype-ask interceptor state (see installAskInterceptor). */
private askInstalled;
private askRestore;
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
private approvalInstalled;
private approvalRestore;
/** callId → tool name (tool/result failure notices) */
private readonly callNames;
/** sessionId → latest todo snapshot (/mission) */
private readonly lastTodos;
/** sessionId → in-flight manual compaction (dedup /compact) */
private readonly compacting;
/** sessionId → compaction abort controller (cancelled on bridge stop) */
private readonly compactAborts;
/** Album accumulation: chatId:media_group_id → parts buffered into one prompt. */
private readonly mediaGroups;
constructor(ctx: Context, options: TelegramBridgeOptions);
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
private modeFor;
start(): void;
stop(): Promise<void>;
processUpdate(update: TelegramUpdate): Promise<void>;
@@ -44,11 +78,87 @@ export declare class TelegramBridge {
/** Resume cold sessions when needed; never dispose the returned handle. */
private ensureLiveAgent;
private sendStatus;
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
private workspaceOf;
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
private readRuntimeValues;
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
private serviceOf;
private requestCompact;
/**
* TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条
* 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。
*/
private requestGoal;
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
private runCompaction;
private followupBound;
/**
* Extract wire prompt parts from a media message.
* Returns undefined when the message carries no media at all; returns [] for
* an unsupported media type (the user was already notified); otherwise the
* caption text part (when present) followed by the image part.
*/
private collectMediaParts;
/** Download one TG file and encode it as a canonical-base64 wire image part. */
private downloadImagePart;
/**
* Album photos arrive as separate updates sharing a media_group_id — debounce
* them into ONE prompt so an album does not spawn N sequential turns.
*/
private accumulateMediaGroup;
/**
* Dispatch an image-bearing message to the bound session through the host
* prompt RPC — the exact channel the Web composer uses. The host checks
* whether the current model accepts image input, admits the bytes into the
* durable attachment store, then queues the message on the agent (mode
* 'queue' — same semantics as the text path's agent.followup).
*/
private sendImageFollowup;
private pollLoop;
private interruptibleDelay;
private interruptibleSleep;
private onSessionEvent;
private handleRichCommand;
private deliver;
private deliverHtml;
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
private withRetry;
/**
* Send a notice; a leading `> ` 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 deliverNotice;
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
private enqueueNotice;
private startTypingHeartbeat;
private stopTypingHeartbeat;
private stopAllHeartbeats;
private installAskInterceptor;
private aroundUserQuestionAsk;
private registerTgAsk;
/** Announce the incoming question, then one message per question (options as native inline-keyboard buttons). */
private deliverAskPrompt;
/** Resolve the pending ask once EVERY question has an answer (keyboard and/or text). */
private finalizeAskIfComplete;
private settleGuiSide;
private handleTgAnswer;
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
private handleAskCallback;
private installApprovalInterceptor;
private aroundApprovalRequest;
/** Approval prompt as native inline-keyboard buttons; falls back to a plain-text prompt. */
private deliverApprovalPrompt;
private handleApprovalCallback;
private handleTgApproval;
private stopBound;
private sendMission;
/** /new: create a session in the bound session's workspace and attach to it. */
private newSessionHere;
private bindingsPath;
private saveBindings;
private loadBindings;
private redact;
}
+1427 -17
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -10,12 +10,38 @@ export interface TelegramChat {
title?: string;
username?: string;
}
export interface TelegramPhotoSize {
file_id: string;
file_unique_id: string;
width: number;
height: number;
file_size?: number;
}
export interface TelegramDocument {
file_id: string;
file_unique_id: string;
file_name?: string;
mime_type?: string;
file_size?: number;
}
export interface TelegramMessage {
message_id: number;
date: number;
chat: TelegramChat;
from?: TelegramUser;
text?: string;
/** Media caption (photos/documents); TG puts it here, not in `text`. */
caption?: string;
photo?: TelegramPhotoSize[];
document?: TelegramDocument;
/** Album grouping id: several photo updates share one media_group_id. */
media_group_id?: string;
}
export interface TelegramFile {
file_id: string;
file_unique_id: string;
file_size?: number;
file_path?: string;
}
export interface TelegramCallbackQuery {
id: string;
@@ -39,6 +65,10 @@ export interface TelegramBotCommand {
command: string;
description: string;
}
export interface TelegramInputRichMessage {
markdown: string;
skip_entity_detection?: boolean;
}
export interface TelegramClientOptions {
fetch?: typeof fetch;
baseUrl?: string;
@@ -48,9 +78,14 @@ export interface TelegramClientLike {
getMe(): Promise<TelegramUser>;
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>;
sendChatAction(chatId: number, action: string): Promise<boolean>;
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
/** Resolve a file_id to a file_path (downloadable URL suffix). */
getFile(fileId: string): Promise<TelegramFile>;
/** Download file bytes from the Bot API file endpoint (GET, proxy-aware). */
downloadFile(filePath: string): Promise<Uint8Array>;
}
export declare class TelegramClient implements TelegramClientLike {
private readonly token;
@@ -63,7 +98,10 @@ export declare class TelegramClient implements TelegramClientLike {
getMe(): Promise<TelegramUser>;
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>;
sendChatAction(chatId: number, action: string): Promise<boolean>;
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
getFile(fileId: string): Promise<TelegramFile>;
downloadFile(filePath: string): Promise<Uint8Array>;
}
+35 -2
View File
@@ -37,12 +37,17 @@ export class TelegramClient {
redact(message) {
return message.split(this.token).join('***');
}
async call(method, body) {
async call(method, body, timeoutMs = 15_000) {
const url = `${this.baseUrl}/bot${this.token}/${method}`;
try {
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Without a client-side timeout a stalled proxy connection hangs the
// request forever — and the serialized notice queue wedges behind it,
// silently swallowing every later tool/ask notice. TimeoutError is
// caught below and surfaces as a normal (retryable) failure.
signal: AbortSignal.timeout(timeoutMs),
};
if (body !== undefined) {
init.body = JSON.stringify(body);
@@ -70,7 +75,8 @@ export class TelegramClient {
if (offset !== undefined) {
body.offset = offset;
}
return this.call('getUpdates', body);
// Long-poll: client-side abort must outlive the server-side hold.
return this.call('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000);
}
async sendMessage(chatId, text, parseMode, replyMarkup) {
const body = { chat_id: chatId, text };
@@ -82,6 +88,16 @@ export class TelegramClient {
}
return this.call('sendMessage', body);
}
async sendRichMessage(chatId, markdown) {
const body = {
chat_id: chatId,
rich_message: {
markdown,
skip_entity_detection: true,
},
};
return this.call('sendRichMessage', body);
}
async sendChatAction(chatId, action) {
return this.call('sendChatAction', { chat_id: chatId, action });
}
@@ -94,4 +110,21 @@ export class TelegramClient {
async setMyCommands(commands) {
return this.call('setMyCommands', { commands });
}
async getFile(fileId) {
return this.call('getFile', { file_id: fileId });
}
async downloadFile(filePath) {
const url = `${this.baseUrl}/file/bot${this.token}/${filePath}`;
try {
const response = await this.fetchImpl(url);
if (!response.ok) {
throw new Error(`Telegram file download failed: HTTP ${String(response.status)}`);
}
return new Uint8Array(await response.arrayBuffer());
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(this.redact(message));
}
}
}
+42
View File
@@ -14,12 +14,31 @@ export declare const MSG: {
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
readonly STATUS_BOUND: (label: string) => string;
readonly STATUS_BOUND_COLD: (label: string) => string;
readonly COMPACT_USAGE: "用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)";
readonly COMPACT_UNAVAILABLE: "当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。";
readonly COMPACT_STARTED: "已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。";
readonly COMPACT_BUSY: "当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。";
readonly COMPACT_INFLIGHT: "该会话已在进行压缩,请稍候(完成后会报告结果)。";
readonly COMPACT_NOTHING: "压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。";
readonly COMPACT_DONE: (n: number, tokens: number) => string;
readonly COMPACT_CANCELLED: "压缩已取消(会话未改变)。";
readonly COMPACT_CHANGED: "压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。";
readonly COMPACT_SUMMARY_FAILED: "压缩失败:未能生成有效的摘要(会话未改变),请重试。";
readonly COMPACT_COMMIT_FAILED: "压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。";
readonly COMPACT_PERSIST_FAILED: "压缩完成但保存失败。请检查本机存储与权限后重试。";
readonly COMPACT_FAILED: (detail?: string) => string;
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
readonly MEDIA_UNSUPPORTED: "暂不支持该消息类型(支持文本与 png/jpeg/webp/gif 图片;语音/视频/其他文件请在 Web 端处理)。";
readonly IMAGE_MODEL_UNSUPPORTED: "当前模型不支持图片输入,请在 Web 端或 /model 切换到多模态模型后重试。";
readonly IMAGE_FAILED: (detail?: string) => string;
readonly LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。";
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
readonly MODEL_UNROUTABLE: (current: string) => string;
readonly MODEL_EMPTY: (current: string) => string;
readonly MODEL_SET: (selected: string) => string;
readonly RICH_USAGE: "用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)";
readonly RICH_STATE: (mode: "rich" | "html") => string;
readonly RICH_SET: (mode: "rich" | "html") => string;
readonly MODEL_FAILED: (detail?: string) => string;
readonly unknown: (command: string) => string;
};
@@ -41,9 +60,32 @@ export type ParsedCommand = {
} | {
type: 'status';
text: string;
} | {
type: 'compact';
text: string;
} | {
type: 'rich';
text: string;
arg?: string;
} | {
type: 'goal';
text: string;
arg?: string;
} | {
type: 'unbind';
text: string;
} | {
type: 'stop';
text: string;
} | {
type: 'mission';
text: string;
} | {
type: 'new';
text: string;
} | {
type: 'cancel';
text: string;
} | {
type: 'unknown';
command: string;
+66 -1
View File
@@ -9,11 +9,14 @@ export const MSG = {
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
'/last — 查看绑定会话的上次问答(便于续接)',
'/model — 切换当前绑定会话的模型(下一回合生效)',
'/status — 查看当前绑定',
'/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens',
'/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)',
'/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效',
'/unbind — 断开绑定(不关闭本机会话)',
'/help — 显示帮助',
'',
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
'直接发送图片(或带文字说明的图片)也会进入该会话,说明文字会一并作为消息文本。',
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
].join('\n'),
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
@@ -36,7 +39,35 @@ export const MSG = {
STATUS_BOUND_COLD(label) {
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
},
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。',
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
COMPACT_NOTHING: '压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。',
COMPACT_DONE(n, tokens) {
return `压缩完成:合并 ${n} 条历史记录(约 ${tokens} tokens)。上下文已缩短,会话可继续。`;
},
COMPACT_CANCELLED: '压缩已取消(会话未改变)。',
COMPACT_CHANGED: '压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。',
COMPACT_SUMMARY_FAILED: '压缩失败:未能生成有效的摘要(会话未改变),请重试。',
COMPACT_COMMIT_FAILED: '压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。',
COMPACT_PERSIST_FAILED: '压缩完成但保存失败。请检查本机存储与权限后重试。',
COMPACT_FAILED(detail) {
const tip = '压缩失败。';
if (!detail)
return tip;
return `${tip}\n详情:${detail}`;
},
GONE: '绑定的会话已不可用。请重新 /sessions。',
MEDIA_UNSUPPORTED: '暂不支持该消息类型(支持文本与 png/jpeg/webp/gif 图片;语音/视频/其他文件请在 Web 端处理)。',
IMAGE_MODEL_UNSUPPORTED: '当前模型不支持图片输入,请在 Web 端或 /model 切换到多模态模型后重试。',
IMAGE_FAILED(detail) {
const tip = '图片发送失败。';
if (!detail)
return tip;
return `${tip}\n详情:${detail}`;
},
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
MODEL_UNAVAILABLE(detail) {
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。';
@@ -53,6 +84,17 @@ export const MSG = {
MODEL_SET(selected) {
return `已切换模型:${selected}\n下一回合生效。`;
},
RICH_USAGE: '用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)',
RICH_STATE(mode) {
return mode === 'rich'
? '当前渲染模式:富文本(on)。若你的客户端把回复显示为“不支持”,请发 /rich off 切回兼容。'
: '当前渲染模式:HTML 兼容(off)。新版客户端可用 /rich on 开启富文本渲染。';
},
RICH_SET(mode) {
return mode === 'rich'
? '已切换为富文本渲染(本聊天永久生效)。注意:需新版 Telegram 客户端才能显示,旧客户端会显示“不支持”。'
: '已切换为 HTML 兼容渲染(本聊天永久生效),所有客户端版本均可正常显示。';
},
MODEL_FAILED(detail) {
const tip = '切换模型失败。请稍后重试或在 Web 中切换。';
if (!detail)
@@ -83,9 +125,32 @@ export function parseCommand(text) {
return { type: 'model', text };
case '/status':
return { type: 'status', text };
case '/compact':
return { type: 'compact', text };
case '/goal':
case '/目标':
// 目标语句是多词文本,保留完整参数(去掉命令名本身)
return { type: 'goal', text, arg: text.slice(command.length).trim() };
case '/rich':
case '/render':
case '/setting': {
const arg = text.split(/\s+/)[1]?.toLowerCase();
return { type: 'rich', text, arg };
}
case '/unbind':
case '/disconnect':
return { type: 'unbind', text };
case '/stop':
case '/halt':
return { type: 'stop', text };
case '/mission':
case '/todos':
return { type: 'mission', text };
case '/new':
case '/create':
return { type: 'new', text };
case '/cancel':
return { type: 'cancel', text };
default:
return { type: 'unknown', command, text };
}
+25 -1
View File
@@ -12,6 +12,30 @@ function inlineToHtml(text) {
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
return html;
}
/** Convert `> …` line groups into `<blockquote>` (Telegram renders it with the accent-colored left bar). */
function blocksToHtml(text) {
const lines = text.split('\n');
const out = [];
let quote = [];
const flush = () => {
if (quote.length === 0)
return;
out.push(`<blockquote>${inlineToHtml(quote.join('\n'))}</blockquote>`);
quote = [];
};
for (const line of lines) {
if (line.startsWith('> '))
quote.push(line.slice(2));
else if (line === '>')
quote.push('');
else {
flush();
out.push(inlineToHtml(line));
}
}
flush();
return out.join('\n');
}
export function markdownToHtml(text) {
const parts = text.split(/```[\w-]*/);
if (parts.length % 2 === 0)
@@ -23,7 +47,7 @@ export function markdownToHtml(text) {
html += `<pre>${escapeHtml(code)}</pre>`;
}
else {
html += inlineToHtml(parts[i]);
html += blocksToHtml(parts[i]);
}
}
return html;
+4 -1
View File
@@ -1,7 +1,7 @@
import type { Context } from '@deepseek-ai/cordis';
import Schema from '@deepseek-ai/schemastery';
export declare const name = "dsh-telegram-channel";
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
/** agents: followup; apiProxy and userQuestions are optional runtime services. */
export declare const inject: string[];
export interface TelegramChannelConfig {
token?: string;
@@ -9,6 +9,9 @@ export interface TelegramChannelConfig {
allowAllUsers?: boolean;
maxMessageLength?: number;
pollingTimeoutSec?: number;
/** 默认 'rich' = Telegram native Rich Messages(需较新客户端);
* 显式 'html'/'compat' 等其它值 = 兼容模式,所有客户端可显示。 */
rendering?: string;
}
export declare const Config: Schema<TelegramChannelConfig>;
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
+4 -2
View File
@@ -1,14 +1,15 @@
import Schema from '@deepseek-ai/schemastery';
import { TelegramBridge } from './bridge.js';
export const name = 'dsh-telegram-channel';
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
export const inject = ['agents', 'apiProxy'];
/** agents: followup; apiProxy and userQuestions are optional runtime services. */
export const inject = ['agents'];
export const Config = Schema.object({
token: Schema.string().default(''),
allowedUserIds: Schema.array(Schema.number()).default([]),
allowAllUsers: Schema.boolean().default(false),
maxMessageLength: Schema.number().default(4096),
pollingTimeoutSec: Schema.number().default(30),
rendering: Schema.string().default('rich'),
});
function resolveAllowedUserIds(config) {
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
@@ -36,6 +37,7 @@ export function apply(ctx, config) {
allowAllUsers: config.allowAllUsers ?? false,
maxMessageLength: config.maxMessageLength ?? 4096,
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
rendering: config.rendering === 'rich' ? 'rich' : 'html',
});
ctx.effect(() => {
bridge.start();
+3
View File
@@ -0,0 +1,3 @@
export declare const RICH_MESSAGE_MAX_CHARS = 32768;
export declare const RICH_MESSAGE_MAX_BLOCKS = 500;
export declare function splitRichMarkdown(markdown: string): string[];
+224
View File
@@ -0,0 +1,224 @@
export const RICH_MESSAGE_MAX_CHARS = 32768;
export const RICH_MESSAGE_MAX_BLOCKS = 500;
function normalizeTelegramNativeMarkdown(markdown) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
let fence;
let displayMath = false;
const hasClosingDisplayMathDelimiter = (source, index) => {
for (let i = index + 1; i < source.length; i++) {
if (source[i].trim() === '$$')
return true;
if (/^ {0,3}(`{3,}|~{3,})/.test(source[i]))
return false;
}
return false;
};
return lines
.map((line, index) => {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
const inFence = fence !== undefined;
if (!inFence && line.trim() === '$$') {
if (displayMath) {
displayMath = false;
return '```';
}
if (hasClosingDisplayMathDelimiter(lines, index)) {
displayMath = true;
return '```math';
}
}
if (displayMath)
return line;
if (!inFence && fenceMatch) {
const markerText = fenceMatch[1];
fence = {
marker: markerText[0],
length: markerText.length,
};
return line;
}
if (inFence
&& new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) {
fence = undefined;
return line;
}
return line;
})
.join('\n');
}
function countTelegramNativeMarkdownBlocks(block) {
if (/^ {0,3}(`{3,}|~{3,})/.test(block))
return 1;
const lines = block.split('\n').filter((line) => line.trim().length > 0);
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
return Math.max(1, lines.length);
}
return 1;
}
function splitTelegramNativeMarkdownBlocks(markdown) {
const blocks = [];
const current = [];
let fence;
const flush = () => {
if (current.length === 0)
return;
blocks.push(current.join('\n'));
current.length = 0;
};
for (const line of markdown.split('\n')) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && line.trim().length === 0) {
flush();
continue;
}
current.push(line);
if (!fence && fenceMatch) {
const markerText = fenceMatch[1];
fence = { marker: markerText[0], length: markerText.length };
continue;
}
if (fence
&& new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) {
fence = undefined;
}
}
flush();
return blocks;
}
function splitTelegramNativeMarkdownCountedBlocks(block) {
if (countTelegramNativeMarkdownBlocks(block) <= RICH_MESSAGE_MAX_BLOCKS) {
return [block];
}
const chunks = [];
let current = [];
let fence;
for (const line of block.split('\n')) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && current.length >= RICH_MESSAGE_MAX_BLOCKS) {
chunks.push(current.join('\n'));
current = [];
}
current.push(line);
if (!fence && fenceMatch) {
const markerText = fenceMatch[1];
fence = { marker: markerText[0], length: markerText.length };
continue;
}
if (fence
&& new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) {
fence = undefined;
}
}
if (current.length > 0)
chunks.push(current.join('\n'));
return chunks;
}
function findTelegramNativeMarkdownSplitIndex(text, hardLimit = RICH_MESSAGE_MAX_CHARS) {
const paragraphIndex = text.lastIndexOf('\n\n', hardLimit);
if (paragraphIndex > 0)
return paragraphIndex + 2;
const lineIndex = text.lastIndexOf('\n', hardLimit);
if (lineIndex > 0)
return lineIndex + 1;
const spaceIndex = text.lastIndexOf(' ', hardLimit);
if (spaceIndex > 0)
return spaceIndex + 1;
return hardLimit;
}
function splitTelegramNativeMarkdownWrappedContent(content, maxContentLength, wrap) {
const chunks = [];
let remaining = content;
while (remaining.length > maxContentLength) {
const window = remaining.slice(0, maxContentLength + 1);
const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength);
chunks.push(wrap(remaining.slice(0, splitIndex)));
remaining = remaining.slice(splitIndex);
}
if (remaining.length > 0)
chunks.push(wrap(remaining));
return chunks;
}
function splitTelegramNativeMarkdownLongFenceBlock(block) {
const lines = block.split('\n');
const opening = lines[0] ?? '';
const closing = lines[lines.length - 1] ?? '';
const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/);
if (!openingMatch || !closing || lines.length < 2)
return undefined;
const markerText = openingMatch[1] ?? '```';
const marker = markerText[0];
if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) {
return undefined;
}
const maxContentLength = RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2;
if (maxContentLength <= 0)
return undefined;
const content = lines.slice(1, -1).join('\n');
return splitTelegramNativeMarkdownWrappedContent(content, maxContentLength, (chunk) => `${opening}\n${chunk}${chunk.endsWith('\n') ? '' : '\n'}${closing}`);
}
function splitTelegramNativeMarkdownLongWrappedInlineBlock(block) {
const delimiter = ['**', '__', '~~', '`', '*', '_'].find((candidate) => block.startsWith(candidate)
&& block.endsWith(candidate)
&& block.length > candidate.length * 2);
if (!delimiter)
return undefined;
const maxContentLength = RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
if (maxContentLength <= 0)
return undefined;
return splitTelegramNativeMarkdownWrappedContent(block.slice(delimiter.length, -delimiter.length), maxContentLength, (chunk) => `${delimiter}${chunk}${delimiter}`);
}
function splitTelegramNativeMarkdownLongPlainBlock(block) {
const chunks = [];
let remaining = block;
while (remaining.length > RICH_MESSAGE_MAX_CHARS) {
const window = remaining.slice(0, RICH_MESSAGE_MAX_CHARS + 1);
const splitIndex = findTelegramNativeMarkdownSplitIndex(window);
chunks.push(remaining.slice(0, splitIndex).trimEnd());
remaining = remaining.slice(splitIndex).trimStart();
}
if (remaining.length > 0)
chunks.push(remaining);
return chunks;
}
function splitTelegramNativeMarkdownLongBlock(block) {
return (splitTelegramNativeMarkdownLongFenceBlock(block)
?? splitTelegramNativeMarkdownLongWrappedInlineBlock(block)
?? splitTelegramNativeMarkdownLongPlainBlock(block));
}
export function splitRichMarkdown(markdown) {
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
if (normalizedMarkdown.length <= RICH_MESSAGE_MAX_CHARS
&& countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= RICH_MESSAGE_MAX_BLOCKS) {
return [normalizedMarkdown];
}
const chunks = [];
let current = '';
let currentBlockCount = 0;
for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
const blockCount = countTelegramNativeMarkdownBlocks(block);
const candidate = current ? `${current}\n\n${block}` : block;
const exceedsChars = candidate.length > RICH_MESSAGE_MAX_CHARS;
const exceedsBlocks = currentBlockCount + blockCount > RICH_MESSAGE_MAX_BLOCKS;
if (!exceedsChars && !exceedsBlocks) {
current = candidate;
currentBlockCount += blockCount;
continue;
}
if (current)
chunks.push(current.trimEnd());
if (block.length <= RICH_MESSAGE_MAX_CHARS
&& blockCount <= RICH_MESSAGE_MAX_BLOCKS) {
current = block;
currentBlockCount = blockCount;
continue;
}
chunks.push(...splitTelegramNativeMarkdownLongBlock(block));
current = '';
currentBlockCount = 0;
}
}
if (current)
chunks.push(current.trimEnd());
return chunks;
}
+71
View File
@@ -0,0 +1,71 @@
/**
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
*
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
*/
export interface StatusWorkspace {
/** 工作区名(basename 或 Web 工作区标题)。 */
title?: string;
/** 完整目录/路径。 */
path?: string;
}
export interface StatusModel {
provider?: string;
model?: string;
/** reasoning effort id(如 off/low/high/max);缺省表示 provider 默认行为。 */
reasoningEffort?: string;
}
export interface StatusRuntime {
turns?: number;
steps?: number;
llmMs?: number;
toolMs?: number;
ttftMs?: number;
ttftSteps?: number;
decodeMs?: number;
decodeTokens?: number;
uncachedInputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
outputTokens?: number;
contextUsed?: number;
contextWindow?: number;
}
export interface StatusInfo {
/** 绑定时保存的会话展示名。 */
label: string;
/** 完整 SessionId 字符串。 */
sessionId: string;
workspace?: StatusWorkspace;
model?: StatusModel;
runtime?: StatusRuntime;
/** false = 会话不在内存中(冷),仅能显示绑定与工作区信息。 */
live?: boolean;
}
export declare function effortLabel(effort?: string): string | undefined;
/**
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
*/
export declare function formatTokens(n: number): string;
/**
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
*/
export declare function formatDuration(ms: number): string;
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
export declare function formatTokensPerSecond(tps: number): string;
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
export declare function billedInputTokens(runtime: StatusRuntime): number;
/**
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
*/
export declare function cacheHitPercent(runtime: StatusRuntime): string | null;
/**
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
* 一组内同 Web 底部:无数据的子项整组省略。
*/
export declare function formatStatusText(info: StatusInfo): string;
+179
View File
@@ -0,0 +1,179 @@
/**
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
*
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
*/
const EFFORT_NAMES = {
off: '关闭(off)',
low: '低(low)',
medium: '中(medium)',
high: '高(high)',
max: '最高(max)',
};
export function effortLabel(effort) {
if (!effort)
return undefined;
return EFFORT_NAMES[effort] ?? String(effort);
}
/**
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
*/
export function formatTokens(n) {
const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10);
if (n < 1_000)
return String(n);
if (n < 1_000_000)
return `${scaled(n / 1_000)}K`;
return `${scaled(n / 1_000_000)}M`;
}
/**
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
*/
export function formatDuration(ms) {
const s = ms / 1_000;
if (s < 60)
return `${Math.round(s * 10) / 10}s`;
const whole = Math.round(s);
return `${Math.floor(whole / 60)}m${whole % 60}s`;
}
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
export function formatTokensPerSecond(tps) {
const clamped = Math.max(0, tps);
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
}
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
export function billedInputTokens(runtime) {
return (runtime.uncachedInputTokens ?? 0)
+ (runtime.cacheReadTokens ?? 0)
+ (runtime.cacheWriteTokens ?? 0);
}
/** 向下取整到 <100 的整数百分比(前端 roundedIntegerPercent 同算法)。 */
function roundedIntegerPercent(cacheReadTokens, denominator) {
const denominatorQuotient = Math.floor(denominator / 200);
const denominatorRemainder = denominator % 200;
let lower = 0;
let upper = 100;
while (lower < upper) {
const candidate = Math.floor((lower + upper + 1) / 2);
const factor = candidate * 2 - 1;
const threshold = factor * denominatorQuotient
+ Math.ceil(factor * denominatorRemainder / 200);
if (cacheReadTokens >= threshold) {
lower = candidate;
}
else {
upper = candidate - 1;
}
}
return lower;
}
/**
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
*/
export function cacheHitPercent(runtime) {
const denominator = billedInputTokens(runtime);
if (denominator === 0)
return null;
const missed = (runtime.uncachedInputTokens ?? 0) + (runtime.cacheWriteTokens ?? 0);
const read = runtime.cacheReadTokens ?? 0;
if (missed === 0)
return '100';
const integerPercent = roundedIntegerPercent(read, denominator);
if (integerPercent < 100)
return String(integerPercent);
// 命中率贴近 100% 时,逐位提高精度直到能区分出 <100 的小数。
let decimalPlaces = 1;
let scaledDoubleGap = missed * 200;
const denominatorTens = Math.floor(denominator / 10);
while (scaledDoubleGap <= denominatorTens) {
scaledDoubleGap *= 10;
decimalPlaces += 1;
}
const denominatorOnes = denominator % 10;
let roundedLoss = 5;
for (let loss = 1; loss < 5; loss += 1) {
const factor = loss * 2 + 1;
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10);
if (scaledDoubleGap <= threshold) {
roundedLoss = loss;
break;
}
}
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`;
}
/**
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
* 一组内同 Web 底部:无数据的子项整组省略。
*/
export function formatStatusText(info) {
const lines = ['会话状态'];
lines.push(`会话:${info.label || info.sessionId}`);
lines.push(`会话 ID:${info.sessionId}`);
const ws = info.workspace;
if (ws?.title || ws?.path) {
const primary = ws.title && ws.path
? `${ws.title}(${ws.path})`
: (ws.title ?? ws.path ?? '');
lines.push(`工作区:${primary}`);
}
const model = info.model;
if (model?.provider && model?.model) {
lines.push(`模型:${model.provider}/${model.model}`);
lines.push(`思考强度:${effortLabel(model.reasoningEffort) ?? '默认(未指定)'}`);
}
const runtime = info.runtime;
if (runtime !== undefined) {
const contextUsed = runtime.contextUsed;
const window = runtime.contextWindow;
if (contextUsed !== undefined && window !== undefined && window > 0) {
const percent = Math.min(100, Math.round(contextUsed / window * 100));
lines.push(`上下文:${formatTokens(contextUsed)} / ${formatTokens(window)} tokens(${percent}%)`);
}
else if (contextUsed !== undefined) {
lines.push(`上下文:${formatTokens(contextUsed)} tokens(窗口大小未知)`);
}
const stats = [];
if ((runtime.steps ?? 0) > 0) {
stats.push(`${runtime.turns ?? 0} 轮 · ${runtime.steps ?? 0} 步`);
const durations = [];
if ((runtime.llmMs ?? 0) > 0)
durations.push(`LLM ${formatDuration(runtime.llmMs)}`);
if ((runtime.toolMs ?? 0) > 0)
durations.push(`工具调用 ${formatDuration(runtime.toolMs)}`);
if (durations.length > 0)
stats.push(durations.join(' · '));
const speeds = [];
if ((runtime.ttftSteps ?? 0) > 0) {
speeds.push(`首 token 平均 ${formatDuration((runtime.ttftMs ?? 0) / runtime.ttftSteps)}`);
}
if ((runtime.decodeMs ?? 0) > 0) {
speeds.push(`${formatTokensPerSecond((runtime.decodeTokens ?? 0) / (runtime.decodeMs / 1_000))} tok/s`);
}
if (speeds.length > 0)
stats.push(speeds.join(' · '));
}
if (billedInputTokens(runtime) > 0 || (runtime.outputTokens ?? 0) > 0) {
const hit = cacheHitPercent(runtime);
const billing = [
hit === null ? '' : `缓存命中 ${hit}%`,
`输入 ${formatTokens(billedInputTokens(runtime))} tok · 输出 ${formatTokens(runtime.outputTokens ?? 0)} tok`,
].filter(Boolean).join(' · ');
stats.push(billing);
}
if (stats.length > 0) {
lines.push('');
lines.push('统计(与 Web 底部一致)');
lines.push(...stats.map((s) => ` ${s}`));
}
}
if (info.live === false) {
lines.push('');
lines.push('(会话当前不在内存中;直接发消息会自动 resume。)');
}
return lines.join('\n');
}
+7
View File
@@ -6,6 +6,13 @@ export type ApiProxySessions = {
history?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
models?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
selectModel?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
/** Create a session (used by /new; payload: workspaceId | cwd). */
create?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
/**
* Prompt dispatch (Web 端同通道):文本 + 图片字节(base64)经宿主附件服务持久化后
* 进入会话;含图片时会校验当前模型是否支持图像输入。
*/
prompt?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
}
export type ApiProxyLike = {
+1529 -18
View File
File diff suppressed because it is too large Load Diff
+76 -2
View File
@@ -14,12 +14,41 @@ export interface TelegramChat {
username?: string
}
export interface TelegramPhotoSize {
file_id: string
file_unique_id: string
width: number
height: number
file_size?: number
}
export interface TelegramDocument {
file_id: string
file_unique_id: string
file_name?: string
mime_type?: string
file_size?: number
}
export interface TelegramMessage {
message_id: number
date: number
chat: TelegramChat
from?: TelegramUser
text?: string
/** Media caption (photos/documents); TG puts it here, not in `text`. */
caption?: string
photo?: TelegramPhotoSize[]
document?: TelegramDocument
/** Album grouping id: several photo updates share one media_group_id. */
media_group_id?: string
}
export interface TelegramFile {
file_id: string
file_unique_id: string
file_size?: number
file_path?: string
}
export interface TelegramCallbackQuery {
@@ -49,6 +78,11 @@ export interface TelegramBotCommand {
description: string
}
export interface TelegramInputRichMessage {
markdown: string
skip_entity_detection?: boolean
}
export interface TelegramClientOptions {
fetch?: typeof fetch
baseUrl?: string
@@ -64,9 +98,14 @@ export interface TelegramClientLike {
parseMode?: string,
replyMarkup?: InlineKeyboardMarkup,
): Promise<TelegramMessage>
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>
sendChatAction(chatId: number, action: string): Promise<boolean>
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
/** Resolve a file_id to a file_path (downloadable URL suffix). */
getFile(fileId: string): Promise<TelegramFile>
/** Download file bytes from the Bot API file endpoint (GET, proxy-aware). */
downloadFile(filePath: string): Promise<Uint8Array>
}
function resolveProxyUrl(): string | undefined {
@@ -114,12 +153,17 @@ export class TelegramClient implements TelegramClientLike {
return message.split(this.token).join('***')
}
private async call<T>(method: string, body?: Record<string, unknown>): Promise<T> {
private async call<T>(method: string, body?: Record<string, unknown>, timeoutMs = 15_000): Promise<T> {
const url = `${this.baseUrl}/bot${this.token}/${method}`
try {
const init: RequestInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Without a client-side timeout a stalled proxy connection hangs the
// request forever — and the serialized notice queue wedges behind it,
// silently swallowing every later tool/ask notice. TimeoutError is
// caught below and surfaces as a normal (retryable) failure.
signal: AbortSignal.timeout(timeoutMs),
}
if (body !== undefined) {
init.body = JSON.stringify(body)
@@ -148,7 +192,8 @@ export class TelegramClient implements TelegramClientLike {
if (offset !== undefined) {
body.offset = offset
}
return this.call<TelegramUpdate[]>('getUpdates', body)
// Long-poll: client-side abort must outlive the server-side hold.
return this.call<TelegramUpdate[]>('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000)
}
async sendMessage(
@@ -167,6 +212,17 @@ export class TelegramClient implements TelegramClientLike {
return this.call<TelegramMessage>('sendMessage', body)
}
async sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage> {
const body: Record<string, unknown> = {
chat_id: chatId,
rich_message: {
markdown,
skip_entity_detection: true,
},
}
return this.call<TelegramMessage>('sendRichMessage', body)
}
async sendChatAction(chatId: number, action: string): Promise<boolean> {
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
}
@@ -180,4 +236,22 @@ export class TelegramClient implements TelegramClientLike {
async setMyCommands(commands: TelegramBotCommand[]): Promise<boolean> {
return this.call<boolean>('setMyCommands', { commands })
}
async getFile(fileId: string): Promise<TelegramFile> {
return this.call<TelegramFile>('getFile', { file_id: fileId })
}
async downloadFile(filePath: string): Promise<Uint8Array> {
const url = `${this.baseUrl}/file/bot${this.token}/${filePath}`
try {
const response = await this.fetchImpl(url)
if (!response.ok) {
throw new Error(`Telegram file download failed: HTTP ${String(response.status)}`)
}
return new Uint8Array(await response.arrayBuffer())
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(this.redact(message))
}
}
}
+71 -1
View File
@@ -9,11 +9,14 @@ export const MSG = {
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
'/last — 查看绑定会话的上次问答(便于续接)',
'/model — 切换当前绑定会话的模型(下一回合生效)',
'/status — 查看当前绑定',
'/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens',
'/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)',
'/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效',
'/unbind — 断开绑定(不关闭本机会话)',
'/help — 显示帮助',
'',
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
'直接发送图片(或带文字说明的图片)也会进入该会话,说明文字会一并作为消息文本。',
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
].join('\n'),
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
@@ -36,7 +39,33 @@ export const MSG = {
STATUS_BOUND_COLD(label: string): string {
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
},
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。',
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
COMPACT_NOTHING: '压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。',
COMPACT_DONE(n: number, tokens: number): string {
return `压缩完成:合并 ${n} 条历史记录(约 ${tokens} tokens)。上下文已缩短,会话可继续。`
},
COMPACT_CANCELLED: '压缩已取消(会话未改变)。',
COMPACT_CHANGED: '压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。',
COMPACT_SUMMARY_FAILED: '压缩失败:未能生成有效的摘要(会话未改变),请重试。',
COMPACT_COMMIT_FAILED: '压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。',
COMPACT_PERSIST_FAILED: '压缩完成但保存失败。请检查本机存储与权限后重试。',
COMPACT_FAILED(detail?: string): string {
const tip = '压缩失败。'
if (!detail) return tip
return `${tip}\n详情:${detail}`
},
GONE: '绑定的会话已不可用。请重新 /sessions。',
MEDIA_UNSUPPORTED: '暂不支持该消息类型(支持文本与 png/jpeg/webp/gif 图片;语音/视频/其他文件请在 Web 端处理)。',
IMAGE_MODEL_UNSUPPORTED: '当前模型不支持图片输入,请在 Web 端或 /model 切换到多模态模型后重试。',
IMAGE_FAILED(detail?: string): string {
const tip = '图片发送失败。'
if (!detail) return tip
return `${tip}\n详情:${detail}`
},
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
MODEL_UNAVAILABLE(detail?: string): string {
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。'
@@ -52,6 +81,17 @@ export const MSG = {
MODEL_SET(selected: string): string {
return `已切换模型:${selected}\n下一回合生效。`
},
RICH_USAGE: '用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)',
RICH_STATE(mode: 'rich' | 'html'): string {
return mode === 'rich'
? '当前渲染模式:富文本(on)。若你的客户端把回复显示为“不支持”,请发 /rich off 切回兼容。'
: '当前渲染模式:HTML 兼容(off)。新版客户端可用 /rich on 开启富文本渲染。'
},
RICH_SET(mode: 'rich' | 'html'): string {
return mode === 'rich'
? '已切换为富文本渲染(本聊天永久生效)。注意:需新版 Telegram 客户端才能显示,旧客户端会显示“不支持”。'
: '已切换为 HTML 兼容渲染(本聊天永久生效),所有客户端版本均可正常显示。'
},
MODEL_FAILED(detail?: string): string {
const tip = '切换模型失败。请稍后重试或在 Web 中切换。'
if (!detail) return tip
@@ -69,7 +109,14 @@ export type ParsedCommand =
| { type: 'last'; text: string }
| { type: 'model'; text: string }
| { type: 'status'; text: string }
| { type: 'compact'; text: string }
| { type: 'rich'; text: string; arg?: string }
| { type: 'goal'; text: string; arg?: string }
| { type: 'unbind'; text: string }
| { type: 'stop'; text: string }
| { type: 'mission'; text: string }
| { type: 'new'; text: string }
| { type: 'cancel'; text: string }
| { type: 'unknown'; command: string; text: string }
| { type: 'plain'; text: string }
@@ -92,9 +139,32 @@ export function parseCommand(text: string): ParsedCommand {
return { type: 'model', text }
case '/status':
return { type: 'status', text }
case '/compact':
return { type: 'compact', text }
case '/goal':
case '/目标':
// 目标语句是多词文本,保留完整参数(去掉命令名本身)
return { type: 'goal', text, arg: text.slice(command.length).trim() }
case '/rich':
case '/render':
case '/setting': {
const arg = text.split(/\s+/)[1]?.toLowerCase()
return { type: 'rich', text, arg }
}
case '/unbind':
case '/disconnect':
return { type: 'unbind', text }
case '/stop':
case '/halt':
return { type: 'stop', text }
case '/mission':
case '/todos':
return { type: 'mission', text }
case '/new':
case '/create':
return { type: 'new', text }
case '/cancel':
return { type: 'cancel', text }
default:
return { type: 'unknown', command, text }
}
+23 -1
View File
@@ -14,6 +14,28 @@ function inlineToHtml(text: string): string {
return html
}
/** Convert `> …` line groups into `<blockquote>` (Telegram renders it with the accent-colored left bar). */
function blocksToHtml(text: string): string {
const lines = text.split('\n')
const out: string[] = []
let quote: string[] = []
const flush = (): void => {
if (quote.length === 0) return
out.push(`<blockquote>${inlineToHtml(quote.join('\n'))}</blockquote>`)
quote = []
}
for (const line of lines) {
if (line.startsWith('> ')) quote.push(line.slice(2))
else if (line === '>') quote.push('')
else {
flush()
out.push(inlineToHtml(line))
}
}
flush()
return out.join('\n')
}
export function markdownToHtml(text: string): string {
const parts = text.split(/```[\w-]*/)
if (parts.length % 2 === 0) return inlineToHtml(text)
@@ -23,7 +45,7 @@ export function markdownToHtml(text: string): string {
const code = parts[i]!.replace(/^\n/, '').replace(/\n$/, '')
html += `<pre>${escapeHtml(code)}</pre>`
} else {
html += inlineToHtml(parts[i]!)
html += blocksToHtml(parts[i]!)
}
}
return html
+7 -2
View File
@@ -3,8 +3,8 @@ import Schema from '@deepseek-ai/schemastery'
import { TelegramBridge } from './bridge.js'
export const name = 'dsh-telegram-channel'
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
export const inject = ['agents', 'apiProxy']
/** agents: followup; apiProxy and userQuestions are optional runtime services. */
export const inject = ['agents']
export interface TelegramChannelConfig {
token?: string
@@ -12,6 +12,9 @@ export interface TelegramChannelConfig {
allowAllUsers?: boolean
maxMessageLength?: number
pollingTimeoutSec?: number
/** 默认 'rich' = Telegram native Rich Messages(需较新客户端);
* 显式 'html'/'compat' 等其它值 = 兼容模式,所有客户端可显示。 */
rendering?: string
}
export const Config: Schema<TelegramChannelConfig> = Schema.object({
@@ -20,6 +23,7 @@ export const Config: Schema<TelegramChannelConfig> = Schema.object({
allowAllUsers: Schema.boolean().default(false),
maxMessageLength: Schema.number().default(4096),
pollingTimeoutSec: Schema.number().default(30),
rendering: Schema.string().default('rich'),
})
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
@@ -51,6 +55,7 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void {
allowAllUsers: config.allowAllUsers ?? false,
maxMessageLength: config.maxMessageLength ?? 4096,
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
rendering: config.rendering === 'rich' ? 'rich' : 'html',
})
ctx.effect(() => {
bridge.start()
+288
View File
@@ -0,0 +1,288 @@
export const RICH_MESSAGE_MAX_CHARS = 32768
export const RICH_MESSAGE_MAX_BLOCKS = 500
function normalizeTelegramNativeMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
let fence: { marker: '`' | '~'; length: number } | undefined
let displayMath = false
const hasClosingDisplayMathDelimiter = (source: string[], index: number): boolean => {
for (let i = index + 1; i < source.length; i++) {
if (source[i]!.trim() === '$$') return true
if (/^ {0,3}(`{3,}|~{3,})/.test(source[i]!)) return false
}
return false
}
return lines
.map((line, index) => {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
const inFence = fence !== undefined
if (!inFence && line.trim() === '$$') {
if (displayMath) {
displayMath = false
return '```'
}
if (hasClosingDisplayMathDelimiter(lines, index)) {
displayMath = true
return '```math'
}
}
if (displayMath) return line
if (!inFence && fenceMatch) {
const markerText = fenceMatch[1]!
fence = {
marker: markerText[0] as '`' | '~',
length: markerText.length,
}
return line
}
if (
inFence
&& new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line)
) {
fence = undefined
return line
}
return line
})
.join('\n')
}
function countTelegramNativeMarkdownBlocks(block: string): number {
if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1
const lines = block.split('\n').filter((line) => line.trim().length > 0)
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
return Math.max(1, lines.length)
}
return 1
}
function splitTelegramNativeMarkdownBlocks(markdown: string): string[] {
const blocks: string[] = []
const current: string[] = []
let fence: { marker: '`' | '~'; length: number } | undefined
const flush = (): void => {
if (current.length === 0) return
blocks.push(current.join('\n'))
current.length = 0
}
for (const line of markdown.split('\n')) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
if (!fence && line.trim().length === 0) {
flush()
continue
}
current.push(line)
if (!fence && fenceMatch) {
const markerText = fenceMatch[1]!
fence = { marker: markerText[0] as '`' | '~', length: markerText.length }
continue
}
if (
fence
&& new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line)
) {
fence = undefined
}
}
flush()
return blocks
}
function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
if (countTelegramNativeMarkdownBlocks(block) <= RICH_MESSAGE_MAX_BLOCKS) {
return [block]
}
const chunks: string[] = []
let current: string[] = []
let fence: { marker: '`' | '~'; length: number } | undefined
for (const line of block.split('\n')) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
if (!fence && current.length >= RICH_MESSAGE_MAX_BLOCKS) {
chunks.push(current.join('\n'))
current = []
}
current.push(line)
if (!fence && fenceMatch) {
const markerText = fenceMatch[1]!
fence = { marker: markerText[0] as '`' | '~', length: markerText.length }
continue
}
if (
fence
&& new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line)
) {
fence = undefined
}
}
if (current.length > 0) chunks.push(current.join('\n'))
return chunks
}
function findTelegramNativeMarkdownSplitIndex(
text: string,
hardLimit = RICH_MESSAGE_MAX_CHARS,
): number {
const paragraphIndex = text.lastIndexOf('\n\n', hardLimit)
if (paragraphIndex > 0) return paragraphIndex + 2
const lineIndex = text.lastIndexOf('\n', hardLimit)
if (lineIndex > 0) return lineIndex + 1
const spaceIndex = text.lastIndexOf(' ', hardLimit)
if (spaceIndex > 0) return spaceIndex + 1
return hardLimit
}
function splitTelegramNativeMarkdownWrappedContent(
content: string,
maxContentLength: number,
wrap: (chunk: string) => string,
): string[] {
const chunks: string[] = []
let remaining = content
while (remaining.length > maxContentLength) {
const window = remaining.slice(0, maxContentLength + 1)
const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength)
chunks.push(wrap(remaining.slice(0, splitIndex)))
remaining = remaining.slice(splitIndex)
}
if (remaining.length > 0) chunks.push(wrap(remaining))
return chunks
}
function splitTelegramNativeMarkdownLongFenceBlock(block: string): string[] | undefined {
const lines = block.split('\n')
const opening = lines[0] ?? ''
const closing = lines[lines.length - 1] ?? ''
const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/)
if (!openingMatch || !closing || lines.length < 2) return undefined
const markerText = openingMatch[1] ?? '```'
const marker = markerText[0] as '`' | '~'
if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) {
return undefined
}
const maxContentLength = RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2
if (maxContentLength <= 0) return undefined
const content = lines.slice(1, -1).join('\n')
return splitTelegramNativeMarkdownWrappedContent(
content,
maxContentLength,
(chunk) => `${opening}\n${chunk}${chunk.endsWith('\n') ? '' : '\n'}${closing}`,
)
}
function splitTelegramNativeMarkdownLongWrappedInlineBlock(block: string): string[] | undefined {
const delimiter = ['**', '__', '~~', '`', '*', '_'].find(
(candidate) =>
block.startsWith(candidate)
&& block.endsWith(candidate)
&& block.length > candidate.length * 2,
)
if (!delimiter) return undefined
const maxContentLength = RICH_MESSAGE_MAX_CHARS - delimiter.length * 2
if (maxContentLength <= 0) return undefined
return splitTelegramNativeMarkdownWrappedContent(
block.slice(delimiter.length, -delimiter.length),
maxContentLength,
(chunk) => `${delimiter}${chunk}${delimiter}`,
)
}
function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
const chunks: string[] = []
let remaining = block
while (remaining.length > RICH_MESSAGE_MAX_CHARS) {
const window = remaining.slice(0, RICH_MESSAGE_MAX_CHARS + 1)
const splitIndex = findTelegramNativeMarkdownSplitIndex(window)
chunks.push(remaining.slice(0, splitIndex).trimEnd())
remaining = remaining.slice(splitIndex).trimStart()
}
if (remaining.length > 0) chunks.push(remaining)
return chunks
}
function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
return (
splitTelegramNativeMarkdownLongFenceBlock(block)
?? splitTelegramNativeMarkdownLongWrappedInlineBlock(block)
?? splitTelegramNativeMarkdownLongPlainBlock(block)
)
}
export function splitRichMarkdown(markdown: string): string[] {
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown)
if (
normalizedMarkdown.length <= RICH_MESSAGE_MAX_CHARS
&& countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= RICH_MESSAGE_MAX_BLOCKS
) {
return [normalizedMarkdown]
}
const chunks: string[] = []
let current = ''
let currentBlockCount = 0
for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
const blockCount = countTelegramNativeMarkdownBlocks(block)
const candidate = current ? `${current}\n\n${block}` : block
const exceedsChars = candidate.length > RICH_MESSAGE_MAX_CHARS
const exceedsBlocks = currentBlockCount + blockCount > RICH_MESSAGE_MAX_BLOCKS
if (!exceedsChars && !exceedsBlocks) {
current = candidate
currentBlockCount += blockCount
continue
}
if (current) chunks.push(current.trimEnd())
if (
block.length <= RICH_MESSAGE_MAX_CHARS
&& blockCount <= RICH_MESSAGE_MAX_BLOCKS
) {
current = block
currentBlockCount = blockCount
continue
}
chunks.push(...splitTelegramNativeMarkdownLongBlock(block))
current = ''
currentBlockCount = 0
}
}
if (current) chunks.push(current.trimEnd())
return chunks
}
+230
View File
@@ -0,0 +1,230 @@
/**
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
*
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
*/
export interface StatusWorkspace {
/** 工作区名(basename 或 Web 工作区标题)。 */
title?: string
/** 完整目录/路径。 */
path?: string
}
export interface StatusModel {
provider?: string
model?: string
/** reasoning effort id(如 off/low/high/max);缺省表示 provider 默认行为。 */
reasoningEffort?: string
}
export interface StatusRuntime {
// sessionStats 投影(全量日志)
turns?: number
steps?: number
llmMs?: number
toolMs?: number
ttftMs?: number
ttftSteps?: number
decodeMs?: number
decodeTokens?: number
// tokenUsage 投影(累计计费桶)
uncachedInputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
outputTokens?: number
// contextPressure 投影(近次请求)
contextUsed?: number
contextWindow?: number
}
export interface StatusInfo {
/** 绑定时保存的会话展示名。 */
label: string
/** 完整 SessionId 字符串。 */
sessionId: string
workspace?: StatusWorkspace
model?: StatusModel
runtime?: StatusRuntime
/** false = 会话不在内存中(冷),仅能显示绑定与工作区信息。 */
live?: boolean
}
const EFFORT_NAMES: Record<string, string> = {
off: '关闭(off)',
low: '低(low)',
medium: '中(medium)',
high: '高(high)',
max: '最高(max)',
}
export function effortLabel(effort?: string): string | undefined {
if (!effort) return undefined
return EFFORT_NAMES[effort] ?? String(effort)
}
/**
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
*/
export function formatTokens(n: number): string {
const scaled = (v: number): string =>
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
if (n < 1_000) return String(n)
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
return `${scaled(n / 1_000_000)}M`
}
/**
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
*/
export function formatDuration(ms: number): string {
const s = ms / 1_000
if (s < 60) return `${Math.round(s * 10) / 10}s`
const whole = Math.round(s)
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
export function formatTokensPerSecond(tps: number): string {
const clamped = Math.max(0, tps)
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
}
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
export function billedInputTokens(runtime: StatusRuntime): number {
return (runtime.uncachedInputTokens ?? 0)
+ (runtime.cacheReadTokens ?? 0)
+ (runtime.cacheWriteTokens ?? 0)
}
/** 向下取整到 <100 的整数百分比(前端 roundedIntegerPercent 同算法)。 */
function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
const denominatorQuotient = Math.floor(denominator / 200)
const denominatorRemainder = denominator % 200
let lower = 0
let upper = 100
while (lower < upper) {
const candidate = Math.floor((lower + upper + 1) / 2)
const factor = candidate * 2 - 1
const threshold = factor * denominatorQuotient
+ Math.ceil(factor * denominatorRemainder / 200)
if (cacheReadTokens >= threshold) {
lower = candidate
} else {
upper = candidate - 1
}
}
return lower
}
/**
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
*/
export function cacheHitPercent(runtime: StatusRuntime): string | null {
const denominator = billedInputTokens(runtime)
if (denominator === 0) return null
const missed = (runtime.uncachedInputTokens ?? 0) + (runtime.cacheWriteTokens ?? 0)
const read = runtime.cacheReadTokens ?? 0
if (missed === 0) return '100'
const integerPercent = roundedIntegerPercent(read, denominator)
if (integerPercent < 100) return String(integerPercent)
// 命中率贴近 100% 时,逐位提高精度直到能区分出 <100 的小数。
let decimalPlaces = 1
let scaledDoubleGap = missed * 200
const denominatorTens = Math.floor(denominator / 10)
while (scaledDoubleGap <= denominatorTens) {
scaledDoubleGap *= 10
decimalPlaces += 1
}
const denominatorOnes = denominator % 10
let roundedLoss = 5
for (let loss = 1; loss < 5; loss += 1) {
const factor = loss * 2 + 1
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
if (scaledDoubleGap <= threshold) {
roundedLoss = loss
break
}
}
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
}
/**
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
* 一组内同 Web 底部:无数据的子项整组省略。
*/
export function formatStatusText(info: StatusInfo): string {
const lines: string[] = ['会话状态']
lines.push(`会话:${info.label || info.sessionId}`)
lines.push(`会话 ID:${info.sessionId}`)
const ws = info.workspace
if (ws?.title || ws?.path) {
const primary = ws.title && ws.path
? `${ws.title}(${ws.path})`
: (ws.title ?? ws.path ?? '')
lines.push(`工作区:${primary}`)
}
const model = info.model
if (model?.provider && model?.model) {
lines.push(`模型:${model.provider}/${model.model}`)
lines.push(`思考强度:${effortLabel(model.reasoningEffort) ?? '默认(未指定)'}`)
}
const runtime = info.runtime
if (runtime !== undefined) {
const contextUsed = runtime.contextUsed
const window = runtime.contextWindow
if (contextUsed !== undefined && window !== undefined && window > 0) {
const percent = Math.min(100, Math.round(contextUsed / window * 100))
lines.push(`上下文:${formatTokens(contextUsed)} / ${formatTokens(window)} tokens(${percent}%)`)
} else if (contextUsed !== undefined) {
lines.push(`上下文:${formatTokens(contextUsed)} tokens(窗口大小未知)`)
}
const stats: string[] = []
if ((runtime.steps ?? 0) > 0) {
stats.push(`${runtime.turns ?? 0} 轮 · ${runtime.steps ?? 0} 步`)
const durations: string[] = []
if ((runtime.llmMs ?? 0) > 0) durations.push(`LLM ${formatDuration(runtime.llmMs!)}`)
if ((runtime.toolMs ?? 0) > 0) durations.push(`工具调用 ${formatDuration(runtime.toolMs!)}`)
if (durations.length > 0) stats.push(durations.join(' · '))
const speeds: string[] = []
if ((runtime.ttftSteps ?? 0) > 0) {
speeds.push(`首 token 平均 ${formatDuration((runtime.ttftMs ?? 0) / runtime.ttftSteps!)}`)
}
if ((runtime.decodeMs ?? 0) > 0) {
speeds.push(`${formatTokensPerSecond((runtime.decodeTokens ?? 0) / (runtime.decodeMs! / 1_000))} tok/s`)
}
if (speeds.length > 0) stats.push(speeds.join(' · '))
}
if (billedInputTokens(runtime) > 0 || (runtime.outputTokens ?? 0) > 0) {
const hit = cacheHitPercent(runtime)
const billing = [
hit === null ? '' : `缓存命中 ${hit}%`,
`输入 ${formatTokens(billedInputTokens(runtime))} tok · 输出 ${formatTokens(runtime.outputTokens ?? 0)} tok`,
].filter(Boolean).join(' · ')
stats.push(billing)
}
if (stats.length > 0) {
lines.push('')
lines.push('统计(与 Web 底部一致)')
lines.push(...stats.map((s) => ` ${s}`))
}
}
if (info.live === false) {
lines.push('')
lines.push('(会话当前不在内存中;直接发消息会自动 resume。)')
}
return lines.join('\n')
}
+5 -1
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { apply } from '../src/index.ts'
import { apply, inject } from '../src/index.ts'
test('optional host services are not hard Cordis dependencies', () => {
assert.deepEqual(inject, ['agents'])
})
test('apply without token does not throw and does not start effect', () => {
const effects: string[] = []
+671
View File
@@ -1,11 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { rmSync } from 'node:fs'
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { TelegramBridge } from '../src/bridge.ts'
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
// 防呆(2026-09-14 事故):binding 落盘路径 = DSH_HOME/telegram-channel-bindings.json。
// 少数用例直接构造 bridge 并 bind,未设 DSH_TELEGRAM_BINDINGS_FILE,就把 fixture
// (chatId 10 / live-img-dl)写进了真实文件、覆盖掉用户真实绑定。
// 这里把 DSH_HOME 指向临时目录兜底:显式 override 的用例仍走自己的临时文件,
// 漏设的用例只会写进临时 HOME。
process.env.DSH_HOME = '/tmp/dsh-tg-test-home'
type SentMessage = {
chatId: number
text: string
@@ -27,6 +35,8 @@ function fakeClient(
sendChatAction: async () => true,
answerCallbackQuery: async () => true,
setMyCommands: async () => true,
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `path/${fileId}` }),
downloadFile: async () => new Uint8Array([1, 2, 3]),
...overrides,
}
}
@@ -298,6 +308,10 @@ test('callback bind then plain text followups live agent; mirror assistant to ch
data: { message: { content: [{ type: 'text', text: 'reply from agent' }] } },
},
)
// Delivery is intentionally resilient now (per-chunk retry + rich→HTML
// fallback), which lengthens the async microtask chain past this listener's
// void return — flush the event loop before asserting.
await new Promise((r) => setTimeout(r, 0))
assert.ok(sent.some((m) => m.text.includes('reply from agent') || m.text.includes('reply')))
await bridge.stop()
@@ -645,3 +659,660 @@ test('/unbind clears binding without needing create/dispose', async () => {
assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
assert.equal(followups.length, 0)
})
// ── /rich: per-chat rendering mode (rich messages need a recent client) ──
function renderHarness(
id: string,
sent: SentMessage[],
rich: string[],
followups: UserMessage[],
): {
bridge: TelegramBridge
emitAssistant: (text: string) => Promise<void>
} {
const agent = makeAgent(id, followups)
let sessionListener: ((session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) | undefined
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (sid: ReturnType<typeof SessionId>) => (String(sid) === id ? agent : undefined),
},
on(event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
if (event === 'session/event') sessionListener = listener
return () => {}
},
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
sendRichMessage: async (chatId, markdown) => {
rich.push(markdown)
return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text: markdown }
},
}),
sleep: async () => {},
})
bridge.start()
const emitAssistant = async (text: string): Promise<void> => {
await sessionListener?.(
{ id: SessionId(id) },
{ type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } },
)
// deliver() chains async work past the listener's void return — flush.
await new Promise((r) => setTimeout(r, 0))
}
return { bridge, emitAssistant }
}
test('default rendering is HTML compat (no rich messages unless /rich on)', async () => {
const sent: SentMessage[] = []
const rich: string[] = []
const followups: UserMessage[] = []
const bindingsFile = `/tmp/dsh-tg-test-bindings-default.json`
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
try {
const { bridge, emitAssistant } = renderHarness('live-render-1', sent, rich, followups)
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'cq',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}live-render-1`,
},
})
await emitAssistant('default-mode reply')
assert.equal(rich.length, 0, 'no rich message without /rich on')
assert.ok(sent.some((m) => m.text.includes('default-mode reply')))
await bridge.stop()
} finally {
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
rmSync(bindingsFile, { force: true })
}
})
test('/rich on routes replies to sendRichMessage; /rich off reverts to HTML; status reports state', async () => {
const sent: SentMessage[] = []
const rich: string[] = []
const followups: UserMessage[] = []
const bindingsFile = `/tmp/dsh-tg-test-bindings-rich.json`
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
try {
const { bridge, emitAssistant } = renderHarness('live-render-2', sent, rich, followups)
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'cq',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}live-render-2`,
},
})
// Default: HTML compat → no rich send yet.
await emitAssistant('before toggle')
assert.equal(rich.length, 0)
// /rich (no arg) reports current state.
await bridge.processUpdate(messageUpdate(10, 1, '/rich', 2))
assert.match(sent.at(-1)!.text, /HTML 兼容/)
// /rich on → assistant replies go through sendRichMessage.
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 3))
assert.match(sent.at(-1)!.text, /已切换为富文本/)
await emitAssistant('rich-mode reply')
assert.equal(rich.length, 1)
assert.match(rich[0]!, /rich-mode reply/)
// /rich on again is idempotent (still one preference, no dupes).
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 4))
assert.ok(!sent.at(-1)!.text.includes('已切换') || rich.length === 1)
// /rich off → back to HTML compat path.
await bridge.processUpdate(messageUpdate(10, 1, '/rich off', 5))
assert.match(sent.at(-1)!.text, /已切换为 HTML/)
await emitAssistant('compat-mode reply')
assert.equal(rich.length, 1, 'no new rich send after /rich off')
assert.ok(sent.some((m) => m.text.includes('compat-mode reply')))
await bridge.stop()
} finally {
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
rmSync(bindingsFile, { force: true })
}
})
// ── /status 通用状态显示(对齐 Web 底部统计条)──
test('/status shows bind/session/workspace/model/effort/context and web-style stats', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-st'
const agent = makeAgent(sessionId, followups, { cwd: '/work/proj-a', title: '演示任务' })
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
apiProxy: {
workspace: {
list: async () => rpcOk({
items: [{
workspaceId: 'w1',
path: '/work/proj-a',
title: 'Proj A',
sessionIds: [sessionId],
}],
archivedSessionIds: [],
}),
},
sessions: {
list: async () => rpcOk({
items: [{
sessionId,
updatedAt: 1,
running: true,
blank: false,
cwd: '/work/proj-a',
}],
}),
models: async () => rpcOk({
current: { provider: 'deepseek', model: 'reasoner', reasoningEffort: 'high' },
routable: true,
groups: [],
}),
},
},
sessionProjections: {
snapshot: () => ({
asOfSeq: 0,
values: {
sessionStats: {
turns: 3,
steps: 5,
llmMs: 4200,
toolMs: 2100,
ttftMs: 1500,
ttftSteps: 5,
decodeMs: 6000,
decodeTokens: 3000,
},
tokenUsage: {
uncachedInputTokens: 3000,
cacheReadTokens: 21000,
cacheWriteTokens: 500,
outputTokens: 9000,
},
contextPressure: {
contextWindow: 131072,
projectedTokens: 64536,
},
},
}),
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
// bind via legacy callback, then ask for status
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/status', 2))
const text = sent.at(-1)!.text
assert.ok(text.includes('会话 ID:live-st'), text)
assert.ok(text.includes('工作区:Proj A(/work/proj-a)'), text)
assert.ok(text.includes('模型:deepseek/reasoner'), text)
assert.ok(text.includes('思考强度:高(high)'), text)
assert.ok(text.includes('上下文:64.5K / 131K tokens(49%)'), text)
assert.ok(text.includes('3 轮 · 5 步'), text)
assert.ok(text.includes('首 token 平均 0.3s'), text)
assert.ok(text.includes('500 tok/s'), text)
assert.ok(text.includes('缓存命中'), text)
assert.ok(text.includes('输入 24.5K tok · 输出 9K tok'), text)
})
test('/status without bind prompts NEED_BIND', async () => {
const sent: SentMessage[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(messageUpdate(10, 1, '/status'))
assert.equal(sent[0]?.text, MSG.STATUS_NONE)
})
// ── /compact 手动压缩 ──
test('/compact compacts bound session and reports result', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-cc'
const agent = makeAgent(sessionId, followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
commands: {
find: () => true,
execute: async () => ({
commandId: 'cmd-1',
result: { kind: 'success', text: 'Compacted 4 history items (~12345 tokens).' },
}),
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
assert.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
// runCompaction runs detached; wait for its result notice
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes('压缩完成')); i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text.includes('压缩完成')), 'compaction result notice arrives')
assert.ok(
sent.some((m) => m.text.includes('4 条历史记录(约 12345 tokens)')),
'result mentions shadowed range',
)
})
test('/compact busy agent replies COMPACT_BUSY without starting', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-busy'
const agent = makeAgent(sessionId, followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
commands: {
find: () => true,
execute: async () => ({
commandId: 'cmd-busy',
result: {
kind: 'error',
text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
},
}),
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
sent.length = 0 // drop the BOUND message; keep only /compact replies
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
assert.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes(MSG.COMPACT_BUSY)); i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text === MSG.COMPACT_BUSY), 'busy notice arrives')
})
test('/compact without registered /compact command reports unavailable', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-noeng'
const agent = makeAgent(sessionId, followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
commands: {
find: () => undefined, // command not registered on this host
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
assert.equal(sent.at(-1)!.text, MSG.COMPACT_UNAVAILABLE)
})
test('/compact without bind prompts NEED_BIND', async () => {
const sent: SentMessage[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(messageUpdate(10, 1, '/compact'))
assert.equal(sent[0]?.text, MSG.NEED_BIND)
})
// ── 图片消息 ──
type PromptPayload = { sessionId: string; mode: string; content: Array<{ type: string; text?: string; mediaType?: string; data?: string; name?: string }> }
function photoUpdate(chatId: number, userId: number, fileId: string, opts?: {
caption?: string
mediaGroupId?: string
updateId?: number
}): TelegramUpdate {
const updateId = opts?.updateId ?? 1
return {
update_id: updateId,
message: {
message_id: updateId,
date: 0,
chat: { id: chatId, type: 'private' },
from: { id: userId },
caption: opts?.caption,
media_group_id: opts?.mediaGroupId,
photo: [
{ file_id: `${fileId}-small`, file_unique_id: `${fileId}-small`, width: 90, height: 90 },
{ file_id: fileId, file_unique_id: fileId, width: 1280, height: 960, file_size: 300000 },
],
},
}
}
function bindUpdate(chatId: number, userId: number, sessionId: string): TelegramUpdate {
return {
update_id: 1,
callback_query: {
id: 'bind',
from: { id: userId },
message: { message_id: 1, date: 0, chat: { id: chatId, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
}
}
function imageBridgeCtx(sessionId: string, prompts: PromptPayload[], promptResult?: unknown) {
const agent = makeAgent(sessionId, [])
return {
agent,
ctx: {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
apiProxy: {
sessions: {
prompt: async (req: { payload: unknown }) => {
prompts.push(req.payload as PromptPayload)
return promptResult ?? rpcOk({ accepted: true })
},
},
},
on() { return () => {} },
},
}
}
test('photo with caption is admitted via sessions.prompt (largest size, jpeg, base64)', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const jpegBytes = new Uint8Array([0xff, 0xd8, 1, 2, 3])
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `dir/${fileId}` }),
downloadFile: async (filePath: string) => {
assert.ok(filePath.includes('big-photo'), 'downloads the largest photo size')
return jpegBytes
},
}),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate(photoUpdate(10, 1, 'big-photo', { caption: '看看这张图', updateId: 2 }))
assert.equal(prompts.length, 1, 'one prompt RPC')
const content = prompts[0]!.content
assert.deepEqual(content[0], { type: 'text', text: '看看这张图' })
assert.equal(content[1]?.type, 'image')
assert.equal(content[1]?.mediaType, 'image/jpeg')
assert.equal(content[1]?.data, Buffer.from(jpegBytes).toString('base64'))
assert.ok(!sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'no unsupported notice')
})
test('image document (png) is admitted with its file name', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-doc'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate({
update_id: 2,
message: {
message_id: 2,
date: 0,
chat: { id: 10, type: 'private' },
from: { id: 1 },
document: {
file_id: 'doc-png',
file_unique_id: 'doc-png',
file_name: 'screenshot.png',
mime_type: 'image/png',
file_size: 5000,
},
},
})
assert.equal(prompts.length, 1)
const image = prompts[0]!.content.find((p) => p.type === 'image')
assert.equal(image?.mediaType, 'image/png')
assert.equal(image?.name, 'screenshot.png')
})
test('non-image document gets MEDIA_UNSUPPORTED and no prompt', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-pdf'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate({
update_id: 2,
message: {
message_id: 2,
date: 0,
chat: { id: 10, type: 'private' },
from: { id: 1 },
document: { file_id: 'doc-pdf', file_unique_id: 'doc-pdf', file_name: 'report.pdf', mime_type: 'application/pdf' },
},
})
assert.equal(prompts.length, 0, 'no prompt dispatched')
assert.ok(sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'unsupported notice sent')
})
test('album photos collapse into a single prompt with all images', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-album'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate(photoUpdate(10, 1, 'album-1', { mediaGroupId: 'g1', updateId: 2 }))
await bridge.processUpdate(photoUpdate(10, 1, 'album-2', { mediaGroupId: 'g1', updateId: 3 }))
for (let i = 0; i < 500 && prompts.length === 0; i++) {
await new Promise((r) => setTimeout(r, 10))
}
assert.equal(prompts.length, 1, 'album debounced into one prompt')
assert.equal(prompts[0]!.content.filter((p) => p.type === 'image').length, 2)
})
test('model without image support replies IMAGE_MODEL_UNSUPPORTED', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-nv'
const { ctx } = imageBridgeCtx(sessionId, prompts, {
rpcId: 'x',
result: {
ok: false,
error: {
code: 'attachment-error',
message: 'Model "deepseek-chat" does not support image input.',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
},
},
})
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate(photoUpdate(10, 1, 'nv-photo', { updateId: 2 }))
for (let i = 0; i < 100 && sent.length === 0; i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text === MSG.IMAGE_MODEL_UNSUPPORTED), 'model unsupported notice')
})
test('photo download failure replies IMAGE_FAILED', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-dl'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
getFile: async () => { throw new Error('telegram timeout') },
}),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate(photoUpdate(10, 1, 'dl-photo', { updateId: 2 }))
for (let i = 0; i < 100 && sent.length === 0; i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.equal(prompts.length, 0, 'no prompt dispatched')
assert.ok(sent.some((m) => m.text.startsWith('图片发送失败。')), 'failure notice')
})
test('photo without bind prompts NEED_BIND and no prompt', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
apiProxy: { sessions: { prompt: async (req: { payload: unknown }) => { prompts.push(req.payload as PromptPayload); return rpcOk({ accepted: true }) } } },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(photoUpdate(10, 1, 'unbound-photo', { updateId: 1 }))
assert.equal(prompts.length, 0)
assert.ok(sent.some((m) => m.text === MSG.NEED_BIND), 'NEED_BIND sent')
})
+21
View File
@@ -51,3 +51,24 @@ test('sendMessage can include reply_markup', async () => {
inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]],
})
})
test('sendRichMessage posts native rich markdown body', async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = []
const fetchImpl: typeof fetch = async (input, init) => {
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) })
return new Response(JSON.stringify({
ok: true,
result: { message_id: 2, date: 0, chat: { id: 1, type: 'private' }, text: '# hi' },
}), { status: 200 })
}
const client = new TelegramClient('TOK', { fetch: fetchImpl })
await client.sendRichMessage(1, '# hi\n\n- a\n- b')
assert.match(calls[0]!.url, /\/botTOK\/sendRichMessage$/)
assert.deepEqual(calls[0]!.body, {
chat_id: 1,
rich_message: {
markdown: '# hi\n\n- a\n- b',
skip_entity_detection: true,
},
})
})
+20
View File
@@ -18,6 +18,20 @@ test('parse slash commands for remote control', () => {
assert.equal(parseCommand('hello').type, 'plain')
})
test('parse /rich with optional mode argument', () => {
const bare = parseCommand('/rich')
assert.equal(bare.type, 'rich')
assert.equal(bare.type === 'rich' ? bare.arg : 'x', undefined)
const on = parseCommand('/rich on')
assert.equal(on.type, 'rich')
assert.equal(on.type === 'rich' ? on.arg : 'x', 'on')
assert.equal(parseCommand('/rich off').type === 'rich' && parseCommand('/rich off').arg, 'off')
assert.equal(parseCommand('/rich ON').type === 'rich' && parseCommand('/rich ON').arg, 'on')
assert.equal(parseCommand('/render auto').type === 'rich' && parseCommand('/render auto').arg, 'auto')
assert.equal(parseCommand('/setting').type, 'rich')
assert.equal(parseCommand('/setting html').type === 'rich' && parseCommand('/setting html').arg, 'html')
})
test('Chinese copy mentions sessions and bind', () => {
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
assert.ok(MSG.HELP.includes('/sessions'))
@@ -26,3 +40,9 @@ test('Chinese copy mentions sessions and bind', () => {
assert.ok(MSG.NEED_BIND.includes('/sessions'))
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
})
test('parse /compact', () => {
assert.equal(parseCommand('/compact').type, 'compact')
assert.equal(parseCommand('/compact@MyBot').type, 'compact')
assert.equal(parseCommand('/compact now').type, 'compact')
})
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
RICH_MESSAGE_MAX_CHARS,
splitRichMarkdown,
} from '../src/rich-format.ts'
test('splitRichMarkdown keeps small markdown as one chunk', () => {
const markdown = '# 标题\n\n- a\n- b\n\n```js\nx\n```\n'
assert.deepEqual(splitRichMarkdown(markdown), [markdown])
})
test('splitRichMarkdown splits long plain text without losing content', () => {
const markdown = 'a'.repeat(RICH_MESSAGE_MAX_CHARS + 100)
const chunks = splitRichMarkdown(markdown)
assert.ok(chunks.length > 1)
assert.equal(chunks.join(''), markdown)
})
test('splitRichMarkdown keeps long fenced code blocks balanced', () => {
const opening = '```js\n'
const closing = '\n```'
const markdown = `${opening}${'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1000)}${closing}`
const chunks = splitRichMarkdown(markdown)
assert.ok(chunks.length > 1)
for (const chunk of chunks) {
assert.ok(chunk.trimStart().startsWith('```'), 'chunk should start with a fence')
assert.ok(chunk.trimEnd().endsWith('```'), 'chunk should end with a fence')
}
assert.equal(chunks.join('').replace(/```js\n/g, '').replace(/\n```/g, ''), 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1000))
})