mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
commit
21ef695c08
@ -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('拒绝')
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 `` 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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user