feat(approval): 审批改用 TG 原生 inline keyboard 按钮
- 审批请求以「✅ 允许一次 / ❌ 拒绝」双按钮消息呈现,点按钮即裁决并 toast 确认 - 按钮发送失败自动降级为原文本提示(回复 A/B),审批绝不丢失 - 文本作答通道保留(handleTgApproval 的 A/B 解析不变),/cancel 语义不变
This commit is contained in:
Vendored
+3
@@ -144,6 +144,9 @@ export declare class TelegramBridge {
|
|||||||
private handleAskCallback;
|
private handleAskCallback;
|
||||||
private installApprovalInterceptor;
|
private installApprovalInterceptor;
|
||||||
private aroundApprovalRequest;
|
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;
|
||||||
|
|||||||
+48
-2
@@ -60,8 +60,10 @@ 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');
|
||||||
@@ -378,6 +380,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);
|
||||||
@@ -1736,7 +1742,9 @@ 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.
|
||||||
@@ -1759,6 +1767,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') {
|
||||||
|
|||||||
+49
-2
@@ -191,8 +191,10 @@ 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')
|
||||||
@@ -520,6 +522,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)
|
||||||
@@ -1911,7 +1917,9 @@ 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.
|
||||||
@@ -1933,6 +1941,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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user