Skip to content
Open
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
3 changes: 2 additions & 1 deletion backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { tenantMiddleware } from '../middlewares/tenantMiddleware'
import { createRateLimiter } from './apiRateLimiter'
import authSocial from './auth/authSocial'
import { publicRouter } from './public'
import { mountInteractivityRoute } from './slack'
import { mountEventsRoute, mountInteractivityRoute } from './slack'
import WebSockets from './websockets'

const serviceLogger = getServiceLogger()
Expand Down Expand Up @@ -112,6 +112,7 @@ setImmediate(async () => {

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

// Initializes and adds the database middleware.
app.use(databaseMiddleware)
Expand Down
12 changes: 12 additions & 0 deletions backend/src/api/slack/eventDeduplication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { RedisClient } from '@crowd/redis'

const EVENT_KEY_PREFIX = 'slack_event'
const EVENT_TTL_SECONDS = 15 * 60

export async function claimSlackEvent(redis: RedisClient, eventId: string): Promise<boolean> {
const result = await redis.set(`${EVENT_KEY_PREFIX}:${eventId}`, '1', {
NX: true,
EX: EVENT_TTL_SECONDS,
})
return result === 'OK'
}
106 changes: 106 additions & 0 deletions backend/src/api/slack/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('./verifySignature', () => ({ verifySlackSignature: vi.fn(() => true) }))
vi.mock('@/services/slack/requestClassificationBot', () => ({
runRequestClassificationBot: vi.fn(async () => undefined),
}))

import { runRequestClassificationBot } from '@/services/slack/requestClassificationBot'

import { createEventsHandler } from './events'
import { verifySlackSignature } from './verifySignature'

const claimEvent = vi.fn(async () => true)
const events = createEventsHandler(claimEvent)

function call(body: object, headers: Record<string, string> = {}) {
const req = {
body,
headers,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as any
const res = { sendStatus: vi.fn(), json: vi.fn() } as any
return events(req, res).then(() => ({ req, res }))
}

const mention = {
type: 'event_callback',
event_id: 'Ev1',
event: { type: 'app_mention', text: '<@U0BOT> hi', channel: 'C1', ts: '100.1' },
}

describe('slack events', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(verifySlackSignature).mockReturnValue(true)
claimEvent.mockResolvedValue(true)
})

it('answers the url verification challenge', async () => {
const { res } = await call({ type: 'url_verification', challenge: 'abc' })

expect(res.json).toHaveBeenCalledWith({ challenge: 'abc' })
})

it('acks and classifies a mention, replying in the message thread', async () => {
const { res } = await call(mention)

expect(res.sendStatus).toHaveBeenCalledWith(200)
expect(runRequestClassificationBot).toHaveBeenCalledWith(
expect.objectContaining({
text: '<@U0BOT> hi',
channelId: 'C1',
messageTs: '100.1',
threadTs: '100.1',
}),
)
})

it('replies in the existing thread when the mention is inside one', async () => {
await call({ ...mention, event: { ...mention.event, thread_ts: '90.0' } })

expect(runRequestClassificationBot).toHaveBeenCalledWith(
expect.objectContaining({ threadTs: '90.0', messageTs: '100.1' }),
)
})

it('processes a Slack retry when the event was not handled yet', async () => {
await call(mention, { 'x-slack-retry-num': '1' })

expect(runRequestClassificationBot).toHaveBeenCalledTimes(1)
})

it('ignores an event that was already handled', async () => {
claimEvent.mockResolvedValue(false)

await call(mention, { 'x-slack-retry-num': '1' })

expect(claimEvent).toHaveBeenCalledWith('Ev1')
expect(runRequestClassificationBot).not.toHaveBeenCalled()
})

it('handles the event when deduplication is unavailable', async () => {
claimEvent.mockRejectedValue(new Error('redis down'))

const { req } = await call(mention)

expect(req.log.warn).toHaveBeenCalled()
expect(runRequestClassificationBot).toHaveBeenCalledTimes(1)
})

it('ignores bot messages and other event types', async () => {
await call({ ...mention, event: { ...mention.event, bot_id: 'B1' } })
await call({ ...mention, event: { ...mention.event, type: 'message' } })

expect(runRequestClassificationBot).not.toHaveBeenCalled()
})

it('does nothing for unverified requests', async () => {
vi.mocked(verifySlackSignature).mockReturnValue(false)

const { res } = await call(mention)

expect(res.sendStatus).toHaveBeenCalledWith(200)
expect(runRequestClassificationBot).not.toHaveBeenCalled()
})
})
94 changes: 94 additions & 0 deletions backend/src/api/slack/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { runRequestClassificationBot } from '@/services/slack/requestClassificationBot'
import { validateOrThrow } from '@/utils/validation'
import { getErrorMessage } from '@crowd/common'

import { verifySlackSignature } from './verifySignature'

const URL_VERIFICATION_TYPE = 'url_verification'
const EVENT_CALLBACK_TYPE = 'event_callback'
const APP_MENTION_EVENT_TYPE = 'app_mention'

const payloadSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal(URL_VERIFICATION_TYPE), challenge: z.string() }),
z.object({
type: z.literal(EVENT_CALLBACK_TYPE),
event_id: z.string(),
event: z.object({
type: z.string(),
text: z.string().optional(),
channel: z.string().optional(),
ts: z.string().optional(),
thread_ts: z.string().optional(),
bot_id: z.string().optional(),
}),
}),
])

type EventPayload = z.infer<typeof payloadSchema>

