import { Router, type NextFunction, type Request, type Response } from 'express'; import { verifySignature } from '../lib/signatures.js'; import { cleanSlackText } from '../lib/slackText.js'; import { enqueue } from '../lib/queue.js'; import { markDone, markFailed, markProcessing, recordDelivery, } from '../db/ingest_events.dao.js'; import { createNotes } from '../db/notes.dao.js'; import type { NoteInput } from '../types/domain.js'; /** * Slash commands are a separate surface from the event webhook: the body is * form-encoded rather than JSON, and Slack renders whatever this route returns * straight into the channel. Mount at '/v1/slack' behind a raw body parser so * signature verification sees the exact bytes Slack signed. */ const router = Router(); const SLASH_COMMAND = '/sticky'; /** notes.id is VARCHAR(64). */ const NOTE_ID_MAX = 64; /** Long enough to confirm the note, short enough not to flood the channel. */ const ECHO_MAX = 120; type SlackCommandResponse = { response_type: 'ephemeral'; text: string; }; const field = (form: URLSearchParams, name: string): string | undefined => { const value = form.get(name); if (value === null || value.length === 0) return undefined; return value; }; const truncate = (value: string, max: number): string => value.length <= max ? value : `${value.slice(0, max - 1)}…`; /** * Slack shows the user a generic failure banner instead of the body on any * non-2xx, so every outcome the user is meant to read is a 200. */ const ephemeral = (res: Response, text: string): void => { const body: SlackCommandResponse = { response_type: 'ephemeral', text }; res.status(200).json(body); }; router.post('/commands', async (req: Request, res: Response, next: NextFunction) => { try { const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); const secret = process.env.SLACK_SIGNING_SECRET; if (!secret) { console.error('No signing secret configured for provider "slack"'); res.status(500).json({ error: 'Slack is not configured' }); return; } // Verified against the raw bytes before any parsing: URLSearchParams // re-encoding would not reproduce what Slack signed. const signature = verifySignature({ provider: 'slack', rawBody: raw, headers: req.headers, secret, }); if (!signature.ok) { res.status(401).json({ error: 'Invalid signature' }); return; } const form = new URLSearchParams(raw.toString('utf8')); const command = field(form, 'command'); const userId = field(form, 'user_id'); const userName = field(form, 'user_name'); const teamId = field(form, 'team_id'); const channelId = field(form, 'channel_id'); const channelName = field(form, 'channel_name'); const triggerId = field(form, 'trigger_id'); if (command !== undefined && command !== SLASH_COMMAND) { ephemeral(res, `Unknown command ${command}.`); return; } const text = (field(form, 'text') ?? '').trim(); // Cleaning happens before the delivery is recorded: markup that reduces to // nothing must not consume the externalId that dedups a Slack retry. const cleaned = cleanSlackText(text); if (cleaned.length === 0) { ephemeral(res, `Usage: ${SLASH_COMMAND} `); return; } // trigger_id is unique per invocation, so a retry of a command we were too // slow to answer dedups against the first attempt instead of duplicating it. const externalId = triggerId ?? `${teamId ?? ''}:${userId ?? ''}:${Date.now()}`; const eventId = await recordDelivery({ provider: 'slack', externalId }); if (eventId === null) { ephemeral(res, 'Already captured.'); return; } const sourceMeta: Record = { provider: 'slack', externalId, receivedAt: new Date().toISOString(), via: 'slash-command', }; const optionalMeta: Record = { channelId, channelName, authorHandle: userName, authorUserId: userId, teamId, }; for (const [key, value] of Object.entries(optionalMeta)) { if (value !== undefined) sourceMeta[key] = value; } const note: NoteInput = { id: `slack_${externalId}`.slice(0, NOTE_ID_MAX), text: cleaned, author: userName ?? userId ?? 'unknown', sourceMeta, }; // Slack abandons the command after three seconds, so the write happens // behind the ack rather than in front of it. enqueue(`slack:${externalId}`, async () => { await markProcessing(eventId); try { await createNotes([note]); await markDone(eventId); } catch (err) { await markFailed(eventId, err instanceof Error ? err.message : String(err)); throw err; } }); ephemeral(res, `Added to Kongruity: "${truncate(cleaned, ECHO_MAX)}"`); } catch (err) { next(err); } }); export default router;