193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
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<string, unknown> => {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
throw new NormalizationError('Payload is not an object');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
};
|
|
|
|
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<NoteProvenance, 'receivedAt'>
|
|
): Record<string, unknown> => ({
|
|
...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<string, unknown>;
|
|
|
|
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<string, unknown>)
|
|
: {};
|
|
|
|
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<string, unknown> =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
|
|
/**
|
|
* 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 } : {}),
|
|
},
|
|
},
|
|
],
|
|
};
|
|
};
|