Files
test1/remote-control/bots/lark/handlers.ts
2026-03-20 07:33:46 +00:00

355 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, Promise<void>>()
const processedMessages = new Set<string>()
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<BotAttachment[]> {
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<string> {
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<void> {
// 检查文本中是否包含 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<void> {
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)
}
})
}
}