Compare commits
5
Commits
0b946ffaa4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e95c2631 | ||
|
|
85a42f28a6 | ||
|
|
f49ac5d3c0 | ||
|
|
566932e0ae | ||
|
|
6d00225cc4 |
Vendored
+13
-2
@@ -42,10 +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). */
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
private askInstalled;
|
private askInstalled;
|
||||||
private askRestore;
|
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) */
|
||||||
@@ -83,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;
|
||||||
@@ -140,7 +147,11 @@ export declare class TelegramBridge {
|
|||||||
private handleTgAnswer;
|
private handleTgAnswer;
|
||||||
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
|
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
|
||||||
private handleAskCallback;
|
private handleAskCallback;
|
||||||
private onApprovalRequest;
|
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;
|
||||||
|
|||||||
+158
-11
@@ -60,11 +60,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} — inline-keyboard taps for ask_user_question. */
|
/** ask:{questionIndex}:{optionIndex|ok|custom} — inline-keyboard taps for ask_user_question. */
|
||||||
const ASK_CB = 'ask:';
|
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. */
|
/** Marker stored on UserQuestionService.prototype for the ask interceptor. */
|
||||||
const kTgAskWrapped = Symbol.for('dsh-telegram-channel.askWrapped');
|
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 }]],
|
||||||
@@ -132,10 +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). */
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
askInstalled = false;
|
askInstalled = false;
|
||||||
askRestore;
|
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) */
|
||||||
@@ -180,8 +186,7 @@ export class TelegramBridge {
|
|||||||
// per-fiber events registry in dsh 0.1.5-rc.1), so the prototype wrap —
|
// 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.
|
// which sees EVERY ask regardless of fiber/instance — is the reliable seam.
|
||||||
this.installAskInterceptor();
|
this.installAskInterceptor();
|
||||||
this.disposeApprovalHook?.();
|
this.installApprovalInterceptor();
|
||||||
this.disposeApprovalHook = this.ctx.on('approval/request', (req, next) => this.onApprovalRequest(req, next));
|
|
||||||
void this.client.setMyCommands([
|
void this.client.setMyCommands([
|
||||||
{ command: 'start', description: '欢迎与用法' },
|
{ command: 'start', description: '欢迎与用法' },
|
||||||
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
@@ -189,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: '中止当前正在运行的任务' },
|
||||||
@@ -240,8 +246,9 @@ export class TelegramBridge {
|
|||||||
this.askRestore?.();
|
this.askRestore?.();
|
||||||
this.askRestore = undefined;
|
this.askRestore = undefined;
|
||||||
this.askInstalled = false;
|
this.askInstalled = false;
|
||||||
this.disposeApprovalHook?.();
|
this.approvalRestore?.();
|
||||||
this.disposeApprovalHook = undefined;
|
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;
|
||||||
@@ -303,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;
|
||||||
@@ -374,6 +384,10 @@ export class TelegramBridge {
|
|||||||
await this.handleAskCallback(chatId, cq);
|
await this.handleAskCallback(chatId, cq);
|
||||||
return;
|
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);
|
||||||
@@ -885,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 {
|
||||||
@@ -1118,6 +1167,8 @@ export class TelegramBridge {
|
|||||||
async onSessionEvent(session, event) {
|
async onSessionEvent(session, event) {
|
||||||
if (!this.askInstalled)
|
if (!this.askInstalled)
|
||||||
this.installAskInterceptor();
|
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)
|
||||||
@@ -1192,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);
|
||||||
@@ -1661,8 +1729,47 @@ export class TelegramBridge {
|
|||||||
await this.client.answerCallbackQuery(cq.id, '已提交本题选择,请继续作答其他问题');
|
await this.client.answerCallbackQuery(cq.id, '已提交本题选择,请继续作答其他问题');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── 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))
|
||||||
@@ -1683,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) {
|
||||||
@@ -1691,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)
|
||||||
@@ -1714,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') {
|
||||||
|
|||||||
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': {
|
||||||
|
|||||||
+159
-14
@@ -191,11 +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} — inline-keyboard taps for ask_user_question. */
|
/** ask:{questionIndex}:{optionIndex|ok|custom} — inline-keyboard taps for ask_user_question. */
|
||||||
const ASK_CB = 'ask:'
|
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. */
|
/** Marker stored on UserQuestionService.prototype for the ask interceptor. */
|
||||||
const kTgAskWrapped = Symbol.for('dsh-telegram-channel.askWrapped')
|
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 {
|
||||||
@@ -269,10 +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). */
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
private askInstalled = false
|
private askInstalled = false
|
||||||
private askRestore: (() => void) | undefined
|
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) */
|
||||||
@@ -320,10 +326,7 @@ export class TelegramBridge {
|
|||||||
// per-fiber events registry in dsh 0.1.5-rc.1), so the prototype wrap —
|
// 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.
|
// which sees EVERY ask regardless of fiber/instance — is the reliable seam.
|
||||||
this.installAskInterceptor()
|
this.installAskInterceptor()
|
||||||
this.disposeApprovalHook?.()
|
this.installApprovalInterceptor()
|
||||||
this.disposeApprovalHook = (this.ctx as unknown as {
|
|
||||||
on: (event: string, handler: (req: ApprovalRequestLike, next: () => Promise<string>) => Promise<string>) => () => void
|
|
||||||
}).on('approval/request', (req, next) => this.onApprovalRequest(req, next))
|
|
||||||
void this.client.setMyCommands([
|
void this.client.setMyCommands([
|
||||||
{ command: 'start', description: '欢迎与用法' },
|
{ command: 'start', description: '欢迎与用法' },
|
||||||
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
@@ -331,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: '中止当前正在运行的任务' },
|
||||||
@@ -381,8 +385,9 @@ export class TelegramBridge {
|
|||||||
this.askRestore?.()
|
this.askRestore?.()
|
||||||
this.askRestore = undefined
|
this.askRestore = undefined
|
||||||
this.askInstalled = false
|
this.askInstalled = false
|
||||||
this.disposeApprovalHook?.()
|
this.approvalRestore?.()
|
||||||
this.disposeApprovalHook = undefined
|
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
|
||||||
@@ -447,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
|
||||||
@@ -518,6 +526,10 @@ export class TelegramBridge {
|
|||||||
await this.handleAskCallback(chatId, cq)
|
await this.handleAskCallback(chatId, cq)
|
||||||
return
|
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)
|
||||||
@@ -1042,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,
|
||||||
@@ -1286,6 +1333,7 @@ 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.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
|
||||||
@@ -1358,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 }>)
|
||||||
@@ -1836,11 +1901,50 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── approval: TG answering via dual-path race on the request waterfall ──
|
// ── 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 async onApprovalRequest(
|
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
|
||||||
@@ -1861,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) {
|
||||||
@@ -1869,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
|
||||||
@@ -1891,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') {
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
Reference in New Issue
Block a user