Merge mobile-friendly IM permission approvals into main

The worktree adds short mobile replies for IM permission prompts while preserving existing requestId-based authorization semantics. Main already had a separate desktop reply visibility fix, so this merge brings the adapter authorization work across without flattening either line of history.

Constraint: Main worktree had unrelated local documentation and adapter edits, so they were stashed before merging
Confidence: high
Scope-risk: moderate
Directive: Keep requestId validation as the source of truth for all IM permission responses
Tested: cd adapters && bun test common/ telegram/ feishu/ dingtalk/ wechat/
Tested: cd adapters && bunx tsc --noEmit
Tested: bun run check:adapters
Not-tested: Post-merge full PR quality gate; earlier gate is blocked by repository policy without allow-cli-core-change approval
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 21:07:22 +08:00
commit 21ef695c08
6 changed files with 154 additions and 19 deletions

View File

@ -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('拒绝')

View File

@ -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<string> | 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<string> | 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<PermissionDecision, 'allowed' | 'rule'>): string {

View File

@ -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)

View File

@ -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<string, StreamingCard>()
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const pendingPermissions = new Map<string, Set<string>>()
// 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<void> {
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<string>()
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<void> {
// ----- 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<void> {
})
}
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<void> {
const sent = applyPermissionDecision(chatId, decision)
if (sent) await sendText(chatId, `${formatPermissionDecisionStatus(decision)}`)
}
async function handleCardAction(data: any): Promise<any> {
const event = data as {
operator?: { open_id?: string }
@ -1168,9 +1218,12 @@ async function handleCardAction(data: any): Promise<any> {
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'

View File

@ -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<string, MessageBuffer>()
// Track chats waiting for project selection
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const pendingPermissions = new Map<string, Set<string>>()
/** Per-chat outbound image watcher for Agent-produced markdown images. */
const tgImageWatchers = new Map<string, ImageBlockWatcher>()
@ -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<void> {
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<string>()
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<void> {
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 {

View File

@ -502,7 +502,7 @@ async function routeUserMessage(message: WechatMessage): Promise<void> {
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)