cc-haha/adapters/dingtalk/permission-card.ts
程序员阿江(Relakkes) f1c5f86b80 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
2026-05-04 19:03:36 +08:00

136 lines
4.6 KiB
TypeScript

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
}