Support DingTalk IM without blocking on project history

DingTalk uses QR registration to store client credentials, then reuses the existing IM pairing and session bridge. The merged main implementation keeps the existing WeChat QR binding path intact while adding DingTalk as a peer IM platform. The default IM workdir now falls back to the local user working directory so a newly bound chat can start immediately even when recent-project history is empty.

Constraint: Local main already carries WeChat IM, so the merge keeps WeChat config, QR binding, sidecar args, and unbind behavior intact while adding DingTalk.
Rejected: Keep empty defaultProjectDir as project-picker-only | newly bound IM users can hit a dead end with no recent projects.
Confidence: high
Scope-risk: moderate
Directive: Keep IM platform unions synchronized across config, pairing, sidecar args, desktop settings, and docs.
Tested: bun test common/ dingtalk/ wechat/; bun test src/server/__tests__/adapters.test.ts; cd adapters && bunx tsc --noEmit; bun run check:policy; bun run check:server; bun run check:desktop; bun run check:adapters; bun run check:native; bun run check:docs
Not-tested: quality:pr full gate was blocked by existing local main CLI-core diff requiring allow-cli-core-change maintainer approval; live post-fix DingTalk second-message delivery was not repeated after the merge.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 17:45:28 +08:00
parent 99b343d6a1
commit 6d9aa98022
25 changed files with 1272 additions and 39 deletions

View File

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

View File

@ -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=="],

View File

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

View File

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

View File

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

View File

@ -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<string, any>,
): 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)

View File

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

View File

@ -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<string, any> | null {
if (!raw) return null
if (typeof raw === 'object') return raw as Record<string, any>
if (typeof raw !== 'string') return null
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null
} catch {
return null
}
}

494
adapters/dingtalk/index.ts Normal file
View File

