285 lines
9.1 KiB
TypeScript
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 };
|
|
};
|