Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/slack-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 14 additions & 12 deletions packages/slack/src/handlers/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <retry|cancel|status|pr-review> [taskId]`')
await reply('Usage: `/parallax <retry|cancel|status|pr-review> [taskId]`')
return
}

const requiresTaskId = subcommand !== 'status'
if (requiresTaskId && !taskId) {
await respond(`Usage: \`/parallax ${subcommand} <taskId>\``)
await reply(`Usage: \`/parallax ${subcommand} <taskId>\``)
return
}

Expand All @@ -21,50 +23,50 @@ 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
}
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}`)
}
})
}
14 changes: 12 additions & 2 deletions packages/slack/src/handlers/plan-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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,
})
}
})

Expand All @@ -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,
})
Expand All @@ -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,
})
}
})
}
91 changes: 91 additions & 0 deletions packages/slack/src/test/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>
respond: (msg: any) => Promise<void>
}) => Promise<void>

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' }))
})
})
Loading