import { NextRequest, NextResponse } from 'next/server' import { getDatabase, db_helpers, Message } from '@/lib/db' import { runOpenClaw } from '@/lib/command' import { getAllGatewaySessions } from '@/lib/sessions' import { eventBus } from '@/lib/event-bus' import { requireRole } from '@/lib/auth' import { logger } from '@/lib/logger' import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard' import { callOpenClawGateway } from '@/lib/openclaw-gateway' import { resolveCoordinatorDeliveryTarget } from '@/lib/coordinator-routing' type ForwardInfo = { attempted: boolean delivered: boolean reason?: string session?: string runId?: string } type ToolEvent = { name: string input?: string output?: string status?: string } type ChatAttachmentInput = { name?: string type?: string dataUrl?: string } const COORDINATOR_AGENT = String(process.env.MC_COORDINATOR_AGENT || process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').trim() || 'coordinator' function parseGatewayJson(raw: string): any | null { const trimmed = String(raw || '').trim() if (!trimmed) return null const start = trimmed.indexOf('{') const end = trimmed.lastIndexOf('}') if (start < 0 || end < start) return null try { return JSON.parse(trimmed.slice(start, end + 1)) } catch { return null } } function toGatewayAttachments(value: unknown): Array<{ type: 'image'; mimeType: string; fileName?: string; content: string }> | undefined { if (!Array.isArray(value)) return undefined const attachments = value.flatMap((entry) => { const file = entry as ChatAttachmentInput if (!file || typeof file !== 'object' || typeof file.dataUrl !== 'string') return [] const match = /^data:([^;]+);base64,(.+)$/.exec(file.dataUrl) if (!match) return [] if (!match[1].startsWith('image/')) return [] return [{ type: 'image' as const, mimeType: match[1], fileName: typeof file.name === 'string' ? file.name : undefined, content: match[2], }] }) return attachments.length > 0 ? attachments : undefined } function safeParseMetadata(raw: string | null | undefined): any | null { if (!raw) return null try { return JSON.parse(raw) } catch { return null } } function createChatReply( db: ReturnType, workspaceId: number, conversationId: string, fromAgent: string, toAgent: string, content: string, messageType: 'text' | 'status' | 'tool_call' = 'status', metadata: Record | null = null ) { const replyInsert = db .prepare(` INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?) `) .run( conversationId, fromAgent, toAgent, content, messageType, metadata ? JSON.stringify(metadata) : null, workspaceId ) const row = db .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?') .get(replyInsert.lastInsertRowid, workspaceId) as Message eventBus.broadcast('chat.message', { ...row, metadata: safeParseMetadata(row.metadata), }) } function extractReplyText(waitPayload: any): string | null { if (!waitPayload || typeof waitPayload !== 'object') return null const directCandidates = [ waitPayload.text, waitPayload.message, waitPayload.response, waitPayload.output, waitPayload.result, ] for (const value of directCandidates) { if (typeof value === 'string' && value.trim()) return value.trim() } if (typeof waitPayload.output === 'object' && waitPayload.output) { const nested = [ waitPayload.output.text, waitPayload.output.message, waitPayload.output.content, ] for (const value of nested) { if (typeof value === 'string' && value.trim()) return value.trim() } } if (Array.isArray(waitPayload.output)) { const parts: string[] = [] for (const item of waitPayload.output) { if (!item || typeof item !== 'object') continue if (typeof item.text === 'string' && item.text.trim()) parts.push(item.text.trim()) if (item.type === 'message' && Array.isArray(item.content)) { for (const block of item.content) { if (!block || typeof block !== 'object') continue const blockType = String(block.type || '') if ((blockType === 'text' || blockType === 'output_text' || blockType === 'input_text') && typeof block.text === 'string' && block.text.trim()) { parts.push(block.text.trim()) } } } } if (parts.length > 0) return parts.join('\n').slice(0, 8000) } return null } function normalizeToolEvent(raw: any): ToolEvent | null { if (!raw || typeof raw !== 'object') return null const name = String(raw.name || raw.tool || raw.toolName || raw.function || raw.call || '').trim() if (!name) return null const inputRaw = raw.input ?? raw.args ?? raw.arguments ?? raw.params const outputRaw = raw.output ?? raw.result ?? raw.response const statusRaw = raw.status ?? (raw.isError === true ? 'error' : undefined) ?? (raw.ok === false ? 'error' : undefined) ?? (raw.success === true ? 'ok' : undefined) const input = typeof inputRaw === 'string' ? inputRaw.slice(0, 2000) : inputRaw !== undefined ? JSON.stringify(inputRaw).slice(0, 2000) : undefined const output = typeof outputRaw === 'string' ? outputRaw.slice(0, 4000) : outputRaw !== undefined ? JSON.stringify(outputRaw).slice(0, 4000) : undefined const status = statusRaw !== undefined ? String(statusRaw).slice(0, 60) : undefined return { name, input, output, status } } function extractToolEvents(waitPayload: any): ToolEvent[] { if (!waitPayload || typeof waitPayload !== 'object') return [] const candidates = [ waitPayload.toolCalls, waitPayload.tools, waitPayload.calls, waitPayload.events, waitPayload.output?.toolCalls, waitPayload.output?.tools, waitPayload.output?.events, ] const events: ToolEvent[] = [] for (const list of candidates) { if (!Array.isArray(list)) continue for (const item of list) { const evt = normalizeToolEvent(item) if (evt) events.push(evt) if (events.length >= 20) return events } } // OpenAI Responses-style output array if (Array.isArray(waitPayload.output)) { for (const item of waitPayload.output) { if (!item || typeof item !== 'object') continue const itemType = String(item.type || '').toLowerCase() if (itemType === 'function_call' || itemType === 'tool_call') { const evt = normalizeToolEvent({ name: item.name || item.tool_name || item.toolName, arguments: item.arguments || item.input, output: item.output || item.result, status: item.status, }) if (evt) events.push(evt) } else if (itemType === 'message' && Array.isArray(item.content)) { for (const block of item.content) { const blockType = String(block?.type || '').toLowerCase() if (blockType === 'tool_use' || blockType === 'tool_call' || blockType === 'function_call') { const evt = normalizeToolEvent(block) if (evt) events.push(evt) } } } if (events.length >= 20) return events } } return events } /** * GET /api/chat/messages - List messages with filters * Query params: conversation_id, from_agent, to_agent, limit, offset, since */ export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) try { const db = getDatabase() const workspaceId = auth.user.workspace_id ?? 1 const { searchParams } = new URL(request.url) const conversation_id = searchParams.get('conversation_id') const from_agent = searchParams.get('from_agent') const to_agent = searchParams.get('to_agent') const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200) const offset = parseInt(searchParams.get('offset') || '0') const since = searchParams.get('since') let query = 'SELECT * FROM messages WHERE workspace_id = ?' const params: any[] = [workspaceId] if (conversation_id) { query += ' AND conversation_id = ?' params.push(conversation_id) } if (from_agent) { query += ' AND from_agent = ?' params.push(from_agent) } if (to_agent) { query += ' AND to_agent = ?' params.push(to_agent) } if (since) { query += ' AND created_at > ?' params.push(parseInt(since)) } query += ' ORDER BY created_at ASC LIMIT ? OFFSET ?' params.push(limit, offset) const messages = db.prepare(query).all(...params) as Message[] const parsed = messages.map((msg) => ({ ...msg, metadata: safeParseMetadata(msg.metadata), })) // Get total count for pagination let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE workspace_id = ?' const countParams: any[] = [workspaceId] if (conversation_id) { countQuery += ' AND conversation_id = ?' countParams.push(conversation_id) } if (from_agent) { countQuery += ' AND from_agent = ?' countParams.push(from_agent) } if (to_agent) { countQuery += ' AND to_agent = ?' countParams.push(to_agent) } if (since) { countQuery += ' AND created_at > ?' countParams.push(parseInt(since)) } const countRow = db.prepare(countQuery).get(...countParams) as { total: number } return NextResponse.json({ messages: parsed, total: countRow.total, page: Math.floor(offset / limit) + 1, limit }) } catch (error) { logger.error({ err: error }, 'GET /api/chat/messages error') return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 }) } } /** * POST /api/chat/messages - Send a new message * Body: { to, content, message_type, conversation_id, metadata } * Sender identity is always resolved server-side from authenticated user. */ export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) try { const db = getDatabase() const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() const requestedFrom = typeof body.from === 'string' ? body.from.trim() : '' const isCoordinatorOverride = requestedFrom.toLowerCase() === COORDINATOR_AGENT.toLowerCase() const from = isCoordinatorOverride ? COORDINATOR_AGENT : (auth.user.display_name || auth.user.username || 'system') const to = body.to ? (body.to as string).trim() : null const content = (body.content || '').trim() const message_type = body.message_type || 'text' const conversation_id = body.conversation_id || `conv_${Date.now()}` const metadata = body.metadata || null if (!content) { return NextResponse.json( { error: '"content" is required' }, { status: 400 } ) } // Scan content for injection when it will be forwarded to an agent if (body.forward && to) { const injectionReport = scanForInjection(content, { context: 'prompt' }) if (!injectionReport.safe) { const criticals = injectionReport.matches.filter(m => m.severity === 'critical') if (criticals.length > 0) { logger.warn({ to, rules: criticals.map(m => m.rule) }, 'Blocked chat message: injection detected') return NextResponse.json( { error: 'Message blocked: potentially unsafe content detected', injection: criticals.map(m => ({ rule: m.rule, description: m.description })) }, { status: 422 } ) } } } const stmt = db.prepare(` INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( conversation_id, from, to, content, message_type, metadata ? JSON.stringify(metadata) : null, workspaceId ) const messageId = result.lastInsertRowid as number let forwardInfo: ForwardInfo | null = null // Log activity db_helpers.logActivity( 'chat_message', 'message', messageId, from, `Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`, { conversation_id, to, message_type }, workspaceId ) // Create notification for recipient if specified if (to) { db_helpers.createNotification( to, 'chat_message', `Message from ${from}`, content.substring(0, 200) + (content.length > 200 ? '...' : ''), 'message', messageId, workspaceId ) // Optionally forward to agent via gateway if (body.forward) { forwardInfo = { attempted: true, delivered: false } const agent = db .prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?') .get(to, workspaceId) as any const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey ? body.sessionKey : null const sessions = getAllGatewaySessions() const isCoordinatorSend = String(to).toLowerCase() === COORDINATOR_AGENT.toLowerCase() const allAgents = isCoordinatorSend ? (db .prepare('SELECT name, session_key, config FROM agents WHERE workspace_id = ?') .all(workspaceId) as Array<{ name: string; session_key?: string | null; config?: string | null }>) : [] const configuredCoordinatorTarget = isCoordinatorSend ? (db .prepare("SELECT value FROM settings WHERE key = 'chat.coordinator_target_agent'") .get() as { value?: string } | undefined)?.value || null : null const coordinatorResolution = resolveCoordinatorDeliveryTarget({ to: String(to), coordinatorAgent: COORDINATOR_AGENT, directAgent: agent ? { name: String(agent.name || to), session_key: typeof agent.session_key === 'string' ? agent.session_key : null, config: typeof agent.config === 'string' ? agent.config : null, } : null, allAgents, sessions, explicitSessionKey, configuredCoordinatorTarget, }) // Use explicit session key from caller if provided, then DB, then on-disk lookup let sessionKey: string | null = coordinatorResolution.sessionKey // Fallback: derive session from on-disk gateway session stores if (!sessionKey) { const match = sessions.find( (s) => s.agent.toLowerCase() === String(to).toLowerCase() || s.agent.toLowerCase() === coordinatorResolution.deliveryName.toLowerCase() || s.agent.toLowerCase() === String(coordinatorResolution.openclawAgentId || '').toLowerCase() ) sessionKey = match?.key || match?.sessionId || null } // Prefer configured openclawId when present, fallback to normalized name let openclawAgentId: string | null = coordinatorResolution.openclawAgentId if (!sessionKey && !openclawAgentId) { forwardInfo.reason = 'no_active_session' // For coordinator messages, emit an immediate visible status reply if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) { try { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, 'I received your message, but my live coordinator session is offline right now. Start/restore the coordinator session and retry.', 'status', { status: 'offline', reason: 'no_active_session' } ) } catch (e) { logger.error({ err: e }, 'Failed to create offline status reply') } } } else { try { const idempotencyKey = `mc-${messageId}-${Date.now()}` if (sessionKey) { const acceptedPayload = await callOpenClawGateway( 'chat.send', { sessionKey, message: content, idempotencyKey, deliver: false, attachments: toGatewayAttachments(body.attachments), }, 12000, ) const status = String(acceptedPayload?.status || '').toLowerCase() forwardInfo.delivered = status === 'started' || status === 'ok' || status === 'in_flight' forwardInfo.session = sessionKey if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) { forwardInfo.runId = acceptedPayload.runId } } else { const invokeParams: any = { message: `Message from ${from}: ${content}`, idempotencyKey, deliver: false, } invokeParams.agentId = openclawAgentId const invokeResult = await runOpenClaw( [ 'gateway', 'call', 'agent', '--timeout', '10000', '--params', JSON.stringify(invokeParams), '--json', ], { timeoutMs: 12000 } ) const acceptedPayload = parseGatewayJson(invokeResult.stdout) forwardInfo.delivered = true forwardInfo.session = openclawAgentId || undefined if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) { forwardInfo.runId = acceptedPayload.runId } } } catch (err) { // OpenClaw may return accepted JSON on stdout but still emit a late stderr warning. // Treat accepted runs as successful delivery. const maybeStdout = String((err as any)?.stdout || '') const acceptedPayload = parseGatewayJson(maybeStdout) if (maybeStdout.includes('"status": "accepted"') || maybeStdout.includes('"status":"accepted"')) { forwardInfo.delivered = true forwardInfo.session = sessionKey || openclawAgentId || undefined if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) { forwardInfo.runId = acceptedPayload.runId } } else { forwardInfo.reason = 'gateway_send_failed' logger.error({ err }, 'Failed to forward message via gateway') // For coordinator messages, emit visible status when send fails if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) { try { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, 'I received your message, but delivery to the live coordinator runtime failed. Please restart the coordinator/gateway session and retry.', 'status', { status: 'delivery_failed', reason: 'gateway_send_failed' } ) } catch (e) { logger.error({ err: e }, 'Failed to create gateway failure status reply') } } } } // Coordinator mode should always show visible coordinator feedback in thread. if ( typeof conversation_id === 'string' && conversation_id.startsWith('coord:') && forwardInfo.delivered ) { try { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, 'Received. I am coordinating downstream agents now.', 'status', { status: 'accepted', runId: forwardInfo.runId || null } ) } catch (e) { logger.error({ err: e }, 'Failed to create accepted status reply') } // Best effort: wait briefly and surface completion/error feedback. if (forwardInfo.runId) { try { const waitResult = await runOpenClaw( [ 'gateway', 'call', 'agent.wait', '--timeout', '8000', '--params', JSON.stringify({ runId: forwardInfo.runId, timeoutMs: 6000 }), '--json', ], { timeoutMs: 9000 } ) const waitPayload = parseGatewayJson(waitResult.stdout) const waitStatus = String(waitPayload?.status || '').toLowerCase() const toolEvents = extractToolEvents(waitPayload) if (toolEvents.length > 0) { for (const evt of toolEvents) { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, evt.name, 'tool_call', { event: 'tool_call', toolName: evt.name, input: evt.input || null, output: evt.output || null, status: evt.status || null, runId: forwardInfo.runId || null, } ) } } if (waitStatus === 'error') { const reason = typeof waitPayload?.error === 'string' ? waitPayload.error : 'Unknown runtime error' createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, `I received your message, but execution failed: ${reason}`, 'status', { status: 'error', runId: forwardInfo.runId } ) } else if (waitStatus === 'timeout') { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, 'I received your message and I am still processing it. I will post results as soon as execution completes.', 'status', { status: 'processing', runId: forwardInfo.runId } ) } else { const replyText = extractReplyText(waitPayload) if (replyText) { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, replyText, 'text', { status: waitStatus || 'completed', runId: forwardInfo.runId } ) } else { createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, 'Execution accepted and completed. No textual response payload was returned by the runtime.', 'status', { status: waitStatus || 'completed', runId: forwardInfo.runId } ) } } } catch (waitErr) { const maybeWaitStdout = String((waitErr as any)?.stdout || '') const maybeWaitStderr = String((waitErr as any)?.stderr || '') const waitPayload = parseGatewayJson(maybeWaitStdout) const reason = typeof waitPayload?.error === 'string' ? waitPayload.error : (maybeWaitStderr || maybeWaitStdout || 'Unable to read completion status from coordinator runtime.').trim() createChatReply( db, workspaceId, conversation_id, COORDINATOR_AGENT, from, `I received your message, but I could not retrieve completion output yet: ${reason}`, 'status', { status: 'unknown', runId: forwardInfo.runId } ) } } } } } } const created = db.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?').get(messageId, workspaceId) as Message const parsedMessage = { ...created, metadata: { ...(safeParseMetadata(created.metadata) || {}), forwardInfo: forwardInfo || undefined, }, } // Broadcast to SSE clients eventBus.broadcast('chat.message', parsedMessage) return NextResponse.json({ message: parsedMessage, forward: forwardInfo }, { status: 201 }) } catch (error) { logger.error({ err: error }, 'POST /api/chat/messages error') return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }) } }