mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
136 lines
4.6 KiB
TypeScript
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
|
|
}
|