/** * AI-бот сервис: обрабатывает упоминания AI-ассистентов в чате задачи и прямых диалогах. * Алгоритм: RAG-поиск → MCP-инструменты → LLM-ответ → публикация в чат. */ import { storage } from '../storage'; import { decrypt } from '../crypto'; import { semanticSearch } from './embedding.service'; import { McpClient } from '../utils/mcp-client'; import { eventBus } from '../routes/shared'; import { publishConvTypingEvent } from '../routes/messenger.helpers'; import { getOllamaNumThread, getOllamaNumCtx } from '../utils/ollama-config'; import type { Bot, Task, SafeUser } from '@shared/schema'; import { db } from '../db'; import { conversationMessages, bots } from '@shared/schema'; import { eq, and, desc } from 'drizzle-orm'; const GLOBAL_OPENAI_API_KEY = process.env.OPENAI_API_KEY; /** * Extracts the first complete JSON object from a string starting at `fromIndex`. * Uses balanced-brace counting to handle nested objects correctly. * Returns the raw JSON string, or null if not found. */ function extractFirstJsonBlock(text: string, fromIndex = 0): string | null { const start = text.indexOf('{', fromIndex); if (start === -1) return null; let depth = 0; let inString = false; let escape = false; for (let i = start; i < text.length; i++) { const ch = text[i]; if (escape) { escape = false; continue; } if (ch === '\\' && inString) { escape = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) return text.slice(start, i + 1); } } return null; } interface AiBotContext { bot: Bot; task: Task; message: string; attachments?: Array<{ url: string; name: string; size: number; mimeType?: string }>; user: SafeUser; organizationId: number; } interface LlmMessage { role: 'system' | 'user' | 'assistant'; content: string; } async function resolveOrgLlmConfig(organizationId: number, bot?: Bot): Promise<{ provider: string; apiKey: string | null; baseUrl: string | null; chatModel: string; customHeaders: Record; botId?: number }> { // Если у бота задан свой провайдер из реестра — используем его if (bot?.llmProviderId) { try { const provider = await storage.getLlmProvider(bot.llmProviderId, organizationId); if (provider && provider.isActive) { const apiKey = provider.apiKey ? decrypt(provider.apiKey) : null; const resolvedKey = apiKey || (provider.providerType === 'openai' ? GLOBAL_OPENAI_API_KEY || null : null); const chatModel = (bot.llmModel as string | null) ?? (provider.enabledModels && provider.enabledModels.length > 0 ? provider.enabledModels[0] : null) ?? (provider.providerType === 'ollama' ? 'llama3' : 'gpt-4o-mini'); return { provider: provider.providerType, apiKey: resolvedKey, baseUrl: provider.baseUrl ?? null, chatModel, customHeaders: (provider.customHeaders ?? {}) as Record, }; } } catch (err) { console.warn('[AI-Bot] resolveOrgLlmConfig: failed to load llmProvider', bot.llmProviderId, err); } } const settings = await storage.getRagSettings(organizationId); if (settings) { const effectiveChatProviderId = settings.chatProviderId ?? settings.embeddingProviderId; if (effectiveChatProviderId) { try { const provider = await storage.getLlmProvider(effectiveChatProviderId, organizationId); if (provider && provider.isActive) { const apiKey = provider.apiKey ? decrypt(provider.apiKey) : null; const resolvedKey = apiKey || (provider.providerType === 'openai' ? GLOBAL_OPENAI_API_KEY || null : null); const chatModel = settings.chatModel ?? (provider.enabledModels && provider.enabledModels.length > 0 ? provider.enabledModels[0] : null) ?? (provider.providerType === 'ollama' ? 'llama3' : 'gpt-4o-mini'); return { provider: provider.providerType, apiKey: resolvedKey, baseUrl: provider.baseUrl ?? null, chatModel, customHeaders: (provider.customHeaders ?? {}) as Record, }; } } catch (err) { console.warn('[AI-Bot] resolveOrgLlmConfig: failed to load chatProvider', effectiveChatProviderId, err); } } const apiKey = settings.apiKey ? decrypt(settings.apiKey) : null; const provider = settings.provider ?? 'openai'; const baseUrl = settings.baseUrl ?? null; const chatModel = settings.chatModel ?? (provider === 'ollama' ? 'llama3' : 'gpt-4o-mini'); return { provider, apiKey: apiKey || GLOBAL_OPENAI_API_KEY || null, baseUrl, chatModel, customHeaders: {} }; } return { provider: 'openai', apiKey: GLOBAL_OPENAI_API_KEY || null, baseUrl: null, chatModel: 'gpt-4o-mini', customHeaders: {}, }; } function resolveApiBase(provider: string, baseUrl: string | null): string { if (provider === 'ollama') return (baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434').replace(/\/$/, ''); if (provider === 'openai_compatible' && baseUrl) return baseUrl.replace(/\/$/, ''); return 'https://api.openai.com'; } function apiUrl(apiBase: string, path: string): string { const hasVersion = /\/v\d+$/.test(apiBase); return hasVersion ? `${apiBase}${path}` : `${apiBase}/v1${path}`; } async function callLlm( messages: LlmMessage[], config: { provider: string; apiKey: string | null; baseUrl: string | null; chatModel: string; customHeaders: Record; botId?: number } ): Promise { const apiBase = resolveApiBase(config.provider, config.baseUrl); if (config.provider === 'ollama') { try { const res = await fetch(`${apiBase}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...config.customHeaders }, body: JSON.stringify({ model: config.chatModel, messages, stream: false, options: { num_thread: getOllamaNumThread(), num_ctx: getOllamaNumCtx() } }), signal: AbortSignal.timeout(300000), }); if (!res.ok) return null; const data = await res.json() as { message?: { content?: string } }; return data.message?.content?.trim() ?? null; } catch (err) { console.error('[AI-Bot] Ollama chat error:', err); return null; } } if (!config.apiKey) return null; try { const res = await fetch(apiUrl(apiBase, '/chat/completions'), { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, ...config.customHeaders, }, body: JSON.stringify({ model: config.chatModel, messages, max_tokens: 2000, temperature: 0.7, ...(config.botId ? { bot_id: String(config.botId) } : {}), }), signal: AbortSignal.timeout(60000), }); if (!res.ok) { const body = await res.text().catch(() => ''); console.error('[AI-Bot] LLM error:', res.status, body); if (res.status === 429) { return '⚠️ Модель временно недоступна из-за ограничения частоты запросов (429). Попробуйте позже или добавьте свой API-ключ в провайдере.'; } return null; } const data = await res.json() as { choices: Array<{ message: { content: string } }> }; return data.choices[0]?.message?.content?.trim() ?? null; } catch (err) { console.error('[AI-Bot] LLM request failed:', err); return null; } } export function checkBotAccess(bot: Bot, user: SafeUser): boolean { const policy = bot.accessPolicy; if (!policy || policy.mode === 'all') return true; if (policy.mode === 'roles') { return (policy.roles ?? []).includes(user.appRole); } if (policy.mode === 'users') { return (policy.userIds ?? []).includes(user.id); } return false; } const HISTORY_MAX_TOKENS = 3000; const CHARS_PER_TOKEN = 3; // приближение для русского текста const HISTORY_MAX_CHARS = HISTORY_MAX_TOKENS * CHARS_PER_TOKEN; /** * Загружает до 10 последних сообщений чата задачи (исключая текущее), * конвертирует в формат LlmMessage и обрезает по токенам. */ async function buildChatHistory( taskId: number, organizationId: number, currentMessage: string ): Promise { try { const allMessages = await storage.getTaskMessages(taskId, organizationId); // Исключаем текущее сообщение (последнее совпадающее по тексту) // getTaskMessages возвращает TaskMessage & { author, bot, ... } — поля message/messageType на верхнем уровне const lastIdx = allMessages.findLastIndex(m => m.message === currentMessage); const historyMessages = lastIdx !== -1 ? allMessages.slice(0, lastIdx) : allMessages; // Берём последние 10 const recent = historyMessages.slice(-10); // Конвертируем в LlmMessage const llmHistory: LlmMessage[] = recent .filter(m => m.messageType === 'text' || m.messageType === 'bot') .map(m => { if (m.messageType === 'bot') { return { role: 'assistant' as const, content: m.message }; } const authorName = m.author ? `${m.author.firstName} ${m.author.middleName || ''} ${m.author.lastName}`.trim() : 'Пользователь'; return { role: 'user' as const, content: `${authorName}: ${m.message}` }; }); // Обрезаем по токенам (убираем самые старые сообщения, пока не уложимся) let totalChars = llmHistory.reduce((sum, m) => sum + m.content.length, 0); while (totalChars > HISTORY_MAX_CHARS && llmHistory.length > 0) { const removed = llmHistory.shift(); if (removed) totalChars -= removed.content.length; } return llmHistory; } catch (err) { console.warn('[AI-Bot] buildChatHistory error:', err); return []; } } interface RagEntry { content: string; score: number | null; } /** * RAG-поиск: сначала пробует семантический поиск (pgvector эмбеддинги), * при отсутствии конфигурации эмбеддингов — fallback на ILIKE по заголовку/описанию задач. */ async function ragSearch(organizationId: number, query: string, topK: number): Promise { try { const semanticResults = await semanticSearch(organizationId, query, undefined, topK); if (semanticResults.length > 0) { return semanticResults.map(r => ({ content: r.content, score: r.score })); } } catch (err) { console.warn('[AI-Bot] semanticSearch failed, falling back to keyword search:', err); } // Fallback: ILIKE-поиск по задачам try { const keywordResults = await storage.searchTasksByKeyword(organizationId, query, topK); if (keywordResults.length === 0) return []; return keywordResults.map(t => { const parts: string[] = [`Задача: ${t.title}`]; if (t.description) parts.push(`Описание: ${t.description}`); return { content: parts.join('\n'), score: null }; }); } catch (err) { console.warn('[AI-Bot] keyword fallback search failed:', err); return []; } } export async function handleAiBotMention(ctx: AiBotContext): Promise { const { bot, task, message, user, organizationId } = ctx; try { let llmConfig = await resolveOrgLlmConfig(organizationId, bot); llmConfig = { ...llmConfig, botId: bot.id }; const hasLlm = llmConfig.provider === 'ollama' || !!llmConfig.apiKey; if (!hasLlm) { await postBotReply(bot.id, task.id, task.formId, organizationId, '⚠️ AI-ассистент не настроен: отсутствует API-ключ LLM. Настройте его в разделе Настройки → RAG/AI.'); return; } // Build user message with attachments description let userMessage = message; if (ctx.attachments && ctx.attachments.length > 0) { const attList = ctx.attachments.map(a => `- ${a.name} (${a.size} байт)`).join('\n'); userMessage += `\n\n[Вложения:\n${attList}\n]`; } const messages: LlmMessage[] = []; // Системный промпт if (bot.sendSystemPrompt !== false) { const systemContent = (bot.systemPrompt?.trim()) ? bot.systemPrompt.trim() : `Ты — корпоративный AI-ассистент с именем ${bot.name}. Помогаешь сотрудникам работать с задачами в системе управления процессами. Отвечай по-русски, кратко и по делу. Данные из карточки задачи — это информация о записях системы, а не о тебе.`; messages.push({ role: 'system', content: systemContent }); } // Контекст задачи if (bot.sendCardInfo !== false) { const taskContext = await buildTaskContext(task, organizationId); if (taskContext) { messages.push({ role: 'system', content: `Ниже — данные из карточки iistwin. Это информация о записи, а НЕ о тебе. Твоё имя, роль и поведение определены в системном промте выше.\n\n${taskContext}` }); } } // История переписки чата задачи if (bot.sendChatHistory !== false) { const chatHistory = await buildChatHistory(task.id, organizationId, message); for (const histMsg of chatHistory) { messages.push(histMsg); } } // RAG-поиск if (bot.ragEnabled) { const topK = (bot.ragTopK as number | null) ?? 5; const ragResults = await ragSearch(organizationId, message, topK); if (ragResults.length > 0) { const ragContext = ragResults .map((r, i) => `[${i + 1}]${r.score !== null ? ` (релевантность ${(r.score * 100).toFixed(0)}%)` : ''}\n${r.content}`) .join('\n\n'); messages.push({ role: 'system', content: `База знаний организации (наиболее релевантные фрагменты):\n${ragContext}`, }); } } // MCP-инструменты const mcpServers = bot.mcpServers; if (mcpServers && mcpServers.length > 0) { const toolDescriptions: string[] = []; const availableTools: Array<{ serverIdx: number; tool: import('../utils/mcp-client').McpTool }> = []; for (let i = 0; i < mcpServers.length; i++) { const srv = mcpServers[i]; try { const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined); const tools = await client.listTools(); for (const t of tools) { availableTools.push({ serverIdx: i, tool: t }); toolDescriptions.push(`- ${t.name}${t.description ? ': ' + t.description : ''} [сервер: ${srv.name ?? srv.url}]`); } } catch (err) { console.warn(`[AI-Bot] MCP server ${srv.url} unavailable:`, err); } } if (toolDescriptions.length > 0) { messages.push({ role: 'system', content: `Доступные MCP-инструменты:\n${toolDescriptions.join('\n')}\n\nЕсли для ответа нужен инструмент, укажи в своём ответе его вызов в формате: TOOL_CALL: {"name": "tool_name", "args": {...}}`, }); // Первый проход LLM для определения нужных инструментов messages.push({ role: 'user', content: userMessage }); const firstPass = await callLlm(messages, llmConfig); if (firstPass && firstPass.includes('TOOL_CALL:')) { const toolCallJson = extractFirstJsonBlock(firstPass, firstPass.indexOf('TOOL_CALL:') + 'TOOL_CALL:'.length); if (toolCallJson !== null) { try { const toolCall = JSON.parse(toolCallJson) as { name: string; args?: Record }; const toolEntry = availableTools.find(t => t.tool.name === toolCall.name); if (toolEntry) { const srv = mcpServers[toolEntry.serverIdx]; const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined); const toolResult = await client.callTool(toolCall.name, toolCall.args ?? {}); const resultText = toolResult.content.map(c => c.text ?? '').join('\n').trim(); messages.push({ role: 'assistant', content: firstPass }); messages.push({ role: 'user', content: `Результат инструмента ${toolCall.name}:\n${resultText}\n\nТеперь ответь на исходный вопрос пользователя, используя результат.` }); const finalAnswer = await callLlm(messages, llmConfig); if (finalAnswer) { await postBotReply(bot.id, task.id, task.formId, organizationId, finalAnswer); return; } } } catch (err) { console.warn('[AI-Bot] MCP tool call parse error:', err); } } } // Если инструмент не нужен или вызов не удался — используем первый проход if (firstPass) { // Remove only the TOOL_CALL marker and its JSON payload, leaving other content intact const cleanedAnswer = firstPass.replace(/TOOL_CALL:\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, '').trim(); if (cleanedAnswer) { await postBotReply(bot.id, task.id, task.formId, organizationId, cleanedAnswer); return; } } // Убираем сообщение пользователя из messages перед повторным добавлением const userMsgIdx = messages.findLastIndex(m => m.role === 'user' && m.content === userMessage); if (userMsgIdx !== -1) messages.splice(userMsgIdx, 1); } } // Финальный LLM-запрос (без MCP или если MCP не использовался) messages.push({ role: 'user', content: userMessage }); const answer = await callLlm(messages, llmConfig); if (answer) { await postBotReply(bot.id, task.id, task.formId, organizationId, answer); } else { await postBotReply(bot.id, task.id, task.formId, organizationId, '⚠️ Не удалось получить ответ от LLM. Проверьте настройки AI в разделе RAG/AI.'); } } catch (err) { console.error('[AI-Bot] handleAiBotMention error:', err); try { await postBotReply(bot.id, task.id, task.formId, organizationId, '⚠️ Внутренняя ошибка AI-ассистента. Попробуйте позже.'); } catch (e2) { console.error('[AI-Bot] Failed to post error reply:', e2); } } } async function buildTaskContext(task: Task, organizationId: number): Promise { try { const parts: string[] = []; parts.push(`Задача: ${task.title}`); if (task.description) parts.push(`Описание: ${task.description}`); const form = await storage.getForm(task.formId, organizationId); if (form) parts.push(`Форма: ${form.name}`); const statuses = await storage.getFormStatuses(task.formId, organizationId); const status = statuses.find(s => s.id === task.currentStatusId); if (status) parts.push(`Статус: ${status.name}`); if (task.assignedTo) { const assignee = await storage.getUser(task.assignedTo); if (assignee) parts.push(`Ответственный: ${assignee.firstName} ${assignee.middleName || ''} ${assignee.lastName}`); } const fieldValues = await storage.getTaskFieldValues(task.id, organizationId); if (fieldValues.length > 0) { const fields = await storage.getFormFields(task.formId, organizationId); const fieldMap = new Map(fields.map(f => [f.id, f])); const lines: string[] = []; for (const fv of fieldValues) { const f = fieldMap.get(fv.fieldId); if (!f || fv.value === null || fv.value === undefined || fv.value === '') continue; if (['file', 'table'].includes(f.type)) continue; const displayVal = typeof fv.value === 'object' ? JSON.stringify(fv.value) : String(fv.value); lines.push(`${f.name}: ${displayVal}`); } if (lines.length > 0) parts.push(`\nПоля карточки:\n${lines.join('\n')}`); } return parts.join('\n'); } catch (err) { console.warn('[AI-Bot] buildTaskContext error:', err); return null; } } /** * Обрабатывает прямое сообщение пользователя боту в bot_direct диалоге мессенджера. * Сохраняет ответ бота в conversationMessages с authorId=null, botId=bot.id */ export async function handleAiBotDirectMessage(ctx: { bot: Bot; conversationId: number; message: string; user: SafeUser; organizationId: number; }): Promise { const { bot, conversationId, message, user, organizationId } = ctx; try { let llmConfig = await resolveOrgLlmConfig(organizationId, bot); llmConfig = { ...llmConfig, botId: bot.id }; const hasLlm = llmConfig.provider === 'ollama' || !!llmConfig.apiKey; if (!hasLlm) { await db.insert(conversationMessages).values({ conversationId, authorId: null, botId: bot.id, message: '⚠️ AI-ассистент не настроен: отсутствует API-ключ LLM. Настройте его в разделе Настройки → RAG/AI.', replyToId: null, mentionedUserIds: null, attachments: null, }); return; } try { await publishConvTypingEvent(conversationId, bot.name, organizationId, true); const messages: LlmMessage[] = []; if (bot.sendSystemPrompt !== false) { const systemContent = bot.systemPrompt?.trim() ? bot.systemPrompt.trim() : `Ты — корпоративный AI-ассистент с именем ${bot.name}. Помогаешь сотрудникам работать с задачами в системе управления процессами. Отвечай по-русски, кратко и по делу. Данные из карточки задачи — это информация о записях системы, а не о тебе.`; messages.push({ role: 'system', content: systemContent }); } // История диалога в мессенджере if (bot.sendChatHistory !== false) { const historyRows = await db .select({ message: conversationMessages.message, botId: conversationMessages.botId, authorId: conversationMessages.authorId }) .from(conversationMessages) .where(and( eq(conversationMessages.conversationId, conversationId), eq(conversationMessages.isDeleted, false), )) .orderBy(desc(conversationMessages.id)) .limit(11); // last 10 + current const history = historyRows.reverse().slice(0, -1); // exclude current message let totalChars = 0; const llmHistory: LlmMessage[] = []; for (const h of history) { const role: 'assistant' | 'user' = h.botId ? 'assistant' : 'user'; const content = h.message; totalChars += content.length; if (totalChars > HISTORY_MAX_CHARS) break; llmHistory.push({ role, content }); } for (const h of llmHistory) messages.push(h); } // RAG if (bot.ragEnabled) { const topK = (bot.ragTopK as number | null) ?? 5; const ragResults = await ragSearch(organizationId, message, topK); if (ragResults.length > 0) { const ragContext = ragResults .map((r, i) => `[${i + 1}]${r.score !== null ? ` (релевантность ${(r.score * 100).toFixed(0)}%)` : ''}\n${r.content}`) .join('\n\n'); messages.push({ role: 'system', content: `База знаний организации:\n${ragContext}` }); } } // MCP-инструменты const mcpServers = bot.mcpServers; if (mcpServers && mcpServers.length > 0) { const toolDescriptions: string[] = []; const availableTools: Array<{ serverIdx: number; tool: import('../utils/mcp-client').McpTool }> = []; for (let i = 0; i < mcpServers.length; i++) { const srv = mcpServers[i]; try { const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined); const tools = await client.listTools(); for (const t of tools) { availableTools.push({ serverIdx: i, tool: t }); toolDescriptions.push(`- ${t.name}${t.description ? ': ' + t.description : ''} [сервер: ${srv.name ?? srv.url}]`); } } catch (err) { console.warn(`[AI-Bot Direct] MCP server ${srv.url} unavailable:`, err); } } if (toolDescriptions.length > 0) { messages.push({ role: 'system', content: `Доступные MCP-инструменты:\n${toolDescriptions.join('\n')}\n\nЕсли для ответа нужен инструмент, укажи в своём ответе его вызов в формате: TOOL_CALL: {"name": "tool_name", "args": {...}}`, }); messages.push({ role: 'user', content: message }); const firstPass = await callLlm(messages, llmConfig); if (firstPass && firstPass.includes('TOOL_CALL:')) { const toolCallJson = extractFirstJsonBlock(firstPass, firstPass.indexOf('TOOL_CALL:') + 'TOOL_CALL:'.length); if (toolCallJson !== null) { try { const toolCall = JSON.parse(toolCallJson) as { name: string; args?: Record }; const toolEntry = availableTools.find(t => t.tool.name === toolCall.name); if (toolEntry) { const srv = mcpServers[toolEntry.serverIdx]; const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined); const toolResult = await client.callTool(toolCall.name, toolCall.args ?? {}); const resultText = toolResult.content.map(c => c.text ?? '').join('\n').trim(); messages.push({ role: 'assistant', content: firstPass }); messages.push({ role: 'user', content: `Результат инструмента ${toolCall.name}:\n${resultText}\n\nТеперь ответь на исходный вопрос пользователя, используя результат.` }); const finalAnswer = await callLlm(messages, llmConfig); if (finalAnswer) { await db.insert(conversationMessages).values({ conversationId, authorId: null, botId: bot.id, message: finalAnswer, replyToId: null, mentionedUserIds: null, attachments: null, }); return; } } } catch (err) { console.warn('[AI-Bot Direct] MCP tool call parse error:', err); } } } if (firstPass) { const cleanedAnswer = firstPass.replace(/TOOL_CALL:\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, '').trim(); if (cleanedAnswer) { await db.insert(conversationMessages).values({ conversationId, authorId: null, botId: bot.id, message: cleanedAnswer, replyToId: null, mentionedUserIds: null, attachments: null, }); return; } } const userMsgIdx = messages.findLastIndex(m => m.role === 'user' && m.content === message); if (userMsgIdx !== -1) messages.splice(userMsgIdx, 1); } } // Финальный LLM-запрос (без MCP или если MCP не использовался) messages.push({ role: 'user', content: message }); const reply = await callLlm(messages, llmConfig); const replyText = reply?.trim() || '⚠️ Бот не смог сгенерировать ответ.'; await db.insert(conversationMessages).values({ conversationId, authorId: null, botId: bot.id, message: replyText, replyToId: null, mentionedUserIds: null, attachments: null, }); } finally { await publishConvTypingEvent(conversationId, bot.name, organizationId, false).catch(() => {}); } } catch (err) { console.error('[AI-Bot Direct] Error:', err); await db.insert(conversationMessages).values({ conversationId, authorId: null, botId: bot.id, message: '⚠️ Произошла ошибка при обработке запроса.', replyToId: null, mentionedUserIds: null, attachments: null, }).catch(() => {}); } } async function postBotReply( botId: number, taskId: number, formId: number, organizationId: number, text: string ): Promise { const botMessage = await storage.createTaskMessage({ taskId, formId, authorId: null, botId, replyToMessageId: null, message: text, messageType: 'bot', mentionedUserIds: null, attachments: null, } as Parameters[0], organizationId); const bot = await storage.getBot(botId, organizationId); eventBus.publishEvent({ type: 'message_created', organizationId, taskId, data: { taskId, message: { ...botMessage, bot: bot ? { id: bot.id, name: bot.name, avatarUrl: bot.avatarUrl ?? null, } : null, }, }, }); }