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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-04 19:03:36 +08:00
parent 2ca5228171
commit f1c5f86b80
20 changed files with 475 additions and 52 deletions

View File

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

View File

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

View File

@ -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 || '',
},
}
}

View File

@ -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<PermissionDecision, 'allowed' | 'rule'>): string {
if (!decision.allowed) return '❌ 已拒绝'
return decision.rule === 'always' ? '♾️ 已永久允许' : '✅ 已允许'
}

View File

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

View File

@ -20,6 +20,13 @@ export type DingTalkAiCardInstance = {
inputingStarted: boolean
}
export type DingTalkCreateCardOptions = {
cardTemplateId?: string
outTrackId?: string
cardParamMap?: Record<string, unknown>
callbackRouteKey?: string
}
type TokenProvider = () => Promise<string>
export class DingTalkAiCardService {
@ -28,22 +35,28 @@ export class DingTalkAiCardService {
private readonly robotCode: string,
) {}
async createForTarget(target: DingTalkAiCardTarget): Promise<DingTalkAiCardInstance | null> {
async createForTarget(
target: DingTalkAiCardTarget,
options: DingTalkCreateCardOptions = {},
): Promise<DingTalkAiCardInstance | null> {
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<string, unknown> = {
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))

View File

@ -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<string, DingTalkAiCardTarget>()
const streamingCards = new Map<string, Promise<DingTalkAiCardInstance | null>>()
const streamingCardText = new Map<string, string>()
const pendingPermissions = new Map<string, Set<string>>()
const pendingPermissionChats = new Map<string, string>()
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<string>()
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<void> {
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<string>()
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<void> {
@ -519,6 +578,23 @@ async function handleRobotMessage(data: DingTalkRobotMessage): Promise<void> {
await routeUserMessage(chatId, text, attachments)
}
async function handleCardCallback(raw: unknown): Promise<void> {
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<typeof extractDingTalkAttachments>,
@ -605,6 +681,16 @@ async function start(): Promise<void> {
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}`)

View File

@ -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<string, unknown> {
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>()): 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<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function readString(obj: Record<string, unknown>, 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<string, unknown>, 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
}

View File

@ -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', () => {

View File

@ -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}`,

View File

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

View File

@ -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',

View File

@ -309,6 +309,9 @@ export const zh: Record<TranslationKey, string> = {
'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': '微信已绑定',

View File

@ -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')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.dingtalkPermissionCardTemplateId')}
value={dtPermissionCardTemplateId}
onChange={(e) => setDtPermissionCardTemplateId(e.target.value)}
placeholder={t('settings.adapters.dingtalkPermissionCardTemplateIdPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.dingtalkPermissionCardTemplateIdHint')}</p>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}

View File

@ -46,5 +46,6 @@ export type AdapterFileConfig = {
pairedUsers?: PairedUser[]
defaultWorkDir?: string
endpoint?: string
permissionCardTemplateId?: string
}
}

View File

@ -19,6 +19,7 @@
- `Client Secret`
- 可选 `Stream Endpoint`
- 可选 `Allowed Users`
- 可选 `权限卡片模板 ID`
## 配对用户
@ -44,6 +45,9 @@ bun run dingtalk
- 只处理钉钉单聊消息
- 使用 `sessionWebhook` 回复文本 / Markdown
- 支持用户图片附件进入模型输入
- 支持 AI Card 流式输出和 thinking / 输入状态展示
- 支持 `/new``/projects``/status``/clear``/stop``/help`
- 权限确认通过 `/allow <requestId>``/deny <requestId>` 回复
- 群聊、媒体附件、AI Card 流式卡片后续再扩展
- 权限确认支持 `/allow <requestId>``/always <requestId>``/deny <requestId>` 文本回复
- 如果配置了已发布的钉钉互动卡片模板 ID会优先发送权限卡片并通过 DingTalk Stream 的 `/v1.0/card/instances/callback` 接收按钮回调;卡片发送失败时自动回退到文本命令
- 群聊后续再扩展

View File

@ -61,6 +61,7 @@ Telegram 方案适合个人私聊远程使用。当前实现只处理 `private c
当 Claude 请求敏感权限时Telegram adapter 会发带按钮的消息:
- `✅ 允许`
- `♾️ 永久允许`
- `❌ 拒绝`
点击后 adapter 会把结果通过 `permission_response` 回传给 Desktop server。

View File

@ -4,7 +4,7 @@
## 适用场景
微信方案适合个人私聊远程使用。当前实现支持文本聊天、项目选择、状态查看、停止生成和权限审批;附件收发后续再补齐
微信方案适合个人私聊远程使用。当前实现支持文本聊天、图片附件理解、项目选择、状态查看、停止生成和权限审批。
## 1. 在桌面端扫码绑定
@ -32,6 +32,7 @@
- `/help` — 显示当前可用命令
- `/stop` — 向当前 session 发送 `stop_generation`
- `/allow <requestId>` — 允许一次权限请求
- `/always <requestId>` — 永久允许同类权限请求
- `/deny <requestId>` — 拒绝一次权限请求
## 解绑

View File

@ -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 () => {

View File

@ -58,6 +58,7 @@ export type AdapterFileConfig = {
pairedUsers?: PairedUser[]
defaultWorkDir?: string
endpoint?: string
permissionCardTemplateId?: string
}
}