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

286 lines
8.7 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 { 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<Buffer | null> {
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] 下载返回 HTMLBot 可能缺少 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<string, Promise<void>>()
/**
* Convert standard Markdown to Slack mrkdwn format.
* - `[text](url)` -> `<url|text>`
* - 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<string> {
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<void> {
// 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)
})
}