diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 3d9184c..01a2dc8 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -42,10 +42,12 @@ export declare class TelegramBridge { private readonly pendingAsks; private hookTimer; private readonly pendingApprovalsTG; - private disposeApprovalHook; /** Prototype-ask interceptor state (see installAskInterceptor). */ private askInstalled; private askRestore; + /** Prototype-approval interceptor state (see installApprovalInterceptor). */ + private approvalInstalled; + private approvalRestore; /** callId → tool name (tool/result failure notices) */ private readonly callNames; /** sessionId → latest todo snapshot (/mission) */ @@ -140,7 +142,8 @@ export declare class TelegramBridge { private handleTgAnswer; /** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */ private handleAskCallback; - private onApprovalRequest; + private installApprovalInterceptor; + private aroundApprovalRequest; private handleTgApproval; private stopBound; private sendMission; diff --git a/lib/bridge.js b/lib/bridge.js index 01c2e17..f79080d 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -65,6 +65,8 @@ const ASK_CB = 'ask:'; 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() { return { inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]], @@ -132,10 +134,12 @@ export class TelegramBridge { pendingAsks = new Map(); hookTimer; pendingApprovalsTG = new Map(); - disposeApprovalHook; /** Prototype-ask interceptor state (see installAskInterceptor). */ askInstalled = false; askRestore; + /** Prototype-approval interceptor state (see installApprovalInterceptor). */ + approvalInstalled = false; + approvalRestore; /** callId → tool name (tool/result failure notices) */ callNames = new Map(); /** sessionId → latest todo snapshot (/mission) */ @@ -180,8 +184,7 @@ export class TelegramBridge { // 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.disposeApprovalHook?.(); - this.disposeApprovalHook = this.ctx.on('approval/request', (req, next) => this.onApprovalRequest(req, next)); + this.installApprovalInterceptor(); void this.client.setMyCommands([ { command: 'start', description: '欢迎与用法' }, { command: 'sessions', description: '按工作区列出并附着会话' }, @@ -240,8 +243,9 @@ export class TelegramBridge { this.askRestore?.(); this.askRestore = undefined; this.askInstalled = false; - this.disposeApprovalHook?.(); - this.disposeApprovalHook = undefined; + this.approvalRestore?.(); + this.approvalRestore = undefined; + this.approvalInstalled = false; if (this.pollPromise) { await this.pollPromise.catch(() => { }); this.pollPromise = undefined; @@ -1118,6 +1122,8 @@ export class TelegramBridge { async onSessionEvent(session, event) { if (!this.askInstalled) this.installAskInterceptor(); + if (!this.approvalInstalled) + this.installApprovalInterceptor(); const id = String(session.id); const targets = [...this.bindings.values()].filter((b) => b.sessionId === id); if (targets.length === 0) @@ -1661,8 +1667,47 @@ export class TelegramBridge { await this.client.answerCallbackQuery(cq.id, '已提交本题选择,请继续作答其他问题'); } } - // ── approval: TG answering via dual-path race on the request waterfall ── - async onApprovalRequest(req, next) { + // ── 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. + 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 targets = sessionId !== undefined ? [...this.bindings.values()].filter((b) => b.sessionId === sessionId).map((b) => String(b.chatId)) @@ -1683,7 +1728,7 @@ export class TelegramBridge { }; if (req?.signal?.aborted) { rejectFn(new Error('approval withdrawn')); - return next(); + return orig.call(service, req); } req?.signal?.addEventListener('abort', onAbort, { once: true }); if (targets.length > 0) { @@ -1695,7 +1740,7 @@ export class TelegramBridge { } } // 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(() => { for (const [chatId, p] of [...this.pendingApprovalsTG]) { if (p.ask !== ask) diff --git a/src/bridge.ts b/src/bridge.ts index 1427c5d..1904ed6 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -196,6 +196,8 @@ const ASK_CB = 'ask:' 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 { return { @@ -269,10 +271,12 @@ export class TelegramBridge { private readonly pendingAsks = new Map() private hookTimer: ReturnType | undefined private readonly pendingApprovalsTG = new Map() - private disposeApprovalHook: (() => void) | undefined /** Prototype-ask interceptor state (see installAskInterceptor). */ private askInstalled = false private askRestore: (() => void) | undefined + /** Prototype-approval interceptor state (see installApprovalInterceptor). */ + private approvalInstalled = false + private approvalRestore: (() => void) | undefined /** callId → tool name (tool/result failure notices) */ private readonly callNames = new Map() /** sessionId → latest todo snapshot (/mission) */ @@ -320,10 +324,7 @@ export class TelegramBridge { // 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.disposeApprovalHook?.() - this.disposeApprovalHook = (this.ctx as unknown as { - on: (event: string, handler: (req: ApprovalRequestLike, next: () => Promise) => Promise) => () => void - }).on('approval/request', (req, next) => this.onApprovalRequest(req, next)) + this.installApprovalInterceptor() void this.client.setMyCommands([ { command: 'start', description: '欢迎与用法' }, { command: 'sessions', description: '按工作区列出并附着会话' }, @@ -381,8 +382,9 @@ export class TelegramBridge { this.askRestore?.() this.askRestore = undefined this.askInstalled = false - this.disposeApprovalHook?.() - this.disposeApprovalHook = undefined + this.approvalRestore?.() + this.approvalRestore = undefined + this.approvalInstalled = false if (this.pollPromise) { await this.pollPromise.catch(() => {}) this.pollPromise = undefined @@ -1286,6 +1288,7 @@ export class TelegramBridge { private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise { if (!this.askInstalled) this.installAskInterceptor() + if (!this.approvalInstalled) this.installApprovalInterceptor() const id = String(session.id) const targets = [...this.bindings.values()].filter((b) => b.sessionId === id) if (targets.length === 0) return @@ -1836,11 +1839,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 } } | undefined + try { + svc = (this.ctx as unknown as { get: (name: string) => unknown }).get('approval') as + | { constructor: { prototype: Record } } + | 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 + const bridge = this + const wrapped = function (this: unknown, req: unknown): Promise { + return bridge.aroundApprovalRequest(this, req as ApprovalRequestLike, orig as (this: unknown, req: ApprovalRequestLike) => Promise) + } + 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, - next: () => Promise, + orig: (this: unknown, req: ApprovalRequestLike) => Promise, ): Promise { const sessionId = req?.agent?.id !== undefined ? String(req.agent.id) : undefined const targets = sessionId !== undefined @@ -1861,7 +1903,7 @@ export class TelegramBridge { } if (req?.signal?.aborted) { rejectFn(new Error('approval withdrawn')) - return next() + return orig.call(service, req) } req?.signal?.addEventListener('abort', onAbort, { once: true }) if (targets.length > 0) { @@ -1873,7 +1915,7 @@ export class TelegramBridge { } } // 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(() => { for (const [chatId, p] of [...this.pendingApprovalsTG]) { if (p.ask !== ask) continue