diff --git a/docs/slack-bot.md b/docs/slack-bot.md index 43e5d54..bd308cf 100644 --- a/docs/slack-bot.md +++ b/docs/slack-bot.md @@ -21,6 +21,8 @@ Parallax can connect to a Slack workspace to post plan notifications, accept app Task IDs appear in plan-ready messages and in the dashboard. +All slash-command replies are posted **in-channel** (`response_type: in_channel`), so they are visible to everyone in the channel rather than only the person who ran the command. + ## How it works Parallax uses Bolt **Socket Mode**: it opens an outbound WebSocket to Slack's API servers. There is no inbound HTTP server to expose, no public URL to configure, and no need to punch through a firewall or NAT. It works on localhost and on air-gapped machines as long as they have outbound HTTPS/WSS access. diff --git a/packages/slack/src/handlers/commands.ts b/packages/slack/src/handlers/commands.ts index d9d98d7..d55d4ef 100644 --- a/packages/slack/src/handlers/commands.ts +++ b/packages/slack/src/handlers/commands.ts @@ -5,14 +5,16 @@ export function registerSlashCommands(app: App, apiBaseUrl: string): void { await ack() const [subcommand, taskId] = command.text.trim().split(/\s+/) + const reply = (text: string) => respond({ response_type: 'in_channel', text }) + if (!subcommand) { - await respond('Usage: `/parallax [taskId]`') + await reply('Usage: `/parallax [taskId]`') return } const requiresTaskId = subcommand !== 'status' if (requiresTaskId && !taskId) { - await respond(`Usage: \`/parallax ${subcommand} \``) + await reply(`Usage: \`/parallax ${subcommand} \``) return } @@ -21,30 +23,30 @@ export function registerSlashCommands(app: App, apiBaseUrl: string): void { case 'retry': { const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/retry`, { method: 'POST' }) if (!res.ok) { - await respond(`Failed to retry task \`${taskId}\`: ${res.statusText}`) + await reply(`Failed to retry task \`${taskId}\`: ${res.statusText}`) return } - await respond(`🔁 Retry triggered for task \`${taskId}\`.`) + await reply(`🔁 Retry triggered for task \`${taskId}\`.`) break } case 'cancel': { const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/cancel`, { method: 'POST' }) if (!res.ok) { - await respond(`Failed to cancel task \`${taskId}\`: ${res.statusText}`) + await reply(`Failed to cancel task \`${taskId}\`: ${res.statusText}`) return } - await respond(`🚫 Cancel requested for task \`${taskId}\`.`) + await reply(`🚫 Cancel requested for task \`${taskId}\`.`) break } case 'status': { const res = await fetch(`${apiBaseUrl}/runtime/health`) if (!res.ok) { - await respond('⚠️ Could not reach Parallax orchestrator.') + await reply('⚠️ Could not reach Parallax orchestrator.') return } const data = (await res.json()) as { activeTasks: number } const taskLabel = data.activeTasks === 1 ? '1 task' : `${data.activeTasks} tasks` - await respond( + await reply( `✅ Parallax is running.\nActive tasks: ${data.activeTasks === 0 ? 'none' : taskLabel} processing.` ) break @@ -52,19 +54,19 @@ export function registerSlashCommands(app: App, apiBaseUrl: string): void { case 'pr-review': { const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/pr-review`, { method: 'POST' }) if (!res.ok) { - await respond(`Failed to trigger PR review for task \`${taskId}\`: ${res.statusText}`) + await reply(`Failed to trigger PR review for task \`${taskId}\`: ${res.statusText}`) return } - await respond(`🔍 PR review triggered for task \`${taskId}\`.`) + await reply(`🔍 PR review triggered for task \`${taskId}\`.`) break } default: - await respond( + await reply( `Unknown subcommand \`${subcommand}\`. Use: retry | cancel | status | pr-review` ) } } catch (err: any) { - await respond(`Error: ${err.message}`) + await reply(`Error: ${err.message}`) } }) } diff --git a/packages/slack/src/handlers/plan-approval.ts b/packages/slack/src/handlers/plan-approval.ts index 1b2ecd3..e10d8b8 100644 --- a/packages/slack/src/handlers/plan-approval.ts +++ b/packages/slack/src/handlers/plan-approval.ts @@ -8,6 +8,7 @@ export function registerPlanApprovalHandlers(app: App, apiBaseUrl: string): void const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/approve`, { method: 'POST' }) if (!res.ok) { await respond({ + response_type: 'in_channel', text: `Failed to approve plan for task ${taskId}: ${res.statusText}`, replace_original: false, }) @@ -27,7 +28,11 @@ export function registerPlanApprovalHandlers(app: App, apiBaseUrl: string): void ], }) } catch (err: any) { - await respond({ text: `Error approving plan: ${err.message}`, replace_original: false }) + await respond({ + response_type: 'in_channel', + text: `Error approving plan: ${err.message}`, + replace_original: false, + }) } }) @@ -38,6 +43,7 @@ export function registerPlanApprovalHandlers(app: App, apiBaseUrl: string): void const res = await fetch(`${apiBaseUrl}/tasks/${taskId}/reject`, { method: 'POST' }) if (!res.ok) { await respond({ + response_type: 'in_channel', text: `Failed to reject plan for task ${taskId}: ${res.statusText}`, replace_original: false, }) @@ -57,7 +63,11 @@ export function registerPlanApprovalHandlers(app: App, apiBaseUrl: string): void ], }) } catch (err: any) { - await respond({ text: `Error rejecting plan: ${err.message}`, replace_original: false }) + await respond({ + response_type: 'in_channel', + text: `Error rejecting plan: ${err.message}`, + replace_original: false, + }) } }) } diff --git a/packages/slack/src/test/commands.test.ts b/packages/slack/src/test/commands.test.ts new file mode 100644 index 0000000..1354c85 --- /dev/null +++ b/packages/slack/src/test/commands.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { App } from '@slack/bolt' +import { registerSlashCommands } from '../handlers/commands.js' + +type CommandHandler = (args: { + command: { text: string } + ack: () => Promise + respond: (msg: any) => Promise +}) => Promise + +function captureHandler(): { app: App; getHandler: () => CommandHandler } { + let handler: CommandHandler | undefined + const app = { + command: (_name: string, fn: CommandHandler) => { + handler = fn + }, + } as unknown as App + return { + app, + getHandler: () => { + if (!handler) { + throw new Error('handler not registered') + } + return handler + }, + } +} + +const API = 'http://localhost:9371' + +describe('registerSlashCommands', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('replies in-channel for the usage message', async () => { + const { app, getHandler } = captureHandler() + registerSlashCommands(app, API) + const respond = vi.fn().mockResolvedValue(undefined) + + await getHandler()({ + command: { text: '' }, + ack: vi.fn().mockResolvedValue(undefined), + respond, + }) + + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ response_type: 'in_channel' })) + }) + + it('replies in-channel for a successful status check', async () => { + const { app, getHandler } = captureHandler() + registerSlashCommands(app, API) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ({ activeTasks: 2 }) }) + ) + const respond = vi.fn().mockResolvedValue(undefined) + + await getHandler()({ + command: { text: 'status' }, + ack: vi.fn().mockResolvedValue(undefined), + respond, + }) + + expect(respond).toHaveBeenCalledWith( + expect.objectContaining({ + response_type: 'in_channel', + text: expect.stringContaining('2 tasks'), + }) + ) + }) + + it('replies in-channel when the orchestrator is unreachable', async () => { + const { app, getHandler } = captureHandler() + registerSlashCommands(app, API) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, statusText: 'Bad Gateway' })) + const respond = vi.fn().mockResolvedValue(undefined) + + await getHandler()({ + command: { text: 'status' }, + ack: vi.fn().mockResolvedValue(undefined), + respond, + }) + + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ response_type: 'in_channel' })) + }) +})