Building Slack integration
This commit is contained in:
158
backend/routes/slack.routes.ts
Normal file
158
backend/routes/slack.routes.ts
Normal 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;
|
||||
@@ -96,7 +96,10 @@ router.post('/:provider', async (req: Request, res: Response, next: NextFunction
|
||||
enqueue(`${slug}:${delivery.externalId}`, async () => {
|
||||
await markProcessing(eventId);
|
||||
try {
|
||||
await createNotes(notes);
|
||||
const enriched = config.enrich
|
||||
? await config.enrich(delivery, payload)
|
||||
: delivery;
|
||||
await createNotes(enriched.notes);
|
||||
await markDone(eventId);
|
||||
} catch (err) {
|
||||
await markFailed(eventId, err instanceof Error ? err.message : String(err));
|
||||
@@ -104,7 +107,12 @@ router.post('/:provider', async (req: Request, res: Response, next: NextFunction
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).json({ accepted: true, notes: notes.length });
|
||||
// The count is omitted for providers that enrich, because enrichment runs
|
||||
// after this response and may drop notes whose content cannot be fetched.
|
||||
// No number is better than one that is sometimes wrong.
|
||||
res.status(200).json(
|
||||
config.enrich ? { accepted: true } : { accepted: true, notes: notes.length }
|
||||
);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user