Building Slack integration
This commit is contained in:
284
backend/services/slack.service.ts
Normal file
284
backend/services/slack.service.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user