@ -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<string, string>()
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const responseBuffers = new Map<string, MessageBuffer>()
const pendingPermissions = new Map<string, Set<string>>()
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<string> {
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<void> {
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<string> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<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} 拒绝。`)
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<string, unknown>).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<void> {
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<void> {
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<void> {
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)
})

View File

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

View File

@ -8,7 +8,7 @@
*
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [ CLI ...]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram] [--wechat]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram] [--wechat] [--dingtalk]
*
* process.env / process.argv await
* src/server/index.tssrc/entrypoints/cli.tsx
@ -52,12 +52,13 @@ if (mode === 'adapters') {
async function runAdapters(rawArgs: string[]): Promise<void> {
// 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<void> {
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<void> {
}
}
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',

View File

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

View File

@ -64,7 +64,7 @@ struct StoredWindowState {
/// 与 ServerState 平级的 adapter 子进程状态。
///
/// adapter sidecarclaude-sidecar adapters --feishu --telegram --wechat)的生命周期
/// adapter sidecarclaude-sidecar adapters --feishu --telegram --wechat --dingtalk)的生命周期
/// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出,
/// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后,
/// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。
@ -989,6 +989,7 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result<CommandChild, String> {
"--feishu",
"--telegram",
"--wechat",
"--dingtalk",
]);
let (mut rx, child) = sidecar

View File

@ -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<AdapterFileConfig>('/api/adapters')
@ -24,4 +40,12 @@ export const adaptersApi = {
unbindWechat() {
return api.post<AdapterFileConfig>('/api/adapters/wechat/unbind', {})
},
beginDingtalkRegistration() {
return api.post<DingtalkRegistrationBegin>('/api/adapters/dingtalk/registration/begin', {})
},
pollDingtalkRegistration(deviceCode: string) {
return api.post<DingtalkRegistrationPoll>('/api/adapters/dingtalk/registration/poll', { deviceCode })
},
}

View File

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

View File

@ -246,10 +246,11 @@ export const zh: Record<TranslationKey, string> = {
// 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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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': '微信已绑定',

View File

@ -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<string | null>(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')}
/>
<ImTabButton
label={t('settings.adapters.dingtalk')}
active={activeIm === 'dingtalk'}
onClick={() => setActiveIm('dingtalk')}
/>
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
@ -457,6 +579,90 @@ export function AdapterSettings() {
</div>
)}
{activeIm === 'dingtalk' && (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.dingtalkQrTitle')}</h4>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.dingtalkQrDesc')}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button onClick={handleStartDingtalkAuth} loading={isStartingDtAuth} size="sm">
{t('settings.adapters.dingtalkStartAuth')}
</Button>
{(config.dingtalk?.clientId || dtClientId) && (
<Button onClick={handleUnbindDingtalkBot} loading={isUnbindingDtBot} size="sm" variant="danger">
{t('settings.adapters.dingtalkUnbindBot')}
</Button>
)}
</div>
</div>
{dtRegistration && (
<div className="flex flex-wrap items-center gap-4">
{dtRegistration.qrDataUrl ? (
<img
src={dtRegistration.qrDataUrl}
alt={t('settings.adapters.dingtalkQrAlt')}
className="h-40 w-40 rounded-lg border border-[var(--color-border)] bg-white object-contain p-2"
/>
) : null}
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.dingtalkWaiting')}</p>
<a
href={dtRegistration.verificationUriComplete}
target="_blank"
rel="noreferrer"
className="block truncate text-xs text-[var(--color-brand)] hover:underline"
>
{dtRegistration.verificationUriComplete}
</a>
</div>
</div>
)}
{dtAuthStatus === 'bound' && (
<p className="text-sm text-[var(--color-success)]">{t('settings.adapters.dingtalkBound')}</p>
)}
{dtAuthStatus === 'error' && (
<p className="text-sm text-[var(--color-error)]">{dtAuthError}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.dingtalkClientId')}
value={dtClientId}
onChange={(e) => setDtClientId(e.target.value)}
placeholder={t('settings.adapters.dingtalkClientIdPlaceholder')}
/>
<Input
label={t('settings.adapters.dingtalkClientSecret')}
type="password"
value={dtClientSecret}
onChange={(e) => setDtClientSecret(e.target.value)}
placeholder={t('settings.adapters.dingtalkClientSecretPlaceholder')}
/>
</div>
<Input
label={t('settings.adapters.dingtalkEndpoint')}
value={dtEndpoint}
onChange={(e) => setDtEndpoint(e.target.value)}
placeholder={t('settings.adapters.dingtalkEndpointPlaceholder')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={dtAllowedUsers}
onChange={(e) => setDtAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.dtAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
)}
{activeIm === 'telegram' && (
<div className="p-4 space-y-4">
<Input

View File

@ -1,10 +1,11 @@
import { create } from 'zustand'
import { adaptersApi } from '../api/adapters'
import type { AdapterFileConfig } from '../types/adapter'
import type { DingtalkRegistrationBegin, DingtalkRegistrationPoll } from '../api/adapters'
/**
* Tauri command kill + respawn adapter sidecar
* ~/.claude/adapters.json / Telegram /
* ~/.claude/adapters.json / Telegram / /
* WebSocket
*
* Tauri /
@ -50,7 +51,10 @@ type AdapterStore = {
generatePairingCode: () => Promise<string>
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<void>
removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise<void>
beginDingtalkRegistration: () => Promise<DingtalkRegistrationBegin>
pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll>
unbindDingtalkBot: () => Promise<void>
}
export const useAdapterStore = create<AdapterStore>((set, get) => ({
@ -101,7 +105,7 @@ export const useAdapterStore = create<AdapterStore>((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<AdapterStore>((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()

View File

@ -39,4 +39,12 @@ export type AdapterFileConfig = {
pairedUsers?: PairedUser[]
defaultWorkDir?: string
}
dingtalk?: {
clientId?: string
clientSecret?: string
allowedUsers?: string[]
pairedUsers?: PairedUser[]
defaultWorkDir?: string
endpoint?: string
}
}

View File

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

49
docs/im/dingtalk.md Normal file
View File

@ -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 <requestId>``/deny <requestId>` 回复
- 群聊、媒体附件、AI Card 流式卡片后续再扩展

View File

@ -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<br/>是否已有 session?"}
K -->|有历史映射| L["复用已有 sessionId"]
K -->|无历史映射| M{"是否配置<br/>defaultProjectDir?"}
M -->|是| N["POST /api/sessions<br/>创建新 session"]
M -->|否| O["GET /api/sessions/recent-projects<br/>让用户选择项目"]
O --> P["用户回复编号"]
P --> N
K -->|无历史映射| M["解析工作目录<br/>defaultProjectDir 或当前用户工作目录"]
M --> N["POST /api/sessions<br/>创建新 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 <requestId>` / `/deny <requestId>`
- 钉钉:扫码给本机写入 Stream 凭据,`dingtalk-stream` 长连接收发消息,配对码绑定用户后开始聊天
分别看:
- [微信接入](./wechat.md)
- [钉钉接入](./dingtalk.md)
- [Telegram 接入](./telegram.md)
- [飞书接入](./feishu.md)

View File

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

View File

@ -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 extends Record<string, unknown>> = 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<T extends Record<string, unknown>>(
path: string,
body: Record<string, unknown>,
action: string,
): Promise<RegistrationApiResponse<T>> {
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<T> | 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<string | undefined> {
try {
const qr = await import('qrcode') as any
return await qr.toDataURL(text, { margin: 1, width: 220 })
} catch {
return undefined
}
}
async function beginDingtalkRegistration(): Promise<RegistrationBeginPayload> {
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<Response> {
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()

View File

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