Skip to content
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

Add basic AI slack bot #29

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dbagent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@fluentui/react-icons": "^2.0.274",
"@internal/components": "workspace:*",
"@internal/theme": "workspace:*",
"@slack/web-api": "^7.8.0",
"@tailwindcss/postcss": "^4.0.9",
"@tanstack/react-query": "^5.67.1",
"@vercel/functions": "^2.0.0",
Expand Down
14 changes: 3 additions & 11 deletions apps/dbagent/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,16 @@ export async function POST(req: Request) {
}
const targetClient = await getTargetDbConnection(connection.connectionString);
try {
const context = chatSystemPrompt;

console.log(context);

const modelInstance = getModelInstance(model);

const result = streamText({
model: modelInstance,
model: getModelInstance(model),
messages,
system: context,
system: chatSystemPrompt,
tools: await getTools(connection, targetClient),
maxSteps: 20,
toolCallStreaming: true
});

return result.toDataStreamResponse({
getErrorMessage: errorHandler
});
return result.toDataStreamResponse({ getErrorMessage: errorHandler });
} finally {
await targetClient.end();
}
Expand Down
51 changes: 51 additions & 0 deletions apps/dbagent/src/app/api/priv/slack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { SlackEvent } from '@slack/web-api';
import { waitUntil } from '@vercel/functions';
import { handleNewAppMention } from '~/lib/slack/handle-app-mentions';
import { assistantThreadMessage, handleNewAssistantMessage } from '~/lib/slack/handle-messages';
import { getBotId, verifyRequest } from '~/lib/slack/utils';

export const runtime = 'nodejs';
export const maxDuration = 30;

export async function POST(request: Request) {
const rawBody = await request.text();
const payload = JSON.parse(rawBody);
const requestType = payload.type as 'url_verification' | 'event_callback';

// See https://api.slack.com/events/url_verification
if (requestType === 'url_verification') {
return new Response(payload.challenge, { status: 200 });
}

await verifyRequest({ requestType, request, rawBody });

try {
const botUserId = await getBotId();

const event = payload.event as SlackEvent;

if (event.type === 'app_mention') {
waitUntil(handleNewAppMention(event, botUserId));
}

if (event.type === 'assistant_thread_started') {
waitUntil(assistantThreadMessage(event));
}

if (
event.type === 'message' &&
!event.subtype &&
event.channel_type === 'im' &&
!event.bot_id &&
!event.bot_profile &&
event.bot_id !== botUserId
) {
waitUntil(handleNewAssistantMessage(event, botUserId));
}

return new Response('Success!', { status: 200 });
} catch (error) {
console.error('Error generating response', error);
return new Response('Error generating response', { status: 500 });
}
}
6 changes: 6 additions & 0 deletions apps/dbagent/src/lib/env/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const schema = z.object({
AUTH_OPENID_SECRET: z.string().optional(),
AUTH_OPENID_ISSUER: z.string().optional(),

// Slack OAuth settings
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_SIGNING_SECRET: z.string().optional(),
SLACK_BOT_TOKEN: z.string().optional(),

// LLM API credentials
OPENAI_API_KEY: z.string(),
DEEPSEEK_API_KEY: z.string().optional(),
Expand Down
23 changes: 23 additions & 0 deletions apps/dbagent/src/lib/slack/generate-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { CoreMessage, generateText } from 'ai';
import { chatSystemPrompt, getModelInstance, getTools } from '../ai/aidba';
import { getConnection } from '../db/connections';
import { getTargetDbConnection } from '../targetdb/db';

export const generateResponse = async (messages: CoreMessage[]) => {
const connection = await getConnection(connectionId);
if (!connection) {
throw new Error('Connection not found');
}
const targetClient = await getTargetDbConnection(connection.connectionString);

const { text } = await generateText({
model: getModelInstance('openai-gpt-4o'),
messages,
system: chatSystemPrompt,
tools: await getTools(connection, targetClient),
maxSteps: 20
});

// Convert markdown to Slack mrkdwn format
return text.replace(/\[(.*?)\]\((.*?)\)/g, '<$2|$1>').replace(/\*\*/g, '*');
};
42 changes: 42 additions & 0 deletions apps/dbagent/src/lib/slack/handle-app-mentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AppMentionEvent } from '@slack/web-api';
import { generateResponse } from './generate-response';
import { client, getThread } from './utils';

const updateStatusUtil = async (initialStatus: string, event: AppMentionEvent) => {
const initialMessage = await client.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
text: initialStatus
});

if (!initialMessage || !initialMessage.ts) throw new Error('Failed to post initial message');

const updateMessage = async (status: string) => {
await client.chat.update({
channel: event.channel,
ts: initialMessage.ts as string,
text: status
});
};
return updateMessage;
};

