import type { NormalizedDelivery, NoteProvenance } from '../types/integration.js'; import type { NoteInput } from '../types/domain.js'; export class NormalizationError extends Error { constructor(message: string) { super(message); this.name = 'NormalizationError'; } } const asRecord = (value: unknown): Record => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new NormalizationError('Payload is not an object'); } return value as Record; }; const asString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; /** notes.id is VARCHAR(64), so composite keys have to be trimmed to fit. */ const noteId = (provider: string, externalId: string): string => `${provider}_${externalId}`.slice(0, 64); const provenance = ( meta: Omit ): Record => ({ ...meta, receivedAt: new Date().toISOString(), }); const isNoteInput = (value: unknown): value is NoteInput => { if (typeof value !== 'object' || value === null) return false; const note = value as Record; if (typeof note.id !== 'string' || note.id.length === 0) return false; if (typeof note.text !== 'string' || note.text.length === 0) return false; if (typeof note.author !== 'string' || note.author.length === 0) return false; if (note.x !== undefined && typeof note.x !== 'number') return false; if (note.y !== undefined && typeof note.y !== 'number') return false; if (note.color !== undefined && typeof note.color !== 'string') return false; return true; }; export const isNoteInputArray = (value: unknown): value is NoteInput[] => Array.isArray(value) && value.every(isNoteInput); /** * Direct push: the caller already speaks our note shape, so normalization is * a validation step rather than a translation. */ export const normalizeRest = (payload: unknown): NormalizedDelivery => { const body = asRecord(payload); if (!isNoteInputArray(body.notes)) { throw new NormalizationError('Expected a "notes" array of note objects'); } const externalId = asString(body.batchId) ?? randomBatchId(); return { externalId, notes: body.notes.map((note) => ({ ...note, sourceMeta: provenance({ provider: 'rest', externalId }), })), }; }; const randomBatchId = (): string => `batch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; /** * Linear comment webhook. Kept minimal on purpose: this proves the webhook * pipeline end to end. Label filtering and issue-thread context belong to the * Linear integration proper, not to this infrastructure. */ export const normalizeLinear = (payload: unknown): NormalizedDelivery => { const body = asRecord(payload); const data = asRecord(body.data); const externalId = asString(data.id); if (!externalId) { throw new NormalizationError('Linear payload is missing data.id'); } const text = asString(data.body); if (!text) { return { externalId, notes: [] }; } const user = typeof data.user === 'object' && data.user !== null ? (data.user as Record) : {}; return { externalId, notes: [ { id: noteId('linear', externalId), text, author: asString(user.name) ?? 'unknown', sourceMeta: provenance({ provider: 'linear', externalId, permalink: asString(data.url), authorHandle: asString(user.name), }), }, ], }; }; /** Stands in for the message body until the enrichment hook backfills it. */ export const SLACK_PENDING_TEXT = '(pending Slack message text)'; const DEFAULT_CAPTURE_REACTION = 'pushpin'; /** Slack suffixes skin-tone modifiers onto emoji names; only the base name identifies the reaction. */ const baseReaction = (name: string): string => name.split('::')[0]; const nested = (value: unknown): Record => typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; /** * Slack Events API. Only one configured reaction is interesting; every other * delivery is ordinary traffic and yields no notes rather than an error. * * A reaction_added event names a message but never carries its text, and this * runs ahead of the HTTP ack inside Slack's three-second budget, so it cannot * go fetch it. The note therefore carries placeholder text and needsMessageText, * which is the enrichment hook's cue to backfill the real body out of band. */ export const normalizeSlack = (payload: unknown): NormalizedDelivery => { const body = asRecord(payload); if (body.type !== 'event_callback') { throw new NormalizationError('Slack payload is not an event_callback'); } const externalId = asString(body.event_id); if (!externalId) { throw new NormalizationError('Slack payload is missing event_id'); } const event = nested(body.event); if (event.type !== 'reaction_added') { return { externalId, notes: [] }; } // Read per call: the captured reaction is operator configuration, not a build-time constant. const configured = asString(process.env.SLACK_CAPTURE_REACTION) ?? DEFAULT_CAPTURE_REACTION; const reaction = asString(event.reaction); if (!reaction || baseReaction(reaction) !== baseReaction(configured)) { return { externalId, notes: [] }; } const item = nested(event.item); const channelId = asString(item.channel); const messageTs = asString(item.ts); if (item.type !== 'message' || !channelId || !messageTs) { return { externalId, notes: [] }; } const teamId = asString(body.team_id); const reactedBy = asString(event.user); const authorUserId = asString(event.item_user); return { externalId, notes: [ { id: noteId('slack', externalId), text: SLACK_PENDING_TEXT, author: authorUserId ?? reactedBy ?? 'unknown', sourceMeta: { ...provenance({ provider: 'slack', externalId }), needsMessageText: true, channelId, messageTs, reaction, ...(teamId !== undefined ? { teamId } : {}), ...(reactedBy !== undefined ? { reactedBy } : {}), ...(authorUserId !== undefined ? { authorUserId } : {}), }, }, ], }; };