Skip to content

Commit c28726f

Browse files
authored
Handle pairing sessions
1 parent 9627ccb commit c28726f

33 files changed

Lines changed: 3039 additions & 200 deletions

docs/slack-setup.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Slack App Configuration
2+
3+
This document outlines the manual Slack app configuration steps required for the HackerRank Queue bot.
4+
5+
## Setup Instructions
6+
7+
Visit the [Slack App Config](https://api.slack.com/apps/A01TFKZKPT7/general) to make changes.
8+
9+
## Shortcuts
10+
11+
### Request Pairing Session
12+
13+
This shortcut allows teammates to request a pairing session with available interviewers.
14+
15+
- **Name**: Request Pairing Session
16+
- **Short Description**: Schedule a pairing session with teammates
17+
- **Callback ID**: `shortcut-request-pairing`
18+
- **Type**: Global shortcut
19+
20+
### Queue Preferences
21+
22+
This shortcut manages interview type and format preferences for the queue.
23+
24+
- **Name**: Queue Preferences
25+
- **Short Description**: Configure your interview type and format preferences
26+
- **Callback ID**: `shortcut-queue-preferences`
27+
- **Type**: Global shortcut
28+
29+
### Removed Shortcuts
30+
31+
The **Leave Queue** shortcut (callback ID: `shortcut-leave-queue`) has been folded into the **Queue Preferences** shortcut modal and is no longer needed.
32+
33+
## Message Shortcuts
34+
35+
The following message shortcuts are available in channels:
36+
37+
- **Callback ID**: `message-shortcut-*` (used for message actions)
38+
39+
## Bot Permissions (Scopes)
40+
41+
Ensure the bot has the following OAuth scopes:
42+
43+
- `chat:write` - Post messages
44+
- `chat:write.customize` - Customize message appearance
45+
- `workflow.steps:execute` - Execute workflow steps
46+
- `commands` - Listen to slash commands
47+
- `users:read` - Read user information
48+
- `reactions:read` - Read message reactions
49+
50+
## Testing Locally
51+
52+
When testing locally with `ngrok`:
53+
54+
1. Start the bot: `pnpm dev`
55+
2. In another terminal, run: `ngrok http 3000`
56+
3. Update the Slack App's "Interactivity & Shortcuts" page with `<ngrok-url>/slack/events`
57+
4. Changes to shortcuts and permissions are reflected immediately in your test workspace
58+
59+
## Notes
60+
61+
- All manual changes to the Slack app dashboard require notification to the team
62+
- Callback IDs in code must match the Slack dashboard configuration exactly
63+
- For production deployments, coordinate manifest changes with AWS infrastructure updates

src/app.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { acceptReviewRequest } from '@bot/acceptReviewRequest';
22
import { declineReviewRequest } from '@bot/declineReviewRequest';
33
import { joinQueue } from '@bot/joinQueue';
4-
import { leaveQueue } from '@bot/leaveQueue';
54
import { requestReview } from '@bot/requestReview';
65
import { triggerCron } from '@bot/triggerCron';
76
import { requestPosition } from '@bot/requestPosition';
7+
import { requestPairingSession } from '@bot/requestPairingSession';
8+
import { acceptPairingSlot } from '@bot/acceptPairingSlot';
89
import { database } from '@database';
910
import { App, ExpressReceiver } from '@slack/bolt';
1011
import log from '@utils/log';
@@ -33,11 +34,12 @@ export async function startApp(): Promise<void> {
3334

3435
// Define shortcuts
3536
joinQueue.setup(app);
36-
leaveQueue.setup(app);
3737
requestReview.setup(app);
3838
acceptReviewRequest.setup(app);
3939
declineReviewRequest.setup(app);
4040
requestPosition.setup(app);
41+
requestPairingSession.setup(app);
42+
acceptPairingSlot.setup(app);
4143
getReviewInfo.setup(app);
4244

4345
// Schedule cron jobs
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { acceptPairingSlot } from '../acceptPairingSlot';
2+
import { pairingSessionsRepo } from '@repos/pairingSessionsRepo';
3+
import { userRepo } from '@repos/userRepo';
4+
import { buildMockApp, buildMockActionParam } from '@utils/slackMocks';
5+
import { PairingSession } from '@models/PairingSession';
6+
import { CandidateType, InterviewFormat, InterviewType } from '@bot/enums';
7+
import * as PairingRequestService from '@/services/PairingRequestService';
8+
import * as PairingSessionCloserModule from '@/services/PairingSessionCloser';
9+
import { App } from '@slack/bolt';
10+
import { chatService } from '@/services/ChatService';
11+
12+
function makeInterview(overrides: Partial<PairingSession> = {}): PairingSession {
13+
return {
14+
threadId: 'thread-1',
15+
requestorId: 'r1',
16+
candidateName: 'Dana',
17+
languages: ['Python'],
18+
format: InterviewFormat.REMOTE,
19+
candidateType: CandidateType.FULL_TIME,
20+
requestedAt: new Date(),
21+
teammatesNeededCount: 2,
22+
slots: [
23+
{
24+
id: 'slot-1',
25+
date: '2026-03-31',
26+
startTime: '13:00',
27+
endTime: '15:00',
28+
interestedTeammates: [],
29+
},
30+
],
31+
pendingTeammates: [{ userId: 'u1', expiresAt: 9999999999, messageTimestamp: 'ts-1' }],
32+
declinedTeammates: [],
33+
...overrides,
34+
};
35+
}
36+
37+
describe('acceptPairingSlot', () => {
38+
let app: App;
39+
40+
beforeEach(() => {
41+
app = buildMockApp();
42+
acceptPairingSlot.app = app;
43+
pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(makeInterview());
44+
userRepo.find = jest.fn().mockResolvedValue({
45+
id: 'u1',
46+
name: 'Alice',
47+
languages: ['Python'],
48+
lastReviewedDate: undefined,
49+
interviewTypes: [InterviewType.PAIRING],
50+
formats: [InterviewFormat.REMOTE],
51+
});
52+
jest
53+
.spyOn(PairingRequestService.pairingRequestService, 'recordSlotSelections')
54+
.mockResolvedValue(makeInterview());
55+
jest
56+
.spyOn(PairingSessionCloserModule.pairingSessionCloser, 'closeIfComplete')
57+
.mockResolvedValue(undefined);
58+
userRepo.markNowAsLastReviewedDate = jest.fn().mockResolvedValue(undefined);
59+
chatService.updateDirectMessage = jest.fn().mockResolvedValue(undefined);
60+
});
61+
62+
describe('handleSubmitSlots', () => {
63+
it('should ack', async () => {
64+
const param = buildMockActionParam();
65+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any];
66+
param.body.user = { id: 'u1', name: 'Alice' } as any;
67+
param.body.message = { ts: 'msg-ts-1' } as any;
68+
param.body.state = {
69+
values: {
70+
'pairing-dm-slots': {
71+
'pairing-slot-selections': {
72+
selected_options: [{ value: 'slot-1' }],
73+
},
74+
},
75+
},
76+
} as any;
77+
78+
await acceptPairingSlot.handleSubmitSlots(param);
79+
80+
expect(param.ack).toHaveBeenCalledTimes(1);
81+
});
82+
83+
it('should call recordSlotSelections with the selected slot IDs', async () => {
84+
const param = buildMockActionParam();
85+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any];
86+
param.body.user = { id: 'u1', name: 'Alice' } as any;
87+
param.body.message = { ts: 'msg-ts-1' } as any;
88+
param.body.state = {
89+
values: {
90+
'pairing-dm-slots': {
91+
'pairing-slot-selections': {
92+
selected_options: [{ value: 'slot-1' }],
93+
},
94+
},
95+
},
96+
} as any;
97+
98+
await acceptPairingSlot.handleSubmitSlots(param);
99+
100+
expect(PairingRequestService.pairingRequestService.recordSlotSelections).toHaveBeenCalledWith(
101+
expect.anything(),
102+
'u1',
103+
['slot-1'],
104+
[InterviewFormat.REMOTE],
105+
);
106+
});
107+
108+
it('should mark the user as last reviewed', async () => {
109+
const param = buildMockActionParam();
110+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any];
111+
param.body.user = { id: 'u1', name: 'Alice' } as any;
112+
param.body.message = { ts: 'msg-ts-1' } as any;
113+
param.body.state = {
114+
values: {
115+
'pairing-dm-slots': {
116+
'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] },
117+
},
118+
},
119+
} as any;
120+
121+
await acceptPairingSlot.handleSubmitSlots(param);
122+
123+
expect(userRepo.markNowAsLastReviewedDate).toHaveBeenCalledWith('u1');
124+
});
125+
126+
it('should call closeIfComplete after recording slot selections', async () => {
127+
const param = buildMockActionParam();
128+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any];
129+
param.body.user = { id: 'u1', name: 'Alice' } as any;
130+
param.body.message = { ts: 'msg-ts-1' } as any;
131+
param.body.state = {
132+
values: {
133+
'pairing-dm-slots': {
134+
'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] },
135+
},
136+
},
137+
} as any;
138+
139+
await acceptPairingSlot.handleSubmitSlots(param);
140+
141+
expect(PairingSessionCloserModule.pairingSessionCloser.closeIfComplete).toHaveBeenCalledWith(
142+
app,
143+
'thread-1',
144+
);
145+
});
146+
147+
it('should update the DM after recording slot selections', async () => {
148+
const param = buildMockActionParam();
149+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any];
150+
param.body.user = { id: 'u1', name: 'Alice' } as any;
151+
param.body.message = { ts: 'msg-ts-1' } as any;
152+
param.body.state = {
153+
values: {
154+
'pairing-dm-slots': {
155+
'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] },
156+
},
157+
},
158+
} as any;
159+
160+
await acceptPairingSlot.handleSubmitSlots(param);
161+
162+
expect(chatService.updateDirectMessage).toHaveBeenCalled();
163+
});
164+
165+
it('should not record slot selections if user is not pending', async () => {
166+
pairingSessionsRepo.getByThreadIdOrUndefined = jest
167+
.fn()
168+
.mockResolvedValue(makeInterview({ pendingTeammates: [] }));
169+
170+
const param = buildMockActionParam();
171+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any];
172+
param.body.user = { id: 'u1', name: 'Alice' } as any;
173+
param.body.message = { ts: 'msg-ts-1' } as any;
174+
param.body.state = {
175+
values: {
176+
'pairing-dm-slots': {
177+
'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] },
178+
},
179+
},
180+
} as any;
181+
182+
await acceptPairingSlot.handleSubmitSlots(param);
183+
184+
expect(
185+
PairingRequestService.pairingRequestService.recordSlotSelections,
186+
).not.toHaveBeenCalled();
187+
});
188+
});
189+
190+
describe('handleDeclineAll', () => {
191+
it('should ack and call declineTeammate', async () => {
192+
jest
193+
.spyOn(PairingRequestService.pairingRequestService, 'declineTeammate')
194+
.mockResolvedValue(makeInterview());
195+
const param = buildMockActionParam();
196+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-decline-all' } as any];
197+
param.body.user = { id: 'u1', name: 'Alice' } as any;
198+
199+
await acceptPairingSlot.handleDeclineAll(param);
200+
201+
expect(param.ack).toHaveBeenCalledTimes(1);
202+
expect(PairingRequestService.pairingRequestService.declineTeammate).toHaveBeenCalled();
203+
});
204+
205+
it('should not call declineTeammate if user is not pending', async () => {
206+
pairingSessionsRepo.getByThreadIdOrUndefined = jest
207+
.fn()
208+
.mockResolvedValue(makeInterview({ pendingTeammates: [] }));
209+
jest
210+
.spyOn(PairingRequestService.pairingRequestService, 'declineTeammate')
211+
.mockResolvedValue(makeInterview());
212+
213+
const param = buildMockActionParam();
214+
param.body.actions = [{ value: 'thread-1', action_id: 'pairing-decline-all' } as any];
215+
param.body.user = { id: 'u1', name: 'Alice' } as any;
216+
217+
await acceptPairingSlot.handleDeclineAll(param);
218+
219+
expect(PairingRequestService.pairingRequestService.declineTeammate).not.toHaveBeenCalled();
220+
});
221+
});
222+
});

0 commit comments

Comments
 (0)