diff --git a/adapters/README.md b/adapters/README.md index 6a640161..4f34711a 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -6,6 +6,7 @@ - `docs/im/index.md` - `docs/im/wechat.md` +- `docs/im/dingtalk.md` - `docs/im/telegram.md` - `docs/im/feishu.md` @@ -25,7 +26,7 @@ Desktop Webapp Settings 注意两点: - IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入` -- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run telegram` 或 `bun run feishu` +- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run dingtalk`、`bun run telegram` 或 `bun run feishu` ## 快速启动 @@ -37,6 +38,8 @@ bun run telegram bun run feishu # 或 bun run wechat +# 或 +bun run dingtalk ``` ## 开发 @@ -50,6 +53,7 @@ bun test common/ bun test telegram/ bun test feishu/ bun test wechat/ +bun test dingtalk/ ``` ### 目录结构 @@ -66,6 +70,9 @@ adapters/ ├── wechat/ │ ├── protocol.ts # 微信 iLink QR 登录 / getupdates / sendmessage 协议封装 │ └── index.ts # 微信文本聊天 Adapter +├── dingtalk/ +│ ├── helpers.ts # 钉钉 Stream 消息解析与会话键 +│ └── index.ts # 钉钉扫码绑定 / Stream 文本聊天 Adapter ├── package.json ├── tsconfig.json └── README.md diff --git a/adapters/bun.lock b/adapters/bun.lock index 760c38ad..2e9bce2f 100644 --- a/adapters/bun.lock +++ b/adapters/bun.lock @@ -6,6 +6,7 @@ "name": "claude-code-im-adapters", "dependencies": { "@larksuiteoapi/node-sdk": "^1.60.0", + "dingtalk-stream": "2.1.4", "grammy": "^1.42.0", "ws": "^8.18.0", }, @@ -62,6 +63,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dingtalk-stream": ["dingtalk-stream@2.1.4", "https://registry.npmmirror.com/dingtalk-stream/-/dingtalk-stream-2.1.4.tgz", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="], + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts index 6f7b4614..89fe8558 100644 --- a/adapters/common/__tests__/config.test.ts +++ b/adapters/common/__tests__/config.test.ts @@ -7,11 +7,13 @@ import { getConfiguredWorkDir, loadConfig } from '../config.js' 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 originalPwd = process.env.PWD afterEach(() => { restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir) + restoreEnv('ADAPTER_DEFAULT_PROJECT_DIR', originalAdapterDefaultProjectDir) restoreEnv('PWD', originalPwd) }) @@ -28,7 +30,9 @@ describe('adapter config defaults', () => { expect(config.telegram.defaultWorkDir).toBe(fs.realpathSync(workDir)) expect(config.feishu.defaultWorkDir).toBe(fs.realpathSync(workDir)) expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir)) expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(workDir)) + expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(workDir)) } finally { fs.rmSync(configDir, { recursive: true, force: true }) fs.rmSync(workDir, { recursive: true, force: true }) @@ -50,13 +54,34 @@ describe('adapter config defaults', () => { const config = loadConfig() expect(getConfiguredWorkDir(config, config.wechat)).toBe(defaultProjectDir) + expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(defaultProjectDir) expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir)) } finally { fs.rmSync(configDir, { recursive: true, force: true }) fs.rmSync(defaultProjectDir, { recursive: true, force: true }) fs.rmSync(workDir, { recursive: true, force: true }) } }) + + it('accepts ADAPTER_DEFAULT_PROJECT_DIR as a sidecar-friendly default work dir override', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-')) + try { + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.ADAPTER_DEFAULT_PROJECT_DIR = defaultProjectDir + delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + delete process.env.PWD + + const config = loadConfig() + + expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(defaultProjectDir)) + expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(defaultProjectDir)) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + fs.rmSync(defaultProjectDir, { recursive: true, force: true }) + } + }) }) function restoreEnv(key: string, value: string | undefined): void { diff --git a/adapters/common/__tests__/pairing.test.ts b/adapters/common/__tests__/pairing.test.ts new file mode 100644 index 00000000..7f455478 --- /dev/null +++ b/adapters/common/__tests__/pairing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'bun:test' +import { isPaired } from '../pairing.js' + +describe('pairing platform support', () => { + it('checks DingTalk paired users with the same shared access rule', () => { + expect(isPaired('dingtalk', 'staff-1', { + dingtalk: { + pairedUsers: [{ userId: 'staff-1', displayName: 'DingTalk User', pairedAt: Date.now() }], + allowedUsers: [], + }, + })).toBe(true) + }) + + it('keeps empty DingTalk allow and pair lists closed by default', () => { + expect(isPaired('dingtalk', 'staff-1', { + dingtalk: { + pairedUsers: [], + allowedUsers: [], + }, + })).toBe(false) + }) +}) diff --git a/adapters/common/config.ts b/adapters/common/config.ts index b806636a..e5945689 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -48,6 +48,15 @@ export type WechatConfig = { defaultWorkDir: string } +export type DingtalkConfig = { + clientId: string + clientSecret: string + allowedUsers: string[] + pairedUsers: PairedUser[] + defaultWorkDir: string + endpoint: string +} + export type AdapterConfig = { serverUrl: string defaultProjectDir: string @@ -55,12 +64,14 @@ export type AdapterConfig = { telegram: TelegramConfig feishu: FeishuConfig wechat: WechatConfig + dingtalk: DingtalkConfig } export type AdapterPlatformConfig = | TelegramConfig | FeishuConfig | WechatConfig + | DingtalkConfig function getConfigPath(): string { const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') @@ -83,6 +94,7 @@ export function loadConfig(): AdapterConfig { const tg = file.telegram ?? {} const fs_ = file.feishu ?? {} const wc = file.wechat ?? {} + const dt = file.dingtalk ?? {} const pairing = file.pairing ?? {} const fallbackWorkDir = resolveUserDefaultWorkDir() @@ -119,6 +131,14 @@ export function loadConfig(): AdapterConfig { pairedUsers: wc.pairedUsers ?? [], defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir, }, + dingtalk: { + clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '', + clientSecret: process.env.DINGTALK_CLIENT_SECRET || dt.clientSecret || '', + allowedUsers: dt.allowedUsers ?? [], + pairedUsers: dt.pairedUsers ?? [], + defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir, + endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com', + }, } } @@ -128,6 +148,7 @@ export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: Adap function resolveUserDefaultWorkDir(): string { const candidates = [ + process.env.ADAPTER_DEFAULT_PROJECT_DIR, process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR, process.env.PWD, process.cwd(), diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts index 3d3ce2fb..5616044e 100644 --- a/adapters/common/pairing.ts +++ b/adapters/common/pairing.ts @@ -13,6 +13,7 @@ import * as crypto from 'node:crypto' import type { PairedUser, PairingState } from './config.js' const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L +export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' // 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试 const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes @@ -74,7 +75,7 @@ export function generatePairingCode(): string { /** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */ export function isPaired( - platform: 'telegram' | 'feishu' | 'wechat', + platform: ImPlatform, userId: string | number, config: Record, ): boolean { @@ -97,7 +98,7 @@ export function isPaired( export function tryPair( messageText: string, senderInfo: { userId: string | number; displayName: string }, - platform: 'telegram' | 'feishu' | 'wechat', + platform: ImPlatform, ): boolean { const file = readConfigFile() const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null } @@ -139,7 +140,7 @@ export function tryPair( } /** 统一的用户授权检查(供各 adapter 调用) */ -export function isAllowedUser(platform: 'telegram' | 'feishu' | 'wechat', userId: string | number): boolean { +export function isAllowedUser(platform: ImPlatform, userId: string | number): boolean { try { const cfgFile = readConfigFile() return isPaired(platform, userId, cfgFile) diff --git a/adapters/dingtalk/__tests__/helpers.test.ts b/adapters/dingtalk/__tests__/helpers.test.ts new file mode 100644 index 00000000..192731d5 --- /dev/null +++ b/adapters/dingtalk/__tests__/helpers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test' +import { + extractDingTalkText, + getDingTalkChatId, + getDingTalkSenderId, + isDingTalkDirectMessage, + parseDingTalkPayload, +} from '../helpers.js' + +describe('DingTalk helpers', () => { + it('parses robot payload JSON safely', () => { + expect(parseDingTalkPayload('{"msgtype":"text"}')?.msgtype).toBe('text') + expect(parseDingTalkPayload('not-json')).toBeNull() + }) + + it('extracts sender and chat ids for direct messages', () => { + const data = { + conversationType: '1', + senderStaffId: 'staff-1', + conversationId: 'cid-1', + } + + expect(isDingTalkDirectMessage(data)).toBe(true) + expect(getDingTalkSenderId(data)).toBe('staff-1') + expect(getDingTalkChatId(data)).toBe('dingtalk:dm:staff-1') + }) + + it('extracts text from common DingTalk content shapes', () => { + expect(extractDingTalkText({ text: { content: ' hello ' } })).toBe('hello') + expect(extractDingTalkText({ content: '{"text":"from content"}' })).toBe('from content') + expect(extractDingTalkText({ + content: { + richText: [ + { text: 'hello' }, + { text: ' world' }, + ], + }, + })).toBe('hello world') + }) +}) diff --git a/adapters/dingtalk/helpers.ts b/adapters/dingtalk/helpers.ts new file mode 100644 index 00000000..33d72945 --- /dev/null +++ b/adapters/dingtalk/helpers.ts @@ -0,0 +1,74 @@ +export type DingTalkRobotMessage = { + msgId?: string + msgtype?: string + conversationType?: string + conversationId?: string + conversationTitle?: string + senderStaffId?: string + senderId?: string + senderNick?: string + sessionWebhook?: string + text?: { content?: string } + markdown?: { text?: string; title?: string } + content?: unknown +} + +export function parseDingTalkPayload(raw: unknown): DingTalkRobotMessage | null { + if (!raw) return null + if (typeof raw === 'object') return raw as DingTalkRobotMessage + if (typeof raw !== 'string') return null + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed as DingTalkRobotMessage : null + } catch { + return null + } +} +export function isDingTalkDirectMessage(data: DingTalkRobotMessage): boolean { + return data.conversationType === '1' +} + +export function getDingTalkSenderId(data: DingTalkRobotMessage): string | null { + const senderId = data.senderStaffId || data.senderId + return senderId ? String(senderId) : null +} + +export function getDingTalkChatId(data: DingTalkRobotMessage): string | null { + const senderId = getDingTalkSenderId(data) + if (isDingTalkDirectMessage(data)) { + return senderId ? `dingtalk:dm:${senderId}` : null + } + return data.conversationId ? `dingtalk:group:${data.conversationId}` : null +} + +export function extractDingTalkText(data: DingTalkRobotMessage): string { + if (typeof data.text?.content === 'string') return data.text.content.trim() + if (typeof data.markdown?.text === 'string') return data.markdown.text.trim() + + const content = resolveContentObject(data.content) + if (typeof content?.text === 'string') return content.text.trim() + if (Array.isArray(content?.richText)) { + return content.richText + .map((item: unknown) => { + if (!item || typeof item !== 'object') return '' + const text = (item as { text?: unknown }).text + return typeof text === 'string' ? text : '' + }) + .join('') + .trim() + } + + return '' +} + +function resolveContentObject(raw: unknown): Record | null { + if (!raw) return null + if (typeof raw === 'object') return raw as Record + if (typeof raw !== 'string') return null + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed as Record : null + } catch { + return null + } +} diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts new file mode 100644 index 00000000..c3bb62e2 --- /dev/null +++ b/adapters/dingtalk/index.ts @@ -0,0 +1,494 @@ +/** + * DingTalk Adapter for Claude Code Desktop. + * + * Uses DingTalk Stream to receive bot messages without a public webhook. + * The desktop Settings page stores clientId/clientSecret via QR registration. + */ + +import path from 'node:path' +import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream' +import { WsBridge, type ServerMessage } 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 { SessionStore } from '../common/session-store.js' +import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { isAllowedUser, tryPair } from '../common/pairing.js' +import { + extractDingTalkText, + getDingTalkChatId, + getDingTalkSenderId, + isDingTalkDirectMessage, + parseDingTalkPayload, + type DingTalkRobotMessage, +} from './helpers.js' + +const DINGTALK_API = 'https://api.dingtalk.com' + +const config = loadConfig() +if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) { + console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.') + process.exit(1) +} +const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk) + +const bridge = new WsBridge(config.serverUrl, 'dingtalk') +const dedup = new MessageDedup() +const sessionStore = new SessionStore() +const httpClient = new AdapterHttpClient(config.serverUrl) +const sessionWebhooks = new Map() +const pendingProjectSelection = new Map() +const runtimeStates = new Map() +const responseBuffers = new Map() +const pendingPermissions = new Map>() + +let accessTokenCache: { token: string; expiresAt: number } | null = null + +type ChatRuntimeState = { + state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending' + verb?: string + model?: string + pendingPermissionCount: number +} + +function getRuntimeState(chatId: string): ChatRuntimeState { + let state = runtimeStates.get(chatId) + if (!state) { + state = { state: 'idle', pendingPermissionCount: 0 } + runtimeStates.set(chatId, state) + } + return state +} + +async function getAccessToken(): Promise { + const now = Date.now() + if (accessTokenCache && accessTokenCache.expiresAt > now + 60_000) { + return accessTokenCache.token + } + + const res = await fetch(`${DINGTALK_API}/v1.0/oauth2/accessToken`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + appKey: config.dingtalk.clientId, + appSecret: config.dingtalk.clientSecret, + }), + }) + const data = await res.json().catch(() => null) as { accessToken?: string; expireIn?: number; message?: string } | null + if (!res.ok || !data?.accessToken) { + throw new Error(data?.message || `accessToken request failed: ${res.status}`) + } + + accessTokenCache = { + token: data.accessToken, + expiresAt: now + Number(data.expireIn ?? 7200) * 1000, + } + return data.accessToken +} + +async function sendText(chatId: string, text: string): Promise { + const sessionWebhook = sessionWebhooks.get(chatId) + if (!sessionWebhook) { + console.warn(`[DingTalk] Missing sessionWebhook for ${chatId}; cannot send response`) + return + } + + const token = await getAccessToken() + for (const chunk of splitMessage(text, 3500)) { + const res = await fetch(sessionWebhook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-acs-dingtalk-access-token': token, + }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { + title: 'Claude Code', + text: chunk, + }, + }), + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + console.warn(`[DingTalk] sendText failed: ${res.status} ${body}`) + } + } +} + +function getResponseBuffer(chatId: string): MessageBuffer { + let buffer = responseBuffers.get(chatId) + if (!buffer) { + buffer = new MessageBuffer( + async (text) => sendText(chatId, text), + 900, + 1200, + ) + responseBuffers.set(chatId, buffer) + } + return buffer +} + +function clearTransientChatState(chatId: string): void { + responseBuffers.get(chatId)?.reset() + responseBuffers.delete(chatId) + const runtime = getRuntimeState(chatId) + runtime.state = 'idle' + runtime.verb = undefined + runtime.pendingPermissionCount = 0 +} + +async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { + const stored = sessionStore.get(chatId) + if (!stored) return null + + if (!bridge.hasSession(chatId)) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) return null + } + + return stored +} + +async function buildStatusText(chatId: string): Promise { + const stored = await ensureExistingSession(chatId) + if (!stored) return formatImStatus(null) + + const runtime = getRuntimeState(chatId) + let projectName = path.basename(stored.workDir) || stored.workDir + let branch: string | null = null + + try { + const gitInfo = await httpClient.getGitInfo(stored.sessionId) + projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName + branch = gitInfo.branch + } catch { + // Status should still be useful when git lookup fails. + } + + let taskCounts: + | { + total: number + pending: number + inProgress: number + completed: number + } + | undefined + + try { + const tasks = await httpClient.getTasksForSession(stored.sessionId) + if (tasks.length > 0) { + taskCounts = { + total: tasks.length, + pending: tasks.filter((task) => task.status === 'pending').length, + inProgress: tasks.filter((task) => task.status === 'in_progress').length, + completed: tasks.filter((task) => task.status === 'completed').length, + } + } + } catch { + // Ignore task lookup failures. + } + + return formatImStatus({ + sessionId: stored.sessionId, + projectName, + branch, + model: runtime.model, + state: runtime.state, + verb: runtime.verb, + pendingPermissionCount: runtime.pendingPermissionCount, + taskCounts, + }) +} + +async function ensureSession(chatId: string): Promise { + if (bridge.hasSession(chatId)) return true + + const stored = sessionStore.get(chatId) + if (stored) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + return await bridge.waitForOpen(chatId) + } + + return await createSessionForChat(chatId, defaultWorkDir) +} + +async function createSessionForChat(chatId: string, workDir: string): Promise { + try { + bridge.resetSession(chatId) + responseBuffers.get(chatId)?.reset() + responseBuffers.delete(chatId) + + const sessionId = await httpClient.createSession(workDir) + sessionStore.set(chatId, sessionId, workDir) + bridge.connectSession(chatId, sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await sendText(chatId, '⚠️ 连接服务器超时,请重试。') + return false + } + return true + } catch (err) { + await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`) + return false + } +} + +function formatProjectList(projects: RecentProject[]): string { + const lines = projects.slice(0, 10).map((project, index) => { + const branch = project.branch ? ` (${project.branch})` : '' + return `${index + 1}. **${project.projectName}**${branch}\n ${project.realPath}` + }) + return `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n也可以发送 /new <编号或名称>` +} + +async function showProjectPicker(chatId: string): Promise { + try { + const projects = await httpClient.listRecentProjects() + if (projects.length === 0) { + await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`) + return + } + pendingProjectSelection.set(chatId, true) + await sendText(chatId, formatProjectList(projects)) + } catch (err) { + await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function startNewSession(chatId: string, query?: string): Promise { + bridge.resetSession(chatId) + sessionStore.delete(chatId) + clearTransientChatState(chatId) + pendingProjectSelection.delete(chatId) + runtimeStates.delete(chatId) + + if (query) { + try { + const { project, ambiguous } = await httpClient.matchProject(query) + if (project) { + const ok = await createSessionForChat(chatId, project.realPath) + if (ok) await sendText(chatId, `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`) + return + } + if (ambiguous) { + const list = ambiguous.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`).join('\n') + await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`) + return + } + await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`) + } catch (err) { + await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`) + } + return + } + + const ok = await createSessionForChat(chatId, defaultWorkDir) + if (ok) await sendText(chatId, '✅ 已新建会话,可以开始对话了。') +} + +async function handleServerMessage(chatId: string, msg: ServerMessage): Promise { + const runtime = getRuntimeState(chatId) + + switch (msg.type) { + case 'connected': + break + case 'status': + runtime.state = msg.state + runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined + break + case 'content_start': + if (msg.blockType === 'text') runtime.state = 'streaming' + if (msg.blockType === 'tool_use') runtime.state = 'tool_executing' + break + case 'content_delta': + if (typeof msg.text === 'string' && msg.text) getResponseBuffer(chatId).append(msg.text) + break + case 'tool_use_complete': + 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} 拒绝。`) + break + } + case 'message_complete': + runtime.state = 'idle' + runtime.verb = undefined + await responseBuffers.get(chatId)?.complete() + break + case 'error': + runtime.state = 'idle' + runtime.verb = undefined + responseBuffers.get(chatId)?.reset() + await sendText(chatId, `❌ ${msg.message}`) + break + case 'system_notification': + if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') { + const model = (msg.data as Record).model + if (typeof model === 'string' && model.trim()) runtime.model = model + } + break + } +} + +function handlePermissionCommand(chatId: string, text: string): boolean { + const match = text.match(/^\/(allow|deny)\s+(\S+)/i) + if (!match) return false + + const [, action, requestId] = match + const pending = pendingPermissions.get(chatId) + if (!pending?.has(requestId!)) { + void sendText(chatId, `未找到待确认的权限请求:${requestId}`) + return true + } + + const allowed = action?.toLowerCase() === 'allow' + bridge.sendPermissionResponse(chatId, requestId!, allowed) + pending.delete(requestId!) + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + void sendText(chatId, allowed ? '✅ 已允许' : '❌ 已拒绝') + return true +} + +async function routeUserMessage(chatId: string, text: string): Promise { + enqueue(chatId, async () => { + const trimmed = text.trim() + + if (handlePermissionCommand(chatId, trimmed)) return + + if (pendingProjectSelection.has(chatId)) { + if (trimmed) await startNewSession(chatId, trimmed) + return + } + + if (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new ')) { + const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : '' + await startNewSession(chatId, arg || undefined) + return + } + if (trimmed === '/help' || trimmed === '帮助') { + await sendText(chatId, formatImHelp()) + return + } + if (trimmed === '/status' || trimmed === '状态') { + await sendText(chatId, await buildStatusText(chatId)) + return + } + if (trimmed === '/clear' || trimmed === '清空') { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + clearTransientChatState(chatId) + if (!bridge.sendUserMessage(chatId, '/clear')) { + await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。') + return + } + await sendText(chatId, '🧹 已清空当前会话上下文。') + return + } + if (trimmed === '/stop' || trimmed === '停止') { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + bridge.sendStopGeneration(chatId) + await sendText(chatId, '⏹ 已发送停止信号。') + return + } + if (trimmed === '/projects' || trimmed === '项目列表') { + await showProjectPicker(chatId) + return + } + + const ready = await ensureSession(chatId) + if (!ready || !trimmed) return + if (!bridge.sendUserMessage(chatId, trimmed)) { + await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。') + } + }) +} + +async function handleRobotMessage(data: DingTalkRobotMessage): Promise { + if (!isDingTalkDirectMessage(data)) return + + const chatId = getDingTalkChatId(data) + const userId = getDingTalkSenderId(data) + const text = extractDingTalkText(data) + if (!chatId || !userId || !text) return + + if (data.sessionWebhook) sessionWebhooks.set(chatId, data.sessionWebhook) + + if (!isAllowedUser('dingtalk', userId)) { + const success = tryPair(text, { userId, displayName: data.senderNick || 'DingTalk User' }, 'dingtalk') + await sendText( + chatId, + success + ? '✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。' + : '🔒 未授权。请先在 Claude Code 桌面端完成钉钉扫码绑定,再生成 IM 配对码后发送给我。', + ) + return + } + + await routeUserMessage(chatId, text) +} + +async function start(): Promise { + const client = new DWClient({ + clientId: config.dingtalk.clientId, + clientSecret: config.dingtalk.clientSecret, + endpoint: config.dingtalk.endpoint, + autoReconnect: true, + keepAlive: true, + } as any) + + client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => { + const messageId = res.headers?.messageId + if (messageId) { + client.socketCallBackResponse(messageId, { success: true }) + if (!dedup.tryRecord(`header:${messageId}`)) return + } + + const data = parseDingTalkPayload(res.data) + if (!data) return + if (data.msgId && !dedup.tryRecord(`body:${data.msgId}`)) return + + await handleRobotMessage(data) + }) + + await client.connect() + console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`) + + const shutdown = async () => { + console.log('[DingTalk] Shutting down...') + bridge.destroy() + dedup.destroy() + try { + await client.disconnect() + } catch { + // ignore + } + process.exit(0) + } + process.once('SIGINT', () => void shutdown()) + process.once('SIGTERM', () => void shutdown()) +} + +start().catch((err) => { + console.error('[DingTalk] Fatal:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/adapters/package.json b/adapters/package.json index 6639e3f3..4456c617 100644 --- a/adapters/package.json +++ b/adapters/package.json @@ -7,13 +7,16 @@ "telegram": "bun run telegram/index.ts", "feishu": "bun run feishu/index.ts", "wechat": "bun run wechat/index.ts", + "dingtalk": "bun run dingtalk/index.ts", "test": "bun test", "test:common": "bun test common/", "test:telegram": "bun test telegram/", "test:feishu": "bun test feishu/", - "test:wechat": "bun test wechat/" + "test:wechat": "bun test wechat/", + "test:dingtalk": "bun test dingtalk/" }, "dependencies": { + "dingtalk-stream": "2.1.4", "grammy": "^1.42.0", "@larksuiteoapi/node-sdk": "^1.60.0", "ws": "^8.18.0" diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index 2cd4e1df..0263b408 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -8,7 +8,7 @@ * * claude-sidecar server --app-root --host 127.0.0.1 --port 12345 * claude-sidecar cli --app-root [其它 CLI 参数...] - * claude-sidecar adapters --app-root [--feishu] [--telegram] [--wechat] + * claude-sidecar adapters --app-root [--feishu] [--telegram] [--wechat] [--dingtalk] * * 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的 * 子模块树。原因:src/server/index.ts、src/entrypoints/cli.tsx、以及 @@ -52,12 +52,13 @@ if (mode === 'adapters') { async function runAdapters(rawArgs: string[]): Promise { // adapters 模式的参数解析独立于 server/cli —— 这里只接受 --feishu / - // --telegram / --wechat 选择启用哪个适配器,再加可选的 --app-root(透传给 + // --telegram / --wechat / --dingtalk 选择启用哪个适配器,再加可选的 --app-root(透传给 // adapters/common/config.ts 内的 process.env 读取)。 let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null let enableFeishu = false let enableTelegram = false let enableWechat = false + let enableDingtalk = false for (let i = 0; i < rawArgs.length; i++) { const arg = rawArgs[i] @@ -78,12 +79,16 @@ async function runAdapters(rawArgs: string[]): Promise { enableWechat = true continue } + if (arg === '--dingtalk') { + enableDingtalk = true + continue + } console.warn(`claude-sidecar adapters: ignoring unknown arg "${arg}"`) } - if (!enableFeishu && !enableTelegram && !enableWechat) { + if (!enableFeishu && !enableTelegram && !enableWechat && !enableDingtalk) { console.error( - 'claude-sidecar adapters: must enable at least one of --feishu / --telegram / --wechat', + 'claude-sidecar adapters: must enable at least one of --feishu / --telegram / --wechat / --dingtalk', ) process.exit(2) } @@ -142,6 +147,18 @@ async function runAdapters(rawArgs: string[]): Promise { } } + if (enableDingtalk) { + if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) { + console.warn( + '[claude-sidecar] --dingtalk requested but DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET missing in env or ~/.claude/adapters.json — skipping', + ) + } else { + console.log('[claude-sidecar] starting DingTalk adapter') + await import('../../adapters/dingtalk/index.ts') + started += 1 + } + } + if (started === 0) { console.error( '[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json', diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index a66e1e71..60f575e3 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -24,7 +24,7 @@ { "args": [ "adapters", - { "validator": "regex", "value": "^(feishu|telegram|wechat|lark|slack)(:.+)?$" } + { "validator": "regex", "value": "^(feishu|telegram|wechat|dingtalk|lark|slack)(:.+)?$" } ], "name": "binaries/claude-sidecar", "sidecar": true @@ -53,7 +53,7 @@ { "args": [ "adapters", - { "validator": "regex", "value": "^(feishu|telegram|wechat|lark|slack)(:.+)?$" } + { "validator": "regex", "value": "^(feishu|telegram|wechat|dingtalk|lark|slack)(:.+)?$" } ], "name": "binaries/claude-sidecar", "sidecar": true @@ -82,7 +82,7 @@ { "args": [ "adapters", - { "validator": "regex", "value": "^(feishu|telegram|wechat|lark|slack)(:.+)?$" } + { "validator": "regex", "value": "^(feishu|telegram|wechat|dingtalk|lark|slack)(:.+)?$" } ], "name": "binaries/claude-sidecar", "sidecar": true diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8af99b57..2b9e0fc4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -64,7 +64,7 @@ struct StoredWindowState { /// 与 ServerState 平级的 adapter 子进程状态。 /// -/// adapter sidecar(claude-sidecar adapters --feishu --telegram --wechat)的生命周期 +/// adapter sidecar(claude-sidecar adapters --feishu --telegram --wechat --dingtalk)的生命周期 /// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出, /// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后, /// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。 @@ -989,6 +989,7 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result { "--feishu", "--telegram", "--wechat", + "--dingtalk", ]); let (mut rx, child) = sidecar diff --git a/desktop/src/api/adapters.ts b/desktop/src/api/adapters.ts index 03586e6b..8855c48d 100644 --- a/desktop/src/api/adapters.ts +++ b/desktop/src/api/adapters.ts @@ -1,6 +1,22 @@ import { api } from './client' import type { AdapterFileConfig } from '../types/adapter' +export type DingtalkRegistrationBegin = { + deviceCode: string + userCode?: string + verificationUri?: string + verificationUriComplete: string + expiresInSeconds: number + intervalSeconds: number + qrDataUrl?: string +} + +export type DingtalkRegistrationPoll = { + status: 'WAITING' | 'SUCCESS' | 'FAIL' | 'EXPIRED' | 'UNKNOWN' + failReason?: string + config?: AdapterFileConfig +} + export const adaptersApi = { getConfig() { return api.get('/api/adapters') @@ -24,4 +40,12 @@ export const adaptersApi = { unbindWechat() { return api.post('/api/adapters/wechat/unbind', {}) }, + + beginDingtalkRegistration() { + return api.post('/api/adapters/dingtalk/registration/begin', {}) + }, + + pollDingtalkRegistration(deviceCode: string) { + return api.post('/api/adapters/dingtalk/registration/poll', { deviceCode }) + }, } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 2476d24f..ff389953 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -244,10 +244,11 @@ export const en = { // Settings > Adapters 'settings.tab.adapters': 'IM Adapters', - 'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via WeChat, Telegram, or Feishu.', + 'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via WeChat, DingTalk, Telegram, or Feishu.', 'settings.adapters.telegram': 'Telegram', 'settings.adapters.feishu': 'Feishu', 'settings.adapters.wechat': 'WeChat', + 'settings.adapters.dingtalk': 'DingTalk', 'settings.adapters.botToken': 'Bot Token', 'settings.adapters.botTokenPlaceholder': 'Paste token from @BotFather', 'settings.adapters.appId': 'App ID', @@ -263,8 +264,9 @@ export const en = { 'settings.adapters.tgAllowedUsersPlaceholder': 'e.g. 123456789, 987654321', 'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy', 'settings.adapters.wcAllowedUsersPlaceholder': 'e.g. wx_user_id_1, wx_user_id_2', + 'settings.adapters.dtAllowedUsersPlaceholder': 'e.g. manager001, staff_yyy', 'settings.adapters.defaultProject': 'Default Project', - 'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot will ask you to choose.', + 'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot uses the current user working directory.', 'settings.adapters.streamingCard': 'Streaming Card Mode', 'settings.adapters.streamingCardDesc': 'Real-time card updates for better experience', 'settings.adapters.serverUrl': 'Server URL', @@ -280,7 +282,7 @@ export const en = { 'settings.adapters.codeExpired': 'Expired', 'settings.adapters.codeExpiresIn': 'Expires in', 'settings.adapters.minutes': 'min', - 'settings.adapters.pairingCodeHint': 'Send this code to the Bot in Feishu or Telegram private chat. WeChat uses QR binding.', + 'settings.adapters.pairingCodeHint': 'Send this code to the Bot in DingTalk, Feishu, or Telegram private chat. WeChat uses QR binding.', 'settings.adapters.pairedUsers': 'Paired Users', 'settings.adapters.noPairedUsers': 'No paired users yet', 'settings.adapters.unbind': 'Unbind', @@ -288,6 +290,23 @@ export const en = { 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': 'Feishu', 'settings.adapters.platform.wechat': 'WeChat', + 'settings.adapters.platform.dingtalk': 'DingTalk', + 'settings.adapters.dingtalkQrTitle': 'Bind DingTalk bot with QR', + 'settings.adapters.dingtalkQrDesc': 'Scan with the DingTalk mobile app to create and authorize the bot. Client ID / Secret are saved automatically when it succeeds.', + 'settings.adapters.dingtalkStartAuth': 'Scan to bind', + 'settings.adapters.dingtalkUnbindBot': 'Unbind bot', + 'settings.adapters.dingtalkQrAlt': 'DingTalk authorization QR code', + 'settings.adapters.dingtalkWaiting': 'Waiting for DingTalk scan confirmation...', + 'settings.adapters.dingtalkBound': 'DingTalk bot is bound.', + 'settings.adapters.dingtalkAuthFailed': 'DingTalk QR authorization failed.', + 'settings.adapters.dingtalkAuthExpired': 'DingTalk QR authorization expired. Generate a new QR code.', + 'settings.adapters.dingtalkUnbindFailed': 'Failed to unbind DingTalk.', + 'settings.adapters.dingtalkClientId': 'Client ID', + 'settings.adapters.dingtalkClientIdPlaceholder': 'Filled by QR auth, or enter manually', + 'settings.adapters.dingtalkClientSecret': 'Client Secret', + '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.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 c9d405db..652602ca 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -246,10 +246,11 @@ export const zh: Record = { // Settings > Adapters 'settings.tab.adapters': 'IM 接入', - 'settings.adapters.description': '配置即时通讯适配器,通过微信、Telegram 或飞书与 Claude Code 对话。', + 'settings.adapters.description': '配置即时通讯适配器,通过微信、钉钉、Telegram 或飞书与 Claude Code 对话。', 'settings.adapters.telegram': 'Telegram', 'settings.adapters.feishu': '飞书 (Feishu)', 'settings.adapters.wechat': '微信', + 'settings.adapters.dingtalk': '钉钉 (DingTalk)', 'settings.adapters.botToken': 'Bot Token', 'settings.adapters.botTokenPlaceholder': '粘贴从 @BotFather 获取的 Token', 'settings.adapters.appId': 'App ID', @@ -265,8 +266,9 @@ export const zh: Record = { 'settings.adapters.tgAllowedUsersPlaceholder': '如 123456789, 987654321', 'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy', 'settings.adapters.wcAllowedUsersPlaceholder': '如 wx_user_id_1, wx_user_id_2', + 'settings.adapters.dtAllowedUsersPlaceholder': '如 manager001, staff_yyy', 'settings.adapters.defaultProject': '默认项目', - 'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则由 Bot 询问选择。', + 'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则使用当前用户工作目录。', 'settings.adapters.streamingCard': '流式卡片模式', 'settings.adapters.streamingCardDesc': '实时更新消息内容,体验更好', 'settings.adapters.serverUrl': '服务器地址', @@ -282,7 +284,7 @@ export const zh: Record = { 'settings.adapters.codeExpired': '已过期', 'settings.adapters.codeExpiresIn': '有效期剩余', 'settings.adapters.minutes': '分钟', - 'settings.adapters.pairingCodeHint': '请在飞书或 Telegram 私聊中发送此配对码给 Bot。微信使用扫码绑定。', + 'settings.adapters.pairingCodeHint': '请在钉钉、飞书或 Telegram 私聊中发送此配对码给 Bot。微信使用扫码绑定。', 'settings.adapters.pairedUsers': '已配对用户', 'settings.adapters.noPairedUsers': '暂无已配对用户', 'settings.adapters.unbind': '解绑', @@ -290,6 +292,23 @@ export const zh: Record = { 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': '飞书', 'settings.adapters.platform.wechat': '微信', + 'settings.adapters.platform.dingtalk': '钉钉', + 'settings.adapters.dingtalkQrTitle': '扫码绑定钉钉机器人', + 'settings.adapters.dingtalkQrDesc': '使用钉钉手机 App 扫码,一键创建并授权机器人。成功后会自动保存 Client ID / Secret 并重启适配器。', + 'settings.adapters.dingtalkStartAuth': '扫码绑定', + 'settings.adapters.dingtalkUnbindBot': '解除绑定', + 'settings.adapters.dingtalkQrAlt': '钉钉授权二维码', + 'settings.adapters.dingtalkWaiting': '等待钉钉扫码确认...', + 'settings.adapters.dingtalkBound': '钉钉机器人已绑定。', + 'settings.adapters.dingtalkAuthFailed': '钉钉扫码授权失败。', + 'settings.adapters.dingtalkAuthExpired': '钉钉扫码授权已过期,请重新生成二维码。', + 'settings.adapters.dingtalkUnbindFailed': '钉钉解绑失败。', + 'settings.adapters.dingtalkClientId': 'Client ID', + 'settings.adapters.dingtalkClientIdPlaceholder': '扫码后自动填写,也可手动输入', + 'settings.adapters.dingtalkClientSecret': 'Client Secret', + 'settings.adapters.dingtalkClientSecretPlaceholder': '扫码后自动填写,也可手动输入', + 'settings.adapters.dingtalkEndpoint': 'Stream Endpoint', + 'settings.adapters.dingtalkEndpointPlaceholder': '默认 https://api.dingtalk.com', 'settings.adapters.wechatBind': '扫码绑定', 'settings.adapters.wechatRebind': '重新扫码', 'settings.adapters.wechatConnected': '微信已绑定', diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index fc8f6a63..d3f691ef 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -7,7 +7,8 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { ConfirmDialog } from '../components/shared/ConfirmDialog' import QRCode from 'qrcode' -type ImTab = 'feishu' | 'wechat' | 'telegram' +type ImTab = 'feishu' | 'wechat' | 'dingtalk' | 'telegram' +type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' export function AdapterSettings() { const t = useTranslation() @@ -20,6 +21,9 @@ export function AdapterSettings() { startWechatLogin, pollWechatLogin, removePairedUser, + beginDingtalkRegistration, + pollDingtalkRegistration, + unbindDingtalkBot, } = useAdapterStore() // Active IM tab —— Feishu 默认展示,在前 @@ -48,6 +52,23 @@ export function AdapterSettings() { const [wechatStatus, setWechatStatus] = useState('') const [isWechatBinding, setIsWechatBinding] = useState(false) + // DingTalk + const [dtClientId, setDtClientId] = useState('') + const [dtClientSecret, setDtClientSecret] = useState('') + const [dtAllowedUsers, setDtAllowedUsers] = useState('') + const [dtEndpoint, setDtEndpoint] = useState('') + const [dtRegistration, setDtRegistration] = useState<{ + deviceCode: string + verificationUriComplete: string + qrDataUrl?: string + intervalSeconds: number + expiresAt: number + } | null>(null) + const [dtAuthStatus, setDtAuthStatus] = useState<'idle' | 'waiting' | 'bound' | 'error'>('idle') + const [dtAuthError, setDtAuthError] = useState('') + const [isStartingDtAuth, setIsStartingDtAuth] = useState(false) + const [isUnbindingDtBot, setIsUnbindingDtBot] = useState(false) + const [isSaving, setIsSaving] = useState(false) const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle') const [saveError, setSaveError] = useState('') @@ -55,7 +76,7 @@ export function AdapterSettings() { // Pairing const [pairingCode, setPairingCode] = useState(null) const [isGenerating, setIsGenerating] = useState(false) - const [pendingUnbind, setPendingUnbind] = useState<{ platform: 'telegram' | 'feishu' | 'wechat'; userId: string | number } | null>(null) + const [pendingUnbind, setPendingUnbind] = useState<{ platform: ImPlatform; userId: string | number } | null>(null) const [isUnbinding, setIsUnbinding] = useState(false) useEffect(() => { @@ -74,6 +95,10 @@ export function AdapterSettings() { setFsAllowedUsers(config.feishu?.allowedUsers?.join(', ') ?? '') setFsStreamingCard(config.feishu?.streamingCard ?? false) setWcAllowedUsers(config.wechat?.allowedUsers?.join(', ') ?? '') + setDtClientId(config.dingtalk?.clientId ?? '') + setDtClientSecret(config.dingtalk?.clientSecret ?? '') + setDtAllowedUsers(config.dingtalk?.allowedUsers?.join(', ') ?? '') + setDtEndpoint(config.dingtalk?.endpoint ?? '') }, [config]) useEffect(() => { @@ -113,6 +138,47 @@ export function AdapterSettings() { } }, [wechatSessionKey, pollWechatLogin, t]) + useEffect(() => { + if (!dtRegistration || dtAuthStatus !== 'waiting') return + + let cancelled = false + const poll = async () => { + if (Date.now() > dtRegistration.expiresAt) { + setDtAuthStatus('error') + setDtAuthError(t('settings.adapters.dingtalkAuthExpired')) + setDtRegistration(null) + return + } + + try { + const result = await pollDingtalkRegistration(dtRegistration.deviceCode) + if (cancelled) return + if (result.status === 'SUCCESS') { + setDtAuthStatus('bound') + setDtRegistration(null) + setDtAuthError('') + await fetchConfig() + } else if (result.status === 'FAIL' || result.status === 'EXPIRED') { + setDtAuthStatus('error') + setDtAuthError(result.failReason || t('settings.adapters.dingtalkAuthFailed')) + setDtRegistration(null) + } + } catch (err) { + if (!cancelled) { + setDtAuthStatus('error') + setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed')) + } + } + } + + const timer = window.setInterval(poll, Math.max(1, dtRegistration.intervalSeconds) * 1000) + void poll() + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [dtRegistration, dtAuthStatus, pollDingtalkRegistration, fetchConfig, t]) + async function handleSave() { setIsSaving(true) setSaveStatus('idle') @@ -158,6 +224,18 @@ export function AdapterSettings() { allowedUsers: wcUsers.length ? wcUsers : [], } + const dtUsers = dtAllowedUsers + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + patch.dingtalk = { + clientId: dtClientId || undefined, + clientSecret: dtClientSecret || undefined, + allowedUsers: dtUsers.length ? dtUsers : [], + endpoint: dtEndpoint || undefined, + } + await updateConfig(patch) setSaveStatus('saved') setTimeout(() => setSaveStatus('idle'), 2000) @@ -203,7 +281,45 @@ export function AdapterSettings() { } }, [startWechatLogin]) - const handleUnbind = useCallback(async (platform: 'telegram' | 'feishu' | 'wechat', userId: string | number) => { + const handleStartDingtalkAuth = useCallback(async () => { + setIsStartingDtAuth(true) + setDtAuthStatus('idle') + setDtAuthError('') + try { + const begin = await beginDingtalkRegistration() + setDtRegistration({ + deviceCode: begin.deviceCode, + verificationUriComplete: begin.verificationUriComplete, + qrDataUrl: begin.qrDataUrl, + intervalSeconds: begin.intervalSeconds, + expiresAt: Date.now() + begin.expiresInSeconds * 1000, + }) + setDtAuthStatus('waiting') + } catch (err) { + setDtAuthStatus('error') + setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed')) + } finally { + setIsStartingDtAuth(false) + } + }, [beginDingtalkRegistration, t]) + + const handleUnbindDingtalkBot = useCallback(async () => { + setIsUnbindingDtBot(true) + setDtAuthError('') + try { + await unbindDingtalkBot() + setDtAuthStatus('idle') + setDtRegistration(null) + await fetchConfig() + } catch (err) { + setDtAuthStatus('error') + setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkUnbindFailed')) + } finally { + setIsUnbindingDtBot(false) + } + }, [unbindDingtalkBot, fetchConfig, t]) + + const handleUnbind = useCallback(async (platform: ImPlatform, userId: string | number) => { setPendingUnbind({ platform, userId }) }, []) @@ -224,6 +340,7 @@ export function AdapterSettings() { ...(config.telegram?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'telegram' as const })), ...(config.feishu?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'feishu' as const })), ...(config.wechat?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'wechat' as const })), + ...(config.dingtalk?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'dingtalk' as const })), ] // Check pairing expiry @@ -346,6 +463,11 @@ export function AdapterSettings() { active={activeIm === 'wechat'} onClick={() => setActiveIm('wechat')} /> + setActiveIm('dingtalk')} + /> )} + {activeIm === 'dingtalk' && ( +
+
+
+
+

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