export async function handleNewAppMention(event: AppMentionEvent, botUserId: string) {
console.log('Handling app mention');
if (event.bot_id || event.bot_id === botUserId || event.bot_profile) {
console.log('Skipping app mention');
return;
}

const { thread_ts, channel } = event;
const updateMessage = await updateStatusUtil('is thinking...', event);

if (thread_ts) {
const messages = await getThread(channel, thread_ts, botUserId);
const result = await generateResponse(messages);
updateMessage(result);
} else {
const result = await generateResponse([{ role: 'user', content: event.text }]);
updateMessage(result);
}
}
62 changes: 62 additions & 0 deletions apps/dbagent/src/lib/slack/handle-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AssistantThreadStartedEvent, GenericMessageEvent } from '@slack/web-api';
import { generateResponse } from './generate-response';
import { client, getThread, updateStatusUtil } from './utils';

export async function assistantThreadMessage(event: AssistantThreadStartedEvent) {
const { channel_id, thread_ts } = event.assistant_thread;
console.log(`Thread started: ${channel_id} ${thread_ts}`);

await client.chat.postMessage({
channel: channel_id,
thread_ts: thread_ts,
text: "Hello! I'm your AI database expert. I can help you manage and optimize your PostgreSQL database. Which project would you like to work with?"
});

await client.assistant.threads.setSuggestedPrompts({
channel_id: channel_id,
thread_ts: thread_ts,
prompts: [
{
title: 'List my projects',
message: 'Show me my available projects'
},
{
title: 'Check database health',
message: 'Check the health of my database'
},
{
title: 'Optimize queries',
message: 'Help me optimize my slow queries'
}
]
});
}

export async function handleNewAssistantMessage(event: GenericMessageEvent, botUserId: string) {
if (event.bot_id || event.bot_id === botUserId || event.bot_profile || !event.thread_ts) return;

const { thread_ts, channel } = event;
const updateStatus = updateStatusUtil(channel, thread_ts);
updateStatus('is thinking...');

const messages = await getThread(channel, thread_ts, botUserId);
const result = await generateResponse(messages);

await client.chat.postMessage({
channel: channel,
thread_ts: thread_ts,
text: result,
unfurl_links: false,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: result
}
}
]
});

updateStatus('');
}
100 changes: 100 additions & 0 deletions apps/dbagent/src/lib/slack/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { WebClient } from '@slack/web-api';
import { CoreMessage } from 'ai';
import crypto from 'crypto';

const signingSecret = process.env.SLACK_SIGNING_SECRET!;

export const client = new WebClient(process.env.SLACK_BOT_TOKEN);

// See https://api.slack.com/authentication/verifying-requests-from-slack
export async function isValidSlackRequest({ request, rawBody }: { request: Request; rawBody: string }) {
// console.log('Validating Slack request')
const timestamp = request.headers.get('X-Slack-Request-Timestamp');
const slackSignature = request.headers.get('X-Slack-Signature');
// console.log(timestamp, slackSignature)

if (!timestamp || !slackSignature) {
console.log('Missing timestamp or signature');
return false;
}

// Prevent replay attacks on the order of 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 60 * 5) {
console.log('Timestamp out of range');
return false;
}

const base = `v0:${timestamp}:${rawBody}`;
const hmac = crypto.createHmac('sha256', signingSecret).update(base).digest('hex');
const computedSignature = `v0=${hmac}`;

// Prevent timing attacks
return crypto.timingSafeEqual(Buffer.from(computedSignature), Buffer.from(slackSignature));
}

export const verifyRequest = async ({
requestType,
request,
rawBody
}: {
requestType: string;
request: Request;
rawBody: string;
}) => {
const validRequest = await isValidSlackRequest({ request, rawBody });
if (!validRequest || requestType !== 'event_callback') {
return new Response('Invalid request', { status: 400 });
}
};

export const updateStatusUtil = (channel: string, thread_ts: string) => {
return async (status: string) => {
await client.assistant.threads.setStatus({
channel_id: channel,
thread_ts: thread_ts,
status: status
});
};
};

export async function getThread(channel_id: string, thread_ts: string, botUserId: string): Promise<CoreMessage[]> {
const { messages } = await client.conversations.replies({
channel: channel_id,
ts: thread_ts,
limit: 50
});

// Ensure we have messages

if (!messages) throw new Error('No messages found in thread');

const result = messages
.map((message) => {
const isBot = !!message.bot_id;
if (!message.text) return null;

// For app mentions, remove the mention prefix
// For IM messages, keep the full text
let content = message.text;
if (!isBot && content.includes(`<@${botUserId}>`)) {
content = content.replace(`<@${botUserId}> `, '');
}

return {
role: isBot ? 'assistant' : 'user',
content: content
} as CoreMessage;
})
.filter((msg): msg is CoreMessage => msg !== null);

return result;
}

export const getBotId = async () => {
const { user_id: botUserId } = await client.auth.test();

if (!botUserId) {
throw new Error('botUserId is undefined');
}
return botUserId;
};
Loading
Loading