Slack integration (#149)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2025-07-01 06:58:58 -08:00 committed by GitHub
parent 3ad3c3a68e
commit d2b8b9551f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 412 additions and 128 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ Thumbs.db
# docker
**/.docker/data
**/.docker/logs
# logs
*.log

View file

@ -9,7 +9,7 @@
"dev": "dotenvx run -f ../../.env.development -- next dev --turbopack --port 3001",
"build": "dotenvx run -f ../../.env.production -- next build",
"start": "dotenvx run -f ../../.env.production -- next start --port 3001",
"controller": "dotenvx run -f ../../.env.development -- tsx src/lib/controller.ts",
"controller": "dotenvx run -f ../../.env.development -f ../../.env.keys -f ../../.env.local -- tsx src/lib/controller.ts",
"controller:production": "dotenvx run -f ../../.env.production -- tsx src/lib/controller.ts",
"worker": "dotenvx run -f ../../.env.development -- tsx src/lib/worker.ts",
"worker:production": "dotenvx run -f ../../.env.production -- tsx src/lib/worker.ts",

View file

@ -0,0 +1,236 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, cloudJobs } from '@roo-code-cloud/db/server';
import { SlackNotifier } from '@/lib/slack';
import { createAndEnqueueJob } from '../github/handlers/utils';
const mentionedThreads = new Set<string>();
const pendingWorkspaceSelections = new Map<string, SlackEvent>();
const slack = new SlackNotifier();
interface SlackEvent {
type: string;
channel: string;
user: string;
text: string;
ts: string;
thread_ts?: string;
bot_id?: string;
app_id?: string;
}
interface SlackInteractivePayload {
type: string;
user: {
id: string;
name: string;
};
channel: {
id: string;
name: string;
};
message: {
ts: string;
thread_ts?: string;
};
actions: Array<{
action_id: string;
value: string;
text: {
text: string;
};
}>;
response_url: string;
trigger_id: string;
}
interface SlackWebhookBody {
type: string;
challenge?: string;
event?: SlackEvent;
team_id?: string;
payload?: string; // For interactive payloads.
}
export async function POST(request: NextRequest) {
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
const payload = formData.get('payload') as string;
if (payload) {
const interactivePayload: SlackInteractivePayload = JSON.parse(payload);
await handleInteractivePayload(interactivePayload);
return NextResponse.json({ ok: true });
}
}
const body: SlackWebhookBody = JSON.parse(await request.text());
if (body.type === 'url_verification') {
console.log('🔐 Slack URL verification challenge received');
return NextResponse.json({ challenge: body.challenge });
}
if (body.type === 'event_callback' && body.event) {
const event = body.event;
if (event.bot_id || event.app_id) {
return NextResponse.json({ ok: true });
}
console.log('🛎️ Slack Event ->', {
type: event.type,
channel: event.channel,
user: event.user,
text: event.text?.substring(0, 100),
thread_ts: event.thread_ts,
ts: event.ts,
});
switch (event.type) {
case 'app_mention':
await handleAppMention(event);
break;
case 'message':
await handleMessage(event);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
return NextResponse.json({ ok: true });
}
async function handleInteractivePayload(payload: SlackInteractivePayload) {
console.log('🎯 Interactive payload received:', {
type: payload.type,
user: payload.user.id,
channel: payload.channel.id,
action: payload.actions[0]?.action_id,
value: payload.actions[0]?.value,
});
if (
payload.actions[0]?.action_id === 'select_roo_code_cloud' ||
payload.actions[0]?.action_id === 'select_roo_code'
) {
const workspace = payload.actions[0].value;
const threadId = payload.message.thread_ts || payload.message.ts;
try {
const originalEvent = pendingWorkspaceSelections.get(threadId);
if (!originalEvent) {
throw new Error('Original mention event not found');
}
pendingWorkspaceSelections.delete(threadId);
const { jobId, enqueuedJobId } = await createAndEnqueueJob(
'slack.app.mention',
{
channel: originalEvent.channel,
user: originalEvent.user,
text: originalEvent.text,
ts: originalEvent.ts,
thread_ts: threadId,
workspace,
},
);
console.log(
`🔗 Enqueued slack.app.mention job for workspace ${workspace} (id: ${jobId}, enqueued: ${enqueuedJobId})`,
);
await slack.postMessage({
text: `✅ Enqueued job ${enqueuedJobId} for workspace ${workspace}.`,
channel: payload.channel.id,
thread_ts: threadId,
});
await db
.update(cloudJobs)
.set({ slackThreadTs: threadId })
.where(eq(cloudJobs.id, jobId));
} catch (error) {
console.error('❌ Failed to process workspace selection:', error);
await slack.postMessage({
text: `❌ Sorry, something went wrong processing your request. Please try again.`,
channel: payload.channel.id,
thread_ts: threadId,
});
}
}
}
async function handleAppMention(event: SlackEvent) {
console.log('🤖 Bot mentioned in channel:', event.channel);
const threadId = event.thread_ts || event.ts;
mentionedThreads.add(threadId);
console.log(`📌 Tracking thread: ${threadId}`);
try {
pendingWorkspaceSelections.set(threadId, event);
const result = await slack.postMessage({
text: '👋 Which workspace would you like me to work in?',
channel: event.channel,
thread_ts: threadId,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: '👋 Which workspace would you like me to work in?',
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'Roo-Code-Cloud', emoji: true },
action_id: 'select_roo_code_cloud',
value: 'roo-code-cloud',
},
{
type: 'button',
text: { type: 'plain_text', text: 'Roo-Code', emoji: true },
action_id: 'select_roo_code',
value: 'roo-code',
},
],
},
],
});
console.log(`✅ Sent workspace selection to thread: ${threadId}`, result);
} catch (error) {
console.error('❌ Failed to process app mention:', error);
}
}
async function handleMessage(event: SlackEvent) {
if (!event.thread_ts || !mentionedThreads.has(event.thread_ts)) {
return;
}
console.log('💬 New message in tracked thread:', {
thread: event.thread_ts,
channel: event.channel,
user: event.user,
text: event.text?.substring(0, 100),
});
// TODO: Process the thread message.
// This is where you'd handle follow-up messages in the thread.
}

