mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
2ca5228171
commit
f1c5f86b80
@ -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 {
|
||||
|
||||
29
adapters/common/__tests__/permission.test.ts
Normal file
29
adapters/common/__tests__/permission.test.ts
Normal 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('拒绝')
|
||||
})
|
||||
})
|
||||
@ -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 || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
45
adapters/common/permission.ts
Normal file
45
adapters/common/permission.ts
Normal 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' ? '♾️ 已永久允许' : '✅ 已允许'
|
||||
}
|
||||
47
adapters/dingtalk/__tests__/permission-card.test.ts
Normal file
47
adapters/dingtalk/__tests__/permission-card.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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}`)
|
||||
|
||||
|
||||
135
adapters/dingtalk/permission-card.ts
Normal file
135
adapters/dingtalk/permission-card.ts
Normal 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
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '微信已绑定',
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -46,5 +46,6 @@ export type AdapterFileConfig = {
|
||||
pairedUsers?: PairedUser[]
|
||||
defaultWorkDir?: string
|
||||
endpoint?: string
|
||||
permissionCardTemplateId?: string
|
||||
}
|
||||
}
|
||||
|
||||
@ -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` 接收按钮回调;卡片发送失败时自动回退到文本命令
|
||||
- 群聊后续再扩展
|
||||
|
||||
@ -61,6 +61,7 @@ Telegram 方案适合个人私聊远程使用。当前实现只处理 `private c
|
||||
当 Claude 请求敏感权限时,Telegram adapter 会发带按钮的消息:
|
||||
|
||||
- `✅ 允许`
|
||||
- `♾️ 永久允许`
|
||||
- `❌ 拒绝`
|
||||
|
||||
点击后 adapter 会把结果通过 `permission_response` 回传给 Desktop server。
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
## 适用场景
|
||||
|
||||
微信方案适合个人私聊远程使用。当前实现先支持文本聊天、项目选择、状态查看、停止生成和权限审批;附件收发后续再补齐。
|
||||
微信方案适合个人私聊远程使用。当前实现支持文本聊天、图片附件理解、项目选择、状态查看、停止生成和权限审批。
|
||||
|
||||
## 1. 在桌面端扫码绑定
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
- `/help` — 显示当前可用命令
|
||||
- `/stop` — 向当前 session 发送 `stop_generation`
|
||||
- `/allow <requestId>` — 允许一次权限请求
|
||||
- `/always <requestId>` — 永久允许同类权限请求
|
||||
- `/deny <requestId>` — 拒绝一次权限请求
|
||||
|
||||
## 解绑
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -58,6 +58,7 @@ export type AdapterFileConfig = {
|
||||
pairedUsers?: PairedUser[]
|
||||
defaultWorkDir?: string
|
||||
endpoint?: string
|
||||
permissionCardTemplateId?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user