Skip to content

Commit 16563e6

Browse files
authored
Merge pull request #3839 from Northeastern-Electric-Racing/slack-rr-integrations
Slack rr integrations
2 parents 5387479 + 585e328 commit 16563e6

11 files changed

Lines changed: 1535 additions & 2026 deletions

File tree

src/backend/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import express from 'express';
1+
import express, { Router } from 'express';
22
import cors from 'cors';
33
import cookieParser from 'cookie-parser';
44
import { getUserAndOrganization, prodHeaders, requireJwtDev, requireJwtProd } from './src/utils/auth.utils.js';
@@ -16,7 +16,8 @@ import wbsElementTemplatesRouter from './src/routes/wbs-element-templates.routes
1616
import carsRouter from './src/routes/cars.routes.js';
1717
import organizationRouter from './src/routes/organizations.routes.js';
1818
import recruitmentRouter from './src/routes/recruitment.routes.js';
19-
import { slackEvents } from './src/routes/slack.routes.js';
19+
import { getReceiver } from './src/integrations/slack.js';
20+
import './src/routes/slack.routes.js';
2021
import announcementsRouter from './src/routes/announcements.routes.js';
2122
import onboardingRouter from './src/routes/onboarding.routes.js';
2223
import popUpsRouter from './src/routes/pop-up.routes.js';
@@ -61,9 +62,15 @@ const options: cors.CorsOptions = {
6162
allowedHeaders
6263
};
6364

64-
// so we can listen to slack messages
65-
// NOTE: must be done before using json
66-
app.use('/slack', slackEvents.requestListener());
65+
// Mount Slack Bolt receiver BEFORE other middleware to handle raw body parsing
66+
// Bolt's receiver handles its own body parsing and request verification
67+
// The receiver is configured to handle requests at /slack/events
68+
// Only mount if Slack is configured (when SLACK_BOT_TOKEN is set)
69+
const receiver = getReceiver();
70+
if (receiver) {
71+
app.use(receiver.router as unknown as Router);
72+
}
73+
6774
app.get('/health', (_req, res) => {
6875
res.status(200).json({ status: 'healthy' });
6976
});

src/backend/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@prisma/client": "^6.2.1",
14-
"@slack/events-api": "^3.0.1",
15-
"@slack/web-api": "^7.8.0",
14+
"@slack/bolt": "^3.22.0",
1615
"@types/concat-stream": "^2.0.0",
1716
"@types/cookie-parser": "^1.4.3",
1817
"@types/cors": "^2.8.12",

src/backend/src/controllers/slack.controllers.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { getWorkspaceId } from '../integrations/slack.js';
1+
import { getWorkspaceId, replyToMessageInThread } from '../integrations/slack.js';
22
import OrganizationsService from '../services/organizations.services.js';
3-
import SlackServices from '../services/slack.services.js';
3+
import SlackServices, { SlackBlockActionBody, SaboSubmissionActionValue } from '../services/slack.services.js';
44

55
export default class SlackController {
66
static async processMessageEvent(event: any) {
@@ -15,4 +15,71 @@ export default class SlackController {
1515
console.log(error);
1616
}
1717
}
18+
19+
/**
20+
* Handles the Slack block action for SABO submission confirmation.
21+
* Performs action-specific validation and extracts relevant fields from the Slack action body.
22+
* If validation fails, replies to the user in Slack with an error message.
23+
*
24+
* @param body The validated Slack block action body (general structure validated in routes)
25+
*/
26+
static async handleSaboSubmittedAction(body: SlackBlockActionBody) {
27+
const { user, container, actions } = body;
28+
const channelId = container.channel_id;
29+
const threadTs = container.thread_ts || container.message_ts;
30+
const [firstAction] = actions;
31+
32+
try {
33+
// Action-specific validation: verify action_id
34+
if (firstAction.action_id !== 'sabo_submitted_confirmation') {
35+
console.error('Unexpected action_id:', firstAction.action_id);
36+
await replyToMessageInThread(
37+
channelId,
38+
threadTs,
39+
`❌ An error occurred: Unexpected action type "${firstAction.action_id}". Please contact the software team.`
40+
);
41+
return;
42+
}
43+
44+
// Action-specific validation: verify value format
45+
let actionValue: SaboSubmissionActionValue;
46+
try {
47+
actionValue = JSON.parse(firstAction.value);
48+
} catch (parseError) {
49+
const parseErrorMsg = parseError instanceof Error ? parseError.message : 'Unknown parse error';
50+
await replyToMessageInThread(
51+
channelId,
52+
threadTs,
53+
`❌ An error occurred: Invalid action data format.\n\n*Error:* ${parseErrorMsg}\n*Value:* \`${firstAction.value}\`\n\nPlease contact the software team.`
54+
);
55+
return;
56+
}
57+
58+
// Validate that reimbursementRequestId exists in the parsed value
59+
if (!actionValue.reimbursementRequestId || typeof actionValue.reimbursementRequestId !== 'string') {
60+
const actionValueStr = JSON.stringify(actionValue, null, 2);
61+
await replyToMessageInThread(
62+
channelId,
63+
threadTs,
64+
`❌ An error occurred: Missing or invalid reimbursement request ID.\n\n*Parsed value:*\n\`\`\`${actionValueStr}\`\`\`\n\nPlease contact the software team.`
65+
);
66+
return;
67+
}
68+
69+
// Extract validated fields
70+
const userSlackId = user.id;
71+
const { reimbursementRequestId } = actionValue;
72+
73+
// Pass the extracted fields to the service layer for business logic
74+
await SlackServices.handleSaboSubmittedAction(userSlackId, reimbursementRequestId);
75+
} catch (error: unknown) {
76+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
77+
await replyToMessageInThread(
78+
channelId,
79+
threadTs,
80+
`❌ An unexpected error occurred while processing your request.\n\n*Error message:* ${errorMessage}\n\nPlease contact the software team and provide them with this information.`
81+
);
82+
throw error;
83+
}
84+
}
1885
}

