diff --git a/app/api/chat/slack/route.ts b/app/api/chat/slack/route.ts new file mode 100644 index 00000000..9b86f10a --- /dev/null +++ b/app/api/chat/slack/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { after } from "next/server"; +import { slackChatBot } from "@/lib/slack/chat/bot"; +import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; +import "@/lib/slack/chat/handlers/registerHandlers"; + +/** + * GET /api/chat/slack + * + * Handles Slack webhook verification handshake. + * + * @param request - The incoming verification request + * @returns The webhook handler response + */ +export async function GET(request: NextRequest) { + return slackChatBot.webhooks.slack(request, { waitUntil: p => after(() => p) }); +} + +/** + * POST /api/chat/slack + * + * Webhook endpoint for the Record Label Agent Slack bot. + * Handles url_verification challenges and delegates messages to the bot. + * + * @param request - The incoming webhook request + * @returns The webhook handler response or url_verification challenge + */ +export async function POST(request: NextRequest) { + const verification = await handleUrlVerification(request); + if (verification) return verification; + + await slackChatBot.initialize(); + + return slackChatBot.webhooks.slack(request, { waitUntil: p => after(() => p) }); +} diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index 996a0a4d..a51a2104 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from "next/server"; import { after } from "next/server"; import { codingAgentBot } from "@/lib/coding-agent/bot"; +import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; import "@/lib/coding-agent/handlers/registerHandlers"; /** @@ -41,13 +42,9 @@ export async function POST( ) { const { platform } = await params; - // Handle Slack url_verification challenge before loading the bot. - // This avoids blocking on Redis/adapter initialization during setup. if (platform === "slack") { - const body = await request.clone().json().catch(() => null); - if (body?.type === "url_verification" && typeof body?.challenge === "string") { - return Response.json({ challenge: body.challenge }); - } + const verification = await handleUrlVerification(request); + if (verification) return verification; } await codingAgentBot.initialize(); diff --git a/lib/slack/chat/bot.ts b/lib/slack/chat/bot.ts new file mode 100644 index 00000000..ff7017e9 --- /dev/null +++ b/lib/slack/chat/bot.ts @@ -0,0 +1,56 @@ +import { Chat, ConsoleLogger } from "chat"; +import { SlackAdapter } from "@chat-adapter/slack"; +import { createIoRedisState } from "@chat-adapter/state-ioredis"; +import redis from "@/lib/redis/connection"; +import type { SlackChatThreadState } from "./types"; +import { validateSlackChatEnv } from "./validateEnv"; + +const logger = new ConsoleLogger(); + +type SlackChatAdapters = { + slack: SlackAdapter; +}; + +/** + * Creates a new Chat bot instance configured with a single Slack adapter + * for the Record Label Agent. + * + * @returns A configured Chat instance + */ +export function createSlackChatBot() { + validateSlackChatEnv(); + + // ioredis is configured with lazyConnect: true, so we must + // explicitly connect before the state adapter listens for "ready". + if (redis.status === "wait") { + redis.connect().catch(() => { + throw new Error("[slack-chat] Redis failed to connect"); + }); + } + + const state = createIoRedisState({ + client: redis, + keyPrefix: "chat", + logger, + }); + + const slack = new SlackAdapter({ + botToken: process.env.SLACK_CHAT_BOT_TOKEN!, + signingSecret: process.env.SLACK_CHAT_SIGNING_SECRET!, + logger, + }); + + return new Chat({ + userName: "Record Label Agent", + adapters: { slack }, + state, + }); +} + +export type SlackChatBot = ReturnType; + +/** + * Singleton bot instance. + * Does NOT call registerSingleton() to avoid conflicting with the coding-agent bot. + */ +export const slackChatBot = createSlackChatBot(); diff --git a/lib/slack/chat/handlers/handleSlackChatMessage.ts b/lib/slack/chat/handlers/handleSlackChatMessage.ts new file mode 100644 index 00000000..5838d84a --- /dev/null +++ b/lib/slack/chat/handlers/handleSlackChatMessage.ts @@ -0,0 +1,129 @@ +import type { UIMessage } from "ai"; +import type { SlackChatThreadState } from "../types"; +import type { ChatRequestBody } from "@/lib/chat/validateChatRequest"; +import { getMessages } from "@/lib/messages/getMessages"; +import convertToUiMessages from "@/lib/messages/convertToUiMessages"; +import { validateMessages } from "@/lib/chat/validateMessages"; +import { setupConversation } from "@/lib/chat/setupConversation"; +import { setupChatRequest } from "@/lib/chat/setupChatRequest"; +import { saveChatCompletion } from "@/lib/chat/saveChatCompletion"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +/** + * Shared handler for both onNewMention and onSubscribedMessage. + * Generates an AI response and posts it back to the Slack thread. + * + * @param thread - The Chat SDK thread instance + * @param thread.state - Promise resolving to the current thread state + * @param thread.post - Posts a message to the thread + * @param thread.setState - Updates the thread state + * @param text - The user's message text + */ +export async function handleSlackChatMessage( + thread: { + state: Promise; + post: (message: string) => Promise; + setState: (state: SlackChatThreadState) => Promise; + }, + text: string, +) { + const currentState = await thread.state; + + // Prevent concurrent generation in the same thread + if (currentState?.status === "generating") { + await thread.post("I'm still working on a response. Please wait a moment."); + return; + } + + const authToken = process.env.SLACK_CHAT_API_KEY!; + + // Derive account from the API key — no hardcoded account ID + const keyDetails = await getApiKeyDetails(authToken); + if (!keyDetails) { + console.error("[slack-chat] Invalid SLACK_CHAT_API_KEY — could not resolve account"); + await thread.post("Sorry, I'm not configured correctly. Please contact support."); + return; + } + + const { accountId, orgId } = keyDetails; + + await thread.setState({ + status: "generating", + prompt: text, + roomId: currentState?.roomId, + }); + + await thread.post("Thinking..."); + + let finalRoomId = currentState?.roomId; + + try { + // Build current message as UIMessage + const newMessages = getMessages(text, "user"); + const newUiMessages = convertToUiMessages(newMessages); + const { lastMessage } = validateMessages(newUiMessages); + + // Setup conversation: create room if needed, persist user message + const { roomId } = await setupConversation({ + accountId, + roomId: currentState?.roomId, + topic: text.slice(0, 100), + promptMessage: lastMessage, + memoryId: lastMessage.id, + }); + + finalRoomId = roomId; + + // Load full conversation history from the room (includes the message we just saved) + const memories = await selectMemories(roomId, { ascending: true }); + const historyMessages: UIMessage[] = (memories ?? []) + .filter(m => { + const content = m.content as unknown as { role?: string; parts?: unknown[] }; + return content?.role && content?.parts; + }) + .map(m => { + const content = m.content as unknown as UIMessage; + return { + id: m.id, + role: content.role, + parts: content.parts, + }; + }); + + const allUiMessages = convertToUiMessages( + historyMessages.length > 0 ? historyMessages : newUiMessages, + ); + + // Build ChatRequestBody — accountId inferred from API key + const body: ChatRequestBody = { + messages: allUiMessages, + accountId, + orgId, + roomId, + authToken, + }; + + const chatConfig = await setupChatRequest(body); + const result = await chatConfig.agent.generate(chatConfig); + + // Persist assistant response + try { + await saveChatCompletion({ text: result.text, roomId }); + } catch (error) { + console.error("[slack-chat] Failed to persist assistant message:", error); + } + + // Post response to Slack thread + await thread.post(result.text); + } catch (error) { + console.error("[slack-chat] Generation failed:", error); + await thread.post("Sorry, something went wrong. Please try again."); + } finally { + await thread.setState({ + status: "idle", + prompt: text, + roomId: finalRoomId, + }); + } +} diff --git a/lib/slack/chat/handlers/onNewMention.ts b/lib/slack/chat/handlers/onNewMention.ts new file mode 100644 index 00000000..215b9ef2 --- /dev/null +++ b/lib/slack/chat/handlers/onNewMention.ts @@ -0,0 +1,19 @@ +import type { SlackChatBot } from "../bot"; +import { handleSlackChatMessage } from "./handleSlackChatMessage"; + +/** + * Registers the onNewMention handler on the Slack chat bot. + * Subscribes to the thread for follow-up messages, then generates a response. + * + * @param bot - The Slack chat bot instance + */ +export function registerOnNewMention(bot: SlackChatBot) { + bot.onNewMention(async (thread, message) => { + try { + await thread.subscribe(); + await handleSlackChatMessage(thread, message.text); + } catch (error) { + console.error("[slack-chat] onNewMention error:", error); + } + }); +} diff --git a/lib/slack/chat/handlers/onSubscribedMessage.ts b/lib/slack/chat/handlers/onSubscribedMessage.ts new file mode 100644 index 00000000..a084d320 --- /dev/null +++ b/lib/slack/chat/handlers/onSubscribedMessage.ts @@ -0,0 +1,18 @@ +import type { SlackChatBot } from "../bot"; +import { handleSlackChatMessage } from "./handleSlackChatMessage"; + +/** + * Registers the onSubscribedMessage handler on the Slack chat bot. + * Reuses the existing roomId from thread state for conversational memory. + * + * @param bot - The Slack chat bot instance + */ +export function registerOnSubscribedMessage(bot: SlackChatBot) { + bot.onSubscribedMessage(async (thread, message) => { + try { + await handleSlackChatMessage(thread, message.text); + } catch (error) { + console.error("[slack-chat] onSubscribedMessage error:", error); + } + }); +} diff --git a/lib/slack/chat/handlers/registerHandlers.ts b/lib/slack/chat/handlers/registerHandlers.ts new file mode 100644 index 00000000..075d2cfe --- /dev/null +++ b/lib/slack/chat/handlers/registerHandlers.ts @@ -0,0 +1,10 @@ +import { slackChatBot } from "../bot"; +import { registerOnNewMention } from "./onNewMention"; +import { registerOnSubscribedMessage } from "./onSubscribedMessage"; + +/** + * Registers all Slack chat bot event handlers on the bot singleton. + * Import this file once to attach handlers to the bot. + */ +registerOnNewMention(slackChatBot); +registerOnSubscribedMessage(slackChatBot); diff --git a/lib/slack/chat/types.ts b/lib/slack/chat/types.ts new file mode 100644 index 00000000..10f5a28c --- /dev/null +++ b/lib/slack/chat/types.ts @@ -0,0 +1,10 @@ +/** + * Thread state for the Slack chat bot. + * Stored in Redis via Chat SDK's state adapter. + */ +export interface SlackChatThreadState { + status: "idle" | "generating"; + prompt: string; + /** Persists across thread messages so the chat has memory. */ + roomId?: string; +} diff --git a/lib/slack/chat/validateEnv.ts b/lib/slack/chat/validateEnv.ts new file mode 100644 index 00000000..f043cef0 --- /dev/null +++ b/lib/slack/chat/validateEnv.ts @@ -0,0 +1,20 @@ +const REQUIRED_ENV_VARS = [ + "SLACK_CHAT_BOT_TOKEN", + "SLACK_CHAT_SIGNING_SECRET", + "SLACK_CHAT_API_KEY", + "REDIS_URL", +] as const; + +/** + * Validates that all required environment variables for the Slack chat bot are set. + * Throws an error listing all missing variables. + */ +export function validateSlackChatEnv(): void { + const missing: string[] = REQUIRED_ENV_VARS.filter(name => !process.env[name]); + + if (missing.length > 0) { + throw new Error( + `[slack-chat] Missing required environment variables:\n${missing.map(v => ` - ${v}`).join("\n")}`, + ); + } +} diff --git a/lib/slack/handleUrlVerification.ts b/lib/slack/handleUrlVerification.ts new file mode 100644 index 00000000..5c29b3a9 --- /dev/null +++ b/lib/slack/handleUrlVerification.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from "next/server"; + +/** + * Handles Slack url_verification challenge before bot initialization. + * This avoids blocking on Redis/adapter initialization during setup. + * + * @param request - The incoming webhook request + * @returns A Response with the challenge if verified, or null to continue processing + */ +export async function handleUrlVerification(request: NextRequest): Promise { + const body = await request + .clone() + .json() + .catch(() => null); + + if (body?.type === "url_verification" && typeof body?.challenge === "string") { + return Response.json({ challenge: body.challenge }); + } + + return null; +}