async function isFirstDelivery(
eventId: string,
claimEvent: (eventId: string) => Promise<boolean>,
req: Request,
): Promise<boolean> {
try {
return await claimEvent(eventId)
} catch (err) {
req.log.warn({ error: getErrorMessage(err), eventId }, 'Could not deduplicate Slack event.')
return true
}
}

function dispatchAppMention(
payload: Extract<EventPayload, { type: 'event_callback' }>,
req: Request,
) {
const { event } = payload
const isUserMention = event.type === APP_MENTION_EVENT_TYPE && !event.bot_id
if (!isUserMention || !event.channel || !event.ts) {
return
}

runRequestClassificationBot({
text: event.text ?? '',
channelId: event.channel,
messageTs: event.ts,
threadTs: event.thread_ts ?? event.ts,
options: { log: req.log },
Comment thread
Copilot marked this conversation as resolved.
}).catch((err) => req.log.error(err, 'Slack request bot failed unexpectedly!'))
}

// Mounted ahead of responseHandlerMiddleware, so errors are handled here
// directly instead of via the global errorMiddleware.
export function createEventsHandler(claimEvent: (eventId: string) => Promise<boolean>) {
return async (req: Request, res: Response) => {
if (!verifySlackSignature(req)) {
req.log.warn('Received unverified Slack event!')
res.sendStatus(200)
return
}

try {
const payload = validateOrThrow(payloadSchema, req.body)

if (payload.type === URL_VERIFICATION_TYPE) {
res.json({ challenge: payload.challenge })
return
}

res.sendStatus(200)

if (!(await isFirstDelivery(payload.event_id, claimEvent, req))) {
return
}

dispatchAppMention(payload, req)
} catch (err) {
req.log.error(err, 'Error processing Slack event!')
res.sendStatus(200)
}
}
}
31 changes: 31 additions & 0 deletions backend/src/api/slack/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import bodyParser from 'body-parser'
import type { Application, NextFunction, Request, Response } from 'express'

import type { RedisClient } from '@crowd/redis'
import { getSlackBotConfig } from '@crowd/slack'

import { SLACK_CONFIG } from '../../conf/index'
import { safeWrap } from '../../middlewares/errorMiddleware'
import { createRateLimiter } from '../apiRateLimiter'
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.
Expand Down Expand Up @@ -38,6 +40,35 @@ export function mountInteractivityRoute(app: Application): void {
)
}

export function mountEventsRoute(app: Application, redis: RedisClient): void {
if (!getSlackBotConfig().signingSecret) {
return
}

const captureRawBody = (req: Request, _res: Response, buf: Buffer) => {
req.rawBody = buf
}

const eventsRateLimiter = createRateLimiter({
max: 200,
windowMs: 60 * 1000,
})

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleParserError = (err: Error, req: Request, res: Response, _next: NextFunction) => {
req.log.error(err, 'Error parsing Slack event payload!')
res.sendStatus(200)
}

app.post(
'/v1/slack/events',
eventsRateLimiter,
bodyParser.json({ limit: '5mb', verify: captureRawBody }),
handleParserError,
require('./events').createEventsHandler((eventId: string) => claimSlackEvent(redis, eventId)),
)
}

export default (app) => {
if (
SLACK_CONFIG.onboardingAppId &&
Expand Down
76 changes: 76 additions & 0 deletions backend/src/services/slack/requestClassificationBot.run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@crowd/slack', () => ({
getSlackPermalink: vi.fn(async () => 'https://slack.test/permalink'),
postSlackMessage: vi.fn(async () => ({ ok: true })),
}))
vi.mock('@crowd/project-onboarding/src/requestClassifierDeps', () => ({
withRequestClassifierDeps: vi.fn(async () => ({
resolution: { kind: 'lf_not_in_pcc', projectName: 'Acme' },
node: 'lf_not_in_pcc_flag_human',
trace: { parsed: null, pccLookup: null, cdpLookup: null, failure: null },
})),
}))
vi.mock('./slackBackground', () => ({ getBgQx: vi.fn(async () => ({})) }))

import { getSlackPermalink, postSlackMessage } from '@crowd/slack'

import { runRequestClassificationBot } from './requestClassificationBot'

const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any

function run(overrides: Record<string, unknown> = {}) {
return runRequestClassificationBot({
text: '<@U0BOT> onboard Acme',
channelId: 'C1',
messageTs: '100.1',
threadTs: '90.0',
options: { log },
...overrides,
})
}

describe('runRequestClassificationBot', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(postSlackMessage).mockResolvedValue({ ok: true })
})

it('links the mentioned message and replies in the thread', async () => {
await run()

expect(getSlackPermalink).toHaveBeenCalledWith('C1', '100.1')
expect(postSlackMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'C1', thread_ts: '90.0' }),
)
expect(log.warn).not.toHaveBeenCalled()
})

it('disables the Snowflake platform detection before loading the classifier deps', async () => {
delete process.env.SNOWFLAKE_DISABLE_PLATFORM_DETECTION

await run()

expect(process.env.SNOWFLAKE_DISABLE_PLATFORM_DETECTION).toBe('true')
})

it('warns when Slack does not deliver the reply', async () => {
vi.mocked(postSlackMessage).mockResolvedValue({ ok: false, error: 'channel_not_found' })

await run()

expect(log.warn).toHaveBeenCalledWith(
expect.objectContaining({ channelId: 'C1', error: 'channel_not_found' }),
'Slack bot reply was not delivered.',
)
})

it('asks for the request details when only the mention is left', async () => {
await run({ text: '<@U0BOT>' })

expect(getSlackPermalink).not.toHaveBeenCalled()
expect(postSlackMessage).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringContaining('Tell me about the project') }),
)
})
})
Loading
Loading