mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
99b343d6a1
commit
6d9aa98022
@ -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
|
||||
|
||||
@ -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=="],
|
||||
|
||||
@ -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 {
|
||||
|
||||
22
adapters/common/__tests__/pairing.test.ts
Normal file
22
adapters/common/__tests__/pairing.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
|
||||
40
adapters/dingtalk/__tests__/helpers.test.ts
Normal file
40
adapters/dingtalk/__tests__/helpers.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
74
adapters/dingtalk/helpers.ts
Normal file
74
adapters/dingtalk/helpers.ts
Normal 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
494
adapters/dingtalk/index.ts
Normal 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)
|
||||
})
|
||||
@ -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"
|
||||
|
||||
@ -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.ts、src/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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<CommandChild, String> {
|
||||
"--feishu",
|
||||
"--telegram",
|
||||
"--wechat",
|
||||
"--dingtalk",
|
||||
]);
|
||||
|
||||
let (mut rx, child) = sidecar
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '微信已绑定',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -39,4 +39,12 @@ export type AdapterFileConfig = {
|
||||
pairedUsers?: PairedUser[]
|
||||
defaultWorkDir?: string
|
||||
}
|
||||
dingtalk?: {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
allowedUsers?: string[]
|
||||
pairedUsers?: PairedUser[]
|
||||
defaultWorkDir?: string
|
||||
endpoint?: string
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
49
docs/im/dingtalk.md
Normal 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 流式卡片后续再扩展
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user