import { mkdirSync } from 'node:fs'; import { lstatSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { resolve, relative } from 'node:path'; import * as Lark from '@larksuiteoapi/node-sdk'; import { FEISHU_CHAT_DIR } from './harness/container/channel-workspace.js'; import { isBotSender, botSenderLabel } from './feishu/message.service.js'; import { batchResolveFeishuUserNames } from './feishu/sender-names.js'; import { expandFeishuMergeForward } from './feishu/merge-forward.js'; import { chatFilesDirOf, resolveChatFileTarget } from './feishu/chat-files.js'; import { fetchFeishuMessageDetail } from './feishu/message-detail.js'; import { wrapFeishuApiError } from './feishu/feishu-api-error.js'; import type { FeishuNormalizedMessage, FeishuPostFileItem } from './feishu/types.js'; import type { MysqlFeishuBindingRepository } from './feishu/binding.repository.js'; import type { MysqlFeishuMessageRepository } from './feishu/message.repository.js'; import type { MysqlUserRepository } from './user/user.repository.js'; // ========== 纯工具函数 ========== export function resolveFeishuChatWorkspace(root: string, accountId: string, leaf: string): string { const safeAccountId = encodeURIComponent(accountId); const safeLeaf = encodeURIComponent(leaf); const workspaceRoot = resolve(root); const workspace = resolve(workspaceRoot, FEISHU_CHAT_DIR, safeAccountId, safeLeaf); for (const candidate of [workspaceRoot, resolve(workspaceRoot, FEISHU_CHAT_DIR), resolve(workspaceRoot, FEISHU_CHAT_DIR, safeAccountId)]) { try { if (lstatSync(candidate).isSymbolicLink()) throw new Error('飞书会话工作区路径不允许符号链接'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } const rel = relative(workspaceRoot, workspace); if (rel.startsWith('..') || rel.includes('..' + '/') || rel.startsWith('/')) { throw new Error('飞书会话工作区路径非法'); } return workspace; } export function sanitizeFeishuFileName(name: string | null | undefined, fallback: string): string { const raw = (name ?? '').trim(); if (raw === '') return fallback; const basename = raw.replace(/\\/g, '/').split('/').pop() ?? fallback; const cleaned = basename.replace(/[^\w.\u4e00-\u9fa5-]/g, '_').replace(/^_+|_+$/g, ''); return cleaned === '' ? fallback : cleaned.length > 128 ? cleaned.slice(0, 128) : cleaned; } export function truncateQuoted(text: string, maxLength = 1500): string { return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…(引用内容过长已截断)`; } export async function downloadFeishuMediaBuffer( client: Lark.Client, messageId: string, fileKey: string, type: 'image' | 'file', maxBytes: number, ): Promise<{ buffer: Buffer; contentType: string }> { let result: Awaited>; try { result = await client.im.v1.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type }, }); } catch (error) { // 资源接口同样只抛 HTTP 400:把业务码带出来(如 234003 File not in msg、230002 不在群),便于定位。 throw wrapFeishuApiError(error, `下载飞书资源失败: message=${messageId}, fileKey=${fileKey}`); } const contentType = String((result as { headers?: Record }).headers?.['content-type'] ?? 'application/octet-stream').split(';')[0].trim(); const stream = result.getReadableStream(); const chunks: Buffer[] = []; let total = 0; for await (const chunk of stream) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); total += buffer.length; if (total > maxBytes) throw new Error(`飞书资源超出大小限制: ${total} > ${maxBytes}`); chunks.push(buffer); } return { buffer: Buffer.concat(chunks), contentType }; } export function dataUriOf(contentType: string, buffer: Buffer): string { return `data:${contentType || 'image/jpeg'};base64,${buffer.toString('base64')}`; } export const IMAGE_EXT_BY_CONTENT_TYPE: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp', }; // ========== 工厂函数 ========== export interface FeishuMediaHelperDeps { getFeishuClient: (botId: number) => Promise; feishuSenderNames: Map>; feishuBinding: MysqlFeishuBindingRepository; feishuMessageRepository: MysqlFeishuMessageRepository; userRepo: MysqlUserRepository; workspaceRoot: string; maxInboundFileMb: number; } export interface FeishuMediaHelper { resolveFeishuSenderName(accountId: string, event: FeishuNormalizedMessage): Promise; resolveFeishuUserNames(accountId: string, openIds: string[]): Promise>; resolveMergeWorkspace(accountId: string, event: FeishuNormalizedMessage): Promise; expandMergeForward(accountId: string, messageId: string, workspace: string | null): Promise; resolveQuotedWorkspace(accountId: string, event: FeishuNormalizedMessage): Promise; downloadFeishuGroupImage(accountId: string, event: FeishuNormalizedMessage, imageKey: string, index?: number): Promise; downloadFeishuImageTo(accountId: string, workspace: string, messageId: string, imageKey: string, index?: number, nameMessageId?: string): Promise; /** item 指定多文件消息(post.files)中的单个文件;缺省取事件顶层 file_key(单文件消息)。 */ downloadFeishuGroupFile(accountId: string, event: FeishuNormalizedMessage, item?: FeishuPostFileItem, index?: number): Promise; } export function createFeishuMediaHelper(deps: FeishuMediaHelperDeps): FeishuMediaHelper { const { getFeishuClient, feishuSenderNames, feishuBinding, feishuMessageRepository, userRepo, workspaceRoot, maxInboundFileMb } = deps; const resolveFeishuSenderName = async (accountId: string, event: FeishuNormalizedMessage): Promise => { const senderId = event.senderId; if (senderId == null) return null; if (isBotSender(event)) return botSenderLabel(event.senderId); const key = `${accountId}:${senderId}`; const cached = feishuSenderNames.get(key); if (cached != null) return cached; const lookup = (async (): Promise => { try { const client = await getFeishuClient(Number(accountId)); if (client == null) return null; const response = await client.contact.v3.user.basicBatch({ data: { user_ids: [senderId] }, params: { user_id_type: 'open_id' }, }); const user = (response as { data?: { users?: Array<{ name?: string }> } }).data?.users?.[0]; return user?.name?.trim() || null; } catch (error) { console.warn(`获取飞书发送人姓名失败, accountId=${accountId}, openId=${event.senderId}`, error); const unionId = event.senderUnionId ?? event.senderId; const userId = unionId == null ? null : await feishuBinding.findUserIdByUnionId(unionId); const bound = userId == null ? null : await userRepo.findById(userId); return bound?.displayName?.trim() || null; } })(); const name = await lookup; if (name != null && name !== '') feishuSenderNames.set(key, Promise.resolve(name)); return name; }; const resolveFeishuUserNames = async (accountId: string, openIds: string[]): Promise> => { const client = await getFeishuClient(Number(accountId)); if (client == null || openIds.length === 0) return new Map(); return batchResolveFeishuUserNames( (req) => client.contact.v3.user.basicBatch(req), openIds, { get: (openId) => feishuSenderNames.get(`${accountId}:${openId}`), set: (openId, name) => { feishuSenderNames.set(`${accountId}:${openId}`, Promise.resolve(name)); }, }, ); }; const resolveMergeWorkspace = async (accountId: string, event: FeishuNormalizedMessage): Promise => { try { if (event.chatType === 'group') { if (event.chatId == null || event.chatId === '') return null; return resolveFeishuChatWorkspace(workspaceRoot, accountId, event.chatId); } const unionId = event.senderUnionId ?? event.senderId; const userId = unionId == null ? null : await feishuBinding.findUserIdByUnionId(unionId); if (userId == null) return null; return resolveFeishuChatWorkspace(workspaceRoot, String(accountId), `private-${userId}`); } catch (error) { console.warn(`定位合并转发工作区失败, accountId=${accountId}: ${error instanceof Error ? error.message : String(error)}`); return null; } }; const downloadFeishuImageTo = async ( accountId: string, workspace: string, messageId: string, imageKey: string, index = 0, nameMessageId = messageId, ): Promise => { const client = await getFeishuClient(Number(accountId)); if (client == null) return null; const maxBytes = Math.max(1, maxInboundFileMb) * 1024 * 1024; const { buffer, contentType } = await downloadFeishuMediaBuffer(client, messageId, imageKey, 'image', maxBytes); if (buffer.length === 0) return null; const dir = chatFilesDirOf(workspace); mkdirSync(dir, { recursive: true }); const ext = IMAGE_EXT_BY_CONTENT_TYPE[contentType] ?? '.jpg'; // messageId 是下载锚点(合并转发子消息必须用父消息 id 才能下载);nameMessageId 仅用于落盘命名,避免同名覆盖。 const target = resolve(dir, index > 0 ? `feishu-image-${nameMessageId}-${index + 1}${ext}` : `feishu-image-${nameMessageId}${ext}`); await writeFile(target, buffer); return target; }; const expandMergeForward = async (accountId: string, messageId: string, workspace: string | null): Promise => { try { const client = await getFeishuClient(Number(accountId)); if (client == null) return null; return await expandFeishuMergeForward(client, messageId, { workspace, resolveUserNames: (openIds) => resolveFeishuUserNames(accountId, openIds), downloadImage: async (ref) => { if (workspace == null || workspace === '') return null; // 合并转发子消息不能直接调资源接口,锚点固定用顶层父消息 id;文件命名仍按子消息 id 保持唯一。 return downloadFeishuImageTo(accountId, workspace, ref.anchorMessageId, ref.imageKey, ref.index, ref.childMessageId); }, }); } catch (error) { console.warn(`展开飞书合并转发失败, messageId=${messageId}, code=unknown: ${error instanceof Error ? error.message : String(error)}`); return null; } }; const resolveQuotedWorkspace = async (accountId: string, event: FeishuNormalizedMessage): Promise => { try { if (event.chatType === 'group') { if (event.chatId == null) return null; const conversation = await feishuMessageRepository.findGroupConversation(String(accountId), event.chatId); return conversation?.workspace ?? null; } const unionId = event.senderUnionId ?? event.senderId; const userId = unionId == null ? null : await feishuBinding.findUserIdByUnionId(unionId); if (userId == null) return null; return resolveFeishuChatWorkspace(workspaceRoot, String(accountId), `private-${userId}`); } catch (error) { console.warn(`定位引用消息工作区失败, accountId=${accountId}: ${error instanceof Error ? error.message : String(error)}`); return null; } }; const downloadFeishuGroupImage = async (accountId: string, event: FeishuNormalizedMessage, imageKey: string, index = 0): Promise => { if (event.chatType !== 'group' || event.chatId == null || event.messageId == null) return null; const workspace = resolveFeishuChatWorkspace(workspaceRoot, accountId, event.chatId); return downloadFeishuImageTo(accountId, workspace, event.messageId, imageKey, index); }; const downloadFeishuGroupFile = async ( accountId: string, event: FeishuNormalizedMessage, item?: FeishuPostFileItem, index = 0, ): Promise => { // 多文件消息(post.files)逐项传入;单文件消息退化为事件顶层 file_key/file_name。 const fileKey = item?.fileKey ?? event.fileKey; const fileName = item != null ? item.fileName : event.fileName; if (event.chatType !== 'group' || event.chatId == null || event.messageId == null || fileKey == null) return null; const client = await getFeishuClient(Number(accountId)); if (client == null) return null; const workspace = resolveFeishuChatWorkspace(workspaceRoot, accountId, event.chatId); const maxBytes = Math.max(1, maxInboundFileMb) * 1024 * 1024; const { buffer } = await downloadFeishuMediaBuffer(client, event.messageId, fileKey, 'file', maxBytes); if (buffer.length === 0) return null; const dir = chatFilesDirOf(workspace); mkdirSync(dir, { recursive: true }); const safeName = sanitizeFeishuFileName(fileName, `feishu-${fileKey}`); // index 参与重名避让:同一消息内的多个文件不会互相覆盖。 const target = resolveChatFileTarget(dir, safeName, event.messageId, index); await writeFile(target, buffer); return target; }; return { resolveFeishuSenderName, resolveFeishuUserNames, resolveMergeWorkspace, expandMergeForward, resolveQuotedWorkspace, downloadFeishuGroupImage, downloadFeishuImageTo, downloadFeishuGroupFile, }; } // ========== 消息文本解析 ========== export interface ResolveMessageTextDeps { getFeishuClient: (botId: number) => Promise; } export function createResolveMessageTextHelper(deps: ResolveMessageTextDeps) { const { getFeishuClient } = deps; return async (accountId: string, messageId: string): Promise => { const client = await getFeishuClient(Number(accountId)); if (client == null) return null; const detail = await fetchFeishuMessageDetail(client, messageId); return detail?.text ?? null; }; }