src/backend/src/integrations/slack.ts

Lines changed: 131 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,52 @@
1-
import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
1+
import bolt from '@slack/bolt';
2+
import type { App, ExpressReceiver } from '@slack/bolt';
23
import { HttpException } from '../utils/errors.utils.js';
34

4-
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
5+
const { App: AppClass, ExpressReceiver: ExpressReceiverClass } = bolt;
6+
7+
let receiver: ExpressReceiver | null = null;
8+
let slackApp: App | null = null;
9+
let slack: any = null; // Type will be inferred from slackApp.client (WebClient from Bolt)
10+
11+
/**
12+
* Initializes the Slack Bolt app, receiver, and client if not already initialized
13+
* Only initializes if SLACK_BOT_TOKEN is present
14+
*/
15+
const initializeSlack = () => {
16+
const { SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET } = process.env;
17+
18+
// Don't initialize if no token is configured (e.g., in tests)
19+
if (!SLACK_BOT_TOKEN) {
20+
return;
21+
}
22+
23+
// Don't re-initialize if already initialized
24+
if (slackApp) {
25+
return;
26+
}
27+
28+
// Initialize the receiver, app, and client
29+
receiver = new ExpressReceiverClass({
30+
signingSecret: SLACK_SIGNING_SECRET || '',
31+
endpoints: '/slack/events'
32+
});
33+
34+
slackApp = new AppClass({
35+
token: SLACK_BOT_TOKEN,
36+
receiver
37+
});
38+
39+
slack = slackApp.client;
40+
};
41+
42+
/**
43+
* Get the Slack WebClient (initializes Slack if needed)
44+
* @returns the Slack WebClient or null if no token is configured
45+
*/
46+
const getSlackClient = () => {
47+
initializeSlack();
48+
return slack;
49+
};
550

