Skip to content

Commit d2b8b95

Browse files
Slack integration (#149)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1 parent 3ad3c3a commit d2b8b95

File tree

16 files changed

+412
-128
lines changed

16 files changed

+412
-128
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ Thumbs.db
2121
# docker
2222
**/.docker/data
2323
**/.docker/logs
24+
25+
# logs
26+
*.log

apps/roomote/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"dev": "dotenvx run -f ../../.env.development -- next dev --turbopack --port 3001",
1010
"build": "dotenvx run -f ../../.env.production -- next build",
1111
"start": "dotenvx run -f ../../.env.production -- next start --port 3001",
12-
"controller": "dotenvx run -f ../../.env.development -- tsx src/lib/controller.ts",
12+
"controller": "dotenvx run -f ../../.env.development -f ../../.env.keys -f ../../.env.local -- tsx src/lib/controller.ts",
1313
"controller:production": "dotenvx run -f ../../.env.production -- tsx src/lib/controller.ts",
1414
"worker": "dotenvx run -f ../../.env.development -- tsx src/lib/worker.ts",
1515
"worker:production": "dotenvx run -f ../../.env.production -- tsx src/lib/worker.ts",
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { eq } from 'drizzle-orm';
3+
4+
import { db, cloudJobs } from '@roo-code-cloud/db/server';
5+
6+
import { SlackNotifier } from '@/lib/slack';
7+
8+
import { createAndEnqueueJob } from '../github/handlers/utils';
9+
10+
const mentionedThreads = new Set<string>();
11+
const pendingWorkspaceSelections = new Map<string, SlackEvent>();
12+
const slack = new SlackNotifier();
13+
14+
interface SlackEvent {
15+
type: string;
16+
channel: string;
17+
user: string;
18+
text: string;
19+
ts: string;
20+
thread_ts?: string;
21+
bot_id?: string;
22+
app_id?: string;
23+
}
24+
25+
interface SlackInteractivePayload {
26+
type: string;
27+
user: {
28+
id: string;
29+
name: string;
30+
};
31+
channel: {
32+
id: string;
33+
name: string;
34+
};
35+
message: {
36+
ts: string;
37+
thread_ts?: string;
38+
};
39+
actions: Array<{
40+
action_id: string;
41+
value: string;
42+
text: {
43+
text: string;
44+
};
45+
}>;
46+
response_url: string;
47+
trigger_id: string;
48+
}
49+
50+
interface SlackWebhookBody {
51+
type: string;
52+
challenge?: string;
53+
event?: SlackEvent;
54+
team_id?: string;
55+
payload?: string; // For interactive payloads.
56+
}
57+
58+
export async function POST(request: NextRequest) {
59+
const contentType = request.headers.get('content-type');
60+
61+
if (contentType?.includes('application/x-www-form-urlencoded')) {
62+
const formData = await request.formData();
63+
const payload = formData.get('payload') as string;
64+
65+
if (payload) {
66+
const interactivePayload: SlackInteractivePayload = JSON.parse(payload);
67+
await handleInteractivePayload(interactivePayload);
68+
return NextResponse.json({ ok: true });
69+
}
70+
}
71+
72+
const body: SlackWebhookBody = JSON.parse(await request.text());
73+
74+
if (body.type === 'url_verification') {
75+
console.log('🔐 Slack URL verification challenge received');
76+
return NextResponse.json({ challenge: body.challenge });
77+
}
78+
79+
if (body.type === 'event_callback' && body.event) {
80+
const event = body.event;
81+
82+
if (event.bot_id || event.app_id) {
83+
return NextResponse.json({ ok: true });
84+
}
85+
86+
console.log('🛎️ Slack Event ->', {
87+
type: event.type,
88+
channel: event.channel,
89+
user: event.user,
90+
text: event.text?.substring(0, 100),
91+
thread_ts: event.thread_ts,
92+
ts: event.ts,
93+
});
94+
95+
switch (event.type) {
96+
case 'app_mention':
97+
await handleAppMention(event);
98+
break;
99+
100+
case 'message':
101+
await handleMessage(event);
102+
break;
103+
104+
default:
105+
console.log(`Unhandled event type: ${event.type}`);
106+
}
107+
}
108+
109+
return NextResponse.json({ ok: true });
110+
}
111+
112+
async function handleInteractivePayload(payload: SlackInteractivePayload) {
113+
console.log('🎯 Interactive payload received:', {
114+
type: payload.type,
115+
user: payload.user.id,
116+
channel: payload.channel.id,
117+
action: payload.actions[0]?.action_id,
118+
value: payload.actions[0]?.value,
119+
});
120+
121+
if (
122+
payload.actions[0]?.action_id === 'select_roo_code_cloud' ||
123+
payload.actions[0]?.action_id === 'select_roo_code'
124+
) {
125+
const workspace = payload.actions[0].value;
126+
const threadId = payload.message.thread_ts || payload.message.ts;
127+
128+
try {
129+
const originalEvent = pendingWorkspaceSelections.get(threadId);
130+
131+
if (!originalEvent) {
132+
throw new Error('Original mention event not found');
133+
}
134+
135+
pendingWorkspaceSelections.delete(threadId);
136+
137+
const { jobId, enqueuedJobId } = await createAndEnqueueJob(
138+
'slack.app.mention',
139+
{
140+
channel: originalEvent.channel,
141+
user: originalEvent.user,
142+
text: originalEvent.text,
143+
ts: originalEvent.ts,
144+
thread_ts: threadId,
145+
workspace,
146+
},
147+
);
148+
149+
console.log(
150+
`🔗 Enqueued slack.app.mention job for workspace ${workspace} (id: ${jobId}, enqueued: ${enqueuedJobId})`,
151+
);
152+
153+
await slack.postMessage({
154+
text: `✅ Enqueued job ${enqueuedJobId} for workspace ${workspace}.`,
155+
channel: payload.channel.id,
156+
thread_ts: threadId,
157+
});
158+
159+
await db
160+
.update(cloudJobs)
161+
.set({ slackThreadTs: threadId })
162+
.where(eq(cloudJobs.id, jobId));
163+
} catch (error) {
164+
console.error('❌ Failed to process workspace selection:', error);
165+
166+
await slack.postMessage({
167+
text: `❌ Sorry, something went wrong processing your request. Please try again.`,
168+
channel: payload.channel.id,
169+
thread_ts: threadId,
170+
});
171+
}
172+
}
173+
}
174+
175+
async function handleAppMention(event: SlackEvent) {
176+
console.log('🤖 Bot mentioned in channel:', event.channel);
177+
const threadId = event.thread_ts || event.ts;
178+
mentionedThreads.add(threadId);
179+
console.log(`📌 Tracking thread: ${threadId}`);
180+
181+
try {
182+
pendingWorkspaceSelections.set(threadId, event);
183+
184+
const result = await slack.postMessage({
185+
text: '👋 Which workspace would you like me to work in?',
186+
channel: event.channel,
187+
thread_ts: threadId,
188+
blocks: [
189+
{
190+
type: 'section',
191+
text: {
192+
type: 'mrkdwn',
193+
text: '👋 Which workspace would you like me to work in?',
194+
},
195+
},
196+
{
197+
type: 'actions',
198+
elements: [
199+
{
200+
type: 'button',
201+
text: { type: 'plain_text', text: 'Roo-Code-Cloud', emoji: true },
202+
action_id: 'select_roo_code_cloud',
203+
value: 'roo-code-cloud',
204+
},
205+
{
206+
type: 'button',
207+
text: { type: 'plain_text', text: 'Roo-Code', emoji: true },
208+
action_id: 'select_roo_code',
209+
value: 'roo-code',
210+
},
211+
],
212+
},
213+
],
214+
});
215+
216+
console.log(`✅ Sent workspace selection to thread: ${threadId}`, result);
217+
} catch (error) {
218+
console.error('❌ Failed to process app mention:', error);
219+
}
220+
}
221+
222+
async function handleMessage(event: SlackEvent) {
223+
if (!event.thread_ts || !mentionedThreads.has(event.thread_ts)) {
224+
return;
225+
}
226+
227+
console.log('💬 New message in tracked thread:', {
228+
thread: event.thread_ts,
229+
channel: event.channel,
230+
user: event.user,
231+
text: event.text?.substring(0, 100),
232+
});
233+
234+
// TODO: Process the thread message.
235+
// This is where you'd handle follow-up messages in the thread.
236+
}

apps/roomote/src/lib/__tests__/controller.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
// npx vitest src/lib/__tests__/controller.test.ts
22

3-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4-
53
const mockQueue = {
64
getWaiting: vi.fn(() => Promise.resolve([])),
75
getActive: vi.fn(() => Promise.resolve([])),
86
close: vi.fn(() => Promise.resolve()),
97
on: vi.fn(),
108
};
119

10+
const mockWorker = {
11+
startStalledCheckTimer: vi.fn(),
12+
};
13+
1214
const mockSpawn = vi.fn(() => ({
1315
stdout: { pipe: vi.fn() },
1416
stderr: { pipe: vi.fn() },
@@ -37,8 +39,11 @@ vi.mock('fs', () => ({
3739

3840
const mockQueueConstructor = vi.fn(() => mockQueue);
3941

42+
const mockWorkerConstructor = vi.fn(() => mockWorker);
43+
4044
vi.mock('bullmq', () => ({
4145
Queue: mockQueueConstructor,
46+
Worker: mockWorkerConstructor,
4247
}));
4348

4449
describe('WorkerController', () => {

apps/roomote/src/lib/checkStalledJobs.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.

apps/roomote/src/lib/cli.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import * as path from 'path';
2-
import * as os from 'node:os';
3-
41
import {
52
command,
63
run,
@@ -19,7 +16,6 @@ import {
1916
processPullRequestComment,
2017
} from '@/lib/jobs';
2118
import { runTask } from '@/lib/runTask';
22-
import { Logger } from '@/lib/logger';
2319

2420
const fixIssueCommand = command({
2521
name: 'fix-issue',
@@ -277,11 +273,6 @@ const promptCommand = command({
277273
jobType: 'test.prompt',
278274
jobPayload: { text },
279275
prompt: text,
280-
logger: new Logger({
281-
logDir: path.resolve(os.tmpdir(), 'logs'),
282-
filename: 'cli.log',
283-
tag: 'worker',
284-
}),
285276
notify: false,
286277
workspacePath,
287278
settings: { mode },

0 commit comments

Comments
 (0)