+

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

+
+
+ + {(config.dingtalk?.clientId || dtClientId) && ( + + )} +
+
+ + {dtRegistration && ( +
+ {dtRegistration.qrDataUrl ? ( + {t('settings.adapters.dingtalkQrAlt')} + ) : null} +
+

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

+ + {dtRegistration.verificationUriComplete} + +
+
+ )} + + {dtAuthStatus === 'bound' && ( +

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

+ )} + {dtAuthStatus === 'error' && ( +

{dtAuthError}

+ )} +
+ +
+ setDtClientId(e.target.value)} + placeholder={t('settings.adapters.dingtalkClientIdPlaceholder')} + /> + setDtClientSecret(e.target.value)} + placeholder={t('settings.adapters.dingtalkClientSecretPlaceholder')} + /> +
+ setDtEndpoint(e.target.value)} + placeholder={t('settings.adapters.dingtalkEndpointPlaceholder')} + /> +
+ setDtAllowedUsers(e.target.value)} + placeholder={t('settings.adapters.dtAllowedUsersPlaceholder')} + /> +

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

+
+
+ )} + {activeIm === 'telegram' && (
Promise startWechatLogin: () => Promise<{ qrcodeUrl?: string; message: string; sessionKey: string }> pollWechatLogin: (sessionKey: string) => Promise<{ connected: boolean; message?: string }> - removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat', userId: string | number) => Promise + removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise + beginDingtalkRegistration: () => Promise + pollDingtalkRegistration: (deviceCode: string) => Promise + unbindDingtalkBot: () => Promise } export const useAdapterStore = create((set, get) => ({ @@ -101,7 +105,7 @@ export const useAdapterStore = create((set, get) => ({ if ('connected' in result && result.connected === false) { return { connected: false, message: result.message } } - if ('wechat' in result || 'telegram' in result || 'feishu' in result) { + if ('wechat' in result || 'telegram' in result || 'feishu' in result || 'dingtalk' in result) { set({ config: result }) void notifyTauriRestartAdapters() return { connected: true } @@ -109,6 +113,27 @@ export const useAdapterStore = create((set, get) => ({ return { connected: false } }, + beginDingtalkRegistration: () => adaptersApi.beginDingtalkRegistration(), + + pollDingtalkRegistration: async (deviceCode) => { + const result = await adaptersApi.pollDingtalkRegistration(deviceCode) + if (result.config) { + set({ config: result.config }) + void notifyTauriRestartAdapters() + } + return result + }, + + unbindDingtalkBot: async () => { + await get().updateConfig({ + dingtalk: { + clientId: undefined, + clientSecret: undefined, + pairedUsers: [], + }, + }) + }, + removePairedUser: async (platform, userId) => { if (platform === 'wechat') { const config = await adaptersApi.unbindWechat() diff --git a/desktop/src/types/adapter.ts b/desktop/src/types/adapter.ts index 552fd661..1539e5f6 100644 --- a/desktop/src/types/adapter.ts +++ b/desktop/src/types/adapter.ts @@ -39,4 +39,12 @@ export type AdapterFileConfig = { pairedUsers?: PairedUser[] defaultWorkDir?: string } + dingtalk?: { + clientId?: string + clientSecret?: string + allowedUsers?: string[] + pairedUsers?: PairedUser[] + defaultWorkDir?: string + endpoint?: string + } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7d74aee5..67bd55f4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -61,6 +61,7 @@ const zhSidebar = [ items: [ { text: '总览', link: '/im/' }, { text: '微信', link: '/im/wechat' }, + { text: '钉钉', link: '/im/dingtalk' }, { text: 'Telegram', link: '/im/telegram' }, { text: '飞书', link: '/im/feishu' }, ], diff --git a/docs/im/dingtalk.md b/docs/im/dingtalk.md new file mode 100644 index 00000000..a13d5119 --- /dev/null +++ b/docs/im/dingtalk.md @@ -0,0 +1,49 @@ +# 钉钉接入 + +钉钉接入走 DingTalk Stream,不需要公网回调地址。桌面端设置页支持扫码创建并授权机器人,授权成功后会把 `clientId / clientSecret` 写入 `~/.claude/adapters.json`。 + +## 配置方式 + +打开 Desktop Webapp 的 `Settings -> IM 接入 -> 钉钉`。 + +推荐方式: + +1. 点击“扫码绑定” +2. 使用钉钉手机 App 扫码 +3. 在钉钉里确认创建机器人 +4. 等待桌面端显示已绑定 + +如果扫码不可用,也可以手动填写: + +- `Client ID` +- `Client Secret` +- 可选 `Stream Endpoint` +- 可选 `Allowed Users` + +## 配对用户 + +钉钉机器人绑定完成后,还需要让具体用户通过 IM 配对码授权: + +1. 在 `配对管理` 里生成配对码 +2. 在钉钉私聊里把配对码发给机器人 +3. 显示配对成功后即可开始聊天 + +配对用户会出现在“已配对用户”列表里,可以随时解绑。解绑后该用户需要重新发送新的配对码才能使用。 + +## 运行 adapter + +桌面端发布版会通过 sidecar 自动启动。开发时可以手动运行: + +```bash +cd adapters +bun install +bun run dingtalk +``` + +## 当前行为 + +- 只处理钉钉单聊消息 +- 使用 `sessionWebhook` 回复文本 / Markdown +- 支持 `/new`、`/projects`、`/status`、`/clear`、`/stop`、`/help` +- 权限确认通过 `/allow ` 和 `/deny ` 回复 +- 群聊、媒体附件、AI Card 流式卡片后续再扩展 diff --git a/docs/im/index.md b/docs/im/index.md index 66ca10f2..26dd09a8 100644 --- a/docs/im/index.md +++ b/docs/im/index.md @@ -1,7 +1,7 @@ # IM 接入 > 当前可用的 IM 接入方案总览。 -> 如果你只是想把微信、Telegram 或飞书接进来,从这篇开始。 +> 如果你只是想把微信、钉钉、Telegram 或飞书接进来,从这篇开始。 ## 当前方案是什么 @@ -16,7 +16,7 @@ flowchart TD C --> D["本地配置持久化"] D --> E["~/.claude/adapters.json"] - E --> F["微信 / Telegram / 飞书 Adapter 进程"] + E --> F["微信 / 钉钉 / Telegram / 飞书 Adapter 进程"] F --> G["加载平台配置"] F --> H["校验配对与授权"] F --> I["读取 / 写入会话映射"] @@ -24,11 +24,8 @@ flowchart TD F --> K{"当前 chatId
是否已有 session?"} K -->|有历史映射| L["复用已有 sessionId"] - K -->|无历史映射| M{"是否配置
defaultProjectDir?"} - M -->|是| N["POST /api/sessions
创建新 session"] - M -->|否| O["GET /api/sessions/recent-projects
让用户选择项目"] - O --> P["用户回复编号"] - P --> N + K -->|无历史映射| M["解析工作目录
defaultProjectDir 或当前用户工作目录"] + M --> N["POST /api/sessions
创建新 session"] N --> Q["获得 sessionId"] L --> R["连接 /ws/:sessionId"] @@ -41,14 +38,14 @@ flowchart TD U --> V["流式消息 / 权限请求 / 状态事件"] V --> S S --> F - F --> W["微信 / Telegram / 飞书用户"] + F --> W["微信 / 钉钉 / Telegram / 飞书用户"] ``` 可以把这条链路理解成四层: - 配置层:桌面端 webapp 负责填写平台凭据、默认项目和配对码管理 - 存储层:本地服务端把配置写入 `~/.claude/adapters.json` -- 适配层:微信 / Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话 +- 适配层:微信 / 钉钉 / Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话 - 会话层:adapter 通过 HTTP 创建 session,再通过 WebSocket 把 IM 消息桥接到 Claude Code 会话 ## 用户怎么用 @@ -59,7 +56,7 @@ flowchart TD - `serverUrl` - `defaultProjectDir` -- 微信扫码绑定,或填写 Telegram / 飞书各自的凭据 +- 微信 / 钉钉扫码绑定,或填写 Telegram / 飞书各自的凭据 - 可选 `allowedUsers` 这里的配置会通过 `GET /api/adapters` 和 `PUT /api/adapters` 读写到 `~/.claude/adapters.json`。 @@ -73,7 +70,7 @@ flowchart TD - 码有效期 60 分钟 - 配对成功后立即失效 -配对码是平台无关的,同一个码可以在 Telegram 或飞书私聊里使用一次。微信不使用配对码;在微信标签页扫码并确认后就会把扫码用户绑定到本机。 +配对码是平台无关的,同一个码可以在钉钉、Telegram 或飞书私聊里使用一次。微信不使用配对码;在微信标签页扫码并确认后就会把扫码用户绑定到本机。 ### 3. 启动对应 Adapter 进程 @@ -87,13 +84,15 @@ bun run telegram bun run feishu # 或 bun run wechat +# 或 +bun run dingtalk ``` ### 4. 在 IM 里私聊 Bot - 未配对用户:先把配对码发给 bot - 已配对用户:直接发送自然语言消息 -- 没有默认项目时:bot 会先让你从最近项目里选一个 +- 没有默认项目时:bot 会使用当前用户工作目录作为新会话目录 - 后续消息会复用同一个 Claude session ## 配置和状态分别存哪 @@ -108,6 +107,7 @@ bun run wechat - `telegram` - `feishu` - `wechat` +- `dingtalk` 其中: @@ -149,17 +149,19 @@ Adapter 不是直接把消息丢给一个全局 Claude 进程,而是: - `stop_generation` 4. 把服务端流式消息再格式化回 IM -如果没有 `defaultProjectDir`,Adapter 会调用 `GET /api/sessions/recent-projects` 让用户先选项目。 +如果没有 `defaultProjectDir`,Adapter 会优先使用当前用户工作目录作为新 session 的工作目录,避免扫码绑定后还必须先在桌面端打开项目。 ## 平台差异 - Telegram:`grammy`,按钮审批,纯私聊模式 - 飞书:`@larksuiteoapi/node-sdk`,长连接事件订阅,交互卡片审批,当前只处理 `p2p` - 微信:扫码绑定账号,`getupdates` 长轮询接收消息,文本命令审批使用 `/allow ` / `/deny ` +- 钉钉:扫码给本机写入 Stream 凭据,`dingtalk-stream` 长连接收发消息,配对码绑定用户后开始聊天 分别看: - [微信接入](./wechat.md) +- [钉钉接入](./dingtalk.md) - [Telegram 接入](./telegram.md) - [飞书接入](./feishu.md) diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index 368a42c4..76c0c37f 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -57,6 +57,36 @@ describe('Adapters API', () => { expect(json.wechat.accountId).toBe('bot-1') }) + it('masks and preserves DingTalk client secrets', async () => { + const put = makeRequest('PUT', '/api/adapters', { + dingtalk: { + clientId: 'ding-client-1', + clientSecret: 'dingtalk-client-secret', + pairedUsers: [{ userId: 'ding-user', displayName: 'DingTalk User', pairedAt: 1 }], + }, + }) + expect((await handleAdaptersApi(put.req, put.url, put.segments)).status).toBe(200) + + const get = makeRequest('GET', '/api/adapters') + const res = await handleAdaptersApi(get.req, get.url, get.segments) + expect(res.status).toBe(200) + const json = await res.json() as any + expect(json.dingtalk.clientSecret).toBe('****cret') + expect(json.dingtalk.clientId).toBe('ding-client-1') + + const maskedPut = makeRequest('PUT', '/api/adapters', { + dingtalk: { + clientSecret: json.dingtalk.clientSecret, + allowedUsers: ['ding-user'], + }, + }) + expect((await handleAdaptersApi(maskedPut.req, maskedPut.url, maskedPut.segments)).status).toBe(200) + + 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']) + }) + it('clears WeChat credentials on unbind', async () => { const put = makeRequest('PUT', '/api/adapters', { wechat: { diff --git a/src/server/api/adapters.ts b/src/server/api/adapters.ts index cd6610ac..277e13f3 100644 --- a/src/server/api/adapters.ts +++ b/src/server/api/adapters.ts @@ -13,7 +13,125 @@ import { WECHAT_DEFAULT_BASE_URL, } from '../../../adapters/wechat/protocol.js' -const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'pairing']) +const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'dingtalk', 'pairing']) + +type RegistrationApiResponse> = T & { + errcode: number + errmsg?: string +} + +type RegistrationBeginPayload = { + deviceCode: string + userCode?: string + verificationUri?: string + verificationUriComplete: string + expiresInSeconds: number + intervalSeconds: number + qrDataUrl?: string +} + +const DINGTALK_REGISTRATION_BASE_URL = + process.env.DINGTALK_REGISTRATION_BASE_URL?.trim() || 'https://oapi.dingtalk.com' +const DINGTALK_REGISTRATION_SOURCE = + process.env.DINGTALK_REGISTRATION_SOURCE?.trim() || 'DING_DWS_CLAW' + +async function postDingtalkRegistration>( + path: string, + body: Record, + action: string, +): Promise> { + const res = await fetch(`${DINGTALK_REGISTRATION_BASE_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json().catch(() => null) as RegistrationApiResponse | null + if (!res.ok || !data || data.errcode !== 0) { + throw ApiError.internal(`[DingTalk ${action}] ${data?.errmsg || res.statusText || 'unknown error'}`) + } + return data +} + +async function createQrDataUrl(text: string): Promise { + try { + const qr = await import('qrcode') as any + return await qr.toDataURL(text, { margin: 1, width: 220 }) + } catch { + return undefined + } +} + +async function beginDingtalkRegistration(): Promise { + const initData = await postDingtalkRegistration<{ nonce?: string }>( + '/app/registration/init', + { source: DINGTALK_REGISTRATION_SOURCE }, + 'init', + ) + const nonce = String(initData.nonce ?? '').trim() + if (!nonce) throw ApiError.internal('[DingTalk init] missing nonce') + + const beginData = await postDingtalkRegistration<{ + device_code?: string + user_code?: string + verification_uri?: string + verification_uri_complete?: string + expires_in?: number + interval?: number + }>('/app/registration/begin', { nonce }, 'begin') + + const deviceCode = String(beginData.device_code ?? '').trim() + const verificationUriComplete = String(beginData.verification_uri_complete ?? '').trim() + if (!deviceCode) throw ApiError.internal('[DingTalk begin] missing device_code') + if (!verificationUriComplete) throw ApiError.internal('[DingTalk begin] missing verification_uri_complete') + + const expiresInSeconds = Number(beginData.expires_in ?? 7200) + const intervalSeconds = Number(beginData.interval ?? 3) + + return { + deviceCode, + userCode: String(beginData.user_code ?? '').trim() || undefined, + verificationUri: String(beginData.verification_uri ?? '').trim() || undefined, + verificationUriComplete, + expiresInSeconds: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 ? expiresInSeconds : 7200, + intervalSeconds: Number.isFinite(intervalSeconds) && intervalSeconds > 0 ? intervalSeconds : 3, + qrDataUrl: await createQrDataUrl(verificationUriComplete), + } +} + +async function pollDingtalkRegistration(deviceCode: string): Promise { + if (!deviceCode) throw ApiError.badRequest('deviceCode is required') + + const pollData = await postDingtalkRegistration<{ + status?: string + client_id?: string + client_secret?: string + fail_reason?: string + }>('/app/registration/poll', { device_code: deviceCode }, 'poll') + + const status = String(pollData.status ?? '').trim().toUpperCase() + if (status === 'SUCCESS') { + const clientId = String(pollData.client_id ?? '').trim() + const clientSecret = String(pollData.client_secret ?? '').trim() + if (!clientId || !clientSecret) { + throw ApiError.internal('DingTalk authorization succeeded but credentials are missing') + } + await adapterService.updateConfig({ + dingtalk: { + clientId, + clientSecret, + }, + }) + return Response.json({ + status, + config: await adapterService.getConfig(), + }) + } + + return Response.json({ + status: status || 'UNKNOWN', + failReason: String(pollData.fail_reason ?? '').trim() || undefined, + }) +} export async function handleAdaptersApi( req: Request, @@ -25,6 +143,15 @@ export async function handleAdaptersApi( if (tail[0] === 'wechat') { return handleWechatAdaptersApi(req, tail.slice(1)) } + if (tail[0] === 'dingtalk' && tail[1] === 'registration') { + if (req.method === 'POST' && tail[2] === 'begin') { + return Response.json(await beginDingtalkRegistration()) + } + if (req.method === 'POST' && tail[2] === 'poll') { + const body = await req.json().catch(() => ({})) as { deviceCode?: string } + return pollDingtalkRegistration(String(body.deviceCode ?? '').trim()) + } + } if (req.method === 'GET') { const config = await adapterService.getConfig() diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index 546a6733..13cbecd1 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -51,6 +51,14 @@ export type AdapterFileConfig = { pairedUsers?: PairedUser[] defaultWorkDir?: string } + dingtalk?: { + clientId?: string + clientSecret?: string + allowedUsers?: string[] + pairedUsers?: PairedUser[] + defaultWorkDir?: string + endpoint?: string + } } function getConfigPath(): string { @@ -96,6 +104,9 @@ class AdapterService { if (config.wechat?.botToken) { config.wechat.botToken = maskSecret(config.wechat.botToken) } + if (config.dingtalk?.clientSecret) { + config.dingtalk.clientSecret = maskSecret(config.dingtalk.clientSecret) + } if (config.pairing?.code) { config.pairing.code = '******' } @@ -118,6 +129,9 @@ class AdapterService { if (patch.wechat && isMasked(patch.wechat.botToken)) { patch.wechat.botToken = current.wechat?.botToken } + if (patch.dingtalk && isMasked(patch.dingtalk.clientSecret)) { + patch.dingtalk.clientSecret = current.dingtalk?.clientSecret + } if (patch.pairing && isMasked(patch.pairing.code ?? undefined)) { patch.pairing.code = current.pairing?.code } @@ -128,6 +142,7 @@ class AdapterService { telegram: patch.telegram ? { ...current.telegram, ...patch.telegram } : current.telegram, feishu: patch.feishu ? { ...current.feishu, ...patch.feishu } : current.feishu, wechat: patch.wechat ? { ...current.wechat, ...patch.wechat } : current.wechat, + dingtalk: patch.dingtalk ? { ...current.dingtalk, ...patch.dingtalk } : current.dingtalk, pairing: patch.pairing !== undefined ? { ...current.pairing, ...patch.pairing } : current.pairing, }