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,16 +190,21 @@ 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
const isAppMention = isAppMentionEvent(event);
const isDM = isMessageImEvent(event);
if (isAppMention || isDM) {
console.info(isDM ? 'DM received:' : 'App mentioned:', {
team_id: payload.team_id, team_id: payload.team_id,
channel: event.channel, channel: event.channel,
user: event.user, user: event.user,
text: event.text, text: event.text,
event_id: payload.event_id, event_id: payload.event_id,
is_dm: isDM,
}); });
// Authenticate the Slack user // Authenticate the Slack user
@ -258,11 +282,13 @@ export async function eventsHandler(payload: SlackWebhookPayload): Promise<Slack
userId, userId,
slackChatAuthorization: mapAuthResultToDbEnum(authResult.type), slackChatAuthorization: mapAuthResultToDbEnum(authResult.type),
teamId: payload.team_id, teamId: payload.team_id,
isDM,
}); });
// Queue the task // Queue the task
await queueSlackAgentTask(chatId, userId); await queueSlackAgentTask(chatId, userId);
} }
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

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) {
// For DMs, we don't need to look for mentions - all messages are for the bot
// Use the most recent message timestamp as the "mention" timestamp for reactions
relevantMessages = slackMessages.filter((msg) => msg.user !== integration.botUserId);
mentionMessageTs =
relevantMessages.length > 0
? relevantMessages[relevantMessages.length - 1]?.ts || null
: null;
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, originalCount: slackMessages.length,
filteredCount: relevantMessages.length, filteredCount: relevantMessages.length,
botUserId: integration.botUserId, botUserId: integration.botUserId,
mentionMessageTs, mentionMessageTs,
}); });
// If no mention was found, we can't proceed // If no mention was found in a channel, we can't proceed
if (!mentionMessageTs) { if (!mentionMessageTs) {
logger.error('No @Buster mention found in thread'); logger.error('No @Buster mention found in channel thread');
throw new Error('No @Buster mention found in the 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) {
logger.error('No message timestamp found for reactions');
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,6 +185,25 @@ 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 (isDM) {
// For DMs, handle follow-ups differently
if (isFollowUp) {
// Find the timestamp of the last bot message
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 for first request
messagesToInclude = slackMessages;
}
} else {
// For channel messages, use the existing logic
if (isFollowUp) { if (isFollowUp) {
// Find the timestamp of the last bot message before the current mention // Find the timestamp of the last bot message before the current mention
const lastBotMessageTs = Math.max( const lastBotMessageTs = Math.max(
@ -169,13 +219,14 @@ export const slackAgentTask: ReturnType<
// Include all messages in the thread for first request // Include all messages in the thread for first request
messagesToInclude = slackMessages; messagesToInclude = slackMessages;
} }
}
// Filter out bot messages and format the conversation // Filter out bot messages and format the conversation
const formattedMessages = messagesToInclude const formattedMessages = messagesToInclude
.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"
} }