Building Slack integration

This commit is contained in:
KS Jannette
2026-08-02 06:13:01 -04:00
parent dae256f6c6
commit b607ee9121
16 changed files with 2351 additions and 8 deletions

View File

@@ -0,0 +1,158 @@
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} <your note>`);
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<string, unknown> = {
provider: 'slack',
externalId,
receivedAt: new Date().toISOString(),
via: 'slash-command',
};
const optionalMeta: Record<string, string | undefined> = {
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;