Skip to content
Merged
35 changes: 35 additions & 0 deletions app/api/chat/slack/route.ts
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) });
}
9 changes: 3 additions & 6 deletions app/api/coding-agent/[platform]/route.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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();
Expand Down
56 changes: 56 additions & 0 deletions lib/slack/chat/bot.ts
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");
});
}
Comment on lines +25 to +29
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify Redis init pattern and whether connect is awaited before state creation.
rg -nP --type=ts -C3 'redis\.status|redis\.connect\(|createIoRedisState\(' lib/slack-chat/bot.ts lib/coding-agent/bot.ts

Repository: recoupable/api

Length of output: 1507


🏁 Script executed:

#!/bin/bash
# Get the full createSlackChatBot function and see if it's async and how errors are handled
head -50 lib/slack-chat/bot.ts | cat -n

Repository: recoupable/api

Length of output: 1776


🏁 Script executed:

#!/bin/bash
# Check if there's a createIoRedisState function and how it uses the redis client
rg -nP --type=ts 'createIoRedisState|export.*createSlackChatBot' lib/slack-chat/ -A 20 | head -100

Repository: recoupable/api

Length of output: 2844


🏁 Script executed:

#!/bin/bash
# Look for imports to understand where createIoRedisState comes from
rg -nP --type=ts 'import.*createIoRedisState|from.*state' lib/slack-chat/bot.ts lib/coding-agent/bot.ts

Repository: recoupable/api

Length of output: 239


Await Redis connection before creating state adapter.

createSlackChatBot() is a synchronous function that calls redis.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 function async and await the connection to guarantee Redis is ready before initialization completes:

Current problematic pattern
  if (redis.status === "wait") {
    redis.connect().catch(() => {
      throw new Error("[slack-chat] Redis failed to connect");
    });
  }

  const state = createIoRedisState({
    client: redis,
    keyPrefix: "chat",
    logger,
  });

Same issue exists in lib/coding-agent/bot.ts at lines 26–30.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/slack-chat/bot.ts` around lines 25 - 29, The bot factory functions
(createSlackChatBot and the analogous createCodingAgentBot) call redis.connect()
without awaiting it, then immediately call createIoRedisState with the client;
make these factory functions async, await redis.connect() when redis.status ===
"wait", and propagate any connect error (e.g., throw or rethrow the caught
error) before calling createIoRedisState so the state adapter is only created
after a successful connection.


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();
129 changes: 129 additions & 0 deletions lib/slack/chat/handlers/handleSlackChatMessage.ts
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,
});
}
}
19 changes: 19 additions & 0 deletions lib/slack/chat/handlers/onNewMention.ts
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);
}
});
}
18 changes: 18 additions & 0 deletions lib/slack/chat/handlers/onSubscribedMessage.ts
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);
}
});
}
10 changes: 10 additions & 0 deletions lib/slack/chat/handlers/registerHandlers.ts
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);
10 changes: 10 additions & 0 deletions lib/slack/chat/types.ts
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;
}
20 changes: 20 additions & 0 deletions lib/slack/chat/validateEnv.ts
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")}`,
);
}
}
21 changes: 21 additions & 0 deletions lib/slack/handleUrlVerification.ts
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;
}
Loading