From f1c5f86b80c79dbc5e26e9c936ea318830faca04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 4 May 2026 19:03:36 +0800 Subject: [PATCH] feat: keep IM permission approvals consistent DingTalk can receive interactive card callbacks, but the card UI still depends on a published template, so the adapter now supports a template-backed card path with text commands as the reliable fallback. Telegram and WeChat use the same allow-once, allow-always, and deny semantics so manual authorization behaves the same across platforms. Constraint: DingTalk button rendering requires an operator-provided interactive card template id Rejected: Treat the existing AI streaming card template as a permission card | it cannot guarantee visible action buttons Confidence: high Scope-risk: moderate Directive: Do not remove the text approval fallback unless DingTalk card template provisioning is guaranteed Tested: bun test adapters/common/__tests__/permission.test.ts adapters/dingtalk/__tests__/permission-card.test.ts adapters/telegram/__tests__/telegram.test.ts adapters/common/__tests__/config.test.ts src/server/__tests__/adapters.test.ts Tested: bunx tsc -p adapters/tsconfig.json --noEmit Tested: bun run check:adapters Tested: cd desktop && bun run build Tested: bun run check:server Tested: bun run check:docs Tested: git diff --check Not-tested: bun run quality:pr is blocked by existing CLI core approval policy; see artifacts/quality-runs/2026-05-03T13-19-56-232Z/report.md --- adapters/common/__tests__/config.test.ts | 20 +++ adapters/common/__tests__/permission.test.ts | 29 ++++ adapters/common/config.ts | 2 + adapters/common/permission.ts | 45 ++++++ .../__tests__/permission-card.test.ts | 47 ++++++ adapters/dingtalk/ai-card.ts | 23 ++- adapters/dingtalk/index.ts | 126 +++++++++++++--- adapters/dingtalk/permission-card.ts | 135 ++++++++++++++++++ adapters/telegram/__tests__/telegram.test.ts | 19 +-- adapters/telegram/index.ts | 17 ++- adapters/wechat/index.ts | 29 ++-- desktop/src/i18n/locales/en.ts | 3 + desktop/src/i18n/locales/zh.ts | 3 + desktop/src/pages/AdapterSettings.tsx | 12 ++ desktop/src/types/adapter.ts | 1 + docs/im/dingtalk.md | 8 +- docs/im/telegram.md | 1 + docs/im/wechat.md | 3 +- src/server/__tests__/adapters.test.ts | 3 + src/server/services/adapterService.ts | 1 + 20 files changed, 475 insertions(+), 52 deletions(-) create mode 100644 adapters/common/__tests__/permission.test.ts create mode 100644 adapters/common/permission.ts create mode 100644 adapters/dingtalk/__tests__/permission-card.test.ts create mode 100644 adapters/dingtalk/permission-card.ts diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts index 89fe8558..6fa34847 100644 --- a/adapters/common/__tests__/config.test.ts +++ b/adapters/common/__tests__/config.test.ts @@ -8,12 +8,14 @@ describe('adapter config defaults', () => { const originalConfigDir = process.env.CLAUDE_CONFIG_DIR const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR const originalAdapterDefaultProjectDir = process.env.ADAPTER_DEFAULT_PROJECT_DIR + const originalDingtalkPermissionCardTemplateId = process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID const originalPwd = process.env.PWD afterEach(() => { restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir) restoreEnv('ADAPTER_DEFAULT_PROJECT_DIR', originalAdapterDefaultProjectDir) + restoreEnv('DINGTALK_PERMISSION_CARD_TEMPLATE_ID', originalDingtalkPermissionCardTemplateId) restoreEnv('PWD', originalPwd) }) @@ -82,6 +84,24 @@ describe('adapter config defaults', () => { fs.rmSync(defaultProjectDir, { recursive: true, force: true }) } }) + + it('loads DingTalk permission card template id from file or env', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + try { + fs.writeFileSync( + path.join(configDir, 'adapters.json'), + JSON.stringify({ dingtalk: { permissionCardTemplateId: 'file-template' } }), + ) + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID + expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('file-template') + + process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID = 'env-template' + expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('env-template') + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + } + }) }) function restoreEnv(key: string, value: string | undefined): void { diff --git a/adapters/common/__tests__/permission.test.ts b/adapters/common/__tests__/permission.test.ts new file mode 100644 index 00000000..2e817eaf --- /dev/null +++ b/adapters/common/__tests__/permission.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'bun:test' +import { + formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, + parsePermitCallbackData, +} from '../permission.js' + +describe('permission helpers', () => { + it('parses text permission commands', () => { + expect(parsePermissionCommand('/allow req-1')).toEqual({ requestId: 'req-1', allowed: true }) + expect(parsePermissionCommand('/always req-2')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('/allow-always req-3')).toEqual({ requestId: 'req-3', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('/deny req-4')).toEqual({ requestId: 'req-4', allowed: false }) + }) + + 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' }) + expect(parsePermitCallbackData('permit:req-3:no')).toEqual({ requestId: 'req-3', allowed: false }) + expect(parsePermitCallbackData('permit:req-4:unknown')).toBeNull() + }) + + it('formats text fallback and status labels', () => { + 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/config.ts b/adapters/common/config.ts index e5945689..095504d5 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -55,6 +55,7 @@ export type DingtalkConfig = { pairedUsers: PairedUser[] defaultWorkDir: string endpoint: string + permissionCardTemplateId: string } export type AdapterConfig = { @@ -138,6 +139,7 @@ export function loadConfig(): AdapterConfig { pairedUsers: dt.pairedUsers ?? [], defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir, endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com', + permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '', }, } } diff --git a/adapters/common/permission.ts b/adapters/common/permission.ts new file mode 100644 index 00000000..b40e2e0b --- /dev/null +++ b/adapters/common/permission.ts @@ -0,0 +1,45 @@ +export type PermissionDecision = { + requestId: string + allowed: boolean + 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 + + 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 parsePermitCallbackData(data: string): PermissionDecision | null { + const parts = data.split(':') + if (parts.length !== 3 || parts[0] !== 'permit' || !parts[1]) return null + + switch (parts[2]) { + case 'yes': + return { requestId: parts[1], allowed: true } + case 'always': + return { requestId: parts[1], allowed: true, rule: 'always' } + case 'no': + return { requestId: parts[1], allowed: false } + default: + return null + } +} + +export function formatPermissionInstructions(requestId: string): string { + return [ + `回复 /allow ${requestId} 允许一次`, + `/always ${requestId} 永久允许`, + `/deny ${requestId} 拒绝。`, + ].join(',') +} + +export function formatPermissionDecisionStatus(decision: Pick): string { + if (!decision.allowed) return '❌ 已拒绝' + return decision.rule === 'always' ? '♾️ 已永久允许' : '✅ 已允许' +} diff --git a/adapters/dingtalk/__tests__/permission-card.test.ts b/adapters/dingtalk/__tests__/permission-card.test.ts new file mode 100644 index 00000000..e33162ea --- /dev/null +++ b/adapters/dingtalk/__tests__/permission-card.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test' +import { + buildDingTalkPermissionCardParams, + parseDingTalkPermissionCardAction, +} from '../permission-card.js' + +describe('DingTalk permission card helpers', () => { + it('builds template params with three permission actions', () => { + const params = buildDingTalkPermissionCardParams('Bash', { command: 'npm test' }, 'req-1') + + expect(params.requestId).toBe('req-1') + expect(params.toolName).toBe('Bash') + expect(String(params.inputPreview)).toContain('npm test') + expect(JSON.parse(String(params.allowValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true }) + expect(JSON.parse(String(params.alwaysValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true, rule: 'always' }) + expect(JSON.parse(String(params.denyValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: false }) + }) + + it('parses nested card private params', () => { + const action = parseDingTalkPermissionCardAction({ + outTrackId: 'permission_req-1', + content: JSON.stringify({ + cardPrivateData: { + params: { + action: 'permit', + requestId: 'req-1', + allowed: true, + rule: 'always', + }, + }, + }), + }) + + expect(action).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + }) + + it('parses compact callback values', () => { + expect(parseDingTalkPermissionCardAction({ actionValue: 'permit:req-2:no' })).toEqual({ + requestId: 'req-2', + allowed: false, + }) + }) + + it('ignores callbacks without permission action data', () => { + expect(parseDingTalkPermissionCardAction({ action: 'open_url', url: 'https://example.com' })).toBeNull() + }) +}) diff --git a/adapters/dingtalk/ai-card.ts b/adapters/dingtalk/ai-card.ts index b3982835..919b3962 100644 --- a/adapters/dingtalk/ai-card.ts +++ b/adapters/dingtalk/ai-card.ts @@ -20,6 +20,13 @@ export type DingTalkAiCardInstance = { inputingStarted: boolean } +export type DingTalkCreateCardOptions = { + cardTemplateId?: string + outTrackId?: string + cardParamMap?: Record + callbackRouteKey?: string +} + type TokenProvider = () => Promise export class DingTalkAiCardService { @@ -28,22 +35,28 @@ export class DingTalkAiCardService { private readonly robotCode: string, ) {} - async createForTarget(target: DingTalkAiCardTarget): Promise { + async createForTarget( + target: DingTalkAiCardTarget, + options: DingTalkCreateCardOptions = {}, + ): Promise { try { const token = await this.getAccessToken() - const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` - await postJson('/v1.0/card/instances', token, { - cardTemplateId: AI_CARD_TEMPLATE_ID, + const cardInstanceId = options.outTrackId ?? `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` + const createBody: Record = { + cardTemplateId: options.cardTemplateId || AI_CARD_TEMPLATE_ID, outTrackId: cardInstanceId, cardData: { cardParamMap: { config: JSON.stringify({ autoLayout: true }), + ...options.cardParamMap, }, }, callbackType: 'STREAM', imGroupOpenSpaceModel: { supportForward: true }, imRobotOpenSpaceModel: { supportForward: true }, - }) + } + if (options.callbackRouteKey) createBody.callbackRouteKey = options.callbackRouteKey + await postJson('/v1.0/card/instances', token, createBody) await postJson('/v1.0/card/instances/deliver', token, buildDeliverBody(cardInstanceId, target, this.robotCode)) diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index ef11f768..1f51597c 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -6,13 +6,19 @@ */ import path from 'node:path' -import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream' +import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from 'dingtalk-stream' import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js' import { MessageDedup } from '../common/message-dedup.js' import { MessageBuffer } from '../common/message-buffer.js' import { enqueue } from '../common/chat-queue.js' 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' @@ -33,6 +39,11 @@ import { type DingTalkAiCardInstance, type DingTalkAiCardTarget, } from './ai-card.js' +import { + buildDingTalkPermissionCardParams, + DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE, + parseDingTalkPermissionCardAction, +} from './permission-card.js' const DINGTALK_API = 'https://api.dingtalk.com' @@ -58,6 +69,7 @@ const aiCardTargets = new Map() const streamingCards = new Map>() const streamingCardText = new Map() const pendingPermissions = new Map>() +const pendingPermissionChats = new Map() let accessTokenCache: { token: string; expiresAt: number } | null = null @@ -196,12 +208,21 @@ function clearTransientChatState(chatId: string): void { aiCardBuffers.delete(chatId) streamingCards.delete(chatId) streamingCardText.delete(chatId) + clearPendingPermissions(chatId) const runtime = getRuntimeState(chatId) runtime.state = 'idle' runtime.verb = undefined runtime.pendingPermissionCount = 0 } +function clearPendingPermissions(chatId: string): void { + const pending = pendingPermissions.get(chatId) + if (pending) { + for (const requestId of pending) pendingPermissionChats.delete(requestId) + } + pendingPermissions.delete(chatId) +} + async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { const stored = sessionStore.get(chatId) if (!stored) return null @@ -378,12 +399,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< runtime.state = 'streaming' break case 'permission_request': { - runtime.pendingPermissionCount += 1 - runtime.state = 'permission_pending' - const set = pendingPermissions.get(chatId) ?? new Set() - set.add(msg.requestId) - pendingPermissions.set(chatId, set) - await sendText(chatId, `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n回复 /allow ${msg.requestId} 允许,或 /deny ${msg.requestId} 拒绝。`) + await sendPermissionRequest(chatId, msg) break } case 'message_complete': @@ -408,24 +424,67 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< } } -function handlePermissionCommand(chatId: string, text: string): boolean { - const match = text.match(/^\/(allow|deny)\s+(\S+)/i) - if (!match) return false +async function sendPermissionRequest(chatId: string, msg: ServerMessage): Promise { + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount += 1 + runtime.state = 'permission_pending' - const [, action, requestId] = match - const pending = pendingPermissions.get(chatId) - if (!pending?.has(requestId!)) { - void sendText(chatId, `未找到待确认的权限请求:${requestId}`) - return true + const set = pendingPermissions.get(chatId) ?? new Set() + set.add(msg.requestId) + pendingPermissions.set(chatId, set) + pendingPermissionChats.set(msg.requestId, chatId) + + const requestText = formatPermissionRequest(msg.toolName, msg.input, msg.requestId) + const instructions = formatPermissionInstructions(msg.requestId) + const templateId = config.dingtalk.permissionCardTemplateId.trim() + const target = aiCardTargets.get(chatId) + + if (templateId && target) { + const card = await aiCards.createForTarget(target, { + cardTemplateId: templateId, + outTrackId: `permission_${msg.requestId}`, + callbackRouteKey: DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE, + cardParamMap: buildDingTalkPermissionCardParams(msg.toolName, msg.input, msg.requestId), + }) + if (card) { + await sendText(chatId, `${requestText}\n\n已发送钉钉权限卡片;如果卡片不可见,也可以${instructions}`) + return + } } - const allowed = action?.toLowerCase() === 'allow' - bridge.sendPermissionResponse(chatId, requestId!, allowed) - pending.delete(requestId!) + await sendText(chatId, `${requestText}\n\n${instructions}`) +} + +function handlePermissionCommand(chatId: string, text: string): boolean { + const decision = parsePermissionCommand(text) + if (!decision) return false + + const sent = applyPermissionDecision(chatId, decision) + if (!sent) return true + + void sendText(chatId, formatPermissionDecisionStatus(decision)) + return true +} + +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) + pendingPermissionChats.delete(requestId) const runtime = getRuntimeState(chatId) runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) - void sendText(chatId, allowed ? '✅ 已允许' : '❌ 已拒绝') - return true + return sent } async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise { @@ -519,6 +578,23 @@ async function handleRobotMessage(data: DingTalkRobotMessage): Promise { await routeUserMessage(chatId, text, attachments) } +async function handleCardCallback(raw: unknown): Promise { + const action = parseDingTalkPermissionCardAction(raw) + if (!action) return + + const chatId = action.chatId && pendingPermissions.has(action.chatId) + ? action.chatId + : pendingPermissionChats.get(action.requestId) + if (!chatId) { + console.warn(`[DingTalk][Card] permission request not found: ${action.requestId}`) + return + } + + if (applyPermissionDecision(chatId, action)) { + await sendText(chatId, formatPermissionDecisionStatus(action)) + } +} + async function collectAttachments( chatId: string, candidates: ReturnType, @@ -605,6 +681,16 @@ async function start(): Promise { await handleRobotMessage(data) }) + client.registerCallbackListener(TOPIC_CARD, async (res: any) => { + const messageId = res.headers?.messageId + if (messageId) { + client.socketCallBackResponse(messageId, { success: true }) + if (!dedup.tryRecord(`card:${messageId}`)) return + } + + await handleCardCallback(res.data ?? res) + }) + await client.connect() console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`) diff --git a/adapters/dingtalk/permission-card.ts b/adapters/dingtalk/permission-card.ts new file mode 100644 index 00000000..fa6bf64c --- /dev/null +++ b/adapters/dingtalk/permission-card.ts @@ -0,0 +1,135 @@ +import { truncateInput } from '../common/format.js' +import { parsePermitCallbackData, type PermissionDecision } from '../common/permission.js' + +export const DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE = 'permission' + +export type DingTalkPermissionCardAction = PermissionDecision & { + outTrackId?: string + chatId?: string +} + +export function buildDingTalkPermissionCardParams( + toolName: string, + input: unknown, + requestId: string, +): Record { + const allowValue = { action: 'permit', requestId, allowed: true } + const alwaysValue = { action: 'permit', requestId, allowed: true, rule: 'always' } + const denyValue = { action: 'permit', requestId, allowed: false } + + return { + title: 'Claude Code 需要权限确认', + toolName, + requestId, + inputPreview: truncateInput(input, 600), + allowText: '允许一次', + alwaysText: '永久允许', + denyText: '拒绝', + allowValue: JSON.stringify(allowValue), + alwaysValue: JSON.stringify(alwaysValue), + denyValue: JSON.stringify(denyValue), + permissionActions: JSON.stringify([ + { text: '允许一次', value: allowValue }, + { text: '永久允许', value: alwaysValue }, + { text: '拒绝', value: denyValue }, + ]), + sys_full_json_obj: JSON.stringify({ + order: ['title', 'toolName', 'inputPreview'], + actions: ['allowValue', 'alwaysValue', 'denyValue'], + }), + config: JSON.stringify({ autoLayout: true }), + } +} + +export function parseDingTalkPermissionCardAction(raw: unknown): DingTalkPermissionCardAction | null { + const root = parseMaybeJson(raw) + const values = collectValues(root) + + for (const value of values) { + if (typeof value === 'string') { + const direct = parsePermitCallbackData(value) + if (direct) return direct + const parsed = parseMaybeJson(value) + if (parsed !== value) { + const nested = parseDingTalkPermissionCardAction(parsed) + if (nested) return nested + } + } + } + + const objects = values.filter(isRecord) + for (const obj of objects) { + const requestId = readString(obj, ['requestId', 'request_id', 'permissionRequestId']) + if (!requestId) continue + + const action = readString(obj, ['action', 'actionType', 'decision', 'value', 'actionValue', 'command'])?.toLowerCase() + const allowed = readBoolean(obj, ['allowed', 'allow', 'approved']) + const rule = readString(obj, ['rule']) === 'always' ? 'always' : undefined + const outTrackId = readString(obj, ['outTrackId', 'cardInstanceId']) + const chatId = readString(obj, ['chatId', 'conversationId', 'openConversationId']) + + if (allowed !== undefined) return { requestId, allowed, rule, outTrackId, chatId } + if (action && ['allow', 'yes', 'approve', 'approved', 'permit'].includes(action)) { + return { requestId, allowed: true, rule, outTrackId, chatId } + } + if (action && ['always', 'allow-always', 'approve-always'].includes(action)) { + return { requestId, allowed: true, rule: 'always', outTrackId, chatId } + } + if (action && ['deny', 'no', 'reject', 'rejected'].includes(action)) { + return { requestId, allowed: false, outTrackId, chatId } + } + } + + return null +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value + try { + return JSON.parse(trimmed) + } catch { + return value + } +} + +function collectValues(value: unknown, seen = new Set()): unknown[] { + const parsed = parseMaybeJson(value) + if (parsed && typeof parsed === 'object') { + if (seen.has(parsed)) return [] + seen.add(parsed) + } + + const values = [parsed] + if (Array.isArray(parsed)) { + for (const item of parsed) values.push(...collectValues(item, seen)) + } else if (isRecord(parsed)) { + for (const item of Object.values(parsed)) values.push(...collectValues(item, seen)) + } + return values +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function readString(obj: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = obj[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return undefined +} + +function readBoolean(obj: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + const value = obj[key] + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + if (/^(true|yes|allow|approve|permit)$/i.test(value)) return true + if (/^(false|no|deny|reject)$/i.test(value)) return false + } + } + return undefined +} diff --git a/adapters/telegram/__tests__/telegram.test.ts b/adapters/telegram/__tests__/telegram.test.ts index 552639ca..896d30cf 100644 --- a/adapters/telegram/__tests__/telegram.test.ts +++ b/adapters/telegram/__tests__/telegram.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'bun:test' import { splitMessage, formatPermissionRequest, truncateInput, escapeMarkdownV2 } from '../../common/format.js' +import { parsePermitCallbackData } from '../../common/permission.js' /** * Telegram Adapter 翻译逻辑测试 @@ -59,17 +60,19 @@ describe('Telegram message formatting', () => { describe('callback_data parsing', () => { it('parses permit:requestId:yes format', () => { - const data = 'permit:abcde:yes' - const parts = data.split(':') - expect(parts[0]).toBe('permit') - expect(parts[1]).toBe('abcde') - expect(parts[2]).toBe('yes') + expect(parsePermitCallbackData('permit:abcde:yes')).toEqual({ requestId: 'abcde', allowed: true }) + }) + + it('parses permit:requestId:always format', () => { + expect(parsePermitCallbackData('permit:abcde:always')).toEqual({ + requestId: 'abcde', + allowed: true, + rule: 'always', + }) }) it('parses permit:requestId:no format', () => { - const data = 'permit:abcde:no' - const parts = data.split(':') - expect(parts[2]).toBe('no') + expect(parsePermitCallbackData('permit:abcde:no')).toEqual({ requestId: 'abcde', allowed: false }) }) it('ignores non-permit callbacks', () => { diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index e242ab81..cdd1d3fd 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -18,6 +18,10 @@ import { formatPermissionRequest, splitMessage, } from '../common/format.js' +import { + formatPermissionDecisionStatus, + parsePermitCallbackData, +} from '../common/permission.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' @@ -411,6 +415,8 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< const text = formatPermissionRequest(msg.toolName, msg.input, msg.requestId) const keyboard = new InlineKeyboard() .text('✅ 允许', `permit:${msg.requestId}:yes`) + .text('♾️ 永久允许', `permit:${msg.requestId}:always`) + .row() .text('❌ 拒绝', `permit:${msg.requestId}:no`) await bot.api.sendMessage(numericChatId, text, { reply_markup: keyboard }) break @@ -714,18 +720,15 @@ bot.on('callback_query:data', async (ctx) => { const data = ctx.callbackQuery.data if (!data.startsWith('permit:')) return - const parts = data.split(':') - if (parts.length !== 3) return - - const requestId = parts[1]! - const allowed = parts[2] === 'yes' + const decision = parsePermitCallbackData(data) + if (!decision) return const chatId = String(ctx.callbackQuery.message?.chat.id) - bridge.sendPermissionResponse(chatId, requestId, allowed) + bridge.sendPermissionResponse(chatId, decision.requestId, decision.allowed, decision.rule) const runtime = getRuntimeState(chatId) runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) - const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝' + const statusText = formatPermissionDecisionStatus(decision) try { await ctx.editMessageText( ctx.callbackQuery.message?.text + `\n\n${statusText}`, diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index eba2d708..b095781e 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -10,6 +10,11 @@ import { formatPermissionRequest, splitMessage, } from '../common/format.js' +import { + formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, +} from '../common/permission.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' @@ -303,7 +308,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< pending.add(msg.requestId) await sendText( chatId, - `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n回复 /allow ${msg.requestId} 允许,或 /deny ${msg.requestId} 拒绝。`, + `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n${formatPermissionInstructions(msg.requestId)}`, ) break } @@ -397,15 +402,21 @@ async function routeUserMessage(message: WechatMessage): Promise { await sendText(chatId, sent ? '已清空当前会话上下文。' : '无法发送 /clear,请先发送 /new 重新连接会话。') return } - if (!hasAttachments && (text.startsWith('/allow ') || text.startsWith('/deny '))) { - const requestId = text.split(/\s+/)[1] - if (!requestId) return - const allowed = text.startsWith('/allow ') - const sent = bridge.sendPermissionResponse(chatId, requestId, allowed) + const permissionDecision = !hasAttachments ? parsePermissionCommand(text) : null + if (permissionDecision) { + const { requestId, allowed, rule } = permissionDecision + const pending = pendingPermissions.get(chatId) + if (!pending?.has(requestId)) { + await sendText(chatId, `未找到待确认的权限请求:${requestId}`) + return + } + const sent = bridge.sendPermissionResponse(chatId, requestId, allowed, rule) const runtime = getRuntimeState(chatId) - runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) - pendingPermissions.get(chatId)?.delete(requestId) - await sendText(chatId, sent ? (allowed ? '已允许。' : '已拒绝。') : '权限响应发送失败,请检查会话状态。') + if (sent) { + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + pending.delete(requestId) + } + await sendText(chatId, sent ? `${formatPermissionDecisionStatus(permissionDecision)}。` : '权限响应发送失败,请检查会话状态。') return } if (!hasAttachments && pendingProjectSelection.has(chatId)) { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index fc088e9f..bed96469 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -307,6 +307,9 @@ export const en = { 'settings.adapters.dingtalkClientSecretPlaceholder': 'Filled by QR auth, or enter manually', 'settings.adapters.dingtalkEndpoint': 'Stream Endpoint', 'settings.adapters.dingtalkEndpointPlaceholder': 'Default https://api.dingtalk.com', + 'settings.adapters.dingtalkPermissionCardTemplateId': 'Permission Card Template ID', + 'settings.adapters.dingtalkPermissionCardTemplateIdPlaceholder': 'Optional interactive card template ID', + 'settings.adapters.dingtalkPermissionCardTemplateIdHint': 'When set, permission requests use a DingTalk interactive card first. Leave empty to use /allow, /always, /deny text approval.', 'settings.adapters.wechatBind': 'Scan to Bind', 'settings.adapters.wechatRebind': 'Rescan', 'settings.adapters.wechatConnected': 'WeChat is bound', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index cd48c2c4..4ceae692 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -309,6 +309,9 @@ export const zh: Record = { 'settings.adapters.dingtalkClientSecretPlaceholder': '扫码后自动填写,也可手动输入', 'settings.adapters.dingtalkEndpoint': 'Stream Endpoint', 'settings.adapters.dingtalkEndpointPlaceholder': '默认 https://api.dingtalk.com', + 'settings.adapters.dingtalkPermissionCardTemplateId': '权限卡片模板 ID', + 'settings.adapters.dingtalkPermissionCardTemplateIdPlaceholder': '可选,互动卡片模板 ID', + 'settings.adapters.dingtalkPermissionCardTemplateIdHint': '填写后权限请求会优先发送钉钉互动卡片;留空则使用 /allow、/always、/deny 文本授权。', 'settings.adapters.wechatBind': '扫码绑定', 'settings.adapters.wechatRebind': '重新扫码', 'settings.adapters.wechatConnected': '微信已绑定', diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index 05effd2c..3a6005e1 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -59,6 +59,7 @@ export function AdapterSettings() { const [dtClientSecret, setDtClientSecret] = useState('') const [dtAllowedUsers, setDtAllowedUsers] = useState('') const [dtEndpoint, setDtEndpoint] = useState('') + const [dtPermissionCardTemplateId, setDtPermissionCardTemplateId] = useState('') const [dtRegistration, setDtRegistration] = useState<{ deviceCode: string verificationUriComplete: string @@ -101,6 +102,7 @@ export function AdapterSettings() { setDtClientSecret(config.dingtalk?.clientSecret ?? '') setDtAllowedUsers(config.dingtalk?.allowedUsers?.join(', ') ?? '') setDtEndpoint(config.dingtalk?.endpoint ?? '') + setDtPermissionCardTemplateId(config.dingtalk?.permissionCardTemplateId ?? '') }, [config]) useEffect(() => { @@ -236,6 +238,7 @@ export function AdapterSettings() { clientSecret: dtClientSecret || undefined, allowedUsers: dtUsers.length ? dtUsers : [], endpoint: dtEndpoint || undefined, + permissionCardTemplateId: dtPermissionCardTemplateId || undefined, } await updateConfig(patch) @@ -677,6 +680,15 @@ export function AdapterSettings() { onChange={(e) => setDtEndpoint(e.target.value)} placeholder={t('settings.adapters.dingtalkEndpointPlaceholder')} /> +
+ setDtPermissionCardTemplateId(e.target.value)} + placeholder={t('settings.adapters.dingtalkPermissionCardTemplateIdPlaceholder')} + /> +

{t('settings.adapters.dingtalkPermissionCardTemplateIdHint')}

+
` 和 `/deny ` 回复 -- 群聊、媒体附件、AI Card 流式卡片后续再扩展 +- 权限确认支持 `/allow `、`/always `、`/deny ` 文本回复 +- 如果配置了已发布的钉钉互动卡片模板 ID,会优先发送权限卡片,并通过 DingTalk Stream 的 `/v1.0/card/instances/callback` 接收按钮回调;卡片发送失败时自动回退到文本命令 +- 群聊后续再扩展 diff --git a/docs/im/telegram.md b/docs/im/telegram.md index ca55a0fe..f5fca031 100644 --- a/docs/im/telegram.md +++ b/docs/im/telegram.md @@ -61,6 +61,7 @@ Telegram 方案适合个人私聊远程使用。当前实现只处理 `private c 当 Claude 请求敏感权限时,Telegram adapter 会发带按钮的消息: - `✅ 允许` +- `♾️ 永久允许` - `❌ 拒绝` 点击后 adapter 会把结果通过 `permission_response` 回传给 Desktop server。 diff --git a/docs/im/wechat.md b/docs/im/wechat.md index 72806139..e2cf3550 100644 --- a/docs/im/wechat.md +++ b/docs/im/wechat.md @@ -4,7 +4,7 @@ ## 适用场景 -微信方案适合个人私聊远程使用。当前实现先支持文本聊天、项目选择、状态查看、停止生成和权限审批;附件收发后续再补齐。 +微信方案适合个人私聊远程使用。当前实现支持文本聊天、图片附件理解、项目选择、状态查看、停止生成和权限审批。 ## 1. 在桌面端扫码绑定 @@ -32,6 +32,7 @@ - `/help` — 显示当前可用命令 - `/stop` — 向当前 session 发送 `stop_generation` - `/allow ` — 允许一次权限请求 +- `/always ` — 永久允许同类权限请求 - `/deny ` — 拒绝一次权限请求 ## 解绑 diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index fc02288e..9f792886 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -62,6 +62,7 @@ describe('Adapters API', () => { dingtalk: { clientId: 'ding-client-1', clientSecret: 'dingtalk-client-secret', + permissionCardTemplateId: 'permission-template', pairedUsers: [{ userId: 'ding-user', displayName: 'DingTalk User', pairedAt: 1 }], }, }) @@ -73,6 +74,7 @@ describe('Adapters API', () => { const json = await res.json() as any expect(json.dingtalk.clientSecret).toBe('****cret') expect(json.dingtalk.clientId).toBe('ding-client-1') + expect(json.dingtalk.permissionCardTemplateId).toBe('permission-template') const maskedPut = makeRequest('PUT', '/api/adapters', { dingtalk: { @@ -85,6 +87,7 @@ describe('Adapters API', () => { const raw = JSON.parse(await fs.readFile(path.join(tmpDir, 'adapters.json'), 'utf-8')) as any expect(raw.dingtalk.clientSecret).toBe('dingtalk-client-secret') expect(raw.dingtalk.allowedUsers).toEqual(['ding-user']) + expect(raw.dingtalk.permissionCardTemplateId).toBe('permission-template') }) it('clears WeChat credentials on unbind', async () => { diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index 13cbecd1..d2c8bfa0 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -58,6 +58,7 @@ export type AdapterFileConfig = { pairedUsers?: PairedUser[] defaultWorkDir?: string endpoint?: string + permissionCardTemplateId?: string } }