Compare commits
13
Commits
d7c74e88ba
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e95c2631 | ||
|
|
85a42f28a6 | ||
|
|
f49ac5d3c0 | ||
|
|
566932e0ae | ||
|
|
6d00225cc4 | ||
|
|
0b946ffaa4 | ||
|
|
2c50a35795 | ||
|
|
373abf586d | ||
|
|
da06485d64 | ||
|
|
4039dec03c | ||
|
|
5fa2827ed6 | ||
|
|
9a45bde25a | ||
|
|
9f03c6c748 |
Vendored
+28
-17
@@ -42,9 +42,12 @@ export declare class TelegramBridge {
|
|||||||
private readonly pendingAsks;
|
private readonly pendingAsks;
|
||||||
private hookTimer;
|
private hookTimer;
|
||||||
private readonly pendingApprovalsTG;
|
private readonly pendingApprovalsTG;
|
||||||
private disposeApprovalHook;
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
/** sessionId → thinking indicator state (one notice per reasoning phase) */
|
private askInstalled;
|
||||||
private readonly thinkingSessions;
|
private askRestore;
|
||||||
|
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
|
||||||
|
private approvalInstalled;
|
||||||
|
private approvalRestore;
|
||||||
/** callId → tool name (tool/result failure notices) */
|
/** callId → tool name (tool/result failure notices) */
|
||||||
private readonly callNames;
|
private readonly callNames;
|
||||||
/** sessionId → latest todo snapshot (/mission) */
|
/** sessionId → latest todo snapshot (/mission) */
|
||||||
@@ -82,6 +85,11 @@ export declare class TelegramBridge {
|
|||||||
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
|
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
|
||||||
private serviceOf;
|
private serviceOf;
|
||||||
private requestCompact;
|
private requestCompact;
|
||||||
|
/**
|
||||||
|
* TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条
|
||||||
|
* 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。
|
||||||
|
*/
|
||||||
|
private requestGoal;
|
||||||
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
private runCompaction;
|
private runCompaction;
|
||||||
private followupBound;
|
private followupBound;
|
||||||
@@ -117,10 +125,10 @@ export declare class TelegramBridge {
|
|||||||
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
||||||
private withRetry;
|
private withRetry;
|
||||||
/**
|
/**
|
||||||
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
* Send a notice; a leading `> ` marks the body for quote rendering as a real
|
||||||
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
* `<blockquote>` (parse_mode HTML) — every current Telegram client draws the
|
||||||
* the blockquote entity at all and show the whole message as “not supported”.
|
* accent-colored left bar. Old-client compatibility is no longer required,
|
||||||
* Falls back to plain text when the send fails — a notice is never lost.
|
* but a failed HTML send still falls back to plain text — a notice is never lost.
|
||||||
*/
|
*/
|
||||||
private deliverNotice;
|
private deliverNotice;
|
||||||
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
||||||
@@ -128,19 +136,22 @@ export declare class TelegramBridge {
|
|||||||
private startTypingHeartbeat;
|
private startTypingHeartbeat;
|
||||||
private stopTypingHeartbeat;
|
private stopTypingHeartbeat;
|
||||||
private stopAllHeartbeats;
|
private stopAllHeartbeats;
|
||||||
private userQuestions;
|
private installAskInterceptor;
|
||||||
/**
|
private aroundUserQuestionAsk;
|
||||||
* 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 registerTgAsk;
|
||||||
|
/** Announce the incoming question, then one message per question (options as native inline-keyboard buttons). */
|
||||||
|
private deliverAskPrompt;
|
||||||
|
/** Resolve the pending ask once EVERY question has an answer (keyboard and/or text). */
|
||||||
|
private finalizeAskIfComplete;
|
||||||
private settleGuiSide;
|
private settleGuiSide;
|
||||||
private formatAskPending;
|
|
||||||
private handleTgAnswer;
|
private handleTgAnswer;
|
||||||
private onApprovalRequest;
|
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
|
||||||
|
private handleAskCallback;
|
||||||
|
private installApprovalInterceptor;
|
||||||
|
private aroundApprovalRequest;
|
||||||
|
/** Approval prompt as native inline-keyboard buttons; falls back to a plain-text prompt. */
|
||||||
|
private deliverApprovalPrompt;
|
||||||
|
private handleApprovalCallback;
|
||||||
private handleTgApproval;
|
private handleTgApproval;
|
||||||
private stopBound;
|
private stopBound;
|
||||||
private sendMission;
|
private sendMission;
|
||||||
|
|||||||
+409
-149
@@ -10,7 +10,7 @@ import { resolveApiProxy } from './apiproxy.js';
|
|||||||
import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForWorkspace, workspacesWithVisibleSessions, } from './catalog.js';
|
import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForWorkspace, workspacesWithVisibleSessions, } from './catalog.js';
|
||||||
import { TelegramClient, } from './client.js';
|
import { TelegramClient, } from './client.js';
|
||||||
import { MSG, LAST_CB, parseCommand } from './commands.js';
|
import { MSG, LAST_CB, parseCommand } from './commands.js';
|
||||||
import { markdownToHtml, splitMessage } from './format.js';
|
import { escapeHtml, markdownToHtml, splitMessage } from './format.js';
|
||||||
import { splitRichMarkdown } from './rich-format.js';
|
import { splitRichMarkdown } from './rich-format.js';
|
||||||
import { formatLastTurn, loadLastTurn } from './history.js';
|
import { formatLastTurn, loadLastTurn } from './history.js';
|
||||||
import { describeAgent, displayLabel, workspaceName } from './label.js';
|
import { describeAgent, displayLabel, workspaceName } from './label.js';
|
||||||
@@ -60,18 +60,44 @@ const MODEL_CB = 'mdl:';
|
|||||||
/** Use eff: (not me:) — short prefix, no collision with other callbacks. */
|
/** Use eff: (not me:) — short prefix, no collision with other callbacks. */
|
||||||
const EFFORT_CB = 'eff:';
|
const EFFORT_CB = 'eff:';
|
||||||
const BACK_MODEL_CB = 'mb';
|
const BACK_MODEL_CB = 'mb';
|
||||||
|
/** ask:{questionIndex}:{optionIndex|ok|custom} — inline-keyboard taps for ask_user_question. */
|
||||||
|
const ASK_CB = 'ask:';
|
||||||
|
/** apr:allow / apr:deny — inline-keyboard taps for tool approvals. */
|
||||||
|
const APR_CB = 'apr:';
|
||||||
const MAX_BUTTONS = 40;
|
const MAX_BUTTONS = 40;
|
||||||
|
/** Marker stored on UserQuestionService.prototype for the ask interceptor. */
|
||||||
|
const kTgAskWrapped = Symbol.for('dsh-telegram-channel.askWrapped');
|
||||||
|
/** Marker stored on ApprovalService.prototype for the approval interceptor. */
|
||||||
|
const kTgApprovalWrapped = Symbol.for('dsh-telegram-channel.approvalWrapped');
|
||||||
function lastContextKeyboard() {
|
function lastContextKeyboard() {
|
||||||
return {
|
return {
|
||||||
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
|
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
/** Cap a single reasoning block so a long chain of thought can't flood the phone. */
|
||||||
|
const REASONING_QUOTE_MAX_CHARS = 800;
|
||||||
|
/**
|
||||||
|
* Assistant message → markdown for delivery. Reasoning (thinking) blocks are
|
||||||
|
* kept, one `> ` quote line each — Telegram renders them as the accent-bar
|
||||||
|
* quote style the phone user expects for bash/thinking-style chatter.
|
||||||
|
*/
|
||||||
function contentToText(content) {
|
function contentToText(content) {
|
||||||
return content
|
const parts = [];
|
||||||
.filter((block) => block.type === 'text')
|
for (const block of content) {
|
||||||
.map((block) => block.text)
|
if (block.type === 'text' && typeof block.text === 'string') {
|
||||||
.join('');
|
parts.push(block.text);
|
||||||
|
}
|
||||||
|
else if (block.type === 'reasoning' && typeof block.text === 'string') {
|
||||||
|
let thinking = block.text.trim();
|
||||||
|
if (!thinking)
|
||||||
|
continue;
|
||||||
|
if (thinking.length > REASONING_QUOTE_MAX_CHARS)
|
||||||
|
thinking = `${thinking.slice(0, REASONING_QUOTE_MAX_CHARS)}…`;
|
||||||
|
parts.push(thinking.split('\n').map((line) => `> ${line}`).join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('\n\n');
|
||||||
}
|
}
|
||||||
function isRichUnsupportedError(err) {
|
function isRichUnsupportedError(err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -110,9 +136,12 @@ export class TelegramBridge {
|
|||||||
pendingAsks = new Map();
|
pendingAsks = new Map();
|
||||||
hookTimer;
|
hookTimer;
|
||||||
pendingApprovalsTG = new Map();
|
pendingApprovalsTG = new Map();
|
||||||
disposeApprovalHook;
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
/** sessionId → thinking indicator state (one notice per reasoning phase) */
|
askInstalled = false;
|
||||||
thinkingSessions = new Map();
|
askRestore;
|
||||||
|
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
|
||||||
|
approvalInstalled = false;
|
||||||
|
approvalRestore;
|
||||||
/** callId → tool name (tool/result failure notices) */
|
/** callId → tool name (tool/result failure notices) */
|
||||||
callNames = new Map();
|
callNames = new Map();
|
||||||
/** sessionId → latest todo snapshot (/mission) */
|
/** sessionId → latest todo snapshot (/mission) */
|
||||||
@@ -151,9 +180,13 @@ export class TelegramBridge {
|
|||||||
// Restore persisted bindings (survive hot reload / restart) and install
|
// Restore persisted bindings (survive hot reload / restart) and install
|
||||||
// the TG answering hooks (ask_user dual-path + approval dual-path).
|
// the TG answering hooks (ask_user dual-path + approval dual-path).
|
||||||
this.loadBindings();
|
this.loadBindings();
|
||||||
this.hookUserQuestions();
|
// ask_user interception: see installAskInterceptor — a prototype wrap of
|
||||||
this.disposeApprovalHook?.();
|
// UserQuestionService.prototype.ask. Plain ctx.on('user-questions/request')
|
||||||
this.disposeApprovalHook = this.ctx.on('approval/request', (req, next) => this.onApprovalRequest(req, next));
|
// registries never see the dispatch (the waterfall fires on a different
|
||||||
|
// per-fiber events registry in dsh 0.1.5-rc.1), so the prototype wrap —
|
||||||
|
// which sees EVERY ask regardless of fiber/instance — is the reliable seam.
|
||||||
|
this.installAskInterceptor();
|
||||||
|
this.installApprovalInterceptor();
|
||||||
void this.client.setMyCommands([
|
void this.client.setMyCommands([
|
||||||
{ command: 'start', description: '欢迎与用法' },
|
{ command: 'start', description: '欢迎与用法' },
|
||||||
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
@@ -161,6 +194,7 @@ export class TelegramBridge {
|
|||||||
{ command: 'model', description: '切换当前绑定会话的模型' },
|
{ command: 'model', description: '切换当前绑定会话的模型' },
|
||||||
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
|
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
|
||||||
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
|
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
|
||||||
|
{ command: 'goal', description: '目标模式:/goal <目标>(clear/edit/pause/resume 管理)' },
|
||||||
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
|
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
|
||||||
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
||||||
{ command: 'stop', description: '中止当前正在运行的任务' },
|
{ command: 'stop', description: '中止当前正在运行的任务' },
|
||||||
@@ -196,7 +230,6 @@ export class TelegramBridge {
|
|||||||
this.noticeQueue.clear();
|
this.noticeQueue.clear();
|
||||||
this.pendingAsks.clear();
|
this.pendingAsks.clear();
|
||||||
this.pendingApprovalsTG.clear();
|
this.pendingApprovalsTG.clear();
|
||||||
this.thinkingSessions.clear();
|
|
||||||
this.callNames.clear();
|
this.callNames.clear();
|
||||||
this.lastTodos.clear();
|
this.lastTodos.clear();
|
||||||
for (const controller of this.compactAborts.values())
|
for (const controller of this.compactAborts.values())
|
||||||
@@ -210,8 +243,12 @@ export class TelegramBridge {
|
|||||||
clearTimeout(this.hookTimer);
|
clearTimeout(this.hookTimer);
|
||||||
this.hookTimer = undefined;
|
this.hookTimer = undefined;
|
||||||
}
|
}
|
||||||
this.disposeApprovalHook?.();
|
this.askRestore?.();
|
||||||
this.disposeApprovalHook = undefined;
|
this.askRestore = undefined;
|
||||||
|
this.askInstalled = false;
|
||||||
|
this.approvalRestore?.();
|
||||||
|
this.approvalRestore = undefined;
|
||||||
|
this.approvalInstalled = false;
|
||||||
if (this.pollPromise) {
|
if (this.pollPromise) {
|
||||||
await this.pollPromise.catch(() => { });
|
await this.pollPromise.catch(() => { });
|
||||||
this.pollPromise = undefined;
|
this.pollPromise = undefined;
|
||||||
@@ -273,6 +310,9 @@ export class TelegramBridge {
|
|||||||
case 'compact':
|
case 'compact':
|
||||||
await this.requestCompact(chatId);
|
await this.requestCompact(chatId);
|
||||||
return;
|
return;
|
||||||
|
case 'goal':
|
||||||
|
await this.requestGoal(chatId, parsed.arg);
|
||||||
|
return;
|
||||||
case 'rich':
|
case 'rich':
|
||||||
await this.handleRichCommand(chatId, parsed.arg);
|
await this.handleRichCommand(chatId, parsed.arg);
|
||||||
return;
|
return;
|
||||||
@@ -340,6 +380,14 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
const data = cq.data ?? '';
|
const data = cq.data ?? '';
|
||||||
const picker = this.pickers.get(String(chatId));
|
const picker = this.pickers.get(String(chatId));
|
||||||
|
if (data.startsWith(ASK_CB)) {
|
||||||
|
await this.handleAskCallback(chatId, cq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.startsWith(APR_CB)) {
|
||||||
|
await this.handleApprovalCallback(chatId, cq, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data === LAST_CB) {
|
if (data === LAST_CB) {
|
||||||
await this.client.answerCallbackQuery(cq.id);
|
await this.client.answerCallbackQuery(cq.id);
|
||||||
await this.sendLastTurn(chatId);
|
await this.sendLastTurn(chatId);
|
||||||
@@ -851,6 +899,41 @@ export class TelegramBridge {
|
|||||||
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`);
|
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条
|
||||||
|
* 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。
|
||||||
|
*/
|
||||||
|
async requestGoal(chatId, arg) {
|
||||||
|
const binding = this.bindings.get(String(chatId));
|
||||||
|
if (!binding) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.NEED_BIND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agent = await this.ensureLiveAgent(binding.sessionId);
|
||||||
|
if (!agent) {
|
||||||
|
this.bindings.delete(String(chatId));
|
||||||
|
await this.client.sendMessage(chatId, MSG.GONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commands = this.serviceOf('commands');
|
||||||
|
if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'goal')) {
|
||||||
|
await this.client.sendMessage(chatId, '当前环境不支持 /goal(command-goal 未装配)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = `/goal${arg ? ` ${arg}` : ''}`;
|
||||||
|
try {
|
||||||
|
const execution = await commands.execute(agent, line, [], new AbortController().signal);
|
||||||
|
if (execution === undefined) {
|
||||||
|
await this.client.sendMessage(chatId, '/goal 未执行:命令在运行时已注销');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = execution.result?.text ?? (execution.result?.kind === 'success' ? '完成' : '失败');
|
||||||
|
await this.client.sendMessage(chatId, text.slice(0, 3500));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
await this.client.sendMessage(chatId, `/goal 执行失败:${this.redact(err).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
async runCompaction(chatId, sessionId, operation, controller) {
|
async runCompaction(chatId, sessionId, operation, controller) {
|
||||||
try {
|
try {
|
||||||
@@ -1082,6 +1165,10 @@ export class TelegramBridge {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
async onSessionEvent(session, event) {
|
async onSessionEvent(session, event) {
|
||||||
|
if (!this.askInstalled)
|
||||||
|
this.installAskInterceptor();
|
||||||
|
if (!this.approvalInstalled)
|
||||||
|
this.installApprovalInterceptor();
|
||||||
const id = String(session.id);
|
const id = String(session.id);
|
||||||
const targets = [...this.bindings.values()].filter((b) => b.sessionId === id);
|
const targets = [...this.bindings.values()].filter((b) => b.sessionId === id);
|
||||||
if (targets.length === 0)
|
if (targets.length === 0)
|
||||||
@@ -1096,7 +1183,6 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
if (event.type === 'turn/end') {
|
if (event.type === 'turn/end') {
|
||||||
this.busySessions.delete(id);
|
this.busySessions.delete(id);
|
||||||
this.thinkingSessions.delete(id);
|
|
||||||
for (const b of targets)
|
for (const b of targets)
|
||||||
this.stopTypingHeartbeat(b.chatId);
|
this.stopTypingHeartbeat(b.chatId);
|
||||||
// Abnormal endings (API failure / abort / crash-recovery) must be
|
// Abnormal endings (API failure / abort / crash-recovery) must be
|
||||||
@@ -1124,7 +1210,6 @@ export class TelegramBridge {
|
|||||||
const name = event.data?.name ?? 'tool';
|
const name = event.data?.name ?? 'tool';
|
||||||
if (name === 'ask_user_question')
|
if (name === 'ask_user_question')
|
||||||
return;
|
return;
|
||||||
this.thinkingSessions.set(id, false);
|
|
||||||
if (event.data?.callId !== undefined) {
|
if (event.data?.callId !== undefined) {
|
||||||
if (this.callNames.size > 400) {
|
if (this.callNames.size > 400) {
|
||||||
const first = this.callNames.keys().next().value;
|
const first = this.callNames.keys().next().value;
|
||||||
@@ -1142,25 +1227,11 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
if (event.type === 'assistant/message') {
|
if (event.type === 'assistant/message') {
|
||||||
const text = contentToText(event.data.message.content);
|
const text = contentToText(event.data.message.content);
|
||||||
this.thinkingSessions.set(id, false);
|
|
||||||
if (!text)
|
if (!text)
|
||||||
return;
|
return;
|
||||||
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)));
|
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === 'assistant/chunk') {
|
|
||||||
// Thinking indicator: once per reasoning phase, status only (no content).
|
|
||||||
const chunk = event.data?.chunk;
|
|
||||||
const ctype = String(chunk?.type ?? '');
|
|
||||||
const btype = String(chunk?.block?.type ?? '');
|
|
||||||
const isReasoning = ctype.includes('reasoning') || btype.includes('reasoning') || btype.includes('think');
|
|
||||||
if (isReasoning && this.thinkingSessions.get(id) !== true) {
|
|
||||||
this.thinkingSessions.set(id, true);
|
|
||||||
for (const b of targets)
|
|
||||||
this.enqueueNotice(b.chatId, '> Thinking…');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.type === 'tool/result') {
|
if (event.type === 'tool/result') {
|
||||||
// Only failures — successful results are too noisy for the phone.
|
// Only failures — successful results are too noisy for the phone.
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
@@ -1172,6 +1243,23 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 'goal/changed' 不在本插件的 SessionEvent 类型联合内,宽松断言读取。
|
||||||
|
const eventType = event.type;
|
||||||
|
if (eventType === 'goal/changed') {
|
||||||
|
// Goal mode lives on the phone too: surface phase transitions (blocked/complete
|
||||||
|
// especially) without waiting for the next assistant text.
|
||||||
|
const data = event.data;
|
||||||
|
const phase = data?.phase;
|
||||||
|
if (phase === 'blocked' || phase === 'complete') {
|
||||||
|
const reason = data?.blockedReason;
|
||||||
|
const label = phase === 'blocked'
|
||||||
|
? `目标被阻塞${reason ? `:${reason.code ?? ''} ${reason.message ?? ''}`.slice(0, 120) : ''}`
|
||||||
|
: '目标已达成 ✓';
|
||||||
|
for (const b of targets)
|
||||||
|
this.enqueueNotice(b.chatId, `> ${label}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.type === 'todo/write') {
|
if (event.type === 'todo/write') {
|
||||||
const todos = Array.isArray(event.data?.todos) ? event.data.todos : [];
|
const todos = Array.isArray(event.data?.todos) ? event.data.todos : [];
|
||||||
this.lastTodos.set(id, todos);
|
this.lastTodos.set(id, todos);
|
||||||
@@ -1299,17 +1387,25 @@ export class TelegramBridge {
|
|||||||
throw lastErr;
|
throw lastErr;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
* Send a notice; a leading `> ` marks the body for quote rendering as a real
|
||||||
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
* `<blockquote>` (parse_mode HTML) — every current Telegram client draws the
|
||||||
* the blockquote entity at all and show the whole message as “not supported”.
|
* accent-colored left bar. Old-client compatibility is no longer required,
|
||||||
* Falls back to plain text when the send fails — a notice is never lost.
|
* but a failed HTML send still falls back to plain text — a notice is never lost.
|
||||||
*/
|
*/
|
||||||
async deliverNotice(chatId, text) {
|
async deliverNotice(chatId, text) {
|
||||||
const quoted = text.startsWith('> ');
|
const quoted = text.startsWith('> ');
|
||||||
const body = quoted ? text.slice(2) : text;
|
const body = quoted ? text.slice(2) : text;
|
||||||
|
if (quoted) {
|
||||||
|
try {
|
||||||
|
await this.withRetry(() => this.client.sendMessage(chatId, `<blockquote>${escapeHtml(body)}</blockquote>`, 'HTML'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: blockquote notice failed (${this.redact(err)}), falling back to plain text`);
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body));
|
await this.withRetry(() => this.client.sendMessage(chatId, body));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.ctx.logger.error(this.redact(err));
|
this.ctx.logger.error(this.redact(err));
|
||||||
@@ -1343,87 +1439,62 @@ export class TelegramBridge {
|
|||||||
clearInterval(h);
|
clearInterval(h);
|
||||||
this.heartbeats.clear();
|
this.heartbeats.clear();
|
||||||
}
|
}
|
||||||
// ── ask_user_question: TG answering via dual-path race with the UI ──
|
// ── ask_user_question: TG answering via a prototype interceptor ──
|
||||||
userQuestions() {
|
//
|
||||||
const ctx = this.ctx;
|
// dsh 0.1.5-rc.1 routes the 'user-questions/request' waterfall through a
|
||||||
if (typeof ctx.get === 'function') {
|
// per-fiber events registry: plain ctx.on listeners in other fibers (this
|
||||||
try {
|
// plugin included) never see the dispatch, so the waterfall hook cannot
|
||||||
const service = ctx.get('userQuestions');
|
// mirror questions to TG. The class is shared across instances though, so
|
||||||
if (service)
|
// wrapping UserQuestionService.prototype.ask intercepts EVERY ask with its
|
||||||
return service;
|
// agent + questions, regardless of fiber. The wrap races the TG answer
|
||||||
}
|
// against the original ask (GUI path), preserving the old dual-path
|
||||||
catch {
|
// semantics. Restore-on-stop keeps hot reloads from stacking wrappers.
|
||||||
// Continue to the plain-object fallback used by tests and simple hosts.
|
installAskInterceptor() {
|
||||||
}
|
if (this.askInstalled)
|
||||||
}
|
return;
|
||||||
|
let svc;
|
||||||
try {
|
try {
|
||||||
return Object.prototype.hasOwnProperty.call(ctx, 'userQuestions')
|
svc = this.ctx.get('userQuestions');
|
||||||
? ctx.userQuestions
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return undefined;
|
return; // service not ready yet — retried lazily from onSessionEvent
|
||||||
}
|
}
|
||||||
}
|
if (!svc)
|
||||||
/**
|
return;
|
||||||
* Wrap the UI provider's ask() so Telegram gets a parallel answer path.
|
const proto = svc.constructor.prototype;
|
||||||
* `Promise.race` decides; the UI path is untouched. The TG promise NEVER
|
const prev = proto[kTgAskWrapped];
|
||||||
* settles when there is no bound chat — race would kill the UI's window
|
if (prev?.bridge === this) {
|
||||||
* with that early rejection.
|
this.askInstalled = true;
|
||||||
*/
|
this.askRestore = prev.restore;
|
||||||
hookUserQuestions(attempt = 0) {
|
|
||||||
try {
|
|
||||||
const provider = this.userQuestions()?.provider;
|
|
||||||
if (provider) {
|
|
||||||
if (provider.__tgHooked && provider.__tgRealAsk) {
|
|
||||||
// Hot reload: re-point the wrapper at THIS bridge instance.
|
|
||||||
const realAsk = provider.__tgRealAsk;
|
|
||||||
const self = this;
|
|
||||||
provider.ask = function (request) {
|
|
||||||
const tg = self.registerTgAsk(request);
|
|
||||||
let gui;
|
|
||||||
try {
|
|
||||||
gui = realAsk(request);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
tg.reject(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request));
|
|
||||||
return Promise.race([tg.promise, gui]);
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const realAsk = provider.ask.bind(provider);
|
|
||||||
provider.__tgRealAsk = realAsk;
|
|
||||||
const self = this;
|
|
||||||
provider.ask = function (request) {
|
|
||||||
const tg = self.registerTgAsk(request);
|
|
||||||
let gui;
|
|
||||||
try {
|
|
||||||
gui = realAsk(request);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
tg.reject(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request));
|
|
||||||
return Promise.race([tg.promise, gui]);
|
|
||||||
};
|
|
||||||
provider.__tgHooked = true;
|
|
||||||
this.ctx.logger.info('dsh-telegram-channel: ask_user TG answering hook installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
if (attempt === 0)
|
|
||||||
this.ctx.logger.warn(`dsh-telegram-channel: userQuestions hook deferred: ${this.redact(err)}`);
|
|
||||||
}
|
|
||||||
if (attempt >= 30) {
|
|
||||||
this.ctx.logger.warn('dsh-telegram-channel: user-questions provider never appeared; TG answering disabled');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.hookTimer = setTimeout(() => this.hookUserQuestions(attempt + 1), 2000);
|
prev?.restore(); // stale wrapper from a disposed bridge instance
|
||||||
|
const orig = proto.ask;
|
||||||
|
const bridge = this;
|
||||||
|
const wrapped = function (request) {
|
||||||
|
return bridge.aroundUserQuestionAsk(this, request, orig);
|
||||||
|
};
|
||||||
|
proto.ask = wrapped;
|
||||||
|
const restore = () => {
|
||||||
|
if (proto.ask === wrapped)
|
||||||
|
proto.ask = orig;
|
||||||
|
};
|
||||||
|
proto[kTgAskWrapped] = { restore, bridge: this };
|
||||||
|
this.askRestore = restore;
|
||||||
|
this.askInstalled = true;
|
||||||
|
}
|
||||||
|
aroundUserQuestionAsk(service, request, orig) {
|
||||||
|
const tg = this.registerTgAsk(request);
|
||||||
|
let gui;
|
||||||
|
try {
|
||||||
|
gui = Promise.resolve().then(() => orig.call(service, request));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
tg.reject(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
void gui.then(() => this.settleGuiSide(request), () => this.settleGuiSide(request));
|
||||||
|
return Promise.race([tg.promise, gui]);
|
||||||
}
|
}
|
||||||
registerTgAsk(request) {
|
registerTgAsk(request) {
|
||||||
let resolveFn = () => { };
|
let resolveFn = () => { };
|
||||||
@@ -1453,16 +1524,83 @@ export class TelegramBridge {
|
|||||||
for (const [chatId, binding] of this.bindings) {
|
for (const [chatId, binding] of this.bindings) {
|
||||||
if (binding.sessionId !== sessionId)
|
if (binding.sessionId !== sessionId)
|
||||||
continue;
|
continue;
|
||||||
this.pendingAsks.set(String(chatId), { req: request, sessionId, questions, ask, at: Date.now() });
|
const pending = {
|
||||||
this.enqueueNotice(Number(chatId), this.formatAskPending(questions));
|
req: request,
|
||||||
|
sessionId,
|
||||||
|
questions,
|
||||||
|
ask,
|
||||||
|
at: Date.now(),
|
||||||
|
kbSel: new Map(),
|
||||||
|
textAnswers: new Map(),
|
||||||
|
textWanted: new Set(),
|
||||||
|
};
|
||||||
|
this.pendingAsks.set(String(chatId), pending);
|
||||||
|
void this.deliverAskPrompt(Number(chatId), pending);
|
||||||
asked = true;
|
asked = true;
|
||||||
}
|
}
|
||||||
if (asked)
|
|
||||||
return { promise, reject: rejectFn };
|
|
||||||
}
|
}
|
||||||
// No bound TG chat: never settle — the UI remains the only answer path.
|
// No bound TG chat: never settle — the UI remains the only answer path.
|
||||||
return { promise, reject: rejectFn };
|
return { promise, reject: rejectFn };
|
||||||
}
|
}
|
||||||
|
/** Announce the incoming question, then one message per question (options as native inline-keyboard buttons). */
|
||||||
|
async deliverAskPrompt(chatId, pending) {
|
||||||
|
const { questions } = pending;
|
||||||
|
try {
|
||||||
|
await this.withRetry(() => this.client.sendMessage(chatId, `❓ 即将向你提问(共 ${questions.length} 题)——点按钮或回复文本作答;/cancel 关闭 TG 作答`));
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
const opts = Array.isArray(q.options) ? q.options : [];
|
||||||
|
const header = questions.length > 1 ? `Q${i + 1}. ` : '';
|
||||||
|
if (opts.length === 0) {
|
||||||
|
await this.withRetry(() => this.client.sendMessage(chatId, `${header}${q.question ?? ''}\n(开放问题,请直接回复文本)`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const keyboard = {
|
||||||
|
inline_keyboard: [
|
||||||
|
...opts.map((o, j) => [{
|
||||||
|
text: `${j + 1}. ${o.label}`.slice(0, 48),
|
||||||
|
callback_data: `${ASK_CB}${i}:${j}`,
|
||||||
|
}]),
|
||||||
|
// Standing third choice: free-text answer, always available.
|
||||||
|
[{ text: '✏️ 输入自定义答案', callback_data: `${ASK_CB}${i}:custom` }],
|
||||||
|
...(q.multiSelect ? [[{ text: '✅ 提交所选', callback_data: `${ASK_CB}${i}:ok` }]] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await this.withRetry(() => this.client.sendMessage(chatId, `${header}${q.question ?? ''}`, undefined, keyboard));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.ctx.logger.error(this.redact(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Resolve the pending ask once EVERY question has an answer (keyboard and/or text). */
|
||||||
|
finalizeAskIfComplete(chatId, pending) {
|
||||||
|
const { questions, ask } = pending;
|
||||||
|
const answers = [];
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
const opts = Array.isArray(q.options) ? q.options : [];
|
||||||
|
const sel = pending.kbSel.get(i);
|
||||||
|
if (sel && sel.size > 0) {
|
||||||
|
answers.push({ id: q.id, selected: [...sel].sort((a, b) => a - b).map((j) => opts[j].label) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = pending.textAnswers.get(i);
|
||||||
|
if (text !== undefined) {
|
||||||
|
answers.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ask.answered = true;
|
||||||
|
for (const [cid, p] of this.pendingAsks) {
|
||||||
|
if (p.ask === ask)
|
||||||
|
this.pendingAsks.delete(cid);
|
||||||
|
}
|
||||||
|
ask.resolve({ answers });
|
||||||
|
this.enqueueNotice(chatId, '已提交你的回答');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
settleGuiSide(request) {
|
settleGuiSide(request) {
|
||||||
for (const [chatId, p] of [...this.pendingAsks]) {
|
for (const [chatId, p] of [...this.pendingAsks]) {
|
||||||
if (p.req !== request)
|
if (p.req !== request)
|
||||||
@@ -1473,25 +1611,8 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
formatAskPending(questions) {
|
|
||||||
const lines = ['待回答(回复字母选项,或直接输入自定义答案;/cancel 关闭 TG 作答)'];
|
|
||||||
questions.forEach((q, i) => {
|
|
||||||
const prefix = questions.length > 1 ? `Q${i + 1}. ` : '';
|
|
||||||
lines.push(`${prefix}${q.question ?? ''}`);
|
|
||||||
const opts = Array.isArray(q.options) ? q.options : [];
|
|
||||||
if (opts.length === 0) {
|
|
||||||
lines.push(' (开放问题,直接输入答案)');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
opts.forEach((o, j) => lines.push(` [${String.fromCharCode(65 + j)}] ${o.label}${o.description ? ` — ${o.description}` : ''}`));
|
|
||||||
if (q.multiSelect)
|
|
||||||
lines.push(' (可多选,如:A,B)');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
async handleTgAnswer(chatId, pending, text) {
|
async handleTgAnswer(chatId, pending, text) {
|
||||||
const { questions, ask } = pending;
|
const { questions } = pending;
|
||||||
let parts;
|
let parts;
|
||||||
if (questions.length === 1) {
|
if (questions.length === 1) {
|
||||||
parts = [[0, text.trim()]];
|
parts = [[0, text.trim()]];
|
||||||
@@ -1507,11 +1628,17 @@ export class TelegramBridge {
|
|||||||
parts.push([null, line]);
|
parts.push([null, line]);
|
||||||
}
|
}
|
||||||
if (parts.some(([idx]) => idx === null)) {
|
if (parts.some(([idx]) => idx === null)) {
|
||||||
await this.enqueueNotice(chatId, `共 ${questions.length} 个问题,请每行用 1:/2: 前缀分别作答`);
|
// ✏️ free-text route: a question awaiting text absorbs the whole message.
|
||||||
return;
|
const target = [...pending.textWanted].sort((a, b) => a - b)[0];
|
||||||
|
if (target !== undefined) {
|
||||||
|
parts = [[target, text.trim()]];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await this.enqueueNotice(chatId, `共 ${questions.length} 个问题,请每行用 1:/2: 前缀分别作答`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const answers = [];
|
|
||||||
for (const [idx, raw] of parts) {
|
for (const [idx, raw] of parts) {
|
||||||
if (idx === null || idx < 0 || idx >= questions.length) {
|
if (idx === null || idx < 0 || idx >= questions.length) {
|
||||||
await this.enqueueNotice(chatId, `题号 ${(idx ?? 0) + 1} 不存在(共 ${questions.length} 题)`);
|
await this.enqueueNotice(chatId, `题号 ${(idx ?? 0) + 1} 不存在(共 ${questions.length} 题)`);
|
||||||
@@ -1534,22 +1661,115 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
selected.push(opts[i].label);
|
selected.push(opts[i].label);
|
||||||
}
|
}
|
||||||
answers.push({ id: q.id, selected });
|
pending.textAnswers.set(idx, { id: q.id, selected });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
answers.push({ id: q.id, selected: [], custom: cleaned });
|
pending.textAnswers.set(idx, { id: q.id, selected: [], custom: cleaned });
|
||||||
|
}
|
||||||
|
pending.textWanted.delete(idx);
|
||||||
|
}
|
||||||
|
// Keyboard selections take precedence per question; resolve when complete.
|
||||||
|
if (!this.finalizeAskIfComplete(chatId, pending)) {
|
||||||
|
const done = questions.filter((_, i) => pending.kbSel.get(i)?.size || pending.textAnswers.has(i)).length;
|
||||||
|
await this.enqueueNotice(chatId, `已记录 ${done}/${questions.length} 题,请继续作答(按钮或 1:/2: 文本)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
|
||||||
|
async handleAskCallback(chatId, cq) {
|
||||||
|
const pending = this.pendingAsks.get(String(chatId));
|
||||||
|
if (!pending) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '该提问已失效(可能已在 Web 端作答)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (cq.data ?? '').slice(ASK_CB.length);
|
||||||
|
const sep = payload.indexOf(':');
|
||||||
|
const qi = Number(payload.slice(0, sep));
|
||||||
|
const action = payload.slice(sep + 1);
|
||||||
|
const q = pending.questions[qi];
|
||||||
|
if (!q) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '题目已失效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = Array.isArray(q.options) ? q.options : [];
|
||||||
|
if (action === 'custom') {
|
||||||
|
// Free-text path: discard button selections for this question and wait
|
||||||
|
// for the user's next text message (routed via textWanted).
|
||||||
|
pending.kbSel.delete(qi);
|
||||||
|
pending.textWanted.add(qi);
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '请直接发送你的答案文本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'ok') {
|
||||||
|
if (!pending.kbSel.get(qi)?.size) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '请先点选选项');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ask.answered = true;
|
else {
|
||||||
for (const [cid, p] of this.pendingAsks) {
|
const j = Number(action);
|
||||||
if (p.ask === ask)
|
if (!Number.isInteger(j) || j < 0 || j >= opts.length) {
|
||||||
this.pendingAsks.delete(cid);
|
await this.client.answerCallbackQuery(cq.id, '选项不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (q.multiSelect) {
|
||||||
|
const set = pending.kbSel.get(qi) ?? new Set();
|
||||||
|
if (set.has(j))
|
||||||
|
set.delete(j);
|
||||||
|
else
|
||||||
|
set.add(j);
|
||||||
|
pending.kbSel.set(qi, set);
|
||||||
|
await this.client.answerCallbackQuery(cq.id, set.size > 0 ? `已选 ${set.size} 项,选完点"提交所选"` : '已取消该选项');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pending.kbSel.set(qi, new Set([j]));
|
||||||
|
await this.client.answerCallbackQuery(cq.id, `已选:${opts[j].label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.finalizeAskIfComplete(chatId, pending) && action === 'ok') {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '已提交本题选择,请继续作答其他问题');
|
||||||
}
|
}
|
||||||
ask.resolve({ answers });
|
|
||||||
this.enqueueNotice(chatId, '已提交你的回答');
|
|
||||||
}
|
}
|
||||||
// ── approval: TG answering via dual-path race on the request waterfall ──
|
// ── approval: TG answering via a prototype interceptor ──
|
||||||
async onApprovalRequest(req, next) {
|
//
|
||||||
|
// Same root cause and same cure as ask_user: the 'approval/request'
|
||||||
|
// waterfall dispatches on a per-fiber events registry that this plugin's
|
||||||
|
// ctx.on never sees (dsh 0.1.5-rc.1), so ApprovalService.prototype.request
|
||||||
|
// is wrapped instead — the class is shared across instances and fibers.
|
||||||
|
installApprovalInterceptor() {
|
||||||
|
if (this.approvalInstalled)
|
||||||
|
return;
|
||||||
|
let svc;
|
||||||
|
try {
|
||||||
|
svc = this.ctx.get('approval');
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return; // service not ready yet — retried lazily from onSessionEvent
|
||||||
|
}
|
||||||
|
if (!svc)
|
||||||
|
return;
|
||||||
|
const proto = svc.constructor.prototype;
|
||||||
|
const prev = proto[kTgApprovalWrapped];
|
||||||
|
if (prev?.bridge === this) {
|
||||||
|
this.approvalInstalled = true;
|
||||||
|
this.approvalRestore = prev.restore;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prev?.restore(); // stale wrapper from a disposed bridge instance
|
||||||
|
const orig = proto.request;
|
||||||
|
const bridge = this;
|
||||||
|
const wrapped = function (req) {
|
||||||
|
return bridge.aroundApprovalRequest(this, req, orig);
|
||||||
|
};
|
||||||
|
proto.request = wrapped;
|
||||||
|
const restore = () => {
|
||||||
|
if (proto.request === wrapped)
|
||||||
|
proto.request = orig;
|
||||||
|
};
|
||||||
|
proto[kTgApprovalWrapped] = { restore, bridge: this };
|
||||||
|
this.approvalRestore = restore;
|
||||||
|
this.approvalInstalled = true;
|
||||||
|
}
|
||||||
|
aroundApprovalRequest(service, req, orig) {
|
||||||
const sessionId = req?.agent?.id !== undefined ? String(req.agent.id) : undefined;
|
const sessionId = req?.agent?.id !== undefined ? String(req.agent.id) : undefined;
|
||||||
const targets = sessionId !== undefined
|
const targets = sessionId !== undefined
|
||||||
? [...this.bindings.values()].filter((b) => b.sessionId === sessionId).map((b) => String(b.chatId))
|
? [...this.bindings.values()].filter((b) => b.sessionId === sessionId).map((b) => String(b.chatId))
|
||||||
@@ -1570,7 +1790,7 @@ export class TelegramBridge {
|
|||||||
};
|
};
|
||||||
if (req?.signal?.aborted) {
|
if (req?.signal?.aborted) {
|
||||||
rejectFn(new Error('approval withdrawn'));
|
rejectFn(new Error('approval withdrawn'));
|
||||||
return next();
|
return orig.call(service, req);
|
||||||
}
|
}
|
||||||
req?.signal?.addEventListener('abort', onAbort, { once: true });
|
req?.signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
@@ -1578,11 +1798,13 @@ export class TelegramBridge {
|
|||||||
const reason = req?.reason ? String(req.reason).slice(0, 200) : '';
|
const reason = req?.reason ? String(req.reason).slice(0, 200) : '';
|
||||||
for (const chatId of targets) {
|
for (const chatId of targets) {
|
||||||
this.pendingApprovalsTG.set(chatId, { toolName, ask });
|
this.pendingApprovalsTG.set(chatId, { toolName, ask });
|
||||||
this.enqueueNotice(Number(chatId), `权限审批:${toolName}${reason ? `\n(${reason})` : ''}\n回复 [A] 允许一次 / [B] 拒绝(/cancel 关闭 TG 审批)`);
|
void this.deliverApprovalPrompt(Number(chatId), toolName, reason).catch((err) => {
|
||||||
|
this.ctx.logger.error(this.redact(err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No bound TG chat: promise never settles — the UI remains the only answer path.
|
// No bound TG chat: promise never settles — the UI remains the only answer path.
|
||||||
const gui = next();
|
const gui = Promise.resolve().then(() => orig.call(service, req));
|
||||||
void gui.then(() => {
|
void gui.then(() => {
|
||||||
for (const [chatId, p] of [...this.pendingApprovalsTG]) {
|
for (const [chatId, p] of [...this.pendingApprovalsTG]) {
|
||||||
if (p.ask !== ask)
|
if (p.ask !== ask)
|
||||||
@@ -1601,6 +1823,44 @@ export class TelegramBridge {
|
|||||||
});
|
});
|
||||||
return Promise.race([promise, gui]);
|
return Promise.race([promise, gui]);
|
||||||
}
|
}
|
||||||
|
/** Approval prompt as native inline-keyboard buttons; falls back to a plain-text prompt. */
|
||||||
|
async deliverApprovalPrompt(chatId, toolName, reason) {
|
||||||
|
const keyboard = {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: '✅ 允许一次', callback_data: `${APR_CB}allow` },
|
||||||
|
{ text: '❌ 拒绝', callback_data: `${APR_CB}deny` },
|
||||||
|
]],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await this.withRetry(() => this.client.sendMessage(chatId, `权限审批:${toolName}${reason ? `\n(${reason})` : ''}`, undefined, keyboard));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.ctx.logger.error(this.redact(err));
|
||||||
|
// Never lose an approval: degrade to the text-answer prompt.
|
||||||
|
this.enqueueNotice(chatId, `权限审批:${toolName}${reason ? `\n(${reason})` : ''}\n回复 [A] 允许一次 / [B] 拒绝(/cancel 关闭 TG 审批)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async handleApprovalCallback(chatId, cq, data) {
|
||||||
|
const approval = this.pendingApprovalsTG.get(String(chatId));
|
||||||
|
if (!approval) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '该审批已失效(可能已在 Web 端处理)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allow = data === `${APR_CB}allow`;
|
||||||
|
approval.ask.answered = true;
|
||||||
|
for (const [cid, p] of this.pendingApprovalsTG) {
|
||||||
|
if (p.ask === approval.ask)
|
||||||
|
this.pendingApprovalsTG.delete(cid);
|
||||||
|
}
|
||||||
|
if (allow) {
|
||||||
|
approval.ask.resolve('allowed-once');
|
||||||
|
await this.client.answerCallbackQuery(cq.id, `已允许 ${approval.toolName} 执行一次`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
approval.ask.resolve('rejected');
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '已拒绝该工具执行');
|
||||||
|
}
|
||||||
|
}
|
||||||
async handleTgApproval(chatId, approval, text) {
|
async handleTgApproval(chatId, approval, text) {
|
||||||
const cleaned = text.trim().replace(/^\[+/, '').replace(/\]+$/, '').toUpperCase();
|
const cleaned = text.trim().replace(/^\[+/, '').replace(/\]+$/, '').toUpperCase();
|
||||||
if (cleaned === 'A' || cleaned === 'B') {
|
if (cleaned === 'A' || cleaned === 'B') {
|
||||||
|
|||||||
+8
-2
@@ -37,12 +37,17 @@ export class TelegramClient {
|
|||||||
redact(message) {
|
redact(message) {
|
||||||
return message.split(this.token).join('***');
|
return message.split(this.token).join('***');
|
||||||
}
|
}
|
||||||
async call(method, body) {
|
async call(method, body, timeoutMs = 15_000) {
|
||||||
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
||||||
try {
|
try {
|
||||||
const init = {
|
const init = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Without a client-side timeout a stalled proxy connection hangs the
|
||||||
|
// request forever — and the serialized notice queue wedges behind it,
|
||||||
|
// silently swallowing every later tool/ask notice. TimeoutError is
|
||||||
|
// caught below and surfaces as a normal (retryable) failure.
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
@@ -70,7 +75,8 @@ export class TelegramClient {
|
|||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
body.offset = offset;
|
body.offset = offset;
|
||||||
}
|
}
|
||||||
return this.call('getUpdates', body);
|
// Long-poll: client-side abort must outlive the server-side hold.
|
||||||
|
return this.call('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000);
|
||||||
}
|
}
|
||||||
async sendMessage(chatId, text, parseMode, replyMarkup) {
|
async sendMessage(chatId, text, parseMode, replyMarkup) {
|
||||||
const body = { chat_id: chatId, text };
|
const body = { chat_id: chatId, text };
|
||||||
|
|||||||
Vendored
+4
@@ -67,6 +67,10 @@ export type ParsedCommand = {
|
|||||||
type: 'rich';
|
type: 'rich';
|
||||||
text: string;
|
text: string;
|
||||||
arg?: string;
|
arg?: string;
|
||||||
|
} | {
|
||||||
|
type: 'goal';
|
||||||
|
text: string;
|
||||||
|
arg?: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'unbind';
|
type: 'unbind';
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ export function parseCommand(text) {
|
|||||||
return { type: 'status', text };
|
return { type: 'status', text };
|
||||||
case '/compact':
|
case '/compact':
|
||||||
return { type: 'compact', text };
|
return { type: 'compact', text };
|
||||||
|
case '/goal':
|
||||||
|
case '/目标':
|
||||||
|
// 目标语句是多词文本,保留完整参数(去掉命令名本身)
|
||||||
|
return { type: 'goal', text, arg: text.slice(command.length).trim() };
|
||||||
case '/rich':
|
case '/rich':
|
||||||
case '/render':
|
case '/render':
|
||||||
case '/setting': {
|
case '/setting': {
|
||||||
|
|||||||
+25
-1
@@ -12,6 +12,30 @@ function inlineToHtml(text) {
|
|||||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
/** Convert `> …` line groups into `<blockquote>` (Telegram renders it with the accent-colored left bar). */
|
||||||
|
function blocksToHtml(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const out = [];
|
||||||
|
let quote = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (quote.length === 0)
|
||||||
|
return;
|
||||||
|
out.push(`<blockquote>${inlineToHtml(quote.join('\n'))}</blockquote>`);
|
||||||
|
quote = [];
|
||||||
|
};
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('> '))
|
||||||
|
quote.push(line.slice(2));
|
||||||
|
else if (line === '>')
|
||||||
|
quote.push('');
|
||||||
|
else {
|
||||||
|
flush();
|
||||||
|
out.push(inlineToHtml(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
export function markdownToHtml(text) {
|
export function markdownToHtml(text) {
|
||||||
const parts = text.split(/```[\w-]*/);
|
const parts = text.split(/```[\w-]*/);
|
||||||
if (parts.length % 2 === 0)
|
if (parts.length % 2 === 0)
|
||||||
@@ -23,7 +47,7 @@ export function markdownToHtml(text) {
|
|||||||
html += `<pre>${escapeHtml(code)}</pre>`;
|
html += `<pre>${escapeHtml(code)}</pre>`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
html += inlineToHtml(parts[i]);
|
html += blocksToHtml(parts[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
Vendored
+2
-2
@@ -9,8 +9,8 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean;
|
allowAllUsers?: boolean;
|
||||||
maxMessageLength?: number;
|
maxMessageLength?: number;
|
||||||
pollingTimeoutSec?: number;
|
pollingTimeoutSec?: number;
|
||||||
/** 'rich' = Telegram native Rich Messages (needs a recent client);
|
/** 默认 'rich' = Telegram native Rich Messages(需较新客户端);
|
||||||
* anything else (default) = HTML compatibility mode that renders on all clients. */
|
* 显式 'html'/'compat' 等其它值 = 兼容模式,所有客户端可显示。 */
|
||||||
rendering?: string;
|
rendering?: string;
|
||||||
}
|
}
|
||||||
export declare const Config: Schema<TelegramChannelConfig>;
|
export declare const Config: Schema<TelegramChannelConfig>;
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ export const Config = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
rendering: Schema.string().default('html'),
|
rendering: Schema.string().default('rich'),
|
||||||
});
|
});
|
||||||
function resolveAllowedUserIds(config) {
|
function resolveAllowedUserIds(config) {
|
||||||
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
||||||
|
|||||||
+424
-159
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path'
|
|||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
||||||
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
import { isAuthorized } from './auth.js'
|
import { isAuthorized } from './auth.js'
|
||||||
import { resolveApiProxy } from './apiproxy.js'
|
import { resolveApiProxy } from './apiproxy.js'
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
type TelegramUpdate,
|
type TelegramUpdate,
|
||||||
} from './client.js'
|
} from './client.js'
|
||||||
import { MSG, LAST_CB, parseCommand } from './commands.js'
|
import { MSG, LAST_CB, parseCommand } from './commands.js'
|
||||||
import { markdownToHtml, splitMessage } from './format.js'
|
import { escapeHtml, markdownToHtml, splitMessage } from './format.js'
|
||||||
import { splitRichMarkdown } from './rich-format.js'
|
import { splitRichMarkdown } from './rich-format.js'
|
||||||
import { formatLastTurn, loadLastTurn } from './history.js'
|
import { formatLastTurn, loadLastTurn } from './history.js'
|
||||||
import { describeAgent, displayLabel, workspaceName } from './label.js'
|
import { describeAgent, displayLabel, workspaceName } from './label.js'
|
||||||
@@ -97,6 +97,12 @@ interface TgPendingAsk {
|
|||||||
questions: TgQuestionItem[]
|
questions: TgQuestionItem[]
|
||||||
ask: TgAnswerHandle
|
ask: TgAnswerHandle
|
||||||
at: number
|
at: number
|
||||||
|
/** question index → option indices chosen via inline-keyboard taps. */
|
||||||
|
kbSel: Map<number, Set<number>>
|
||||||
|
/** question index → answer parsed from a text reply (letters or custom). */
|
||||||
|
textAnswers: Map<number, { id?: string; selected: string[]; custom?: string }>
|
||||||
|
/** questions the user chose to answer with free text (✏️ button): the next text reply fills them. */
|
||||||
|
textWanted: Set<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** chatId → pending approval request mirrored to Telegram. */
|
/** chatId → pending approval request mirrored to Telegram. */
|
||||||
@@ -105,15 +111,6 @@ interface TgPendingApproval {
|
|||||||
ask: TgAnswerHandle
|
ask: TgAnswerHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Structural view of the user-questions seam (types live in the host's dsh-user-questions package). */
|
|
||||||
interface UserQuestionsServiceLike {
|
|
||||||
provider?: {
|
|
||||||
ask: (request: unknown) => Promise<unknown>
|
|
||||||
__tgHooked?: boolean
|
|
||||||
__tgRealAsk?: (request: unknown) => Promise<unknown>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loose view of the approval request passed through the `approval/request` waterfall hook. */
|
/** Loose view of the approval request passed through the `approval/request` waterfall hook. */
|
||||||
interface ApprovalRequestLike {
|
interface ApprovalRequestLike {
|
||||||
agent?: Agent
|
agent?: Agent
|
||||||
@@ -194,7 +191,15 @@ const MODEL_CB = 'mdl:'
|
|||||||
/** Use eff: (not me:) — short prefix, no collision with other callbacks. */
|
/** Use eff: (not me:) — short prefix, no collision with other callbacks. */
|
||||||
const EFFORT_CB = 'eff:'
|
const EFFORT_CB = 'eff:'
|
||||||
const BACK_MODEL_CB = 'mb'
|
const BACK_MODEL_CB = 'mb'
|
||||||
|
/** ask:{questionIndex}:{optionIndex|ok|custom} — inline-keyboard taps for ask_user_question. */
|
||||||
|
const ASK_CB = 'ask:'
|
||||||
|
/** apr:allow / apr:deny — inline-keyboard taps for tool approvals. */
|
||||||
|
const APR_CB = 'apr:'
|
||||||
const MAX_BUTTONS = 40
|
const MAX_BUTTONS = 40
|
||||||
|
/** Marker stored on UserQuestionService.prototype for the ask interceptor. */
|
||||||
|
const kTgAskWrapped = Symbol.for('dsh-telegram-channel.askWrapped')
|
||||||
|
/** Marker stored on ApprovalService.prototype for the approval interceptor. */
|
||||||
|
const kTgApprovalWrapped = Symbol.for('dsh-telegram-channel.approvalWrapped')
|
||||||
|
|
||||||
function lastContextKeyboard(): InlineKeyboardMarkup {
|
function lastContextKeyboard(): InlineKeyboardMarkup {
|
||||||
return {
|
return {
|
||||||
@@ -204,11 +209,28 @@ function lastContextKeyboard(): InlineKeyboardMarkup {
|
|||||||
|
|
||||||
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
/** Cap a single reasoning block so a long chain of thought can't flood the phone. */
|
||||||
|
const REASONING_QUOTE_MAX_CHARS = 800
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant message → markdown for delivery. Reasoning (thinking) blocks are
|
||||||
|
* kept, one `> ` quote line each — Telegram renders them as the accent-bar
|
||||||
|
* quote style the phone user expects for bash/thinking-style chatter.
|
||||||
|
*/
|
||||||
function contentToText(content: readonly ContentBlock[]): string {
|
function contentToText(content: readonly ContentBlock[]): string {
|
||||||
return content
|
const parts: string[] = []
|
||||||
.filter((block): block is TextBlock => block.type === 'text')
|
for (const block of content) {
|
||||||
.map((block) => block.text)
|
if (block.type === 'text' && typeof block.text === 'string') {
|
||||||
.join('')
|
parts.push(block.text)
|
||||||
|
} else if (block.type === 'reasoning' && typeof block.text === 'string') {
|
||||||
|
let thinking = block.text.trim()
|
||||||
|
if (!thinking) continue
|
||||||
|
if (thinking.length > REASONING_QUOTE_MAX_CHARS)
|
||||||
|
thinking = `${thinking.slice(0, REASONING_QUOTE_MAX_CHARS)}…`
|
||||||
|
parts.push(thinking.split('\n').map((line) => `> ${line}`).join('\n'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRichUnsupportedError(err: unknown): boolean {
|
function isRichUnsupportedError(err: unknown): boolean {
|
||||||
@@ -251,9 +273,12 @@ export class TelegramBridge {
|
|||||||
private readonly pendingAsks = new Map<string, TgPendingAsk>()
|
private readonly pendingAsks = new Map<string, TgPendingAsk>()
|
||||||
private hookTimer: ReturnType<typeof setTimeout> | undefined
|
private hookTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
private readonly pendingApprovalsTG = new Map<string, TgPendingApproval>()
|
private readonly pendingApprovalsTG = new Map<string, TgPendingApproval>()
|
||||||
private disposeApprovalHook: (() => void) | undefined
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
/** sessionId → thinking indicator state (one notice per reasoning phase) */
|
private askInstalled = false
|
||||||
private readonly thinkingSessions = new Map<string, boolean>()
|
private askRestore: (() => void) | undefined
|
||||||
|
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
|
||||||
|
private approvalInstalled = false
|
||||||
|
private approvalRestore: (() => void) | undefined
|
||||||
/** callId → tool name (tool/result failure notices) */
|
/** callId → tool name (tool/result failure notices) */
|
||||||
private readonly callNames = new Map<string, string>()
|
private readonly callNames = new Map<string, string>()
|
||||||
/** sessionId → latest todo snapshot (/mission) */
|
/** sessionId → latest todo snapshot (/mission) */
|
||||||
@@ -295,11 +320,13 @@ export class TelegramBridge {
|
|||||||
// Restore persisted bindings (survive hot reload / restart) and install
|
// Restore persisted bindings (survive hot reload / restart) and install
|
||||||
// the TG answering hooks (ask_user dual-path + approval dual-path).
|
// the TG answering hooks (ask_user dual-path + approval dual-path).
|
||||||
this.loadBindings()
|
this.loadBindings()
|
||||||
this.hookUserQuestions()
|
// ask_user interception: see installAskInterceptor — a prototype wrap of
|
||||||
this.disposeApprovalHook?.()
|
// UserQuestionService.prototype.ask. Plain ctx.on('user-questions/request')
|
||||||
this.disposeApprovalHook = (this.ctx as unknown as {
|
// registries never see the dispatch (the waterfall fires on a different
|
||||||
on: (event: string, handler: (req: ApprovalRequestLike, next: () => Promise<string>) => Promise<string>) => () => void
|
// per-fiber events registry in dsh 0.1.5-rc.1), so the prototype wrap —
|
||||||
}).on('approval/request', (req, next) => this.onApprovalRequest(req, next))
|
// which sees EVERY ask regardless of fiber/instance — is the reliable seam.
|
||||||
|
this.installAskInterceptor()
|
||||||
|
this.installApprovalInterceptor()
|
||||||
void this.client.setMyCommands([
|
void this.client.setMyCommands([
|
||||||
{ command: 'start', description: '欢迎与用法' },
|
{ command: 'start', description: '欢迎与用法' },
|
||||||
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
@@ -307,6 +334,7 @@ export class TelegramBridge {
|
|||||||
{ command: 'model', description: '切换当前绑定会话的模型' },
|
{ command: 'model', description: '切换当前绑定会话的模型' },
|
||||||
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
|
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
|
||||||
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
|
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
|
||||||
|
{ command: 'goal', description: '目标模式:/goal <目标>(clear/edit/pause/resume 管理)' },
|
||||||
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
|
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
|
||||||
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
||||||
{ command: 'stop', description: '中止当前正在运行的任务' },
|
{ command: 'stop', description: '中止当前正在运行的任务' },
|
||||||
@@ -343,7 +371,6 @@ export class TelegramBridge {
|
|||||||
this.noticeQueue.clear()
|
this.noticeQueue.clear()
|
||||||
this.pendingAsks.clear()
|
this.pendingAsks.clear()
|
||||||
this.pendingApprovalsTG.clear()
|
this.pendingApprovalsTG.clear()
|
||||||
this.thinkingSessions.clear()
|
|
||||||
this.callNames.clear()
|
this.callNames.clear()
|
||||||
this.lastTodos.clear()
|
this.lastTodos.clear()
|
||||||
for (const controller of this.compactAborts.values()) controller.abort()
|
for (const controller of this.compactAborts.values()) controller.abort()
|
||||||
@@ -355,8 +382,12 @@ export class TelegramBridge {
|
|||||||
clearTimeout(this.hookTimer)
|
clearTimeout(this.hookTimer)
|
||||||
this.hookTimer = undefined
|
this.hookTimer = undefined
|
||||||
}
|
}
|
||||||
this.disposeApprovalHook?.()
|
this.askRestore?.()
|
||||||
this.disposeApprovalHook = undefined
|
this.askRestore = undefined
|
||||||
|
this.askInstalled = false
|
||||||
|
this.approvalRestore?.()
|
||||||
|
this.approvalRestore = undefined
|
||||||
|
this.approvalInstalled = false
|
||||||
if (this.pollPromise) {
|
if (this.pollPromise) {
|
||||||
await this.pollPromise.catch(() => {})
|
await this.pollPromise.catch(() => {})
|
||||||
this.pollPromise = undefined
|
this.pollPromise = undefined
|
||||||
@@ -421,6 +452,9 @@ export class TelegramBridge {
|
|||||||
case 'compact':
|
case 'compact':
|
||||||
await this.requestCompact(chatId)
|
await this.requestCompact(chatId)
|
||||||
return
|
return
|
||||||
|
case 'goal':
|
||||||
|
await this.requestGoal(chatId, (parsed as { arg?: string }).arg)
|
||||||
|
return
|
||||||
case 'rich':
|
case 'rich':
|
||||||
await this.handleRichCommand(chatId, parsed.arg)
|
await this.handleRichCommand(chatId, parsed.arg)
|
||||||
return
|
return
|
||||||
@@ -488,6 +522,14 @@ export class TelegramBridge {
|
|||||||
const data = cq.data ?? ''
|
const data = cq.data ?? ''
|
||||||
const picker = this.pickers.get(String(chatId))
|
const picker = this.pickers.get(String(chatId))
|
||||||
|
|
||||||
|
if (data.startsWith(ASK_CB)) {
|
||||||
|
await this.handleAskCallback(chatId, cq)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.startsWith(APR_CB)) {
|
||||||
|
await this.handleApprovalCallback(chatId, cq, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (data === LAST_CB) {
|
if (data === LAST_CB) {
|
||||||
await this.client.answerCallbackQuery(cq.id)
|
await this.client.answerCallbackQuery(cq.id)
|
||||||
await this.sendLastTurn(chatId)
|
await this.sendLastTurn(chatId)
|
||||||
@@ -1012,6 +1054,41 @@ export class TelegramBridge {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条
|
||||||
|
* 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。
|
||||||
|
*/
|
||||||
|
private async requestGoal(chatId: number, arg?: string): Promise<void> {
|
||||||
|
const binding = this.bindings.get(String(chatId))
|
||||||
|
if (!binding) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const agent = await this.ensureLiveAgent(binding.sessionId)
|
||||||
|
if (!agent) {
|
||||||
|
this.bindings.delete(String(chatId))
|
||||||
|
await this.client.sendMessage(chatId, MSG.GONE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const commands = this.serviceOf<CommandsRuntimeLike>('commands')
|
||||||
|
if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'goal')) {
|
||||||
|
await this.client.sendMessage(chatId, '当前环境不支持 /goal(command-goal 未装配)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const line = `/goal${arg ? ` ${arg}` : ''}`
|
||||||
|
try {
|
||||||
|
const execution = await commands.execute(agent as never, line, [], new AbortController().signal)
|
||||||
|
if (execution === undefined) {
|
||||||
|
await this.client.sendMessage(chatId, '/goal 未执行:命令在运行时已注销')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = execution.result?.text ?? (execution.result?.kind === 'success' ? '完成' : '失败')
|
||||||
|
await this.client.sendMessage(chatId, text.slice(0, 3500))
|
||||||
|
} catch (err) {
|
||||||
|
await this.client.sendMessage(chatId, `/goal 执行失败:${this.redact(err).slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
private async runCompaction(
|
private async runCompaction(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
@@ -1255,6 +1332,8 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> {
|
private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> {
|
||||||
|
if (!this.askInstalled) this.installAskInterceptor()
|
||||||
|
if (!this.approvalInstalled) this.installApprovalInterceptor()
|
||||||
const id = String(session.id)
|
const id = String(session.id)
|
||||||
const targets = [...this.bindings.values()].filter((b) => b.sessionId === id)
|
const targets = [...this.bindings.values()].filter((b) => b.sessionId === id)
|
||||||
if (targets.length === 0) return
|
if (targets.length === 0) return
|
||||||
@@ -1269,7 +1348,6 @@ export class TelegramBridge {
|
|||||||
|
|
||||||
if (event.type === 'turn/end') {
|
if (event.type === 'turn/end') {
|
||||||
this.busySessions.delete(id)
|
this.busySessions.delete(id)
|
||||||
this.thinkingSessions.delete(id)
|
|
||||||
for (const b of targets) this.stopTypingHeartbeat(b.chatId)
|
for (const b of targets) this.stopTypingHeartbeat(b.chatId)
|
||||||
// Abnormal endings (API failure / abort / crash-recovery) must be
|
// Abnormal endings (API failure / abort / crash-recovery) must be
|
||||||
// visible on the phone — previously they ended in silence.
|
// visible on the phone — previously they ended in silence.
|
||||||
@@ -1294,7 +1372,6 @@ export class TelegramBridge {
|
|||||||
// Progress summary; ask_user is owned by the provider hook (avoids duplicates).
|
// Progress summary; ask_user is owned by the provider hook (avoids duplicates).
|
||||||
const name = event.data?.name ?? 'tool'
|
const name = event.data?.name ?? 'tool'
|
||||||
if (name === 'ask_user_question') return
|
if (name === 'ask_user_question') return
|
||||||
this.thinkingSessions.set(id, false)
|
|
||||||
if (event.data?.callId !== undefined) {
|
if (event.data?.callId !== undefined) {
|
||||||
if (this.callNames.size > 400) {
|
if (this.callNames.size > 400) {
|
||||||
const first = this.callNames.keys().next().value
|
const first = this.callNames.keys().next().value
|
||||||
@@ -1312,26 +1389,11 @@ export class TelegramBridge {
|
|||||||
|
|
||||||
if (event.type === 'assistant/message') {
|
if (event.type === 'assistant/message') {
|
||||||
const text = contentToText(event.data.message.content)
|
const text = contentToText(event.data.message.content)
|
||||||
this.thinkingSessions.set(id, false)
|
|
||||||
if (!text) return
|
if (!text) return
|
||||||
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)))
|
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'assistant/chunk') {
|
|
||||||
// Thinking indicator: once per reasoning phase, status only (no content).
|
|
||||||
const chunk = event.data?.chunk as { type?: string; block?: { type?: string } } | undefined
|
|
||||||
const ctype = String(chunk?.type ?? '')
|
|
||||||
const btype = String(chunk?.block?.type ?? '')
|
|
||||||
const isReasoning = ctype.includes('reasoning') || btype.includes('reasoning') || btype.includes('think')
|
|
||||||
if (isReasoning && this.thinkingSessions.get(id) !== true) {
|
|
||||||
this.thinkingSessions.set(id, true)
|
|
||||||
for (const b of targets)
|
|
||||||
this.enqueueNotice(b.chatId, '> Thinking…')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'tool/result') {
|
if (event.type === 'tool/result') {
|
||||||
// Only failures — successful results are too noisy for the phone.
|
// Only failures — successful results are too noisy for the phone.
|
||||||
const data = event.data as { error?: { name?: string; code?: string }; message?: { tool_use_id?: string; callId?: string } }
|
const data = event.data as { error?: { name?: string; code?: string }; message?: { tool_use_id?: string; callId?: string } }
|
||||||
@@ -1344,6 +1406,23 @@ export class TelegramBridge {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'goal/changed' 不在本插件的 SessionEvent 类型联合内,宽松断言读取。
|
||||||
|
const eventType = (event as { type: string }).type
|
||||||
|
if (eventType === 'goal/changed') {
|
||||||
|
// Goal mode lives on the phone too: surface phase transitions (blocked/complete
|
||||||
|
// especially) without waiting for the next assistant text.
|
||||||
|
const data = (event as { data?: { phase?: string; blockedReason?: { code?: string; message?: string } } }).data
|
||||||
|
const phase = data?.phase
|
||||||
|
if (phase === 'blocked' || phase === 'complete') {
|
||||||
|
const reason = data?.blockedReason
|
||||||
|
const label = phase === 'blocked'
|
||||||
|
? `目标被阻塞${reason ? `:${reason.code ?? ''} ${reason.message ?? ''}`.slice(0, 120) : ''}`
|
||||||
|
: '目标已达成 ✓'
|
||||||
|
for (const b of targets) this.enqueueNotice(b.chatId, `> ${label}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === 'todo/write') {
|
if (event.type === 'todo/write') {
|
||||||
const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
|
const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
|
||||||
this.lastTodos.set(id, todos as Array<{ content?: string; status?: string }>)
|
this.lastTodos.set(id, todos as Array<{ content?: string; status?: string }>)
|
||||||
@@ -1471,17 +1550,28 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
* Send a notice; a leading `> ` marks the body for quote rendering as a real
|
||||||
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
* `<blockquote>` (parse_mode HTML) — every current Telegram client draws the
|
||||||
* the blockquote entity at all and show the whole message as “not supported”.
|
* accent-colored left bar. Old-client compatibility is no longer required,
|
||||||
* Falls back to plain text when the send fails — a notice is never lost.
|
* but a failed HTML send still falls back to plain text — a notice is never lost.
|
||||||
*/
|
*/
|
||||||
private async deliverNotice(chatId: number, text: string): Promise<void> {
|
private async deliverNotice(chatId: number, text: string): Promise<void> {
|
||||||
const quoted = text.startsWith('> ')
|
const quoted = text.startsWith('> ')
|
||||||
const body = quoted ? text.slice(2) : text
|
const body = quoted ? text.slice(2) : text
|
||||||
|
if (quoted) {
|
||||||
|
try {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.client.sendMessage(chatId, `<blockquote>${escapeHtml(body)}</blockquote>`, 'HTML'),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
this.ctx.logger.warn(
|
||||||
|
`dsh-telegram-channel: blockquote notice failed (${this.redact(err)}), falling back to plain text`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body))
|
await this.withRetry(() => this.client.sendMessage(chatId, body))
|
||||||
return
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.ctx.logger.error(this.redact(err))
|
this.ctx.logger.error(this.redact(err))
|
||||||
}
|
}
|
||||||
@@ -1518,86 +1608,65 @@ export class TelegramBridge {
|
|||||||
this.heartbeats.clear()
|
this.heartbeats.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ask_user_question: TG answering via dual-path race with the UI ──
|
// ── ask_user_question: TG answering via a prototype interceptor ──
|
||||||
|
//
|
||||||
|
// dsh 0.1.5-rc.1 routes the 'user-questions/request' waterfall through a
|
||||||
|
// per-fiber events registry: plain ctx.on listeners in other fibers (this
|
||||||
|
// plugin included) never see the dispatch, so the waterfall hook cannot
|
||||||
|
// mirror questions to TG. The class is shared across instances though, so
|
||||||
|
// wrapping UserQuestionService.prototype.ask intercepts EVERY ask with its
|
||||||
|
// agent + questions, regardless of fiber. The wrap races the TG answer
|
||||||
|
// against the original ask (GUI path), preserving the old dual-path
|
||||||
|
// semantics. Restore-on-stop keeps hot reloads from stacking wrappers.
|
||||||
|
|
||||||
private userQuestions(): UserQuestionsServiceLike | undefined {
|
private installAskInterceptor(): void {
|
||||||
const ctx = this.ctx as Context & {
|
if (this.askInstalled) return
|
||||||
get?: (name: string, strict?: boolean) => unknown
|
let svc: { constructor: { prototype: Record<string | symbol, unknown> } } | undefined
|
||||||
userQuestions?: UserQuestionsServiceLike
|
|
||||||
}
|
|
||||||
if (typeof ctx.get === 'function') {
|
|
||||||
try {
|
|
||||||
const service = ctx.get('userQuestions') as UserQuestionsServiceLike | undefined
|
|
||||||
if (service) return service
|
|
||||||
} catch {
|
|
||||||
// Continue to the plain-object fallback used by tests and simple hosts.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return Object.prototype.hasOwnProperty.call(ctx, 'userQuestions')
|
svc = (this.ctx as unknown as { get: (name: string) => unknown }).get('userQuestions') as
|
||||||
? ctx.userQuestions
|
| { constructor: { prototype: Record<string | symbol, unknown> } }
|
||||||
: undefined
|
| undefined
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return // service not ready yet — retried lazily from onSessionEvent
|
||||||
}
|
}
|
||||||
}
|
if (!svc) return
|
||||||
|
const proto = svc.constructor.prototype
|
||||||
/**
|
const prev = proto[kTgAskWrapped] as { restore: () => void; bridge: unknown } | undefined
|
||||||
* Wrap the UI provider's ask() so Telegram gets a parallel answer path.
|
if (prev?.bridge === this) {
|
||||||
* `Promise.race` decides; the UI path is untouched. The TG promise NEVER
|
this.askInstalled = true
|
||||||
* settles when there is no bound chat — race would kill the UI's window
|
this.askRestore = prev.restore
|
||||||
* with that early rejection.
|
|
||||||
*/
|
|
||||||
private hookUserQuestions(attempt = 0): void {
|
|
||||||
try {
|
|
||||||
const provider = this.userQuestions()?.provider
|
|
||||||
if (provider) {
|
|
||||||
if (provider.__tgHooked && provider.__tgRealAsk) {
|
|
||||||
// Hot reload: re-point the wrapper at THIS bridge instance.
|
|
||||||
const realAsk = provider.__tgRealAsk
|
|
||||||
const self = this
|
|
||||||
provider.ask = function (request: unknown): Promise<unknown> {
|
|
||||||
const tg = self.registerTgAsk(request)
|
|
||||||
let gui: Promise<unknown>
|
|
||||||
try {
|
|
||||||
gui = realAsk(request)
|
|
||||||
} catch (err) {
|
|
||||||
tg.reject(err as Error)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request))
|
|
||||||
return Promise.race([tg.promise, gui])
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const realAsk = provider.ask.bind(provider) as (request: unknown) => Promise<unknown>
|
|
||||||
provider.__tgRealAsk = realAsk
|
|
||||||
const self = this
|
|
||||||
provider.ask = function (request: unknown): Promise<unknown> {
|
|
||||||
const tg = self.registerTgAsk(request)
|
|
||||||
let gui: Promise<unknown>
|
|
||||||
try {
|
|
||||||
gui = realAsk(request)
|
|
||||||
} catch (err) {
|
|
||||||
tg.reject(err as Error)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request))
|
|
||||||
return Promise.race([tg.promise, gui])
|
|
||||||
}
|
|
||||||
provider.__tgHooked = true
|
|
||||||
this.ctx.logger.info('dsh-telegram-channel: ask_user TG answering hook installed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (attempt === 0)
|
|
||||||
this.ctx.logger.warn(`dsh-telegram-channel: userQuestions hook deferred: ${this.redact(err)}`)
|
|
||||||
}
|
|
||||||
if (attempt >= 30) {
|
|
||||||
this.ctx.logger.warn('dsh-telegram-channel: user-questions provider never appeared; TG answering disabled')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.hookTimer = setTimeout(() => this.hookUserQuestions(attempt + 1), 2000)
|
prev?.restore() // stale wrapper from a disposed bridge instance
|
||||||
|
const orig = proto.ask as (this: unknown, request: unknown) => Promise<unknown>
|
||||||
|
const bridge = this
|
||||||
|
const wrapped = function (this: unknown, request: unknown): Promise<unknown> {
|
||||||
|
return bridge.aroundUserQuestionAsk(this, request, orig)
|
||||||
|
}
|
||||||
|
proto.ask = wrapped
|
||||||
|
const restore = (): void => {
|
||||||
|
if (proto.ask === wrapped) proto.ask = orig
|
||||||
|
}
|
||||||
|
proto[kTgAskWrapped] = { restore, bridge: this }
|
||||||
|
this.askRestore = restore
|
||||||
|
this.askInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private aroundUserQuestionAsk(
|
||||||
|
service: unknown,
|
||||||
|
request: unknown,
|
||||||
|
orig: (this: unknown, request: unknown) => Promise<unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const tg = this.registerTgAsk(request)
|
||||||
|
let gui: Promise<unknown>
|
||||||
|
try {
|
||||||
|
gui = Promise.resolve().then(() => orig.call(service, request))
|
||||||
|
} catch (err) {
|
||||||
|
tg.reject(err as Error)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
void gui.then(() => this.settleGuiSide(request), () => this.settleGuiSide(request))
|
||||||
|
return Promise.race([tg.promise, gui])
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerTgAsk(request: unknown): { promise: Promise<unknown>; reject: (err: Error) => void } {
|
private registerTgAsk(request: unknown): { promise: Promise<unknown>; reject: (err: Error) => void } {
|
||||||
@@ -1626,16 +1695,91 @@ export class TelegramBridge {
|
|||||||
let asked = false
|
let asked = false
|
||||||
for (const [chatId, binding] of this.bindings) {
|
for (const [chatId, binding] of this.bindings) {
|
||||||
if (binding.sessionId !== sessionId) continue
|
if (binding.sessionId !== sessionId) continue
|
||||||
this.pendingAsks.set(String(chatId), { req: request, sessionId, questions, ask, at: Date.now() })
|
const pending: TgPendingAsk = {
|
||||||
this.enqueueNotice(Number(chatId), this.formatAskPending(questions))
|
req: request,
|
||||||
|
sessionId,
|
||||||
|
questions,
|
||||||
|
ask,
|
||||||
|
at: Date.now(),
|
||||||
|
kbSel: new Map(),
|
||||||
|
textAnswers: new Map(),
|
||||||
|
textWanted: new Set(),
|
||||||
|
}
|
||||||
|
this.pendingAsks.set(String(chatId), pending)
|
||||||
|
void this.deliverAskPrompt(Number(chatId), pending)
|
||||||
asked = true
|
asked = true
|
||||||
}
|
}
|
||||||
if (asked) return { promise, reject: rejectFn }
|
|
||||||
}
|
}
|
||||||
// No bound TG chat: never settle — the UI remains the only answer path.
|
// No bound TG chat: never settle — the UI remains the only answer path.
|
||||||
return { promise, reject: rejectFn }
|
return { promise, reject: rejectFn }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Announce the incoming question, then one message per question (options as native inline-keyboard buttons). */
|
||||||
|
private async deliverAskPrompt(chatId: number, pending: TgPendingAsk): Promise<void> {
|
||||||
|
const { questions } = pending
|
||||||
|
try {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.client.sendMessage(
|
||||||
|
chatId,
|
||||||
|
`❓ 即将向你提问(共 ${questions.length} 题)——点按钮或回复文本作答;/cancel 关闭 TG 作答`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]!
|
||||||
|
const opts = Array.isArray(q.options) ? q.options : []
|
||||||
|
const header = questions.length > 1 ? `Q${i + 1}. ` : ''
|
||||||
|
if (opts.length === 0) {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.client.sendMessage(chatId, `${header}${q.question ?? ''}\n(开放问题,请直接回复文本)`),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const keyboard: InlineKeyboardMarkup = {
|
||||||
|
inline_keyboard: [
|
||||||
|
...opts.map((o, j) => [{
|
||||||
|
text: `${j + 1}. ${o.label}`.slice(0, 48),
|
||||||
|
callback_data: `${ASK_CB}${i}:${j}`,
|
||||||
|
}]),
|
||||||
|
// Standing third choice: free-text answer, always available.
|
||||||
|
[{ text: '✏️ 输入自定义答案', callback_data: `${ASK_CB}${i}:custom` }],
|
||||||
|
...(q.multiSelect ? [[{ text: '✅ 提交所选', callback_data: `${ASK_CB}${i}:ok` }]] : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
await this.withRetry(() => this.client.sendMessage(chatId, `${header}${q.question ?? ''}`, undefined, keyboard))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.ctx.logger.error(this.redact(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the pending ask once EVERY question has an answer (keyboard and/or text). */
|
||||||
|
private finalizeAskIfComplete(chatId: number, pending: TgPendingAsk): boolean {
|
||||||
|
const { questions, ask } = pending
|
||||||
|
const answers: Array<{ id?: string; selected: string[]; custom?: string }> = []
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]!
|
||||||
|
const opts = Array.isArray(q.options) ? q.options : []
|
||||||
|
const sel = pending.kbSel.get(i)
|
||||||
|
if (sel && sel.size > 0) {
|
||||||
|
answers.push({ id: q.id, selected: [...sel].sort((a, b) => a - b).map((j) => opts[j]!.label) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const text = pending.textAnswers.get(i)
|
||||||
|
if (text !== undefined) {
|
||||||
|
answers.push(text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ask.answered = true
|
||||||
|
for (const [cid, p] of this.pendingAsks) {
|
||||||
|
if (p.ask === ask) this.pendingAsks.delete(cid)
|
||||||
|
}
|
||||||
|
ask.resolve({ answers })
|
||||||
|
this.enqueueNotice(chatId, '已提交你的回答')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private settleGuiSide(request: unknown): void {
|
private settleGuiSide(request: unknown): void {
|
||||||
for (const [chatId, p] of [...this.pendingAsks]) {
|
for (const [chatId, p] of [...this.pendingAsks]) {
|
||||||
if (p.req !== request) continue
|
if (p.req !== request) continue
|
||||||
@@ -1646,24 +1790,8 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatAskPending(questions: TgQuestionItem[]): string {
|
|
||||||
const lines = ['待回答(回复字母选项,或直接输入自定义答案;/cancel 关闭 TG 作答)']
|
|
||||||
questions.forEach((q, i) => {
|
|
||||||
const prefix = questions.length > 1 ? `Q${i + 1}. ` : ''
|
|
||||||
lines.push(`${prefix}${q.question ?? ''}`)
|
|
||||||
const opts = Array.isArray(q.options) ? q.options : []
|
|
||||||
if (opts.length === 0) {
|
|
||||||
lines.push(' (开放问题,直接输入答案)')
|
|
||||||
} else {
|
|
||||||
opts.forEach((o, j) => lines.push(` [${String.fromCharCode(65 + j)}] ${o.label}${o.description ? ` — ${o.description}` : ''}`))
|
|
||||||
if (q.multiSelect) lines.push(' (可多选,如:A,B)')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTgAnswer(chatId: number, pending: TgPendingAsk, text: string): Promise<void> {
|
private async handleTgAnswer(chatId: number, pending: TgPendingAsk, text: string): Promise<void> {
|
||||||
const { questions, ask } = pending
|
const { questions } = pending
|
||||||
let parts: Array<[number | null, string]>
|
let parts: Array<[number | null, string]>
|
||||||
if (questions.length === 1) {
|
if (questions.length === 1) {
|
||||||
parts = [[0, text.trim()]]
|
parts = [[0, text.trim()]]
|
||||||
@@ -1676,11 +1804,16 @@ export class TelegramBridge {
|
|||||||
else parts.push([null, line])
|
else parts.push([null, line])
|
||||||
}
|
}
|
||||||
if (parts.some(([idx]) => idx === null)) {
|
if (parts.some(([idx]) => idx === null)) {
|
||||||
await this.enqueueNotice(chatId, `共 ${questions.length} 个问题,请每行用 1:/2: 前缀分别作答`)
|
// ✏️ free-text route: a question awaiting text absorbs the whole message.
|
||||||
return
|
const target = [...pending.textWanted].sort((a, b) => a - b)[0]
|
||||||
|
if (target !== undefined) {
|
||||||
|
parts = [[target, text.trim()]]
|
||||||
|
} else {
|
||||||
|
await this.enqueueNotice(chatId, `共 ${questions.length} 个问题,请每行用 1:/2: 前缀分别作答`)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const answers: Array<{ id?: string; selected: string[]; custom?: string }> = []
|
|
||||||
for (const [idx, raw] of parts) {
|
for (const [idx, raw] of parts) {
|
||||||
if (idx === null || idx < 0 || idx >= questions.length) {
|
if (idx === null || idx < 0 || idx >= questions.length) {
|
||||||
await this.enqueueNotice(chatId, `题号 ${(idx ?? 0) + 1} 不存在(共 ${questions.length} 题)`)
|
await this.enqueueNotice(chatId, `题号 ${(idx ?? 0) + 1} 不存在(共 ${questions.length} 题)`)
|
||||||
@@ -1703,24 +1836,115 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
selected.push(opts[i]!.label)
|
selected.push(opts[i]!.label)
|
||||||
}
|
}
|
||||||
answers.push({ id: q.id, selected })
|
pending.textAnswers.set(idx, { id: q.id, selected })
|
||||||
} else {
|
} else {
|
||||||
answers.push({ id: q.id, selected: [], custom: cleaned })
|
pending.textAnswers.set(idx, { id: q.id, selected: [], custom: cleaned })
|
||||||
}
|
}
|
||||||
|
pending.textWanted.delete(idx)
|
||||||
}
|
}
|
||||||
ask.answered = true
|
// Keyboard selections take precedence per question; resolve when complete.
|
||||||
for (const [cid, p] of this.pendingAsks) {
|
if (!this.finalizeAskIfComplete(chatId, pending)) {
|
||||||
if (p.ask === ask) this.pendingAsks.delete(cid)
|
const done = questions.filter((_, i) => pending.kbSel.get(i)?.size || pending.textAnswers.has(i)).length
|
||||||
|
await this.enqueueNotice(chatId, `已记录 ${done}/${questions.length} 题,请继续作答(按钮或 1:/2: 文本)`)
|
||||||
}
|
}
|
||||||
ask.resolve({ answers })
|
|
||||||
this.enqueueNotice(chatId, '已提交你的回答')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── approval: TG answering via dual-path race on the request waterfall ──
|
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
|
||||||
|
private async handleAskCallback(chatId: number, cq: { id: string; data?: string }): Promise<void> {
|
||||||
|
const pending = this.pendingAsks.get(String(chatId))
|
||||||
|
if (!pending) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '该提问已失效(可能已在 Web 端作答)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = (cq.data ?? '').slice(ASK_CB.length)
|
||||||
|
const sep = payload.indexOf(':')
|
||||||
|
const qi = Number(payload.slice(0, sep))
|
||||||
|
const action = payload.slice(sep + 1)
|
||||||
|
const q = pending.questions[qi]
|
||||||
|
if (!q) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '题目已失效')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const opts = Array.isArray(q.options) ? q.options : []
|
||||||
|
if (action === 'custom') {
|
||||||
|
// Free-text path: discard button selections for this question and wait
|
||||||
|
// for the user's next text message (routed via textWanted).
|
||||||
|
pending.kbSel.delete(qi)
|
||||||
|
pending.textWanted.add(qi)
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '请直接发送你的答案文本')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'ok') {
|
||||||
|
if (!pending.kbSel.get(qi)?.size) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '请先点选选项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const j = Number(action)
|
||||||
|
if (!Number.isInteger(j) || j < 0 || j >= opts.length) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '选项不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (q.multiSelect) {
|
||||||
|
const set = pending.kbSel.get(qi) ?? new Set<number>()
|
||||||
|
if (set.has(j)) set.delete(j)
|
||||||
|
else set.add(j)
|
||||||
|
pending.kbSel.set(qi, set)
|
||||||
|
await this.client.answerCallbackQuery(cq.id, set.size > 0 ? `已选 ${set.size} 项,选完点"提交所选"` : '已取消该选项')
|
||||||
|
} else {
|
||||||
|
pending.kbSel.set(qi, new Set([j]))
|
||||||
|
await this.client.answerCallbackQuery(cq.id, `已选:${opts[j]!.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.finalizeAskIfComplete(chatId, pending) && action === 'ok') {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '已提交本题选择,请继续作答其他问题')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async onApprovalRequest(
|
// ── approval: TG answering via a prototype interceptor ──
|
||||||
|
//
|
||||||
|
// Same root cause and same cure as ask_user: the 'approval/request'
|
||||||
|
// waterfall dispatches on a per-fiber events registry that this plugin's
|
||||||
|
// ctx.on never sees (dsh 0.1.5-rc.1), so ApprovalService.prototype.request
|
||||||
|
// is wrapped instead — the class is shared across instances and fibers.
|
||||||
|
|
||||||
|
private installApprovalInterceptor(): void {
|
||||||
|
if (this.approvalInstalled) return
|
||||||
|
let svc: { constructor: { prototype: Record<string | symbol, unknown> } } | undefined
|
||||||
|
try {
|
||||||
|
svc = (this.ctx as unknown as { get: (name: string) => unknown }).get('approval') as
|
||||||
|
| { constructor: { prototype: Record<string | symbol, unknown> } }
|
||||||
|
| undefined
|
||||||
|
} catch {
|
||||||
|
return // service not ready yet — retried lazily from onSessionEvent
|
||||||
|
}
|
||||||
|
if (!svc) return
|
||||||
|
const proto = svc.constructor.prototype
|
||||||
|
const prev = proto[kTgApprovalWrapped] as { restore: () => void; bridge: unknown } | undefined
|
||||||
|
if (prev?.bridge === this) {
|
||||||
|
this.approvalInstalled = true
|
||||||
|
this.approvalRestore = prev.restore
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev?.restore() // stale wrapper from a disposed bridge instance
|
||||||
|
const orig = proto.request as (this: unknown, req: unknown) => Promise<string>
|
||||||
|
const bridge = this
|
||||||
|
const wrapped = function (this: unknown, req: unknown): Promise<string> {
|
||||||
|
return bridge.aroundApprovalRequest(this, req as ApprovalRequestLike, orig as (this: unknown, req: ApprovalRequestLike) => Promise<string>)
|
||||||
|
}
|
||||||
|
proto.request = wrapped
|
||||||
|
const restore = (): void => {
|
||||||
|
if (proto.request === wrapped) proto.request = orig
|
||||||
|
}
|
||||||
|
proto[kTgApprovalWrapped] = { restore, bridge: this }
|
||||||
|
this.approvalRestore = restore
|
||||||
|
this.approvalInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private aroundApprovalRequest(
|
||||||
|
service: unknown,
|
||||||
req: ApprovalRequestLike,
|
req: ApprovalRequestLike,
|
||||||
next: () => Promise<string>,
|
orig: (this: unknown, req: ApprovalRequestLike) => Promise<string>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const sessionId = req?.agent?.id !== undefined ? String(req.agent.id) : undefined
|
const sessionId = req?.agent?.id !== undefined ? String(req.agent.id) : undefined
|
||||||
const targets = sessionId !== undefined
|
const targets = sessionId !== undefined
|
||||||
@@ -1741,7 +1965,7 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
if (req?.signal?.aborted) {
|
if (req?.signal?.aborted) {
|
||||||
rejectFn(new Error('approval withdrawn'))
|
rejectFn(new Error('approval withdrawn'))
|
||||||
return next()
|
return orig.call(service, req)
|
||||||
}
|
}
|
||||||
req?.signal?.addEventListener('abort', onAbort, { once: true })
|
req?.signal?.addEventListener('abort', onAbort, { once: true })
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
@@ -1749,11 +1973,13 @@ export class TelegramBridge {
|
|||||||
const reason = req?.reason ? String(req.reason).slice(0, 200) : ''
|
const reason = req?.reason ? String(req.reason).slice(0, 200) : ''
|
||||||
for (const chatId of targets) {
|
for (const chatId of targets) {
|
||||||
this.pendingApprovalsTG.set(chatId, { toolName, ask })
|
this.pendingApprovalsTG.set(chatId, { toolName, ask })
|
||||||
this.enqueueNotice(Number(chatId), `权限审批:${toolName}${reason ? `\n(${reason})` : ''}\n回复 [A] 允许一次 / [B] 拒绝(/cancel 关闭 TG 审批)`)
|
void this.deliverApprovalPrompt(Number(chatId), toolName, reason).catch((err) => {
|
||||||
|
this.ctx.logger.error(this.redact(err))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No bound TG chat: promise never settles — the UI remains the only answer path.
|
// No bound TG chat: promise never settles — the UI remains the only answer path.
|
||||||
const gui = next()
|
const gui = Promise.resolve().then(() => orig.call(service, req))
|
||||||
void gui.then(() => {
|
void gui.then(() => {
|
||||||
for (const [chatId, p] of [...this.pendingApprovalsTG]) {
|
for (const [chatId, p] of [...this.pendingApprovalsTG]) {
|
||||||
if (p.ask !== ask) continue
|
if (p.ask !== ask) continue
|
||||||
@@ -1771,6 +1997,45 @@ export class TelegramBridge {
|
|||||||
return Promise.race([promise, gui]) as Promise<string>
|
return Promise.race([promise, gui]) as Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Approval prompt as native inline-keyboard buttons; falls back to a plain-text prompt. */
|
||||||
|
private async deliverApprovalPrompt(chatId: number, toolName: string, reason: string): Promise<void> {
|
||||||
|
const keyboard: InlineKeyboardMarkup = {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: '✅ 允许一次', callback_data: `${APR_CB}allow` },
|
||||||
|
{ text: '❌ 拒绝', callback_data: `${APR_CB}deny` },
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.client.sendMessage(chatId, `权限审批:${toolName}${reason ? `\n(${reason})` : ''}`, undefined, keyboard),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
this.ctx.logger.error(this.redact(err))
|
||||||
|
// Never lose an approval: degrade to the text-answer prompt.
|
||||||
|
this.enqueueNotice(chatId, `权限审批:${toolName}${reason ? `\n(${reason})` : ''}\n回复 [A] 允许一次 / [B] 拒绝(/cancel 关闭 TG 审批)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleApprovalCallback(chatId: number, cq: { id: string; data?: string }, data: string): Promise<void> {
|
||||||
|
const approval = this.pendingApprovalsTG.get(String(chatId))
|
||||||
|
if (!approval) {
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '该审批已失效(可能已在 Web 端处理)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const allow = data === `${APR_CB}allow`
|
||||||
|
approval.ask.answered = true
|
||||||
|
for (const [cid, p] of this.pendingApprovalsTG) {
|
||||||
|
if (p.ask === approval.ask) this.pendingApprovalsTG.delete(cid)
|
||||||
|
}
|
||||||
|
if (allow) {
|
||||||
|
approval.ask.resolve('allowed-once')
|
||||||
|
await this.client.answerCallbackQuery(cq.id, `已允许 ${approval.toolName} 执行一次`)
|
||||||
|
} else {
|
||||||
|
approval.ask.resolve('rejected')
|
||||||
|
await this.client.answerCallbackQuery(cq.id, '已拒绝该工具执行')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleTgApproval(chatId: number, approval: TgPendingApproval, text: string): Promise<void> {
|
private async handleTgApproval(chatId: number, approval: TgPendingApproval, text: string): Promise<void> {
|
||||||
const cleaned = text.trim().replace(/^\[+/, '').replace(/\]+$/, '').toUpperCase()
|
const cleaned = text.trim().replace(/^\[+/, '').replace(/\]+$/, '').toUpperCase()
|
||||||
if (cleaned === 'A' || cleaned === 'B') {
|
if (cleaned === 'A' || cleaned === 'B') {
|
||||||
|
|||||||
+8
-2
@@ -153,12 +153,17 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
return message.split(this.token).join('***')
|
return message.split(this.token).join('***')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async call<T>(method: string, body?: Record<string, unknown>): Promise<T> {
|
private async call<T>(method: string, body?: Record<string, unknown>, timeoutMs = 15_000): Promise<T> {
|
||||||
const url = `${this.baseUrl}/bot${this.token}/${method}`
|
const url = `${this.baseUrl}/bot${this.token}/${method}`
|
||||||
try {
|
try {
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Without a client-side timeout a stalled proxy connection hangs the
|
||||||
|
// request forever — and the serialized notice queue wedges behind it,
|
||||||
|
// silently swallowing every later tool/ask notice. TimeoutError is
|
||||||
|
// caught below and surfaces as a normal (retryable) failure.
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
}
|
}
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
init.body = JSON.stringify(body)
|
init.body = JSON.stringify(body)
|
||||||
@@ -187,7 +192,8 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
body.offset = offset
|
body.offset = offset
|
||||||
}
|
}
|
||||||
return this.call<TelegramUpdate[]>('getUpdates', body)
|
// Long-poll: client-side abort must outlive the server-side hold.
|
||||||
|
return this.call<TelegramUpdate[]>('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export type ParsedCommand =
|
|||||||
| { type: 'status'; text: string }
|
| { type: 'status'; text: string }
|
||||||
| { type: 'compact'; text: string }
|
| { type: 'compact'; text: string }
|
||||||
| { type: 'rich'; text: string; arg?: string }
|
| { type: 'rich'; text: string; arg?: string }
|
||||||
|
| { type: 'goal'; text: string; arg?: string }
|
||||||
| { type: 'unbind'; text: string }
|
| { type: 'unbind'; text: string }
|
||||||
| { type: 'stop'; text: string }
|
| { type: 'stop'; text: string }
|
||||||
| { type: 'mission'; text: string }
|
| { type: 'mission'; text: string }
|
||||||
@@ -140,6 +141,10 @@ export function parseCommand(text: string): ParsedCommand {
|
|||||||
return { type: 'status', text }
|
return { type: 'status', text }
|
||||||
case '/compact':
|
case '/compact':
|
||||||
return { type: 'compact', text }
|
return { type: 'compact', text }
|
||||||
|
case '/goal':
|
||||||
|
case '/目标':
|
||||||
|
// 目标语句是多词文本,保留完整参数(去掉命令名本身)
|
||||||
|
return { type: 'goal', text, arg: text.slice(command.length).trim() }
|
||||||
case '/rich':
|
case '/rich':
|
||||||
case '/render':
|
case '/render':
|
||||||
case '/setting': {
|
case '/setting': {
|
||||||
|
|||||||
+23
-1
@@ -14,6 +14,28 @@ function inlineToHtml(text: string): string {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert `> …` line groups into `<blockquote>` (Telegram renders it with the accent-colored left bar). */
|
||||||
|
function blocksToHtml(text: string): string {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const out: string[] = []
|
||||||
|
let quote: string[] = []
|
||||||
|
const flush = (): void => {
|
||||||
|
if (quote.length === 0) return
|
||||||
|
out.push(`<blockquote>${inlineToHtml(quote.join('\n'))}</blockquote>`)
|
||||||
|
quote = []
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('> ')) quote.push(line.slice(2))
|
||||||
|
else if (line === '>') quote.push('')
|
||||||
|
else {
|
||||||
|
flush()
|
||||||
|
out.push(inlineToHtml(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
export function markdownToHtml(text: string): string {
|
export function markdownToHtml(text: string): string {
|
||||||
const parts = text.split(/```[\w-]*/)
|
const parts = text.split(/```[\w-]*/)
|
||||||
if (parts.length % 2 === 0) return inlineToHtml(text)
|
if (parts.length % 2 === 0) return inlineToHtml(text)
|
||||||
@@ -23,7 +45,7 @@ export function markdownToHtml(text: string): string {
|
|||||||
const code = parts[i]!.replace(/^\n/, '').replace(/\n$/, '')
|
const code = parts[i]!.replace(/^\n/, '').replace(/\n$/, '')
|
||||||
html += `<pre>${escapeHtml(code)}</pre>`
|
html += `<pre>${escapeHtml(code)}</pre>`
|
||||||
} else {
|
} else {
|
||||||
html += inlineToHtml(parts[i]!)
|
html += blocksToHtml(parts[i]!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html
|
return html
|
||||||
|
|||||||
+3
-3
@@ -12,8 +12,8 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean
|
allowAllUsers?: boolean
|
||||||
maxMessageLength?: number
|
maxMessageLength?: number
|
||||||
pollingTimeoutSec?: number
|
pollingTimeoutSec?: number
|
||||||
/** 'rich' = Telegram native Rich Messages (needs a recent client);
|
/** 默认 'rich' = Telegram native Rich Messages(需较新客户端);
|
||||||
* anything else (default) = HTML compatibility mode that renders on all clients. */
|
* 显式 'html'/'compat' 等其它值 = 兼容模式,所有客户端可显示。 */
|
||||||
rendering?: string
|
rendering?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
rendering: Schema.string().default('html'),
|
rendering: Schema.string().default('rich'),
|
||||||
})
|
})
|
||||||
|
|
||||||
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { TelegramBridge } from '../src/bridge.ts'
|
|||||||
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
|
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
|
||||||
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
|
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
|
||||||
|
|
||||||
|
// 防呆(2026-09-14 事故):binding 落盘路径 = DSH_HOME/telegram-channel-bindings.json。
|
||||||
|
// 少数用例直接构造 bridge 并 bind,未设 DSH_TELEGRAM_BINDINGS_FILE,就把 fixture
|
||||||
|
// (chatId 10 / live-img-dl)写进了真实文件、覆盖掉用户真实绑定。
|
||||||
|
// 这里把 DSH_HOME 指向临时目录兜底:显式 override 的用例仍走自己的临时文件,
|
||||||
|
// 漏设的用例只会写进临时 HOME。
|
||||||
|
process.env.DSH_HOME = '/tmp/dsh-tg-test-home'
|
||||||
|
|
||||||
type SentMessage = {
|
type SentMessage = {
|
||||||
chatId: number
|
chatId: number
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
Reference in New Issue
Block a user