mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
720 lines
25 KiB
TypeScript
720 lines
25 KiB
TypeScript
import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js'
|
|
/**
|
|
* 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_CARD, TOPIC_ROBOT } from 'dingtalk-stream'
|
|
import { WsBridge, type ServerMessage, type AttachmentRef } 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 { loadConfig } from '../common/config.js'
|
|
import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js'
|
|
import {
|
|
formatPermissionDecisionStatus,
|
|
formatPermissionInstructions,
|
|
parsePermissionCommand,
|
|
type PermissionDecision,
|
|
} from '../common/permission.js'
|
|
import { SessionStore } from '../common/session-store.js'
|
|
import { syncImPermissionState } from '../common/permission-sync.js'
|
|
import { SessionSelectionController } from '../common/session-selection.js'
|
|
import { type RecentProject } from '../common/http-client.js'
|
|
import { createAdapterClient } from '../common/adapter-client.js'
|
|
import {
|
|
formatProjectSelectionOutcome,
|
|
ProjectSelectionController,
|
|
} from '../common/project-selection-router.js'
|
|
import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js'
|
|
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
|
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
|
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
|
import {
|
|
extractDingTalkAttachments,
|
|
extractDingTalkText,
|
|
getDingTalkChatId,
|
|
getDingTalkSenderId,
|
|
isDingTalkDirectMessage,
|
|
parseDingTalkPayload,
|
|
type DingTalkRobotMessage,
|
|
} from './helpers.js'
|
|
import { DingTalkMediaService } from './media.js'
|
|
import {
|
|
DingTalkAiCardService,
|
|
type DingTalkAiCardInstance,
|
|
type DingTalkAiCardTarget,
|
|
} from './ai-card.js'
|
|
import {
|
|
buildDingTalkPermissionCardParams,
|
|
DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE,
|
|
parseDingTalkPermissionCardAction,
|
|
} from './permission-card.js'
|
|
import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from './stream-state.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 { httpClient, defaultWorkDir } = createAdapterClient(config, config.dingtalk)
|
|
|
|
const bridge = new WsBridge(config.serverUrl, 'dingtalk')
|
|
const dedup = new MessageDedup()
|
|
const sessionStore = new SessionStore()
|
|
const attachmentStore = new AttachmentStore()
|
|
const media = new DingTalkMediaService(attachmentStore)
|
|
const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId)
|
|
const sessionWebhooks = new Map<string, string>()
|
|
const projectSelectionController = new ProjectSelectionController({
|
|
httpClient,
|
|
defaultWorkDir,
|
|
prepareNewSession,
|
|
createSession: createSessionForChat,
|
|
})
|
|
const runtimeStates = new Map<string, ChatRuntimeState>()
|
|
const aiCardBuffers = new Map<string, MessageBuffer>()
|
|
const aiCardTargets = new Map<string, DingTalkAiCardTarget>()
|
|
const streamingCards = new Map<string, Promise<DingTalkAiCardInstance | null>>()
|
|
const streamingCardText = new Map<string, string>()
|
|
const pendingPermissions = new Map<string, Set<string>>()
|
|
const pendingPermissionChats = new Map<string, string>()
|
|
|
|
let accessTokenCache: { token: string; expiresAt: number } | null = null
|
|
|
|
attachmentStore.gc().catch((err) => {
|
|
console.warn('[DingTalk] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
|
|
})
|
|
|
|
const sessionSelectionController = new SessionSelectionController({
|
|
httpClient,
|
|
bridge,
|
|
sessionStore,
|
|
sendNotice: async (chatId, text) => { await sendText(chatId, text) },
|
|
onServerMessage: handleServerMessage,
|
|
clearTransientState: clearTransientChatState,
|
|
clearProjectSelection: (chatId) => { projectSelectionController.clear(chatId) },
|
|
isBusy: (chatId) => {
|
|
const runtime = getRuntimeState(chatId)
|
|
return runtime.state !== 'idle' || runtime.pendingPermissionCount > 0
|
|
|| (pendingPermissions.get(chatId)?.size ?? 0) > 0
|
|
},
|
|
})
|
|
|
|
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 getAiCardBuffer(chatId: string): MessageBuffer {
|
|
let buffer = aiCardBuffers.get(chatId)
|
|
if (!buffer) {
|
|
buffer = new MessageBuffer(
|
|
async (text, isComplete) => flushToAiCard(chatId, text, isComplete),
|
|
1200,
|
|
200,
|
|
)
|
|
aiCardBuffers.set(chatId, buffer)
|
|
}
|
|
return buffer
|
|
}
|
|
|
|
function getOrCreateAiCard(chatId: string): Promise<DingTalkAiCardInstance | null> | null {
|
|
const target = aiCardTargets.get(chatId)
|
|
if (!target) return null
|
|
|
|
let card = streamingCards.get(chatId)
|
|
if (!card) {
|
|
card = aiCards.createForTarget(target)
|
|
streamingCards.set(chatId, card)
|
|
}
|
|
return card
|
|
}
|
|
|
|
async function flushToAiCard(chatId: string, newText: string, isComplete: boolean): Promise<void> {
|
|
const fullText = (streamingCardText.get(chatId) ?? '') + newText
|
|
streamingCardText.set(chatId, fullText)
|
|
if (!fullText.trim()) return
|
|
|
|
const cardPromise = getOrCreateAiCard(chatId)
|
|
const card = cardPromise ? await cardPromise : null
|
|
if (!card) {
|
|
if (isComplete) await sendText(chatId, fullText)
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (isComplete) {
|
|
await aiCards.finish(card, fullText)
|
|
streamingCards.delete(chatId)
|
|
streamingCardText.delete(chatId)
|
|
aiCardBuffers.get(chatId)?.reset()
|
|
aiCardBuffers.delete(chatId)
|
|
} else {
|
|
await aiCards.stream(card, `${fullText} ▍`, false)
|
|
}
|
|
} catch (err) {
|
|
console.warn('[DingTalk][AICard] stream failed, falling back to markdown:', err instanceof Error ? err.message : err)
|
|
streamingCards.delete(chatId)
|
|
if (isComplete) await sendText(chatId, fullText)
|
|
}
|
|
}
|
|
|
|
function clearTransientChatState(chatId: string): void {
|
|
resetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText }, chatId)
|
|
clearPendingPermissions(chatId)
|
|
const runtime = getRuntimeState(chatId)
|
|
runtime.state = 'idle'
|
|
runtime.verb = undefined
|
|
runtime.pendingPermissionCount = 0
|
|
}
|
|
|
|
function clearPendingPermissions(chatId: string): void {
|
|
const pending = pendingPermissions.get(chatId)
|
|
if (pending) {
|
|
for (const requestId of pending) pendingPermissionChats.delete(requestId)
|
|
}
|
|
pendingPermissions.delete(chatId)
|
|
}
|
|
|
|
async function ensureExistingSession(chatId: string): Promise<SessionRestoreResult> {
|
|
return await restoreStoredSessionBinding({
|
|
chatId,
|
|
bridge,
|
|
sessionStore,
|
|
httpClient,
|
|
onServerMessage: (msg) => handleServerMessage(chatId, msg),
|
|
logPrefix: '[DingTalk]',
|
|
clearTransientState: () => clearTransientChatState(chatId),
|
|
})
|
|
}
|
|
|
|
async function buildStatusText(chatId: string): Promise<string> {
|
|
const result = await ensureExistingSession(chatId)
|
|
if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE
|
|
if (result.status === 'missing') return formatImStatus(null)
|
|
const stored = result.session
|
|
|
|
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> {
|
|
const result = await ensureExistingSession(chatId)
|
|
if (result.status === 'restored') return true
|
|
if (result.status === 'unavailable') {
|
|
await sendText(chatId, SESSION_RECONNECT_NOTICE)
|
|
return false
|
|
}
|
|
|
|
return await createSessionForChat(chatId, defaultWorkDir)
|
|
}
|
|
|
|
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
|
|
sessionSelectionController.clear(chatId)
|
|
try {
|
|
bridge.resetSession(chatId)
|
|
clearTransientChatState(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> {
|
|
sessionSelectionController.clear(chatId)
|
|
try {
|
|
const projects = await projectSelectionController.listProjects(chatId)
|
|
if (projects.length === 0) {
|
|
await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
|
|
return
|
|
}
|
|
await sendText(chatId, formatProjectList(projects))
|
|
} catch (err) {
|
|
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
}
|
|
|
|
function prepareNewSession(chatId: string): void {
|
|
sessionSelectionController.clear(chatId)
|
|
bridge.resetSession(chatId)
|
|
sessionStore.delete(chatId)
|
|
clearTransientChatState(chatId)
|
|
runtimeStates.delete(chatId)
|
|
}
|
|
|
|
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
|
|
const runtime = getRuntimeState(chatId)
|
|
if (syncImPermissionState(chatId, msg, runtime, pendingPermissions)) return
|
|
|
|
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) getAiCardBuffer(chatId).append(msg.text)
|
|
break
|
|
case 'tool_use_complete':
|
|
runtime.state = 'streaming'
|
|
break
|
|
case 'permission_request': {
|
|
await sendPermissionRequest(chatId, msg)
|
|
break
|
|
}
|
|
case 'message_complete':
|
|
runtime.state = 'idle'
|
|
runtime.verb = undefined
|
|
await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId)
|
|
break
|
|
case 'error':
|
|
runtime.state = 'idle'
|
|
runtime.verb = undefined
|
|
aiCardBuffers.get(chatId)?.reset()
|
|
streamingCards.delete(chatId)
|
|
streamingCardText.delete(chatId)
|
|
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
|
|
}
|
|
}
|
|
|
|
async function sendPermissionRequest(chatId: string, msg: ServerMessage): Promise<void> {
|
|
const runtime = getRuntimeState(chatId)
|
|
runtime.pendingPermissionCount += 1
|
|
runtime.state = 'permission_pending'
|
|
await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId)
|
|
|
|
const set = pendingPermissions.get(chatId) ?? new Set<string>()
|
|
set.add(msg.requestId)
|
|
pendingPermissions.set(chatId, set)
|
|
pendingPermissionChats.set(msg.requestId, chatId)
|
|
|
|
const requestText = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
|
|
const instructions = formatPermissionInstructions(msg.requestId)
|
|
const templateId = config.dingtalk.permissionCardTemplateId.trim()
|
|
const target = aiCardTargets.get(chatId)
|
|
|
|
if (templateId && target) {
|
|
const card = await aiCards.createForTarget(target, {
|
|
cardTemplateId: templateId,
|
|
outTrackId: `permission_${msg.requestId}`,
|
|
callbackRouteKey: DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE,
|
|
cardParamMap: buildDingTalkPermissionCardParams(msg.toolName, msg.input, msg.requestId),
|
|
})
|
|
if (card) {
|
|
await sendText(chatId, `${requestText}\n\n已发送钉钉权限卡片;如果卡片不可见,也可以${instructions}`)
|
|
return
|
|
}
|
|
}
|
|
|
|
await sendText(chatId, `${requestText}\n\n${instructions}`)
|
|
}
|
|
|
|
function handlePermissionCommand(chatId: string, text: string): boolean {
|
|
const decision = parsePermissionCommand(text, pendingPermissions.get(chatId))
|
|
if (!decision) return false
|
|
|
|
const sent = applyPermissionDecision(chatId, decision)
|
|
if (!sent) return true
|
|
|
|
void sendText(chatId, formatPermissionDecisionStatus(decision))
|
|
return true
|
|
}
|
|
|
|
function applyPermissionDecision(chatId: string, decision: PermissionDecision): boolean {
|
|
const { requestId, allowed, rule } = decision
|
|
const pending = pendingPermissions.get(chatId)
|
|
if (!pending?.has(requestId)) {
|
|
void sendText(chatId, `未找到待确认的权限请求:${requestId}`)
|
|
return false
|
|
}
|
|
|
|
const sent = bridge.sendPermissionResponse(chatId, requestId, allowed, rule)
|
|
if (!sent) {
|
|
void sendText(chatId, '权限响应发送失败,请检查会话状态。')
|
|
return false
|
|
}
|
|
|
|
pending.delete(requestId)
|
|
pendingPermissionChats.delete(requestId)
|
|
const runtime = getRuntimeState(chatId)
|
|
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
|
|
return sent
|
|
}
|
|
|
|
async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise<void> {
|
|
enqueue(chatId, async () => {
|
|
const trimmed = text.trim()
|
|
const hasAttachments = attachments.length > 0
|
|
|
|
if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return
|
|
|
|
if (!hasAttachments && await sessionSelectionController.handleInput(chatId, trimmed)) return
|
|
|
|
const projectOutcome = !hasAttachments
|
|
? await projectSelectionController.handleInput(chatId, trimmed)
|
|
: null
|
|
if (projectOutcome) {
|
|
const response = formatProjectSelectionOutcome(projectOutcome)
|
|
if (response) await sendText(chatId, response)
|
|
return
|
|
}
|
|
if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) {
|
|
await sendText(chatId, formatImHelp())
|
|
return
|
|
}
|
|
if (!hasAttachments && (trimmed === '/status' || trimmed === '状态')) {
|
|
await sendText(chatId, await buildStatusText(chatId))
|
|
return
|
|
}
|
|
if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) {
|
|
const result = await ensureExistingSession(chatId)
|
|
if (result.status !== 'restored') {
|
|
await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null))
|
|
return
|
|
}
|
|
clearTransientChatState(chatId)
|
|
if (!bridge.sendUserMessage(chatId, '/clear')) {
|
|
await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
|
|
return
|
|
}
|
|
getRuntimeState(chatId).state = 'thinking'
|
|
await sendText(chatId, '🧹 已清空当前会话上下文。')
|
|
return
|
|
}
|
|
if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) {
|
|
const result = await ensureExistingSession(chatId)
|
|
if (result.status !== 'restored') {
|
|
await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null))
|
|
return
|
|
}
|
|
bridge.sendStopGeneration(chatId)
|
|
await sendText(chatId, '⏹ 已发送停止信号。')
|
|
return
|
|
}
|
|
if (!hasAttachments && (trimmed === '/projects' || trimmed === '项目列表')) {
|
|
await showProjectPicker(chatId)
|
|
return
|
|
}
|
|
|
|
const ready = await ensureSession(chatId)
|
|
if (!ready) return
|
|
const effectiveText = trimmed || (attachments.length > 0 ? '(用户发送了附件)' : '')
|
|
if (!effectiveText && attachments.length === 0) return
|
|
const sent = bridge.sendUserMessage(chatId, effectiveText, attachments.length ? attachments : undefined)
|
|
if (sent) {
|
|
getRuntimeState(chatId).state = 'thinking'
|
|
} else {
|
|
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)
|
|
const mediaCandidates = extractDingTalkAttachments(data)
|
|
if (!chatId || !userId || (!text && mediaCandidates.length === 0)) 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 对话。发送 /help 查看可用命令。'
|
|
: '🔒 未授权。请先在 Claude Code 桌面端完成钉钉扫码绑定,再生成 IM 配对码后发送给我。',
|
|
)
|
|
return
|
|
}
|
|
|
|
aiCardTargets.set(chatId, { type: 'user', userId })
|
|
const attachments = await collectAttachments(chatId, mediaCandidates)
|
|
await routeUserMessage(chatId, text, attachments)
|
|
}
|
|
|
|
async function handleCardCallback(raw: unknown): Promise<void> {
|
|
const action = parseDingTalkPermissionCardAction(raw)
|
|
if (!action) return
|
|
|
|
const chatId = action.chatId && pendingPermissions.has(action.chatId)
|
|
? action.chatId
|
|
: pendingPermissionChats.get(action.requestId)
|
|
if (!chatId) {
|
|
console.warn(`[DingTalk][Card] permission request not found: ${action.requestId}`)
|
|
return
|
|
}
|
|
|
|
if (applyPermissionDecision(chatId, action)) {
|
|
await sendText(chatId, formatPermissionDecisionStatus(action))
|
|
}
|
|
}
|
|
|
|
async function collectAttachments(
|
|
chatId: string,
|
|
candidates: ReturnType<typeof extractDingTalkAttachments>,
|
|
): Promise<AttachmentRef[]> {
|
|
if (candidates.length === 0) return []
|
|
const stored = sessionStore.get(chatId)
|
|
const sessionId = stored?.sessionId ?? chatId
|
|
let token: string
|
|
try {
|
|
token = await getAccessToken()
|
|
} catch (err) {
|
|
console.error('[DingTalk] access token for attachment download failed:', err)
|
|
await sendText(chatId, '📎 附件下载授权失败,请稍后重试。')
|
|
return []
|
|
}
|
|
|
|
const settled = await Promise.allSettled(
|
|
candidates.map((candidate) =>
|
|
media.downloadCandidate(candidate, sessionId, {
|
|
clientId: config.dingtalk.clientId,
|
|
accessToken: token,
|
|
}),
|
|
),
|
|
)
|
|
const attachments: AttachmentRef[] = []
|
|
let failures = 0
|
|
for (const result of settled) {
|
|
if (result.status === 'rejected') {
|
|
failures += 1
|
|
console.error('[DingTalk] media download failed:', result.reason)
|
|
continue
|
|
}
|
|
const local = result.value
|
|
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
|
|
if (!check.ok) {
|
|
await sendText(chatId, check.hint)
|
|
continue
|
|
}
|
|
if (local.kind === 'image') {
|
|
attachments.push({
|
|
type: 'image',
|
|
name: local.name,
|
|
data: local.buffer.toString('base64'),
|
|
mimeType: local.mimeType,
|
|
})
|
|
} else {
|
|
attachments.push({
|
|
type: 'file',
|
|
name: local.name,
|
|
path: local.path,
|
|
mimeType: local.mimeType,
|
|
})
|
|
}
|
|
}
|
|
if (failures > 0) {
|
|
await sendText(
|
|
chatId,
|
|
failures === candidates.length ? '📎 附件下载失败,请稍后重试。' : `📎 ${failures} 个附件下载失败,已跳过。`,
|
|
)
|
|
}
|
|
return attachments
|
|
}
|
|
|
|
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)
|
|
|
|
registerAdapterShutdown(async () => {
|
|
bridge.destroy()
|
|
dedup.destroy()
|
|
await client.disconnect()
|
|
})
|
|
|
|
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 adapterMigrationLifecycle.track(handleRobotMessage(data))
|
|
})
|
|
|
|
client.registerCallbackListener(TOPIC_CARD, async (res: any) => {
|
|
const messageId = res.headers?.messageId
|
|
if (messageId) {
|
|
client.socketCallBackResponse(messageId, { success: true })
|
|
if (!dedup.tryRecord(`card:${messageId}`)) return
|
|
}
|
|
|
|
await adapterMigrationLifecycle.track(handleCardCallback(res.data ?? res))
|
|
})
|
|
|
|
if (adapterMigrationLifecycle.isQuiescing) return
|
|
await client.connect()
|
|
console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`)
|
|
|
|
}
|
|
|
|
if (import.meta.main || process.argv.includes('--dingtalk')) adapterMigrationLifecycle.track(start()).catch((err) => {
|
|
console.error('[DingTalk] Fatal:', err instanceof Error ? err.message : err)
|
|
process.exit(1)
|
|
})
|
|
|
|
export { bridge, dedup, sessionStore, sessionSelectionController, handleServerMessage, getRuntimeState, clearTransientChatState, createSessionForChat, showProjectPicker, routeUserMessage, handleRobotMessage, prepareNewSession }
|