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: 1 addition & 1 deletion backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ setImmediate(async () => {
const temporal = TEMPORAL_CONFIG.serverUrl ? await getTemporalClient(TEMPORAL_CONFIG) : null

// Mounted before DB/Redis/OpenSearch middleware to protect Slack's 3s ack window.
mountInteractivityRoute(app)
mountInteractivityRoute(app, { temporal })
mountEventsRoute(app, { redis, temporal })

// Initializes and adds the database middleware.
Expand Down
7 changes: 5 additions & 2 deletions backend/src/api/slack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { claimSlackEvent } from './eventDeduplication'

// Mounted ahead of the shared rate limiter and tenant/segment middleware
// to protect Slack's 3-second acknowledgement window; keeps its own limiter.
export function mountInteractivityRoute(app: Application): void {
export function mountInteractivityRoute(
app: Application,
{ temporal }: { temporal: TemporalClient | null },
): void {
if (!getSlackBotConfig().signingSecret) {
return
}
Expand All @@ -37,7 +40,7 @@ export function mountInteractivityRoute(app: Application): void {
interactivityRateLimiter,
bodyParser.urlencoded({ limit: '5mb', extended: true, verify: captureRawBody }),
handleParserError,
require('./interactivity').default,
require('./interactivity').createInteractivityHandler(temporal),
)
}

Expand Down
60 changes: 59 additions & 1 deletion backend/src/api/slack/interactivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ vi.mock('@/services/slack/forceOnboardingCommand', () => ({
runForceOnboardingCommand: vi.fn(async () => undefined),
replaceWithForceOnboardingFailure: vi.fn(async () => undefined),
}))
vi.mock('@/services/slack/reviewDecisionCommand', () => ({
runReviewDecisionCommand: vi.fn(async () => undefined),
postReviewDecisionFailure: vi.fn(async () => undefined),
}))

import {
replaceWithForceOnboardingFailure,
runForceOnboardingCommand,
} from '@/services/slack/forceOnboardingCommand'
import {
postReviewDecisionFailure,
runReviewDecisionCommand,
} from '@/services/slack/reviewDecisionCommand'

import interactivity from './interactivity'
import { createInteractivityHandler } from './interactivity'
import { verifySlackSignature } from './verifySignature'

const temporal = { workflow: { start: vi.fn() } } as any
const interactivity = createInteractivityHandler(temporal)

function call(payload: object) {
const req = {
body: { payload: JSON.stringify(payload) },
Expand All @@ -30,6 +41,11 @@ const forcePayload = {
actions: [{ action_id: 'force_onboarding', value: 'catalog-1' }],
}

const approvePayload = {
...forcePayload,
actions: [{ action_id: 'approve_review', value: 'catalog-2' }],
}

describe('interactivity', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -62,11 +78,53 @@ describe('interactivity', () => {
)
})

it.each([
['approve_review', 'approve'],
['reject_review', 'reject'],
])('runs the review decision for %s', async (actionId, decision) => {
const { res } = await call({
...approvePayload,
actions: [{ action_id: actionId, value: 'catalog-2' }],
})

expect(res.sendStatus).toHaveBeenCalledWith(200)
expect(runReviewDecisionCommand).toHaveBeenCalledWith(
expect.objectContaining({
catalogId: 'catalog-2',
decision,
responseUrl: 'https://hooks.slack.com/response',
actorId: 'U123',
temporal,
}),
)
expect(runForceOnboardingCommand).not.toHaveBeenCalled()
})

it('replaces the review message when the decision flow rejects', async () => {
vi.mocked(runReviewDecisionCommand).mockRejectedValueOnce(new Error('db down'))

const { req } = await call(approvePayload)
await new Promise((resolve) => setImmediate(resolve))

expect(postReviewDecisionFailure).toHaveBeenCalledWith(
'https://hooks.slack.com/response',
req.log,
)
})

it('ignores a review click without a user', async () => {
const { req } = await call({ ...approvePayload, user: undefined })

expect(runReviewDecisionCommand).not.toHaveBeenCalled()
expect(req.log.warn).toHaveBeenCalledWith('Unhandled Slack block action.')
})

it('ignores unknown actions', async () => {
const { res } = await call({ ...forcePayload, actions: [{ action_id: 'other' }] })

expect(res.sendStatus).toHaveBeenCalledWith(200)
expect(runForceOnboardingCommand).not.toHaveBeenCalled()
expect(runReviewDecisionCommand).not.toHaveBeenCalled()
})

it('does nothing for an unverified request', async () => {
Expand Down
109 changes: 81 additions & 28 deletions backend/src/api/slack/interactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import {
replaceWithForceOnboardingFailure,
runForceOnboardingCommand,
} from '@/services/slack/forceOnboardingCommand'
import { FORCE_ONBOARDING_ACTION_ID } from '@/services/slack/slackActionIds'
import type { OnboardingRequestTemporal } from '@/services/slack/onboardingRequestBot'
import {
postReviewDecisionFailure,
runReviewDecisionCommand,
} from '@/services/slack/reviewDecisionCommand'
import {
APPROVE_REVIEW_ACTION_ID,
FORCE_ONBOARDING_ACTION_ID,
REJECT_REVIEW_ACTION_ID,
} from '@/services/slack/slackActionIds'
import { validateOrThrow } from '@/utils/validation'

import { verifySlackSignature } from './verifySignature'
Expand All @@ -23,51 +32,95 @@ const payloadSchema = z.object({

type InteractivityPayload = z.infer<typeof payloadSchema>

function dispatchForceOnboarding(payload: InteractivityPayload, req: Request) {
const action = payload.actions?.find((a) => a.action_id === FORCE_ONBOARDING_ACTION_ID)
interface IActionClick {
value: string
responseUrl: string
actorId: string
}

function findActionClick(payload: InteractivityPayload, actionId: string): IActionClick | null {
const action = payload.actions?.find((a) => a.action_id === actionId)
if (!action || !payload.user || !payload.response_url || !action.value) {
return null
}

return { value: action.value, responseUrl: payload.response_url, actorId: payload.user.id }
}

function dispatchForceOnboarding(payload: InteractivityPayload, req: Request) {
const click = findActionClick(payload, FORCE_ONBOARDING_ACTION_ID)
if (!click) {
return false
}

const responseUrl = payload.response_url
runForceOnboardingCommand({
catalogId: action.value,
responseUrl,
actorId: payload.user.id,
catalogId: click.value,
responseUrl: click.responseUrl,
actorId: click.actorId,
log: req.log,
}).catch((err) => {
req.log.error(err, 'Force onboarding failed unexpectedly!')
replaceWithForceOnboardingFailure(responseUrl, req.log)
replaceWithForceOnboardingFailure(click.responseUrl, req.log)
})
return true
}

function dispatchReviewDecision(
payload: InteractivityPayload,
req: Request,
temporal: OnboardingRequestTemporal | null,
) {
const approve = findActionClick(payload, APPROVE_REVIEW_ACTION_ID)
const click = approve ?? findActionClick(payload, REJECT_REVIEW_ACTION_ID)
if (!click) {
return false
}

runReviewDecisionCommand({
catalogId: click.value,
decision: approve ? 'approve' : 'reject',
responseUrl: click.responseUrl,
actorId: click.actorId,
temporal,
log: req.log,
}).catch((err) => {
req.log.error(err, 'Review decision failed unexpectedly!')
postReviewDecisionFailure(click.responseUrl, req.log)
})
return true
}

// Mounted ahead of responseHandlerMiddleware, so errors are handled here
// directly instead of via the global errorMiddleware.
export default async (req: Request, res: Response) => {
if (!verifySlackSignature(req)) {
req.log.warn('Received unverified Slack interactivity payload!')
res.sendStatus(200)
return
}
export function createInteractivityHandler(temporal: OnboardingRequestTemporal | null) {
return async (req: Request, res: Response) => {
if (!verifySlackSignature(req)) {
req.log.warn('Received unverified Slack interactivity payload!')
res.sendStatus(200)
return
}

try {
const { payload: rawPayload } = validateOrThrow(bodySchema, req.body)
const payload = validateOrThrow(payloadSchema, JSON.parse(rawPayload))
try {
const { payload: rawPayload } = validateOrThrow(bodySchema, req.body)
const payload = validateOrThrow(payloadSchema, JSON.parse(rawPayload))

req.log.info(
{ type: payload.type, actionIds: payload.actions?.map((a) => a.action_id) },
'Received Slack interactivity payload.',
)
req.log.info(
{ type: payload.type, actionIds: payload.actions?.map((a) => a.action_id) },
'Received Slack interactivity payload.',
)

res.sendStatus(200)
res.sendStatus(200)

if (payload.type === 'block_actions' && !dispatchForceOnboarding(payload, req)) {
// TODO(CM-1791): wire up the claim-button interactive handler.
req.log.warn('Unhandled Slack block action.')
if (
payload.type === 'block_actions' &&
!dispatchForceOnboarding(payload, req) &&
!dispatchReviewDecision(payload, req, temporal)
) {
req.log.warn('Unhandled Slack block action.')
}
} catch (err) {
req.log.error(err, 'Error processing Slack interactivity payload!')
res.sendStatus(200)
}
} catch (err) {
req.log.error(err, 'Error processing Slack interactivity payload!')
res.sendStatus(200)
}
}
6 changes: 4 additions & 2 deletions backend/src/services/slack/onboardProjectCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from '@crowd/data-access-layer'
import type { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
import { onboardProject } from '@crowd/project-onboarding'
import { getSlackPermalink, postSlackMessage } from '@crowd/slack'
import { IReviewDecisionMessage, getSlackPermalink, postSlackMessage } from '@crowd/slack'

import { createDailyProjectCatalogCap } from '../../api/public/v1/projectCatalog/dailyRequestCap'
import { createDailyLlmCap } from '../../api/public/v1/projectEvaluation/dailyLlmCap'
Expand Down Expand Up @@ -64,9 +64,11 @@ function forceOnboardingPrompt(
.buildToObject()
}

export type ResponseUrlMessage = SlackMessageDto | IReviewDecisionMessage

export async function postToResponseUrl(
responseUrl: string | undefined,
message: SlackMessageDto,
message: ResponseUrlMessage,
log: IServiceOptions['log'],
): Promise<void> {
if (!responseUrl) {
Expand Down
Loading
Loading