-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add Slack chat bot integration for Record Label Agent #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
63f1f11
feat: add Slack chat bot integration for Record Label Agent
sweetmantech d1e7d39
fix: derive accountId from API key instead of hardcoding
sweetmantech 51c46fc
fix: log Redis connection error instead of throwing unhandled rejection
sweetmantech 84af910
revert: match coding-agent Redis connect pattern exactly
sweetmantech df7f568
fix: load full conversation history for Slack thread memory
sweetmantech 732d01d
fix: type error in conversation history mapping
sweetmantech 5911f72
refactor: extract shared Slack url_verification handler (DRY)
sweetmantech 9740d1d
refactor: move lib/slack-chat/ to lib/slack/chat/
sweetmantech 4727966
fix: prevent thread state stuck in "generating" on error
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SlackChatAdapters, SlackChatThreadState>({ | ||
| userName: "Record Label Agent", | ||
| adapters: { slack }, | ||
| state, | ||
| }); | ||
| } | ||
|
|
||
| export type SlackChatBot = ReturnType<typeof createSlackChatBot>; | ||
|
|
||
| /** | ||
| * Singleton bot instance. | ||
| * Does NOT call registerSingleton() to avoid conflicting with the coding-agent bot. | ||
| */ | ||
| export const slackChatBot = createSlackChatBot(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SlackChatThreadState | null>; | ||
| post: (message: string) => Promise<unknown>; | ||
| setState: (state: SlackChatThreadState) => Promise<void>; | ||
| }, | ||
| 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, | ||
| }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")}`, | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response | null> { | ||
| 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; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: recoupable/api
Length of output: 1507
🏁 Script executed:
Repository: recoupable/api
Length of output: 1776
🏁 Script executed:
Repository: recoupable/api
Length of output: 2844
🏁 Script executed:
Repository: recoupable/api
Length of output: 239
Await Redis connection before creating state adapter.
createSlackChatBot()is a synchronous function that callsredis.connect()without awaiting it. The error thrown in the.catch()handler (line 27) won't propagate to the caller; the function returns immediately with a state adapter backed by a potentially-unconnected Redis client. Make the functionasyncand await the connection to guarantee Redis is ready before initialization completes:Current problematic pattern
Same issue exists in
lib/coding-agent/bot.tsat lines 26–30.🤖 Prompt for AI Agents