159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
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;
|