import type { App, SayFn } from '@slack/bolt' import { type WebClient } from '@slack/web-api' import axios from 'axios' 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' /** * Download a file from Slack using axios (same HTTP client as @slack/web-api). * Node's undici fetch strips Authorization headers on cross-origin redirects, * but axios preserves them properly. */ async function downloadSlackFile(url: string, token: string): Promise { try { const resp = await axios.get(url, { headers: { Authorization: `Bearer ${token}` }, responseType: 'arraybuffer', maxRedirects: 5, validateStatus: (status) => status < 400, }) // Guard: reject HTML responses (Slack login page on auth failure) const contentType = resp.headers['content-type'] || '' if (contentType.includes('text/html')) { console.error('[Slack Bot] 下载返回 HTML,Bot 可能缺少 files:read 权限') return null } return Buffer.from(resp.data) } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.error(`[Slack Bot] 文件下载失败: ${msg}`) return null } } 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>() /** * Convert standard Markdown to Slack mrkdwn format. * - `[text](url)` -> `` * - Remove `![alt](url)` image syntax (images are handled via Block Kit) */ function convertMarkdownToMrkdwn(text: string): string { // Remove image syntax first let result = text.replace(/!\[[^\]]*\]\([^)]+\)/g, '') // Convert links result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>') return result.trim() } /** * Extract image URLs from Markdown `![alt](url)` syntax. */ function extractImageUrls(text: string): string[] { const regex = /!\[[^\]]*\]\(([^)]+)\)/g const urls: string[] = [] let match: RegExpExecArray | null while ((match = regex.exec(text)) !== null) { urls.push(match[1]) } return urls } async function getOrCreateConversation(slackUserId: string, name: string): Promise { const existing = getConversationId(slackUserId) if (existing) return existing const res = await oapiClient.post<{ conversation_id: string }>( '/v1/oapi/super_agent/chat/create_conversation', { agent_id: getDefaultAgentId(), title: `Slack User ${name || slackUserId}`, }, ) const conversationId = res.conversation_id setConversationId(slackUserId, conversationId, name) return conversationId } async function processMessage( message: any, say: SayFn, client: WebClient, botToken: string, onMessage?: () => void, ): Promise { // Filter out bot messages if (message.bot_id || message.subtype === 'bot_message') return const userId = message.user as string if (!userId) return // Extract text, strip mention markup const text = ((message.text as string) || '').replace(/<@[A-Z0-9]+>/g, '').trim() const hasFiles = message.files && Array.isArray(message.files) && message.files.length > 0 // Skip empty messages (no text, no files) if (!text && !hasFiles) return // Fetch user info for display name let userName = userId try { const userInfo = await client.users.info({ user: userId }) userName = userInfo.user?.real_name || userInfo.user?.name || userId } catch { // fallback to userId } // Extract file attachments — download with auth and convert to data URI const attachments: BotAttachment[] = [] let fileDownloadFailed = false if (hasFiles) { for (const file of message.files as any[]) { const downloadUrl = file.url_private_download || file.url_private if (!downloadUrl) { console.warn('[Slack Bot] 文件无下载地址:', file.id, file.name); continue } try { console.log(`[Slack Bot] 开始下载文件: ${file.name}, URL: ${downloadUrl.substring(0, 80)}...`) const buffer = await downloadSlackFile(downloadUrl, botToken) if (!buffer) { console.error(`[Slack Bot] 下载文件失败: ${file.name},请检查 Bot 是否有 files:read 权限`) fileDownloadFailed = true continue } const mimeType = file.mimetype || 'application/octet-stream' const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}` attachments.push({ url: dataUri, fileName: file.name ?? `slack_file_${Date.now()}`, mimeType, size: buffer.length, }) } catch (err) { console.error(`[Slack Bot] 下载文件异常:`, err) fileDownloadFailed = true } } } BotLogger.log({ platform: 'slack', eventType: 'message_received', severity: 'info', message: `收到来自 ${userName} 的消息 (${attachments.length} 附件)`, details: { userId, channel: message.channel }, }) // If only files were sent and all downloads failed, notify user if (!text && attachments.length === 0 && fileDownloadFailed) { await say({ text: '文件下载失败,请联系管理员检查 Bot 的 files:read 权限配置。' }).catch(() => {}) BotLogger.log({ platform: 'slack', eventType: 'error', severity: 'error', message: `文件下载失败,可能缺少 files:read 权限`, details: { userId }, }) return } if (!text && attachments.length === 0) return 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(userId, userName) const response = await sendMessageAndWaitResponse( `slack:${userId}`, conversationId, text || '', uploadFileIds, ) if (!response) { await say({ text: '没有收到回复,请稍后重试。' }) return } // Check for images in response const imageUrls = extractImageUrls(response) if (imageUrls.length > 0) { const mrkdwnText = convertMarkdownToMrkdwn(response) const blocks: any[] = [] // Add text section if there's text content if (mrkdwnText) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: mrkdwnText, }, }) } // Add image blocks for (const url of imageUrls) { blocks.push({ type: 'image', image_url: url, alt_text: 'image', }) } await say({ text: mrkdwnText || 'image', blocks, }) } else { await say({ text: convertMarkdownToMrkdwn(response) }) } if (onMessage) onMessage() BotLogger.log({ platform: 'slack', eventType: 'message_sent', severity: 'info', message: `已回复用户 ${userName}`, }) } catch (error) { const errMsg = error instanceof Error ? error.message : '未知错误' console.error('[Slack Bot] 处理消息失败:', errMsg) await say({ text: `处理消息时出错: ${errMsg}` }).catch(() => {}) BotLogger.log({ platform: 'slack', eventType: 'error', severity: 'error', message: `处理消息失败: ${errMsg}`, details: { userId }, }) } } function enqueueMessage( userId: string, message: any, say: SayFn, client: WebClient, botToken: string, onMessage?: () => void, ): void { const currentQueue = userQueues.get(userId) ?? Promise.resolve() const newQueue = currentQueue.then(() => processMessage(message, say, client, botToken, onMessage)) userQueues.set(userId, newQueue) newQueue.finally(() => { if (userQueues.get(userId) === newQueue) { userQueues.delete(userId) } }) } export function registerHandlers(app: App, botToken: string, onMessage?: () => void): void { // Handle direct messages and channel messages app.message(async ({ message, say, client }) => { const userId = (message as any).user as string | undefined if (!userId) return enqueueMessage(userId, message, say, client, botToken, onMessage) }) // Handle @mentions app.event('app_mention', async ({ event, say, client }) => { const userId = event.user if (!userId) return enqueueMessage(userId, event, say, client, botToken, onMessage) }) }