mirror of https://github.com/buster-so/buster.git
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:
commit
1d3146e35b
|
@ -43,4 +43,4 @@ jobs:
|
|||
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
|
||||
run: |
|
||||
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' }}
|
|
@ -1,6 +1,12 @@
|
|||
import { chats, db, getSecretByName, slackIntegrations } from '@buster/database';
|
||||
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 { and, eq } from 'drizzle-orm';
|
||||
import type { Context } from 'hono';
|
||||
|
@ -38,6 +44,7 @@ export async function findOrCreateSlackChat({
|
|||
userId,
|
||||
slackChatAuthorization,
|
||||
teamId,
|
||||
isDM = false,
|
||||
}: {
|
||||
threadTs: string;
|
||||
channelId: string;
|
||||
|
@ -45,6 +52,7 @@ export async function findOrCreateSlackChat({
|
|||
userId: string;
|
||||
slackChatAuthorization: 'unauthorized' | 'authorized' | 'auto_added';
|
||||
teamId: string;
|
||||
isDM?: boolean;
|
||||
}): Promise<string> {
|
||||
// Run both queries concurrently for better performance
|
||||
const [existingChat, slackIntegration] = await Promise.all([
|
||||
|
@ -101,11 +109,22 @@ export async function findOrCreateSlackChat({
|
|||
slackChatAuthorization,
|
||||
slackThreadTs: threadTs,
|
||||
slackChannelId: channelId,
|
||||
// Set workspace sharing based on Slack integration settings
|
||||
workspaceSharing: defaultSharingPermissions === 'shareWithWorkspace' ? 'can_view' : 'none',
|
||||
workspaceSharingEnabledBy: defaultSharingPermissions === 'shareWithWorkspace' ? userId : null,
|
||||
workspaceSharingEnabledAt:
|
||||
defaultSharingPermissions === 'shareWithWorkspace' ? new Date().toISOString() : null,
|
||||
// DM chats are NEVER shared with workspace, regardless of settings
|
||||
workspaceSharing: isDM
|
||||
? 'none'
|
||||
: defaultSharingPermissions === 'shareWithWorkspace'
|
||||
? 'can_view'
|
||||
: 'none',
|
||||
workspaceSharingEnabledBy: isDM
|
||||
? null
|
||||
: defaultSharingPermissions === 'shareWithWorkspace'
|
||||
? userId
|
||||
: null,
|
||||
workspaceSharingEnabledAt: isDM
|
||||
? null
|
||||
: defaultSharingPermissions === 'shareWithWorkspace'
|
||||
? new Date().toISOString()
|
||||
: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
@ -171,97 +190,104 @@ export async function handleSlackEventsEndpoint(c: Context) {
|
|||
export async function eventsHandler(payload: SlackWebhookPayload): Promise<SlackEventsResponse> {
|
||||
try {
|
||||
// Handle the event based on type
|
||||
if (isEventCallback(payload) && payload.event.type === 'app_mention') {
|
||||
// Handle app_mention event
|
||||
if (isEventCallback(payload)) {
|
||||
const event = payload.event;
|
||||
|
||||
console.info('App mentioned:', {
|
||||
team_id: payload.team_id,
|
||||
channel: event.channel,
|
||||
user: event.user,
|
||||
text: event.text,
|
||||
event_id: payload.event_id,
|
||||
});
|
||||
// Check if this is an app_mention or DM event
|
||||
const isAppMention = isAppMentionEvent(event);
|
||||
const isDM = isMessageImEvent(event);
|
||||
|
||||
// Authenticate the Slack user
|
||||
const authResult = await authenticateSlackUser(event.user, payload.team_id);
|
||||
|
||||
// Check if authentication was successful
|
||||
const userId = getUserIdFromAuthResult(authResult);
|
||||
if (!userId) {
|
||||
console.warn('Slack user authentication failed:', {
|
||||
slackUserId: event.user,
|
||||
teamId: payload.team_id,
|
||||
reason: authResult.type === 'unauthorized' ? authResult.reason : 'Unknown',
|
||||
if (isAppMention || isDM) {
|
||||
console.info(isDM ? 'DM received:' : 'App mentioned:', {
|
||||
team_id: payload.team_id,
|
||||
channel: event.channel,
|
||||
user: event.user,
|
||||
text: event.text,
|
||||
event_id: payload.event_id,
|
||||
is_dm: isDM,
|
||||
});
|
||||
// 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
|
||||
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,
|
||||
// Check if authentication was successful
|
||||
const userId = getUserIdFromAuthResult(authResult);
|
||||
if (!userId) {
|
||||
console.warn('Slack user authentication failed:', {
|
||||
slackUserId: event.user,
|
||||
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;
|
||||
|
||||
// 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 };
|
||||
|
|
|
@ -123,26 +123,57 @@ export const slackAgentTask: ReturnType<
|
|||
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
|
||||
if (!integration.botUserId) {
|
||||
logger.error('No bot user ID found for Slack integration');
|
||||
throw new Error('Slack integration is missing bot user ID');
|
||||
}
|
||||
|
||||
const { filteredMessages: relevantMessages, mentionMessageTs } =
|
||||
filterMessagesAfterLastMention(slackMessages, integration.botUserId);
|
||||
let relevantMessages: typeof slackMessages;
|
||||
let mentionMessageTs: string | null;
|
||||
|
||||
logger.log('Filtered relevant messages', {
|
||||
originalCount: slackMessages.length,
|
||||
filteredCount: relevantMessages.length,
|
||||
botUserId: integration.botUserId,
|
||||
mentionMessageTs,
|
||||
});
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
logger.error('No @Buster mention found in thread');
|
||||
throw new Error('No @Buster mention found in the thread');
|
||||
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
|
||||
|
@ -154,20 +185,40 @@ export const slackAgentTask: ReturnType<
|
|||
// Get all messages for context, not just after the mention
|
||||
let messagesToInclude: typeof 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
|
||||
);
|
||||
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);
|
||||
// Include messages after the last bot response
|
||||
messagesToInclude = slackMessages.slice(lastBotMessageIndex + 1);
|
||||
} else {
|
||||
// Include all messages for first request
|
||||
messagesToInclude = slackMessages;
|
||||
}
|
||||
} else {
|
||||
// Include all messages in the thread for first request
|
||||
messagesToInclude = slackMessages;
|
||||
// For channel messages, use the existing logic
|
||||
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
|
||||
|
@ -175,7 +226,7 @@ export const slackAgentTask: ReturnType<
|
|||
.filter((msg) => msg.user !== integration.botUserId) // Exclude bot messages
|
||||
.map((msg) => {
|
||||
let text = msg.text || '';
|
||||
// Replace bot user ID mentions with @Buster
|
||||
// Replace bot user ID mentions with @Buster for consistency
|
||||
if (integration.botUserId) {
|
||||
text = text.replace(new RegExp(`<@${integration.botUserId}>`, 'g'), '@Buster');
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -8,9 +8,14 @@ export {
|
|||
urlVerificationSchema,
|
||||
slackRequestHeadersSchema,
|
||||
appMentionEventSchema,
|
||||
messageImEventSchema,
|
||||
eventCallbackSchema,
|
||||
slackEventEnvelopeSchema,
|
||||
slackWebhookPayloadSchema,
|
||||
isUrlVerification,
|
||||
isEventCallback,
|
||||
isAppMentionEvent,
|
||||
isMessageImEvent,
|
||||
} from './types/webhooks';
|
||||
|
||||
// Services
|
||||
|
|
|
@ -39,6 +39,23 @@ export const appMentionEventSchema = z.object({
|
|||
|
||||
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
|
||||
* The wrapper for all event_callback type events
|
||||
|
@ -47,7 +64,7 @@ export const eventCallbackSchema = z.object({
|
|||
token: z.string(),
|
||||
team_id: z.string(),
|
||||
api_app_id: z.string(),
|
||||
event: appMentionEventSchema,
|
||||
event: z.union([appMentionEventSchema, messageImEventSchema]),
|
||||
type: z.literal('event_callback'),
|
||||
event_id: z.string(),
|
||||
event_time: z.number(),
|
||||
|
@ -88,3 +105,11 @@ export function isUrlVerification(payload: SlackWebhookPayload): payload is UrlV
|
|||
export function isEventCallback(payload: SlackWebhookPayload): payload is EventCallback {
|
||||
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';
|
||||
}
|
||||
|
|
|
@ -125,7 +125,11 @@
|
|||
"R2_BUCKET",
|
||||
|
||||
"PLAYWRIGHT_START_COMMAND",
|
||||
"DAYTONA_API_KEY"
|
||||
"DAYTONA_API_KEY",
|
||||
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
"SLACK_SIGNING_SECRET"
|
||||
],
|
||||
"envMode": "strict"
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue