Compare commits
10
Commits
819010de1c
...
d7c74e88ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7c74e88ba | ||
|
|
e748132541 | ||
|
|
72e1d399a7 | ||
|
|
f3f6a3ebaf | ||
|
|
8600835436 | ||
|
|
d712fb3494 | ||
|
|
20898d5d21 | ||
|
|
cecb655a4b | ||
|
|
cde6979f2d | ||
|
|
93f3bec975 |
@@ -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`)。
|
||||
|
||||
|
||||
Vendored
+13
@@ -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?: {
|
||||
|
||||
Vendored
+99
@@ -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,31 @@ 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;
|
||||
private disposeApprovalHook;
|
||||
/** sessionId → thinking indicator state (one notice per reasoning phase) */
|
||||
private readonly thinkingSessions;
|
||||
/** callId → tool name (tool/result failure notices) */
|
||||
private readonly callNames;
|
||||
/** sessionId → latest todo snapshot (/mission) */
|
||||
private readonly lastTodos;
|
||||
/** sessionId → in-flight manual compaction (dedup /compact) */
|
||||
private readonly compacting;
|
||||
/** sessionId → compaction abort controller (cancelled on bridge stop) */
|
||||
private readonly compactAborts;
|
||||
/** Album accumulation: chatId:media_group_id → parts buffered into one prompt. */
|
||||
private readonly mediaGroups;
|
||||
constructor(ctx: Context, options: TelegramBridgeOptions);
|
||||
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
|
||||
private modeFor;
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
processUpdate(update: TelegramUpdate): Promise<void>;
|
||||
@@ -44,11 +75,79 @@ 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;
|
||||
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||
private runCompaction;
|
||||
private followupBound;
|
||||
/**
|
||||
* Extract wire prompt parts from a media message.
|
||||
* Returns undefined when the message carries no media at all; returns [] for
|
||||
* an unsupported media type (the user was already notified); otherwise the
|
||||
* caption text part (when present) followed by the image part.
|
||||
*/
|
||||
private collectMediaParts;
|
||||
/** Download one TG file and encode it as a canonical-base64 wire image part. */
|
||||
private downloadImagePart;
|
||||
/**
|
||||
* Album photos arrive as separate updates sharing a media_group_id — debounce
|
||||
* them into ONE prompt so an album does not spawn N sequential turns.
|
||||
*/
|
||||
private accumulateMediaGroup;
|
||||
/**
|
||||
* Dispatch an image-bearing message to the bound session through the host
|
||||
* prompt RPC — the exact channel the Web composer uses. The host checks
|
||||
* whether the current model accepts image input, admits the bytes into the
|
||||
* durable attachment store, then queues the message on the agent (mode
|
||||
* 'queue' — same semantics as the text path's agent.followup).
|
||||
*/
|
||||
private sendImageFollowup;
|
||||
private pollLoop;
|
||||
private interruptibleDelay;
|
||||
private interruptibleSleep;
|
||||
private onSessionEvent;
|
||||
private handleRichCommand;
|
||||
private deliver;
|
||||
private deliverHtml;
|
||||
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
||||
private withRetry;
|
||||
/**
|
||||
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
||||
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
||||
* the blockquote entity at all and show the whole message as “not supported”.
|
||||
* Falls back to plain text when the send fails — a notice is never lost.
|
||||
*/
|
||||
private deliverNotice;
|
||||
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
||||
private enqueueNotice;
|
||||
private startTypingHeartbeat;
|
||||
private stopTypingHeartbeat;
|
||||
private stopAllHeartbeats;
|
||||
private userQuestions;
|
||||
/**
|
||||
* Wrap the UI provider's ask() so Telegram gets a parallel answer path.
|
||||
* `Promise.race` decides; the UI path is untouched. The TG promise NEVER
|
||||
* settles when there is no bound chat — race would kill the UI's window
|
||||
* with that early rejection.
|
||||
*/
|
||||
private hookUserQuestions;
|
||||
private registerTgAsk;
|
||||
private settleGuiSide;
|
||||
private formatAskPending;
|
||||
private handleTgAnswer;
|
||||
private onApprovalRequest;
|
||||
private handleTgApproval;
|
||||
private stopBound;
|
||||
private sendMission;
|
||||
/** /new: create a session in the bound session's workspace and attach to it. */
|
||||
private newSessionHere;
|
||||
private bindingsPath;
|
||||
private saveBindings;
|
||||
private loadBindings;
|
||||
private redact;
|
||||
}
|
||||
|
||||
+1162
-12
File diff suppressed because it is too large
Load Diff
Vendored
+38
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,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 +104,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+38
@@ -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,28 @@ export type ParsedCommand = {
|
||||
} | {
|
||||
type: 'status';
|
||||
text: string;
|
||||
} | {
|
||||
type: 'compact';
|
||||
text: string;
|
||||
} | {
|
||||
type: 'rich';
|
||||
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;
|
||||
|
||||
+62
-1
@@ -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,28 @@ export function parseCommand(text) {
|
||||
return { type: 'model', text };
|
||||
case '/status':
|
||||
return { type: 'status', text };
|
||||
case '/compact':
|
||||
return { type: 'compact', text };
|
||||
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 };
|
||||
}
|
||||
|
||||
Vendored
+4
-1
@@ -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 (needs a recent client);
|
||||
* anything else (default) = HTML compatibility mode that renders on all clients. */
|
||||
rendering?: string;
|
||||
}
|
||||
export declare const Config: Schema<TelegramChannelConfig>;
|
||||
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
|
||||
|
||||
+4
-2
@@ -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('html'),
|
||||
});
|
||||
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();
|
||||
|
||||
Vendored
+3
@@ -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[];
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+71
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
+1258
-12
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
@@ -167,6 +206,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 +230,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -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,13 @@ 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: '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 +138,28 @@ export function parseCommand(text: string): ParsedCommand {
|
||||
return { type: 'model', text }
|
||||
case '/status':
|
||||
return { type: 'status', text }
|
||||
case '/compact':
|
||||
return { type: 'compact', text }
|
||||
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 }
|
||||
}
|
||||
|
||||
+7
-2
@@ -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 (needs a recent client);
|
||||
* anything else (default) = HTML compatibility mode that renders on all clients. */
|
||||
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('html'),
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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[] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -27,6 +28,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 +301,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 +652,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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
Reference in New Issue
Block a user