277 lines
8.5 KiB
TypeScript
277 lines
8.5 KiB
TypeScript
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<string, Promise<void>>()
|
|
|
|
async function getOrCreateConversation(staffId: string, senderNick: string): Promise<string> {
|
|
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<void> {
|
|
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<string> {
|
|
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<string, string> = {
|
|
picture: '.png',
|
|
audio: '.mp3',
|
|
video: '.mp4',
|
|
file: '',
|
|
}
|
|
return `dingtalk_${msgtype}_${Date.now()}${extMap[msgtype] || ''}`
|
|
}
|
|
|
|
async function extractAttachments(
|
|
msg: ExtendedMessage,
|
|
client: DWClient,
|
|
): Promise<BotAttachment[]> {
|
|
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<void> {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|