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

@@ -111,3 +111,82 @@ export const normalizeLinear = (payload: unknown): NormalizedDelivery => {
],
};
};
/** 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 } : {}),
},
},
],
};
};

View File

@@ -3,7 +3,8 @@ import type {
ProviderSlug,
SignatureScheme,
} from '../types/integration.js';
import { normalizeLinear, normalizeRest } from './normalizers.js';
import { normalizeLinear, normalizeRest, normalizeSlack } from './normalizers.js';
import { enrichSlackDelivery } from '../services/slack.service.js';
export type ChallengeResponse = { status: number; body: unknown };
@@ -22,6 +23,15 @@ export type ProviderConfig = {
* infrastructure; the payload mapping belongs to the integration itself.
*/
normalize?: (payload: unknown) => NormalizedDelivery;
/**
* Runs after the ack, inside the retry queue, so it may make network calls.
* Receives the raw payload because some providers deliver a reference rather
* than content: a Slack reaction names a message without including its text.
*/
enrich?: (
delivery: NormalizedDelivery,
payload: unknown
) => Promise<NormalizedDelivery>;
oauth?: {
authorizeUrl: string;
tokenUrl: string;
@@ -63,6 +73,8 @@ export const providers: Record<ProviderSlug, ProviderConfig> = {
timestampHeader: 'x-slack-request-timestamp',
secretEnvVar: 'SLACK_SIGNING_SECRET',
challenge: slackChallenge,
normalize: normalizeSlack,
enrich: enrichSlackDelivery,
oauth: {
authorizeUrl: 'https://slack.com/oauth/v2/authorize',
tokenUrl: 'https://slack.com/api/oauth.v2.access',