diff --git a/adapters/common/__tests__/permission.test.ts b/adapters/common/__tests__/permission.test.ts index 2e817eaf..6fa01885 100644 --- a/adapters/common/__tests__/permission.test.ts +++ b/adapters/common/__tests__/permission.test.ts @@ -14,6 +14,19 @@ describe('permission helpers', () => { expect(parsePermissionCommand('/deny req-4')).toEqual({ requestId: 'req-4', allowed: false }) }) + it('parses short replies when one permission is pending', () => { + const pending = new Set(['req-1']) + expect(parsePermissionCommand('1', pending)).toEqual({ requestId: 'req-1', allowed: true }) + expect(parsePermissionCommand('2', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('3', pending)).toEqual({ requestId: 'req-1', allowed: false }) + expect(parsePermissionCommand('/always', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('永久允许', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + }) + + it('does not parse short replies when multiple permissions are pending', () => { + expect(parsePermissionCommand('1', new Set(['req-1', 'req-2']))).toBeNull() + }) + it('parses callback permission actions', () => { expect(parsePermitCallbackData('permit:req-1:yes')).toEqual({ requestId: 'req-1', allowed: true }) expect(parsePermitCallbackData('permit:req-2:always')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' }) @@ -22,6 +35,7 @@ describe('permission helpers', () => { }) it('formats text fallback and status labels', () => { + expect(formatPermissionInstructions('req-1')).toContain('回复 1') expect(formatPermissionInstructions('req-1')).toContain('/always req-1') expect(formatPermissionDecisionStatus({ allowed: true, rule: 'always' })).toContain('永久允许') expect(formatPermissionDecisionStatus({ allowed: false })).toContain('拒绝') diff --git a/adapters/common/permission.ts b/adapters/common/permission.ts index b40e2e0b..2937d796 100644 --- a/adapters/common/permission.ts +++ b/adapters/common/permission.ts @@ -4,15 +4,41 @@ export type PermissionDecision = { rule?: 'always' } -export function parsePermissionCommand(text: string): PermissionDecision | null { - const match = text.trim().match(/^\/(allow|always|allow-always|deny)\s+(\S+)/i) - if (!match) return null +function getSinglePendingRequestId(requestIds?: Iterable | null): string | null { + if (!requestIds) return null + const ids = Array.from(requestIds) + return ids.length === 1 ? ids[0]! : null +} - const action = match[1]!.toLowerCase() - const requestId = match[2]! - if (action === 'deny') return { requestId, allowed: false } - if (action === 'always' || action === 'allow-always') return { requestId, allowed: true, rule: 'always' } - return { requestId, allowed: true } +export function parsePermissionCommand( + text: string, + pendingRequestIds?: Iterable | null, +): PermissionDecision | null { + const trimmed = text.trim() + const match = text.trim().match(/^\/(allow|always|allow-always|deny)\s+(\S+)/i) + if (match) { + const action = match[1]!.toLowerCase() + const requestId = match[2]! + if (action === 'deny') return { requestId, allowed: false } + if (action === 'always' || action === 'allow-always') return { requestId, allowed: true, rule: 'always' } + return { requestId, allowed: true } + } + + const requestId = getSinglePendingRequestId(pendingRequestIds) + if (!requestId) return null + + const shortcut = trimmed.toLowerCase() + if (['1', '/1', 'allow', '/allow', 'y', 'yes', '允许', '允许一次', '同意', '批准'].includes(shortcut)) { + return { requestId, allowed: true } + } + if (['2', '/2', 'always', '/always', 'allow-always', '/allow-always', '永久允许', '一直允许'].includes(shortcut)) { + return { requestId, allowed: true, rule: 'always' } + } + if (['3', '/3', 'deny', '/deny', 'n', 'no', '拒绝', '不允许', '否'].includes(shortcut)) { + return { requestId, allowed: false } + } + + return null } export function parsePermitCallbackData(data: string): PermissionDecision | null { @@ -33,10 +59,9 @@ export function parsePermitCallbackData(data: string): PermissionDecision | null export function formatPermissionInstructions(requestId: string): string { return [ - `回复 /allow ${requestId} 允许一次`, - `/always ${requestId} 永久允许`, - `/deny ${requestId} 拒绝。`, - ].join(',') + '回复 1 允许一次,2 永久允许,3 拒绝。', + `也可回复 /allow ${requestId}、/always ${requestId}、/deny ${requestId}。`, + ].join('\n') } export function formatPermissionDecisionStatus(decision: Pick): string { diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index 15bafce1..b6637c11 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -456,7 +456,7 @@ async function sendPermissionRequest(chatId: string, msg: ServerMessage): Promis } function handlePermissionCommand(chatId: string, text: string): boolean { - const decision = parsePermissionCommand(text) + const decision = parsePermissionCommand(text, pendingPermissions.get(chatId)) if (!decision) return false const sent = applyPermissionDecision(chatId, decision) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index c58ab4ca..9fa535e9 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -18,8 +18,15 @@ import { getConfiguredWorkDir, loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, + formatPermissionRequest, splitMessage, } from '../common/format.js' +import { + formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, + type PermissionDecision, +} from '../common/permission.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' @@ -63,6 +70,7 @@ attachmentStore.gc().catch((err) => { const streamingCards = new Map() const pendingProjectSelection = new Map() const runtimeStates = new Map() +const pendingPermissions = new Map>() // Per-chat outbound watchers for Agent-produced markdown image references. // `imageWatchers` extracts `![alt](src)` from streaming text; @@ -205,6 +213,7 @@ function clearTransientChatState(chatId: string): void { runtime.state = 'idle' runtime.verb = undefined runtime.pendingPermissionCount = 0 + pendingPermissions.delete(chatId) } async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { @@ -728,6 +737,7 @@ async function startNewSession(chatId: string, query?: string): Promise { imageWatchers.delete(chatId) uploadedImageKeys.delete(chatId) pendingProjectSelection.delete(chatId) + pendingPermissions.delete(chatId) runtimeStates.delete(chatId) if (query) { @@ -855,6 +865,9 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'permission_request': { runtime.pendingPermissionCount += 1 runtime.state = 'permission_pending' + const pending = pendingPermissions.get(chatId) ?? new Set() + pending.add(msg.requestId) + pendingPermissions.set(chatId, pending) const stored = sessionStore.get(chatId) const card = buildPermissionCard( msg.toolName, @@ -862,7 +875,13 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< msg.requestId, stored?.workDir, ) - await sendCard(chatId, card) + const cardId = await sendCard(chatId, card) + if (!cardId) { + await sendText( + chatId, + `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n${formatPermissionInstructions(msg.requestId)}`, + ) + } break } @@ -999,6 +1018,12 @@ async function handleMessage(data: any): Promise { // ----- Commands (only when there are no attachments — `command + image` // isn't a meaningful combo, so attachments always take precedence) ----- + const permissionDecision = !hasAttachments ? parsePermissionCommand(msgText, pendingPermissions.get(chatId)) : null + if (permissionDecision) { + await handlePermissionDecision(chatId, permissionDecision) + return + } + if (!hasAttachments && (msgText === '/new' || msgText === '新会话' || msgText.startsWith('/new '))) { const arg = msgText.startsWith('/new ') ? msgText.slice(5).trim() : '' await startNewSession(chatId, arg || undefined) @@ -1142,6 +1167,31 @@ async function handleMessage(data: any): Promise { }) } +function applyPermissionDecision(chatId: string, decision: PermissionDecision): boolean { + const { requestId, allowed, rule } = decision + const pending = pendingPermissions.get(chatId) + if (!pending?.has(requestId)) { + void sendText(chatId, `未找到待确认的权限请求:${requestId}`) + return false + } + + const sent = bridge.sendPermissionResponse(chatId, requestId, allowed, rule) + if (!sent) { + void sendText(chatId, '权限响应发送失败,请检查会话状态。') + return false + } + + pending.delete(requestId) + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + return true +} + +async function handlePermissionDecision(chatId: string, decision: PermissionDecision): Promise { + const sent = applyPermissionDecision(chatId, decision) + if (sent) await sendText(chatId, `${formatPermissionDecisionStatus(decision)}。`) +} + async function handleCardAction(data: any): Promise { const event = data as { operator?: { open_id?: string } @@ -1168,9 +1218,12 @@ async function handleCardAction(data: any): Promise { const rule = event.action?.value?.rule if (!requestId) return - bridge.sendPermissionResponse(chatId, requestId, allowed, rule) - const runtime = getRuntimeState(chatId) - runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + const sent = applyPermissionDecision(chatId, { + requestId, + allowed, + rule: rule === 'always' ? 'always' : undefined, + }) + if (!sent) return { toast: { type: 'warning', content: '权限响应发送失败' } } const statusText = allowed ? rule === 'always' diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index 90cba96b..a3985169 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -20,7 +20,10 @@ import { } from '../common/format.js' import { formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, parsePermitCallbackData, + type PermissionDecision, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient } from '../common/http-client.js' @@ -64,6 +67,7 @@ const buffers = new Map() // Track chats waiting for project selection const pendingProjectSelection = new Map() const runtimeStates = new Map() +const pendingPermissions = new Map>() /** Per-chat outbound image watcher for Agent-produced markdown images. */ const tgImageWatchers = new Map() @@ -113,9 +117,29 @@ function clearTransientChatState(chatId: string): void { runtime.state = 'idle' runtime.verb = undefined runtime.pendingPermissionCount = 0 + pendingPermissions.delete(chatId) tgImageWatchers.delete(chatId) } +async function handlePermissionDecision(chatId: string, decision: PermissionDecision): Promise { + const pending = pendingPermissions.get(chatId) + if (!pending?.has(decision.requestId)) { + await bot.api.sendMessage(Number(chatId), `未找到待确认的权限请求:${decision.requestId}`) + return + } + + const sent = bridge.sendPermissionResponse(chatId, decision.requestId, decision.allowed, decision.rule) + if (sent) { + pending.delete(decision.requestId) + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + } + await bot.api.sendMessage( + Number(chatId), + sent ? `${formatPermissionDecisionStatus(decision)}。` : '权限响应发送失败,请检查会话状态。', + ) +} + async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { const stored = sessionStore.get(chatId) if (!stored) return null @@ -412,7 +436,10 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'permission_request': { runtime.pendingPermissionCount += 1 runtime.state = 'permission_pending' - const text = formatPermissionRequest(msg.toolName, msg.input, msg.requestId) + const pending = pendingPermissions.get(chatId) ?? new Set() + pending.add(msg.requestId) + pendingPermissions.set(chatId, pending) + const text = `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n${formatPermissionInstructions(msg.requestId)}` const keyboard = new InlineKeyboard() .text('✅ 允许', `permit:${msg.requestId}:yes`) .text('♾️ 永久允许', `permit:${msg.requestId}:always`) @@ -504,6 +531,7 @@ async function startNewSession(chatId: string, query?: string): Promise { buffers.get(chatId)?.reset() buffers.delete(chatId) pendingProjectSelection.delete(chatId) + pendingPermissions.delete(chatId) runtimeStates.delete(chatId) tgImageWatchers.delete(chatId) @@ -587,6 +615,12 @@ bot.command('clear', (ctx) => { })() }) +for (const command of ['allow', 'always', 'allow-always', 'deny'] as const) { + bot.command(command, async (ctx) => { + await routeUserMessage(ctx, `/${command}${ctx.match ? ` ${ctx.match}` : ''}`, []) + }) +} + /** Shared per-user-message pipeline: dedup, pairing check, project-pick * routing, enqueue, ensureSession, sendUserMessage with attachments. * Caller has already extracted text and attachments from the context. */ @@ -613,6 +647,14 @@ async function routeUserMessage( } enqueue(chatId, async () => { + const permissionDecision = attachments.length === 0 + ? parsePermissionCommand(text, pendingPermissions.get(chatId)) + : null + if (permissionDecision) { + await handlePermissionDecision(chatId, permissionDecision) + return + } + if (pendingProjectSelection.has(chatId)) { if (text.trim()) await startNewSession(chatId, text.trim()) return @@ -727,6 +769,7 @@ bot.on('callback_query:data', async (ctx) => { bridge.sendPermissionResponse(chatId, decision.requestId, decision.allowed, decision.rule) const runtime = getRuntimeState(chatId) runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + pendingPermissions.get(chatId)?.delete(decision.requestId) const statusText = formatPermissionDecisionStatus(decision) try { diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index 273e71e3..1f7d99d4 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -502,7 +502,7 @@ async function routeUserMessage(message: WechatMessage): Promise { await sendText(chatId, sent ? '已清空当前会话上下文。' : '无法发送 /clear,请先发送 /new 重新连接会话。') return } - const permissionDecision = !hasAttachments ? parsePermissionCommand(text) : null + const permissionDecision = !hasAttachments ? parsePermissionCommand(text, pendingPermissions.get(chatId)) : null if (permissionDecision) { const { requestId, allowed, rule } = permissionDecision const pending = pendingPermissions.get(chatId)