651
/**
752
* Send a slack message
@@ -12,14 +57,13 @@ const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
1257
* @returns the channel id and timestamp of the created slack message
1358
*/
1459
export const sendMessage = async (slackId: string, message: string, link?: string, linkButtonText?: string) => {
15-
const { SLACK_BOT_TOKEN } = process.env;
16-
if (!SLACK_BOT_TOKEN) return;
60+
const client = getSlackClient();
61+
if (!client) return;
1762

1863
const block = generateSlackTextBlock(message, link, linkButtonText);
1964

2065
try {
21-
const response: ChatPostMessageResponse = await slack.chat.postMessage({
22-
token: SLACK_BOT_TOKEN,
66+
const response = await client.chat.postMessage({
2367
channel: slackId,
2468
text: message,
2569
blocks: [block],
@@ -48,14 +92,13 @@ export const replyToMessageInThread = async (
4892
link?: string,
4993
linkButtonText?: string
5094
) => {
51-
const { SLACK_BOT_TOKEN } = process.env;
52-
if (!SLACK_BOT_TOKEN) return;
95+
const client = getSlackClient();
96+
if (!client) return;
5397

5498
const block = generateSlackTextBlock(message, link, linkButtonText);
5599

56100
try {
57-
await slack.chat.postMessage({
58-
token: SLACK_BOT_TOKEN,
101+
await client.chat.postMessage({
59102
channel: slackId,
60103
thread_ts: parentTimestamp,
61104
text: message,
@@ -82,14 +125,13 @@ export const editMessage = async (
82125
link?: string,
83126
linkButtonText?: string
84127
) => {
85-
const { SLACK_BOT_TOKEN } = process.env;
86-
if (!SLACK_BOT_TOKEN) return;
128+
const client = getSlackClient();
129+
if (!client) return;
87130

88131
const block = generateSlackTextBlock(message, link, linkButtonText);
89132

90133
try {
91-
await slack.chat.update({
92-
token: SLACK_BOT_TOKEN,
134+
await client.chat.update({
93135
channel: slackId,
94136
ts: timestamp,
95137
text: message,
@@ -108,12 +150,11 @@ export const editMessage = async (
108150
* @param emoji - the emoji to react with
109151
*/
110152
export const reactToMessage = async (slackId: string, parentTimestamp: string, emoji: string) => {
111-
const { SLACK_BOT_TOKEN } = process.env;
112-
if (!SLACK_BOT_TOKEN) return;
153+
const client = getSlackClient();
154+
if (!client) return;
113155

114156
try {
115-
await slack.reactions.add({
116-
token: SLACK_BOT_TOKEN,
157+
await client.reactions.add({
117158
channel: slackId,
118159
timestamp: parentTimestamp,
119160
name: emoji
@@ -165,12 +206,15 @@ const generateSlackTextBlock = (message: string, link?: string, linkButtonText?:
165206
* @returns an array of strings of all the slack ids of the users in the given channel
166207
*/
167208
export const getUsersInChannel = async (channelId: string) => {
209+
const client = getSlackClient();
210+
if (!client) return [];
211+
168212
let members: string[] = [];
169213
let cursor: string | undefined;
170214

171215
try {
172216
do {
173-
const response = await slack.conversations.members({
217+
const response = await client.conversations.members({
174218
channel: channelId,
175219
cursor,
176220
limit: 200
@@ -196,8 +240,11 @@ export const getUsersInChannel = async (channelId: string) => {
196240
* @returns the name of the channel or undefined if it cannot be found
197241
*/
198242
export const getChannelName = async (channelId: string) => {
243+
const client = getSlackClient();
244+
if (!client) return undefined;
245+
199246
try {
200-
const channelRes = await slack.conversations.info({ channel: channelId });
247+
const channelRes = await client.conversations.info({ channel: channelId });
201248
return channelRes.channel?.name;
202249
} catch (error) {
203250
return undefined;
@@ -210,8 +257,11 @@ export const getChannelName = async (channelId: string) => {
210257
* @returns the name of the user (real name if no display name), undefined if cannot be found
211258
*/
212259
export const getUserName = async (userId: string) => {
260+
const client = getSlackClient();
261+
if (!client) return undefined;
262+
213263
try {
214-
const userRes = await slack.users.info({ user: userId });
264+
const userRes = await client.users.info({ user: userId });
215265
return userRes.user?.profile?.display_name || userRes.user?.real_name;
216266
} catch (error) {
217267
return undefined;
@@ -223,8 +273,13 @@ export const getUserName = async (userId: string) => {
223273
* @returns the id of the workspace
224274
*/
225275
export const getWorkspaceId = async () => {
276+
const client = getSlackClient();
277+
if (!client) {
278+
throw new HttpException(500, 'Slack client not configured');
279+
}
280+
226281
try {
227-
const response = await slack.auth.test();
282+
const response = await client.auth.test();
228283
if (response.ok) {
229284
return response.team_id;
230285
}
@@ -234,4 +289,57 @@ export const getWorkspaceId = async () => {
234289
}
235290
};
236291

237-
export default slack;
292+
/**
293+
* Sends a slack ephemeral message to a user
294+
* @param channelId - the channel id of the channel to send to
295+
* @param threadTs - the timestamp of the thread to send to
296+
* @param userId - the id of the user to send to
297+
* @param text - the text of the message to send (should always be populated in case blocks can't be rendered, but if blocks render text will not)
298+
* @param blocks - the blocks of the message to send
299+
*/
300+
export async function sendEphemeralMessage(
301+
channelId: string,
302+
threadTs: string,
303+
userId: string,
304+
text: string,
305+
blocks: any[]
306+
) {
307+
const client = getSlackClient();
308+
if (!client) return;
309+
310+
try {
311+
await client.chat.postEphemeral({
312+
channel: channelId,
313+
user: userId,
314+
thread_ts: threadTs,
315+
text,
316+
blocks
317+
});
318+
} catch (err: unknown) {
319+
if (err instanceof Error) {
320+
throw new HttpException(500, `Failed to send slack notifications: ${err.message}`);
321+
}
322+
}
323+
}
324+
325+
/**
326+
* Get the Slack Bolt app instance (initializes Slack if needed)
327+
* @returns the Slack Bolt App or null if no token is configured
328+
*/
329+
export const getSlackApp = (): App | null => {
330+
initializeSlack();
331+
return slackApp;
332+
};
333+
334+
/**
335+
* Get the Express receiver instance (initializes Slack if needed)
336+
* @returns the ExpressReceiver or null if no token is configured
337+
*/
338+
export const getReceiver = (): ExpressReceiver | null => {
339+
initializeSlack();
340+
return receiver;
341+
};
342+
343+
// Export the getters for any direct usage if needed
344+
export { getSlackClient };
345+
export default getSlackClient;

0 commit comments

Comments
 (0)