Files
kongruity/backend/services/slack.service.ts
2026-08-02 06:13:01 -04:00

285 lines
9.1 KiB
TypeScript

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<string> = 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<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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 <T>(
method: string,
params: Record<string, string> = {}
): Promise<T> => {
// 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<unknown>(`${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<unknown>('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<string, string>();
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<string> => {
const cached = displayNames.get(userId);
if (cached !== undefined) {
return cached;
}
let name: string;
try {
name = pickDisplayName(await callSlack<unknown>('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<string, unknown> | undefined => {
const messages = asRecord(body)?.messages;
if (!Array.isArray(messages)) {
return undefined;
}
const candidates = messages
.map(asRecord)
.filter((entry): entry is Record<string, unknown> => 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<unknown>('conversations.history', {
channel: channelId,
latest: ts,
oldest: ts,
inclusive: 'true',
limit: '1',
});
let message = pickMessage(history, ts);
if (message === undefined) {
const replies = await callSlack<unknown>('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<NormalizedDelivery> => {
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<string, unknown> = { ...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 };
};