import type * as lark from '@larksuiteoapi/node-sdk' import { HTTPClient } from '@/http' import { getDefaultAgentId } from '@/app/api/nova-config' 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' 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>() const processedMessages = new Set() function extractTextContent(messageType: string, content: string): string { try { const parsed = JSON.parse(content) if (messageType === 'text') { return (parsed.text || '').trim() } if (messageType === 'post') { // 富文本:遍历 content 数组提取 text 类型的文本 const contentArray = parsed.content || [] const texts: string[] = [] for (const paragraph of contentArray) { if (Array.isArray(paragraph)) { for (const item of paragraph) { if (item.tag === 'text' && item.text) { texts.push(item.text) } } } } return texts.join(' ').trim() } return '' } catch { return '' } } function cleanMentions(text: string): string { // 清除 @_user_N 占位符 return text.replace(/@_user_\d+/g, '').trim() } async function extractAttachments( client: lark.Client, messageType: string, content: string, messageId: string, ): Promise { const attachments: BotAttachment[] = [] try { const parsed = JSON.parse(content) // 处理 image 类型 if (messageType === 'image' && parsed.image_key) { const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: parsed.image_key }, params: { type: 'image' }, }) const stream = resp.getReadableStream() const chunks: Buffer[] = [] for await (const chunk of stream) { chunks.push(chunk) } const buffer = Buffer.concat(chunks) // 将 buffer 转为临时 URL(通过 data URI) const dataUri = `data:image/png;base64,${buffer.toString('base64')}` attachments.push({ url: dataUri, fileName: `lark_image_${Date.now()}.png`, }) } // 处理 file 类型 if (messageType === 'file' && parsed.file_key) { const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: parsed.file_key }, params: { type: 'file' }, }) const stream = resp.getReadableStream() const chunks: Buffer[] = [] for await (const chunk of stream) { chunks.push(chunk) } const buffer = Buffer.concat(chunks) const dataUri = `data:application/octet-stream;base64,${buffer.toString('base64')}` attachments.push({ url: dataUri, fileName: parsed.file_name || `lark_file_${Date.now()}`, }) } // 处理 post 富文本中的图片 if (messageType === 'post' && parsed.content) { for (const paragraph of parsed.content) { if (Array.isArray(paragraph)) { for (const item of paragraph) { if (item.tag === 'img' && item.image_key) { const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: item.image_key }, params: { type: 'image' }, }) const stream = resp.getReadableStream() const chunks: Buffer[] = [] for await (const chunk of stream) { chunks.push(chunk) } const buffer = Buffer.concat(chunks) const dataUri = `data:image/png;base64,${buffer.toString('base64')}` attachments.push({ url: dataUri, fileName: `lark_post_image_${Date.now()}.png`, }) } } } } } } catch (err) { console.error('[Lark Bot] 提取附件失败:', err) } return attachments } async function getOrCreateConversation(openId: string, name: string): Promise { const existing = getConversationId(openId) 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: `Lark User ${name || openId}`, }, ) const conversationId = res.conversation_id setConversationId(openId, conversationId, name) return conversationId } async function replyMessage( client: lark.Client, chatId: string, text: string, ): Promise { // 检查文本中是否包含 Markdown 图片语法 const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g const images: Array<{ alt: string; url: string }> = [] let match: RegExpExecArray | null while ((match = imageRegex.exec(text)) !== null) { images.push({ alt: match[1], url: match[2] }) } // 如果包含图片,使用富文本消息 if (images.length > 0) { // 移除 Markdown 图片语法,保留其他文本 const textWithoutImages = text.replace(imageRegex, '').trim() // 构建富文本内容 const content: any[][] = [] // 添加文本段落 if (textWithoutImages) { const lines = textWithoutImages.split('\n').filter(line => line.trim()) for (const line of lines) { content.push([{ tag: 'text', text: line }]) } } // 上传图片并添加到富文本中 for (const image of images) { try { // 下载图片 const response = await fetch(image.url) const buffer = Buffer.from(await response.arrayBuffer()) // 上传到飞书 const uploadResp = await client.im.image.create({ data: { image_type: 'message', image: buffer, }, }) const imageKey = uploadResp.image_key || uploadResp.data?.image_key if (imageKey) { // 添加图片到富文本 content.push([{ tag: 'img', image_key: imageKey }]) } else { // 如果没有 image_key,添加链接 content.push([{ tag: 'a', text: image.alt || '查看图片', href: image.url }]) } } catch (err) { console.error('[Lark Bot] 上传图片失败:', err) // 上传失败,添加图片链接 content.push([{ tag: 'a', text: image.alt || '查看图片', href: image.url }]) } } // 飞书富文本格式需要包含语言标识 await client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, content: JSON.stringify({ zh_cn: { title: '', content: content } }), msg_type: 'post', }, }) } else { // 纯文本消息 await client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, content: JSON.stringify({ text }), msg_type: 'text', }, }) } } async function processMessage( client: lark.Client, data: any, onMessage?: () => void, ): Promise { const message = data.message const sender = data.sender const messageId = message.message_id const openId = sender.sender_id.open_id const name = sender.sender_id.union_id || openId const chatId = message.chat_id const messageType = message.message_type const content = message.content // 消息去重 if (processedMessages.has(messageId)) { return } processedMessages.add(messageId) // 限制 Set 大小,防止内存泄漏 if (processedMessages.size > 10000) { const firstItem = processedMessages.values().next().value if (firstItem) { processedMessages.delete(firstItem) } } // 提取文本和附件 let text = extractTextContent(messageType, content) text = cleanMentions(text) const attachments = await extractAttachments(client, messageType, content, messageId) if (!text && attachments.length === 0) { return } BotLogger.log({ platform: 'lark', eventType: 'message_received', severity: 'info', message: `收到来自 ${name} 的消息 (${messageType}, ${attachments.length} 附件)`, details: { openId, chatId }, }) try { // 上传附件 let uploadFileIds: string[] | undefined if (attachments.length > 0) { uploadFileIds = await uploadBotAttachments(attachments) if (uploadFileIds.length === 0) uploadFileIds = undefined } const conversationId = await getOrCreateConversation(openId, name) const response = await sendMessageAndWaitResponse( `lark:${openId}`, conversationId, text || '', uploadFileIds, ) if (!response) { await replyMessage(client, chatId, '没有收到回复,请稍后重试。') } else { await replyMessage(client, chatId, response) } if (onMessage) onMessage() BotLogger.log({ platform: 'lark', eventType: 'message_sent', severity: 'info', message: `已回复用户 ${name}`, }) } catch (error) { const errMsg = error instanceof Error ? error.message : '未知错误' console.error('[Lark Bot] 处理消息失败:', errMsg) await replyMessage(client, chatId, `处理消息时出错: ${errMsg}`).catch(() => {}) BotLogger.log({ platform: 'lark', eventType: 'error', severity: 'error', message: `处理消息失败: ${errMsg}`, details: { openId }, }) } } export function createEventHandler(client: lark.Client, onMessage?: () => void) { return async (data: any) => { const openId = data.sender?.sender_id?.open_id if (!openId) return // 使用队列确保单用户消息顺序处理 const currentQueue = userQueues.get(openId) ?? Promise.resolve() const newQueue = currentQueue.then(() => processMessage(client, data, onMessage)) userQueues.set(openId, newQueue) newQueue.finally(() => { if (userQueues.get(openId) === newQueue) { userQueues.delete(openId) } }) } }