import { requestJson } from '../lib/httpClient.js'; import { cleanSlackText } from '../lib/slackText.js'; import type { NormalizedDelivery } from '../types/integration.js'; import type { NoteInput } from '../types/domain.js'; const SLACK_API_BASE = 'https://slack.com/api'; export type SlackErrorKind = 'permanent' | 'retryable'; /** * Errors a retry cannot fix: a bad credential, a missing scope, or a message * that is gone. Everything absent from this list is treated as retryable, * including error strings we do not recognize, so a transient failure Slack * adds tomorrow is not silently dropped today. The queue bounds attempts, so * an unknown-but-really-permanent error wastes a few calls rather than looping. */ const PERMANENT_ERRORS: ReadonlySet = new Set([ 'no_token', 'invalid_auth', 'not_authed', 'account_inactive', 'token_revoked', 'token_expired', 'no_permission', 'missing_scope', 'channel_not_found', 'not_in_channel', 'message_not_found', 'user_not_found', 'invalid_arguments', 'invalid_form_data', 'is_archived', ]); export class SlackApiError extends Error { readonly method: string; readonly slackError: string; readonly kind: SlackErrorKind; /** The flag lib/queue.ts reads to stop retrying a settled failure. */ readonly permanent: boolean; constructor(method: string, slackError: string) { super(`Slack API ${method} failed: ${slackError}`); this.name = 'SlackApiError'; this.method = method; this.slackError = slackError; this.kind = PERMANENT_ERRORS.has(slackError) ? 'permanent' : 'retryable'; this.permanent = this.kind === 'permanent'; } } export const isSlackApiError = (err: unknown): err is SlackApiError => err instanceof SlackApiError; const asRecord = (value: unknown): Record | undefined => typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : undefined; const asNonEmptyString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; /** * Posts a form-encoded Web API call and unwraps Slack's envelope. * * Slack answers HTTP 200 even for failures, putting the outcome in the body as * `{ ok: false, error: "invalid_auth" }`. requestJson cannot see that, so the * `ok` field is checked here; transport-level failures (429, 5xx, timeouts) are * left to requestJson's retry loop and surface as HttpRequestError unchanged. */ export const callSlack = async ( method: string, params: Record = {} ): Promise => { // Read at call time, never at module load: the token may be rotated into the // environment after import, and tests set and unset it per case. // Trimmed because a token pasted into a .env file often carries a newline. const token = process.env.SLACK_BOT_TOKEN?.trim(); if (token === undefined || token === '') { throw new SlackApiError(method, 'no_token'); } const body = await requestJson(`${SLACK_API_BASE}/${method}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', }, body: new URLSearchParams(params).toString(), }); const envelope = asRecord(body); if (envelope === undefined || envelope.ok !== true) { const slackError = asNonEmptyString(envelope?.error) ?? 'unknown_error'; throw new SlackApiError(method, slackError); } return body as T; }; /** * Credential health check. `ok: true` means Slack accepted the token, so the * identity fields are present in practice; they fall back to empty strings * rather than throwing, since a malformed success body is not a credential * problem and should not read as one. */ export const authTest = async (): Promise<{ teamId: string; teamName: string; botUserId: string; }> => { const body = await callSlack('auth.test'); const envelope = asRecord(body) ?? {}; return { teamId: asNonEmptyString(envelope.team_id) ?? '', teamName: asNonEmptyString(envelope.team) ?? '', botUserId: asNonEmptyString(envelope.user_id) ?? '', }; }; /** userId to resolved display name. Only successful lookups are stored. */ const displayNames = new Map(); export const resetSlackCaches = (): void => { displayNames.clear(); }; const pickDisplayName = (body: unknown, userId: string): string => { const user = asRecord(asRecord(body)?.user); const profile = asRecord(user?.profile); return ( asNonEmptyString(profile?.display_name) ?? asNonEmptyString(profile?.real_name) ?? asNonEmptyString(user?.real_name) ?? asNonEmptyString(user?.name) ?? userId ); }; /** * Resolves a human-readable name for a Slack user, memoized for the process. * A permanent failure degrades to the raw user ID: a name we cannot look up is * cosmetic, and failing the whole ingest over it would lose the note. Retryable * failures propagate so the queue can try again. */ export const getUserDisplayName = async (userId: string): Promise => { const cached = displayNames.get(userId); if (cached !== undefined) { return cached; } let name: string; try { name = pickDisplayName(await callSlack('users.info', { user: userId }), userId); } catch (err) { if (isSlackApiError(err) && err.kind === 'permanent') { // Deliberately not cached: a missing scope can be granted without a // restart, and caching the degraded answer would outlive the cause. return userId; } throw err; } displayNames.set(userId, name); return name; }; /** * Slack returns the whole thread when `conversations.replies` is given a reply's * ts, so the exact timestamp is preferred over position; the first message is * the fallback for `conversations.history`, which returns only the one asked for. */ const pickMessage = (body: unknown, ts: string): Record | undefined => { const messages = asRecord(body)?.messages; if (!Array.isArray(messages)) { return undefined; } const candidates = messages .map(asRecord) .filter((entry): entry is Record => entry !== undefined); return candidates.find((entry) => entry.ts === ts) ?? candidates[0]; }; /** * Reads one message's text by timestamp. `conversations.history` cannot see a * threaded reply, so `conversations.replies` is tried before giving up: a * reaction on a reply is otherwise indistinguishable from a deleted message. * Returns null when there is nothing worth clustering. */ export const getMessageText = async ( channelId: string, ts: string ): Promise<{ text: string; userId?: string } | null> => { const history = await callSlack('conversations.history', { channel: channelId, latest: ts, oldest: ts, inclusive: 'true', limit: '1', }); let message = pickMessage(history, ts); if (message === undefined) { const replies = await callSlack('conversations.replies', { channel: channelId, ts, limit: '1', inclusive: 'true', }); message = pickMessage(replies, ts); } if (message === undefined) { return null; } const raw = asNonEmptyString(message.text); const text = raw === undefined ? '' : cleanSlackText(raw); if (text === '') { return null; } const userId = asNonEmptyString(message.user); return userId === undefined ? { text } : { text, userId }; }; /** * Fills in what a Slack event referenced but did not carry. Runs after the ack, * inside the retry queue, so the network calls here are safe to be slow. * * Notes are processed in sequence rather than in parallel: a delivery carries * one or two of them, and serializing keeps a burst of reactions from spending * the per-method rate limit all at once. */ export const enrichSlackDelivery = async ( delivery: NormalizedDelivery, _payload: unknown ): Promise => { const notes: NoteInput[] = []; for (const note of delivery.notes) { const meta = note.sourceMeta; if (meta === undefined || meta.needsMessageText !== true) { notes.push(note); continue; } const channelId = asNonEmptyString(meta.channelId); const messageTs = asNonEmptyString(meta.messageTs); // No usable reference means the placeholder text can never be resolved, so // the note is dropped for the same reason an unfetchable message is. const message = channelId === undefined || messageTs === undefined ? null : await getMessageText(channelId, messageTs); if (message === null) { continue; } // Stripped unconditionally so a stale marker can never reach the database. const sourceMeta: Record = { ...meta }; delete sourceMeta.needsMessageText; const authorUserId = asNonEmptyString(meta.authorUserId) ?? message.userId; if (authorUserId === undefined) { notes.push({ ...note, text: message.text, sourceMeta }); continue; } const author = await getUserDisplayName(authorUserId); sourceMeta.authorHandle = author; notes.push({ ...note, text: message.text, author, sourceMeta }); } return { ...delivery, notes }; };