import type { DWClient, DWClientDownStream, RobotMessage } from 'dingtalk-stream' import { HTTPClient } from '@/http' import { getConversationId, setConversationId } from './user-store' import { sendMessageAndWaitResponse } from '@/remote-control/shared/nova-bridge' import { uploadBotAttachments, type BotAttachment } from '@/remote-control/shared/file-uploader' import { BotLogger } from '@/remote-control/shared/logger' import { getDefaultAgentId } from '@/app/api/nova-config' // Extended message types — dingtalk-stream SDK only types text messages interface DingTalkMediaContent { downloadCode: string fileName?: string } interface DingTalkRichTextItem { text?: string downloadCode?: string type?: string } type ExtendedMessage = { msgtype: string senderStaffId: string senderNick: string sessionWebhook: string robotCode: string conversationType?: string text?: { content: string } content?: DingTalkMediaContent | { richText: DingTalkRichTextItem[] } | string } const oapiClient = new HTTPClient({ baseURL: process.env.NOVA_BASE_URL, headers: { 'Content-Type': 'application/json', 'Tenant-Id': process.env.NOVA_TENANT_ID!, 'Authorization': process.env.NOVA_ACCESS_KEY!, }, }) const userQueues = new Map>() async function getOrCreateConversation(staffId: string, senderNick: string): Promise { const existing = getConversationId(staffId) if (existing) return existing const agentId = getDefaultAgentId() const res = await oapiClient.post<{ conversation_id: string }>( '/v1/oapi/super_agent/chat/create_conversation', { agent_id: agentId, title: `DingTalk User ${senderNick || staffId}`, }, ) const conversationId = res.conversation_id setConversationId(staffId, conversationId, senderNick) return conversationId } async function replyMessage( client: DWClient, sessionWebhook: string, senderStaffId: string, text: string, ): Promise { const accessToken = await client.getAccessToken() const body = { msgtype: 'markdown', markdown: { title: '回复', text, }, at: { atUserIds: [senderStaffId], isAtAll: false, }, } const response = await fetch(sessionWebhook, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': accessToken, }, body: JSON.stringify(body), }) if (!response.ok) { console.error(`[DingTalk Bot] 回复消息失败: ${response.status} ${response.statusText}`) } } // --- Attachment extraction helpers --- function extractTextContent(msg: ExtendedMessage): string { if (msg.msgtype === 'text' && msg.text?.content) { return msg.text.content.trim() } if (msg.msgtype === 'richText' && msg.content && typeof msg.content === 'object' && 'richText' in msg.content) { return (msg.content as { richText: DingTalkRichTextItem[] }).richText .filter(item => item.text) .map(item => item.text) .join('') .trim() } return '' } async function getDingTalkDownloadUrl( downloadCode: string, robotCode: string, accessToken: string, ): Promise { const response = await fetch('https://api.dingtalk.com/v1.0/robot/messageFiles/download', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': accessToken, }, body: JSON.stringify({ downloadCode, robotCode }), }) if (!response.ok) { throw new Error(`DingTalk download API failed: ${response.status}`) } const data = await response.json() as { downloadUrl: string } return data.downloadUrl } function getFileNameForMsgType(msgtype: string, content: DingTalkMediaContent): string { if (content.fileName) return content.fileName const extMap: Record = { picture: '.png', audio: '.mp3', video: '.mp4', file: '', } return `dingtalk_${msgtype}_${Date.now()}${extMap[msgtype] || ''}` } async function extractAttachments( msg: ExtendedMessage, client: DWClient, ): Promise { const attachments: BotAttachment[] = [] const accessToken = await client.getAccessToken() const robotCode = msg.robotCode // Handle picture/file/audio/video message types if (['picture', 'file', 'audio', 'video'].includes(msg.msgtype)) { const content = typeof msg.content === 'string' ? JSON.parse(msg.content) as DingTalkMediaContent : msg.content as DingTalkMediaContent if (content?.downloadCode) { try { const downloadUrl = await getDingTalkDownloadUrl(content.downloadCode, robotCode, accessToken) attachments.push({ url: downloadUrl, fileName: getFileNameForMsgType(msg.msgtype, content), }) } catch (err) { console.error('[DingTalk Bot] Failed to get download URL:', err) } } } // Handle richText messages (may contain inline images) if (msg.msgtype === 'richText' && msg.content && typeof msg.content === 'object' && 'richText' in msg.content) { const richText = (msg.content as { richText: DingTalkRichTextItem[] }).richText for (const item of richText) { if (item.downloadCode) { try { const downloadUrl = await getDingTalkDownloadUrl(item.downloadCode, robotCode, accessToken) attachments.push({ url: downloadUrl, fileName: `dingtalk_richtext_${Date.now()}.png`, }) } catch (err) { console.error('[DingTalk Bot] Failed to get richText download URL:', err) } } } } return attachments } // --- Main message processing --- async function processMessage(client: DWClient, msg: RobotMessage, messageId: string, onMessage?: () => void): Promise { const extMsg = msg as unknown as ExtendedMessage const content = extractTextContent(extMsg) const attachments = await extractAttachments(extMsg, client) if (!content && attachments.length === 0) { client.socketCallBackResponse(messageId, { status: 'OK' }) return } const staffId = msg.senderStaffId const senderNick = msg.senderNick || staffId BotLogger.log({ platform: 'dingtalk', eventType: 'message_received', severity: 'info', message: `收到来自 ${senderNick} 的消息 (${extMsg.msgtype}, ${attachments.length} 附件)`, details: { staffId, conversationType: msg.conversationType }, }) try { // Upload attachments if any let uploadFileIds: string[] | undefined if (attachments.length > 0) { uploadFileIds = await uploadBotAttachments(attachments) if (uploadFileIds.length === 0) uploadFileIds = undefined } const conversationId = await getOrCreateConversation(staffId, senderNick) const response = await sendMessageAndWaitResponse( `dingtalk:${staffId}`, conversationId, content || '', uploadFileIds, ) if (!response) { await replyMessage(client, msg.sessionWebhook, staffId, '没有收到回复,请稍后重试。') } else { await replyMessage(client, msg.sessionWebhook, staffId, response) } if (onMessage) onMessage() BotLogger.log({ platform: 'dingtalk', eventType: 'message_sent', severity: 'info', message: `已回复用户 ${senderNick}`, }) } catch (error) { const errMsg = error instanceof Error ? error.message : '未知错误' console.error('[DingTalk Bot] 处理消息失败:', errMsg) await replyMessage(client, msg.sessionWebhook, staffId, `处理消息时出错: ${errMsg}`).catch(() => {}) BotLogger.log({ platform: 'dingtalk', eventType: 'error', severity: 'error', message: `处理消息失败: ${errMsg}`, details: { staffId }, }) } client.socketCallBackResponse(messageId, { status: 'OK' }) } export function handleRobotCallback(client: DWClient, onMessage?: () => void) { return async (res: DWClientDownStream) => { const msg = JSON.parse(res.data) as RobotMessage const extMsg = msg as unknown as ExtendedMessage const messageId = res.headers.messageId const staffId = msg.senderStaffId console.log(`[DingTalk Bot] 收到消息 from ${msg.senderNick} (${msg.conversationType === '1' ? '单聊' : '群聊'}, type: ${extMsg.msgtype}): ${msg.text?.content?.trim()?.slice(0, 50) || '[非文本消息]'}`) const currentQueue = userQueues.get(staffId) ?? Promise.resolve() const newQueue = currentQueue.then(() => processMessage(client, msg, messageId, onMessage)) userQueues.set(staffId, newQueue) newQueue.finally(() => { if (userQueues.get(staffId) === newQueue) { userQueues.delete(staffId) } }) } }