Enhance Slack integration by adding support for direct messages (DMs) and updating event handling. Introduced new environment variables for Slack integration, updated deployment command to use the latest version, and improved message filtering logic for DMs. Refactored Slack event processing to differentiate between app mentions and direct messages, ensuring proper handling and logging. Updated webhook types to include message IM events.

This commit is contained in:
dal 2025-08-18 23:19:51 -06:00
commit 1d3146e35b
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
7 changed files with 227 additions and 116 deletions

View File

@ -43,4 +43,4 @@ jobs:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
run: | run: |
cd apps/trigger cd apps/trigger
pnpm dlx trigger.dev@v4-beta deploy --env ${{ github.ref_name == 'main' && 'production' || 'staging' }} pnpm dlx trigger.dev@latest deploy --env ${{ github.ref_name == 'main' && 'production' || 'staging' }}

View File

@ -1,6 +1,12 @@
import { chats, db, getSecretByName, slackIntegrations } from '@buster/database'; import { chats, db, getSecretByName, slackIntegrations } from '@buster/database';
import type { SlackEventsResponse } from '@buster/server-shared/slack'; import type { SlackEventsResponse } from '@buster/server-shared/slack';
import { type SlackWebhookPayload, addReaction, isEventCallback } from '@buster/slack'; import {
type SlackWebhookPayload,
addReaction,
isAppMentionEvent,
isEventCallback,
isMessageImEvent,
} from '@buster/slack';
import { tasks } from '@trigger.dev/sdk'; import { tasks } from '@trigger.dev/sdk';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import type { Context } from 'hono'; import type { Context } from 'hono';
@ -38,6 +44,7 @@ export async function findOrCreateSlackChat({
userId, userId,
slackChatAuthorization, slackChatAuthorization,
teamId, teamId,
isDM = false,
}: { }: {
threadTs: string; threadTs: string;
channelId: string; channelId: string;
@ -45,6 +52,7 @@ export async function findOrCreateSlackChat({
userId: string; userId: string;
slackChatAuthorization: 'unauthorized' | 'authorized' | 'auto_added'; slackChatAuthorization: 'unauthorized' | 'authorized' | 'auto_added';
teamId: string; teamId: string;
isDM?: boolean;
}): Promise<string> { }): Promise<string> {
// Run both queries concurrently for better performance // Run both queries concurrently for better performance
const [existingChat, slackIntegration] = await Promise.all([ const [existingChat, slackIntegration] = await Promise.all([
@ -101,11 +109,22 @@ export async function findOrCreateSlackChat({
slackChatAuthorization, slackChatAuthorization,
slackThreadTs: threadTs, slackThreadTs: threadTs,
slackChannelId: channelId, slackChannelId: channelId,
// Set workspace sharing based on Slack integration settings // DM chats are NEVER shared with workspace, regardless of settings
workspaceSharing: defaultSharingPermissions === 'shareWithWorkspace' ? 'can_view' : 'none', workspaceSharing: isDM
workspaceSharingEnabledBy: defaultSharingPermissions === 'shareWithWorkspace' ? userId : null, ? 'none'
workspaceSharingEnabledAt: : defaultSharingPermissions === 'shareWithWorkspace'
defaultSharingPermissions === 'shareWithWorkspace' ? new Date().toISOString() : null, ? 'can_view'
: 'none',
workspaceSharingEnabledBy: isDM
? null
: defaultSharingPermissions === 'shareWithWorkspace'
? userId
: null,
workspaceSharingEnabledAt: isDM
? null
: defaultSharingPermissions === 'shareWithWorkspace'
? new Date().toISOString()
: null,
}) })
.returning(); .returning();
@ -171,97 +190,104 @@ export async function handleSlackEventsEndpoint(c: Context) {
export async function eventsHandler(payload: SlackWebhookPayload): Promise<SlackEventsResponse> { export async function eventsHandler(payload: SlackWebhookPayload): Promise<SlackEventsResponse> {
try { try {
// Handle the event based on type // Handle the event based on type
if (isEventCallback(payload) && payload.event.type === 'app_mention') { if (isEventCallback(payload)) {
// Handle app_mention event
const event = payload.event; const event = payload.event;
console.info('App mentioned:', { // Check if this is an app_mention or DM event
team_id: payload.team_id, const isAppMention = isAppMentionEvent(event);
channel: event.channel, const isDM = isMessageImEvent(event);
user: event.user,
text: event.text,
event_id: payload.event_id,
});
// Authenticate the Slack user if (isAppMention || isDM) {
const authResult = await authenticateSlackUser(event.user, payload.team_id); console.info(isDM ? 'DM received:' : 'App mentioned:', {
team_id: payload.team_id,
// Check if authentication was successful channel: event.channel,
const userId = getUserIdFromAuthResult(authResult); user: event.user,
if (!userId) { text: event.text,
console.warn('Slack user authentication failed:', { event_id: payload.event_id,
slackUserId: event.user, is_dm: isDM,
teamId: payload.team_id,
reason: authResult.type === 'unauthorized' ? authResult.reason : 'Unknown',
}); });
// Throw unauthorized error
throw new Error('Unauthorized: Slack user authentication failed');
}
const organizationId = authResult.type === 'unauthorized' ? '' : authResult.organization.id; // Authenticate the Slack user
const authResult = await authenticateSlackUser(event.user, payload.team_id);
// Extract thread timestamp - if no thread_ts, this is a new thread so use ts // Check if authentication was successful
const threadTs = event.thread_ts || event.ts; const userId = getUserIdFromAuthResult(authResult);
if (!userId) {
// Add hourglass reaction immediately after authentication console.warn('Slack user authentication failed:', {
if (organizationId) { slackUserId: event.user,
try { teamId: payload.team_id,
// Fetch Slack integration to get token vault key reason: authResult.type === 'unauthorized' ? authResult.reason : 'Unknown',
const slackIntegration = await db
.select({
tokenVaultKey: slackIntegrations.tokenVaultKey,
})
.from(slackIntegrations)
.where(
and(
eq(slackIntegrations.organizationId, organizationId),
eq(slackIntegrations.teamId, payload.team_id),
eq(slackIntegrations.status, 'active')
)
)
.limit(1);
if (slackIntegration.length > 0 && slackIntegration[0]?.tokenVaultKey) {
// Get the access token from vault
const vaultSecret = await getSecretByName(slackIntegration[0].tokenVaultKey);
if (vaultSecret?.secret) {
// Add the hourglass reaction
await addReaction({
accessToken: vaultSecret.secret,
channelId: event.channel,
messageTs: event.ts,
emoji: 'hourglass_flowing_sand',
});
console.info('Added hourglass reaction to app mention', {
channel: event.channel,
messageTs: event.ts,
});
}
}
} catch (error) {
// Log but don't fail the entire process if reaction fails
console.warn('Failed to add hourglass reaction', {
error: error instanceof Error ? error.message : 'Unknown error',
channel: event.channel,
messageTs: event.ts,
}); });
// Throw unauthorized error
throw new Error('Unauthorized: Slack user authentication failed');
} }
const organizationId = authResult.type === 'unauthorized' ? '' : authResult.organization.id;
// Extract thread timestamp - if no thread_ts, this is a new thread so use ts
const threadTs = event.thread_ts || event.ts;
// Add hourglass reaction immediately after authentication
if (organizationId) {
try {
// Fetch Slack integration to get token vault key
const slackIntegration = await db
.select({
tokenVaultKey: slackIntegrations.tokenVaultKey,
})
.from(slackIntegrations)
.where(
and(
eq(slackIntegrations.organizationId, organizationId),
eq(slackIntegrations.teamId, payload.team_id),
eq(slackIntegrations.status, 'active')
)
)
.limit(1);
if (slackIntegration.length > 0 && slackIntegration[0]?.tokenVaultKey) {
// Get the access token from vault
const vaultSecret = await getSecretByName(slackIntegration[0].tokenVaultKey);
if (vaultSecret?.secret) {
// Add the hourglass reaction
await addReaction({
accessToken: vaultSecret.secret,
channelId: event.channel,
messageTs: event.ts,
emoji: 'hourglass_flowing_sand',
});
console.info('Added hourglass reaction to app mention', {
channel: event.channel,
messageTs: event.ts,
});
}
}
} catch (error) {
// Log but don't fail the entire process if reaction fails
console.warn('Failed to add hourglass reaction', {
error: error instanceof Error ? error.message : 'Unknown error',
channel: event.channel,
messageTs: event.ts,
});
}
}
// Find or create chat
const chatId = await findOrCreateSlackChat({
threadTs,
channelId: event.channel,
organizationId,
userId,
slackChatAuthorization: mapAuthResultToDbEnum(authResult.type),
teamId: payload.team_id,
isDM,
});
// Queue the task
await queueSlackAgentTask(chatId, userId);
} }
// Find or create chat
const chatId = await findOrCreateSlackChat({
threadTs,
channelId: event.channel,
organizationId,
userId,
slackChatAuthorization: mapAuthResultToDbEnum(authResult.type),
teamId: payload.team_id,
});
// Queue the task
await queueSlackAgentTask(chatId, userId);
} }
return { success: true }; return { success: true };

View File

@ -123,26 +123,57 @@ export const slackAgentTask: ReturnType<
hasParentMessage: slackMessages.length > 0, hasParentMessage: slackMessages.length > 0,
}); });
// Check if this is a DM (channel ID starts with 'D')
const isDM = chatDetails.slackChannelId?.startsWith('D') || false;
// Filter messages to only include non-bot messages after the most recent app mention // Filter messages to only include non-bot messages after the most recent app mention
if (!integration.botUserId) { if (!integration.botUserId) {
logger.error('No bot user ID found for Slack integration'); logger.error('No bot user ID found for Slack integration');
throw new Error('Slack integration is missing bot user ID'); throw new Error('Slack integration is missing bot user ID');
} }
const { filteredMessages: relevantMessages, mentionMessageTs } = let relevantMessages: typeof slackMessages;
filterMessagesAfterLastMention(slackMessages, integration.botUserId); let mentionMessageTs: string | null;
logger.log('Filtered relevant messages', { if (isDM) {
originalCount: slackMessages.length, // For DMs, we don't need to look for mentions - all messages are for the bot
filteredCount: relevantMessages.length, // Use the most recent message timestamp as the "mention" timestamp for reactions
botUserId: integration.botUserId, relevantMessages = slackMessages.filter((msg) => msg.user !== integration.botUserId);
mentionMessageTs, mentionMessageTs =
}); relevantMessages.length > 0
? relevantMessages[relevantMessages.length - 1]?.ts || null
: null;
// If no mention was found, we can't proceed logger.log('Processing DM messages', {
originalCount: slackMessages.length,
filteredCount: relevantMessages.length,
botUserId: integration.botUserId,
mentionMessageTs,
});
} else {
// For channel messages, look for @Buster mentions
const filterResult = filterMessagesAfterLastMention(slackMessages, integration.botUserId);
relevantMessages = filterResult.filteredMessages;
mentionMessageTs = filterResult.mentionMessageTs;
logger.log('Filtered channel messages', {
originalCount: slackMessages.length,
filteredCount: relevantMessages.length,
botUserId: integration.botUserId,
mentionMessageTs,
});
// If no mention was found in a channel, we can't proceed
if (!mentionMessageTs) {
logger.error('No @Buster mention found in channel thread');
throw new Error('No @Buster mention found in the channel thread');
}
}
// If no relevant timestamp found (shouldn't happen), we can't proceed
if (!mentionMessageTs) { if (!mentionMessageTs) {
logger.error('No @Buster mention found in thread'); logger.error('No message timestamp found for reactions');
throw new Error('No @Buster mention found in the thread'); throw new Error('No message timestamp found to react to');
} }
// Find all bot messages in the thread to determine if this is a follow-up // Find all bot messages in the thread to determine if this is a follow-up
@ -154,20 +185,40 @@ export const slackAgentTask: ReturnType<
// Get all messages for context, not just after the mention // Get all messages for context, not just after the mention
let messagesToInclude: typeof slackMessages; let messagesToInclude: typeof slackMessages;
if (isFollowUp) { if (isDM) {
// Find the timestamp of the last bot message before the current mention // For DMs, handle follow-ups differently
const lastBotMessageTs = Math.max( if (isFollowUp) {
...previousBotMessages.map((msg) => Number.parseFloat(msg.ts)) // Find the timestamp of the last bot message
); const lastBotMessageTs = Math.max(
const lastBotMessageIndex = slackMessages.findIndex( ...previousBotMessages.map((msg) => Number.parseFloat(msg.ts))
(msg) => Number.parseFloat(msg.ts) === lastBotMessageTs );
); const lastBotMessageIndex = slackMessages.findIndex(
(msg) => Number.parseFloat(msg.ts) === lastBotMessageTs
);
// Include messages after the last bot response // Include messages after the last bot response
messagesToInclude = slackMessages.slice(lastBotMessageIndex + 1); messagesToInclude = slackMessages.slice(lastBotMessageIndex + 1);
} else {
// Include all messages for first request
messagesToInclude = slackMessages;
}
} else { } else {
// Include all messages in the thread for first request // For channel messages, use the existing logic
messagesToInclude = slackMessages; if (isFollowUp) {
// Find the timestamp of the last bot message before the current mention
const lastBotMessageTs = Math.max(
...previousBotMessages.map((msg) => Number.parseFloat(msg.ts))
);
const lastBotMessageIndex = slackMessages.findIndex(
(msg) => Number.parseFloat(msg.ts) === lastBotMessageTs
);
// Include messages after the last bot response
messagesToInclude = slackMessages.slice(lastBotMessageIndex + 1);
} else {
// Include all messages in the thread for first request
messagesToInclude = slackMessages;
}
} }
// Filter out bot messages and format the conversation // Filter out bot messages and format the conversation
@ -175,7 +226,7 @@ export const slackAgentTask: ReturnType<
.filter((msg) => msg.user !== integration.botUserId) // Exclude bot messages .filter((msg) => msg.user !== integration.botUserId) // Exclude bot messages
.map((msg) => { .map((msg) => {
let text = msg.text || ''; let text = msg.text || '';
// Replace bot user ID mentions with @Buster // Replace bot user ID mentions with @Buster for consistency
if (integration.botUserId) { if (integration.botUserId) {
text = text.replace(new RegExp(`<@${integration.botUserId}>`, 'g'), '@Buster'); text = text.replace(new RegExp(`<@${integration.botUserId}>`, 'g'), '@Buster');
} }

File diff suppressed because one or more lines are too long

View File

@ -8,9 +8,14 @@ export {
urlVerificationSchema, urlVerificationSchema,
slackRequestHeadersSchema, slackRequestHeadersSchema,
appMentionEventSchema, appMentionEventSchema,
messageImEventSchema,
eventCallbackSchema, eventCallbackSchema,
slackEventEnvelopeSchema, slackEventEnvelopeSchema,
slackWebhookPayloadSchema, slackWebhookPayloadSchema,
isUrlVerification,
isEventCallback,
isAppMentionEvent,
isMessageImEvent,
} from './types/webhooks'; } from './types/webhooks';
// Services // Services

View File

@ -39,6 +39,23 @@ export const appMentionEventSchema = z.object({
export type AppMentionEvent = z.infer<typeof appMentionEventSchema>; export type AppMentionEvent = z.infer<typeof appMentionEventSchema>;
/**
* Message IM Event
* Sent when a user sends a direct message to the bot
*/
export const messageImEventSchema = z.object({
type: z.literal('message'),
channel_type: z.literal('im'),
user: z.string(),
text: z.string(),
ts: z.string(),
channel: z.string(),
event_ts: z.string(),
thread_ts: z.string().optional(),
});
export type MessageImEvent = z.infer<typeof messageImEventSchema>;
/** /**
* Event Callback Envelope * Event Callback Envelope
* The wrapper for all event_callback type events * The wrapper for all event_callback type events
@ -47,7 +64,7 @@ export const eventCallbackSchema = z.object({
token: z.string(), token: z.string(),
team_id: z.string(), team_id: z.string(),
api_app_id: z.string(), api_app_id: z.string(),
event: appMentionEventSchema, event: z.union([appMentionEventSchema, messageImEventSchema]),
type: z.literal('event_callback'), type: z.literal('event_callback'),
event_id: z.string(), event_id: z.string(),
event_time: z.number(), event_time: z.number(),
@ -88,3 +105,11 @@ export function isUrlVerification(payload: SlackWebhookPayload): payload is UrlV
export function isEventCallback(payload: SlackWebhookPayload): payload is EventCallback { export function isEventCallback(payload: SlackWebhookPayload): payload is EventCallback {
return payload.type === 'event_callback'; return payload.type === 'event_callback';
} }
export function isAppMentionEvent(event: EventCallback['event']): event is AppMentionEvent {
return event.type === 'app_mention';
}
export function isMessageImEvent(event: EventCallback['event']): event is MessageImEvent {
return event.type === 'message' && 'channel_type' in event && event.channel_type === 'im';
}

View File

@ -125,7 +125,11 @@
"R2_BUCKET", "R2_BUCKET",
"PLAYWRIGHT_START_COMMAND", "PLAYWRIGHT_START_COMMAND",
"DAYTONA_API_KEY" "DAYTONA_API_KEY",
"SLACK_CLIENT_ID",
"SLACK_CLIENT_SECRET",
"SLACK_SIGNING_SECRET"
], ],
"envMode": "strict" "envMode": "strict"
} }