View file

@ -1,7 +1,5 @@
// npx vitest src/lib/__tests__/controller.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockQueue = {
getWaiting: vi.fn(() => Promise.resolve([])),
getActive: vi.fn(() => Promise.resolve([])),
@ -9,6 +7,10 @@ const mockQueue = {
on: vi.fn(),
};
const mockWorker = {
startStalledCheckTimer: vi.fn(),
};
const mockSpawn = vi.fn(() => ({
stdout: { pipe: vi.fn() },
stderr: { pipe: vi.fn() },
@ -37,8 +39,11 @@ vi.mock('fs', () => ({
const mockQueueConstructor = vi.fn(() => mockQueue);
const mockWorkerConstructor = vi.fn(() => mockWorker);
vi.mock('bullmq', () => ({
Queue: mockQueueConstructor,
Worker: mockWorkerConstructor,
}));
describe('WorkerController', () => {

View file

@ -1,37 +0,0 @@
// npx dotenvx run -f ../../.env.production -- tsx src/lib/checkStalledJobs.ts
import { Worker } from 'bullmq';
import { redis } from './redis';
async function checkStalledJobs() {
const worker = new Worker('roomote', undefined, {
autorun: false,
connection: redis,
});
while (true) {
console.log('startStalledCheckTimer()');
await worker.startStalledCheckTimer();
await new Promise((resolve) => setTimeout(resolve, 30_000));
}
}
process.on('SIGTERM', async () => {
console.log('SIGTERM');
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT');
process.exit(0);
});
checkStalledJobs()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -1,6 +1,3 @@
import * as path from 'path';
import * as os from 'node:os';
import {
command,
run,
@ -19,7 +16,6 @@ import {
processPullRequestComment,
} from '@/lib/jobs';
import { runTask } from '@/lib/runTask';
import { Logger } from '@/lib/logger';
const fixIssueCommand = command({
name: 'fix-issue',
@ -277,11 +273,6 @@ const promptCommand = command({
jobType: 'test.prompt',
jobPayload: { text },
prompt: text,
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'cli.log',
tag: 'worker',
}),
notify: false,
workspacePath,
settings: { mode },

View file

@ -1,4 +1,4 @@
import { Queue } from 'bullmq';
import { Queue, Worker } from 'bullmq';
import { execa } from 'execa';
import { redis } from './redis';
@ -9,28 +9,34 @@ export class WorkerController {
private readonly MAX_WORKERS = 2;
private queue: Queue;
private stalledJobsWorker: Worker;
public isRunning = false;
private pollingInterval: NodeJS.Timeout | null = null;
private activeWorkers = new Set<string>();
constructor() {
this.queue = new Queue('roomote', { connection: redis });
this.stalledJobsWorker = new Worker('roomote', undefined, {
autorun: false,
connection: redis,
});
}
async start() {
if (this.isRunning) {
console.log('Controller is already running');
return;
}
this.isRunning = true;
console.log('Worker controller started');
await this.checkAndSpawnWorker();
this.pollingInterval = setInterval(async () => {
await this.checkAndSpawnWorker();
}, this.POLL_INTERVAL_MS);
await this.stalledJobsWorker.startStalledCheckTimer();
}
async stop() {
@ -92,7 +98,9 @@ export class WorkerController {
'--network roo-code-cloud_default',
`-e APP_ENV=${process.env.APP_ENV || 'development'}`,
`-e GH_TOKEN=${process.env.GH_TOKEN}`,
`-e DOTENV_PRIVATE_KEY_DEVELOPMENT=${process.env.DOTENV_PRIVATE_KEY_DEVELOPMENT}`,
`-e DOTENV_PRIVATE_KEY_PRODUCTION=${process.env.DOTENV_PRIVATE_KEY_PRODUCTION}`,
`-e WORKSPACE_ROOT=${process.env.WORKSPACE_ROOT}`,
'-v /var/run/docker.sock:/var/run/docker.sock',
'-v /tmp/roomote:/var/log/roomote',
];

View file

@ -1,6 +1,7 @@
import { eq } from 'drizzle-orm';
import { Job } from 'bullmq';
import type { ClineMessage } from '@roo-code/types';
import {
type JobType,
type JobStatus,
@ -14,6 +15,10 @@ import {
import { fixGitHubIssue } from './jobs/fixGitHubIssue';
import { processPullRequestComment } from './jobs/processPullRequestComment';
import { processIssueComment } from './jobs/processIssueComment';
import { processSlackMention } from './jobs/processSlackMention';
import { SlackNotifier } from './slack';
const slack = new SlackNotifier();
export async function processJob<T extends JobType>({
data: { type, payload, jobId },
@ -31,20 +36,17 @@ export async function processJob<T extends JobType>({
result = await fixGitHubIssue(
payload as JobPayload<'github.issue.fix'>,
{
onTaskStarted: async (
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) => {
if (slackThreadTs) {
await updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
);
}
},
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
},
);
@ -53,20 +55,17 @@ export async function processJob<T extends JobType>({
result = await processIssueComment(
payload as JobPayload<'github.issue.comment.respond'>,
{
onTaskStarted: async (
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) => {
if (slackThreadTs) {
await updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
);
}
},
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
},
);
@ -75,34 +74,60 @@ export async function processJob<T extends JobType>({
result = await processPullRequestComment(
payload as JobPayload<'github.pr.comment.respond'>,
{
onTaskStarted: async (
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) => {
if (slackThreadTs) {
await updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
);
}
},
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
},
);
break;
case 'slack.app.mention': {
const jobPayload = payload as JobPayload<'slack.app.mention'>;
const { channel, thread_ts } = jobPayload;
result = await processSlackMention(jobPayload, {
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
onTaskMessage: async (message: ClineMessage) => {
console.log(`onTaskMessage (${channel}, ${thread_ts}) ->`, message);
if (
(message.say === 'text' || message.say === 'completion_result') &&
message.text &&
thread_ts
) {
slack.postMessage({ text: message.text, channel, thread_ts });
}
},
});
break;
}
default:
throw new Error(`Unknown job type: ${type}`);
}
await updateJobStatus(jobId, 'completed', result);
console.log(
`[${job.name} | ${job.id}] Job ${jobId} completed successfully`,
);
console.log(`[${job.name} | ${job.id}] ✅`);
} catch (error) {
console.error(`[${job.name} | ${job.id}] Job ${jobId} failed:`, error);
console.error(`[${job.name} | ${job.id}] ❌`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
await updateJobStatus(jobId, 'failed', undefined, errorMessage);
throw error; // Re-throw to mark job as failed in BullMQ.
@ -114,7 +139,7 @@ async function updateJobStatus(
status: JobStatus,
result?: unknown,
error?: string,
slackThreadTs?: string,
slackThreadTs?: string | null,
) {
const values: UpdateCloudJob = { status };

View file

@ -1,10 +1,6 @@
import * as path from 'path';
import * as os from 'node:os';
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { Logger } from '../logger';
const jobType: JobType = 'github.issue.fix';
@ -31,11 +27,6 @@ Issue #${jobPayload.issue}
jobType,
jobPayload,
prompt,
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
}),
callbacks,
settings: {
mode: 'issue-fixer',

View file

@ -1,3 +1,4 @@
export { fixGitHubIssue } from './fixGitHubIssue';
export { processIssueComment } from './processIssueComment';
export { processPullRequestComment } from './processPullRequestComment';
export { processSlackMention } from './processSlackMention';

View file

@ -1,10 +1,6 @@
import * as path from 'path';
import * as os from 'node:os';
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { Logger } from '../logger';
const jobType: JobType = 'github.issue.comment.respond';
@ -58,11 +54,6 @@ gh api repos/${jobPayload.repo}/issues/${jobPayload.issueNumber}/comments --meth
jobType,
jobPayload,
prompt,
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
}),
callbacks,
});

View file

@ -1,10 +1,6 @@
import * as path from 'path';
import * as os from 'node:os';
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { Logger } from '../logger';
const jobType: JobType = 'github.pr.comment.respond';
@ -72,11 +68,6 @@ Do not create a new pull request - work directly on the existing PR branch.
jobType,
jobPayload,
prompt,
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
}),
callbacks,
});

View file

@ -0,0 +1,33 @@
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
const jobType: JobType = 'slack.app.mention';
type ProcessSlackMentionJobPayload = JobPayload<'slack.app.mention'>;
export async function processSlackMention(
jobPayload: ProcessSlackMentionJobPayload,
callbacks?: RunTaskCallbacks,
): Promise<{
channel: string;
user: string;
result: unknown;
}> {
const { text: prompt, channel, user } = jobPayload;
// Add your workspace root to .env.local to override the default
// workspace root that our containers use.
const workspaceRoot = process.env.WORKSPACE_ROOT || '/roo/repos';
const result = await runTask({
jobType,
jobPayload,
prompt,
callbacks,
notify: false,
workspacePath: `${workspaceRoot}/${jobPayload.workspace}`,
});
return { channel, user, result };
}

View file

@ -8,6 +8,7 @@ import { execa } from 'execa';
import {
type RooCodeSettings,
type ClineMessage,
TaskCommandName,
RooCodeEventName,
IpcMessageType,
@ -35,6 +36,7 @@ export type RunTaskCallbacks = {
slackThreadTs: string | null | undefined,
rooTaskId: string,
) => Promise<void>;
onTaskMessage?: (message: ClineMessage) => Promise<void>;
onTaskAborted?: (slackThreadTs: string | null | undefined) => Promise<void>;
onTaskCompleted?: (
slackThreadTs: string | null | undefined,
@ -50,7 +52,7 @@ type RunTaskOptions<T extends JobType> = {
jobType: T;
jobPayload: JobPayload<T>;
prompt: string;
logger: Logger;
logger?: Logger;
callbacks?: RunTaskCallbacks;
notify?: boolean;
workspacePath?: string;
@ -80,6 +82,14 @@ export const runTask = async <T extends JobType>({
? `ROO_CODE_IPC_SOCKET_PATH=${ipcSocketPath} xvfb-run --auto-servernum --server-num=1 code --wait --log trace --disable-workspace-trust --disable-gpu --disable-lcd-text --no-sandbox --user-data-dir /roo/.vscode --password-store="basic" -n ${workspacePath}`
: `ROO_CODE_IPC_SOCKET_PATH=${ipcSocketPath} code --disable-workspace-trust -n ${workspacePath}`;
if (!logger) {
logger = new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
});
}
logger.info(codeCommand);
const subprocess = execa({
@ -152,6 +162,14 @@ export const runTask = async <T extends JobType>({
logger.info(`${eventName} ->`, payload);
}
if (
eventName === RooCodeEventName.Message &&
payload[0].message.partial !== true &&
callbacks?.onTaskMessage
) {
await callbacks.onTaskMessage(payload[0].message);
}
if (eventName === RooCodeEventName.TaskStarted) {
taskStartedAt = Date.now();
rooTaskId = payload[0];
@ -252,11 +270,14 @@ export const runTask = async <T extends JobType>({
if (rooTaskId && !isClientDisconnected) {
logger.info('cancelling task');
client.sendCommand({
commandName: TaskCommandName.CancelTask,
data: rooTaskId,
});
await new Promise((resolve) => setTimeout(resolve, 5_000)); // Allow some time for the task to cancel.
// Allow some time for the task to cancel.
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
taskFinishedAt = Date.now();
@ -282,11 +303,14 @@ export const runTask = async <T extends JobType>({
if (rooTaskId && !isClientDisconnected) {
logger.info('closing task');
client.sendCommand({
commandName: TaskCommandName.CloseTask,
data: rooTaskId,
});
await new Promise((resolve) => setTimeout(resolve, 2_000)); // Allow some time for the window to close.
// Allow some time for the window to close.
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
if (!isClientDisconnected) {

View file

@ -27,7 +27,7 @@ export class SlackNotifier {
this.token = token;
}
private async postMessage(message: SlackMessage): Promise<string | null> {
public async postMessage(message: SlackMessage): Promise<string | null> {
try {
const messageWithChannel = {
...message,
@ -120,6 +120,21 @@ export class SlackNotifier {
],
});
}
case 'slack.app.mention': {
const payload = jobPayload as JobPayload<'slack.app.mention'>;
return await this.postMessage({
text: `🚀 Task Started`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `🚀 *Task Started*\nProcessing your Slack mention from <#${payload.channel}>\n*Message:* ${payload.text.slice(0, 100)}${payload.text.length > 100 ? '...' : ''}`,
},
},
],
});
}
default:
throw new Error(`Unknown job type: ${jobType}`);
}
@ -159,8 +174,4 @@ export class SlackNotifier {
thread_ts: threadTs,
});
}
public async sendMessage(message: SlackMessage): Promise<string | null> {
return await this.postMessage(message);
}
}

View file

@ -122,6 +122,17 @@ export const createJobSchema = z.discriminatedUnion('type', [
commentUrl: z.string(),
}),
}),
z.object({
type: z.literal('slack.app.mention'),
payload: z.object({
channel: z.string(),
user: z.string(),
text: z.string(),
ts: z.string(),
thread_ts: z.string().optional(),
workspace: z.string(),
}),
}),
z.object({
type: z.literal('test.prompt'),
payload: z.object({