Building Slack integration
This commit is contained in:
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user