refactor(discord): split send pipeline
This commit is contained in:
394
src/discord/send.shared.ts
Normal file
394
src/discord/send.shared.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { RequestClient } from "@buape/carbon";
|
||||
import { PollLayoutType } from "discord-api-types/payloads/v10";
|
||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import {
|
||||
createDiscordRetryRunner,
|
||||
type RetryRunner,
|
||||
} from "../infra/retry-policy.js";
|
||||
import {
|
||||
normalizePollDurationHours,
|
||||
normalizePollInput,
|
||||
type PollInput,
|
||||
} from "../polls.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { chunkDiscordText } from "./chunk.js";
|
||||
import {
|
||||
fetchChannelPermissionsDiscord,
|
||||
isThreadChannelType,
|
||||
} from "./send.permissions.js";
|
||||
import { DiscordSendError } from "./send.types.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
const DISCORD_MAX_STICKERS = 3;
|
||||
const DISCORD_POLL_MAX_ANSWERS = 10;
|
||||
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
|
||||
const DISCORD_MISSING_PERMISSIONS = 50013;
|
||||
const DISCORD_CANNOT_DM = 50007;
|
||||
|
||||
type DiscordRequest = RetryRunner;
|
||||
|
||||
type DiscordRecipient =
|
||||
| {
|
||||
kind: "user";
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
kind: "channel";
|
||||
id: string;
|
||||
};
|
||||
|
||||
type DiscordClientOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
retry?: RetryConfig;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
function resolveToken(params: {
|
||||
explicit?: string;
|
||||
accountId: string;
|
||||
fallbackToken?: string;
|
||||
}) {
|
||||
const explicit = normalizeDiscordToken(params.explicit);
|
||||
if (explicit) return explicit;
|
||||
const fallback = normalizeDiscordToken(params.fallbackToken);
|
||||
if (!fallback) {
|
||||
throw new Error(
|
||||
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveRest(token: string, rest?: RequestClient) {
|
||||
return rest ?? new RequestClient(token);
|
||||
}
|
||||
|
||||
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) {
|
||||
const account = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const token = resolveToken({
|
||||
explicit: opts.token,
|
||||
accountId: account.accountId,
|
||||
fallbackToken: account.token,
|
||||
});
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const request = createDiscordRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
return { token, rest, request };
|
||||
}
|
||||
|
||||
function resolveDiscordRest(opts: DiscordClientOpts) {
|
||||
return createDiscordClient(opts).rest;
|
||||
}
|
||||
|
||||
function normalizeReactionEmoji(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("emoji required");
|
||||
}
|
||||
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
|
||||
const identifier = customMatch
|
||||
? `${customMatch[1]}:${customMatch[2]}`
|
||||
: trimmed.replace(/[\uFE0E\uFE0F]/g, "");
|
||||
return encodeURIComponent(identifier);
|
||||
}
|
||||
|
||||
function parseRecipient(raw: string): DiscordRecipient {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Discord sends");
|
||||
}
|
||||
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return { kind: "user", id: mentionMatch[1] };
|
||||
}
|
||||
if (trimmed.startsWith("user:")) {
|
||||
return { kind: "user", id: trimmed.slice("user:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
return { kind: "channel", id: trimmed.slice("channel:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("discord:")) {
|
||||
return { kind: "user", id: trimmed.slice("discord:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const candidate = trimmed.slice(1);
|
||||
if (!/^\d+$/.test(candidate)) {
|
||||
throw new Error(
|
||||
"Discord DMs require a user id (use user:<id> or a <@id> mention)",
|
||||
);
|
||||
}
|
||||
return { kind: "user", id: candidate };
|
||||
}
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
`Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
|
||||
);
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
function normalizeStickerIds(raw: string[]) {
|
||||
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
throw new Error("At least one sticker id is required");
|
||||
}
|
||||
if (ids.length > DISCORD_MAX_STICKERS) {
|
||||
throw new Error("Discord supports up to 3 stickers per message");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function normalizeEmojiName(raw: string, label: string) {
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll {
|
||||
const poll = normalizePollInput(input, {
|
||||
maxOptions: DISCORD_POLL_MAX_ANSWERS,
|
||||
});
|
||||
const duration = normalizePollDurationHours(poll.durationHours, {
|
||||
defaultHours: 24,
|
||||
maxHours: DISCORD_POLL_MAX_DURATION_HOURS,
|
||||
});
|
||||
return {
|
||||
question: { text: poll.question },
|
||||
answers: poll.options.map((answer) => ({ poll_media: { text: answer } })),
|
||||
duration,
|
||||
allow_multiselect: poll.maxSelections > 1,
|
||||
layout_type: PollLayoutType.Default,
|
||||
};
|
||||
}
|
||||
|
||||
function getDiscordErrorCode(err: unknown) {
|
||||
if (!err || typeof err !== "object") return undefined;
|
||||
const candidate =
|
||||
"code" in err && err.code !== undefined
|
||||
? err.code
|
||||
: "rawError" in err && err.rawError && typeof err.rawError === "object"
|
||||
? (err.rawError as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (typeof candidate === "number") return candidate;
|
||||
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
|
||||
return Number(candidate);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function buildDiscordSendError(
|
||||
err: unknown,
|
||||
ctx: {
|
||||
channelId: string;
|
||||
rest: RequestClient;
|
||||
token: string;
|
||||
hasMedia: boolean;
|
||||
},
|
||||
) {
|
||||
if (err instanceof DiscordSendError) return err;
|
||||
const code = getDiscordErrorCode(err);
|
||||
if (code === DISCORD_CANNOT_DM) {
|
||||
return new DiscordSendError(
|
||||
"discord dm failed: user blocks dms or privacy settings disallow it",
|
||||
{ kind: "dm-blocked" },
|
||||
);
|
||||
}
|
||||
if (code !== DISCORD_MISSING_PERMISSIONS) return err;
|
||||
|
||||
let missing: string[] = [];
|
||||
try {
|
||||
const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, {
|
||||
rest: ctx.rest,
|
||||
token: ctx.token,
|
||||
});
|
||||
const current = new Set(permissions.permissions);
|
||||
const required = ["ViewChannel", "SendMessages"];
|
||||
if (isThreadChannelType(permissions.channelType)) {
|
||||
required.push("SendMessagesInThreads");
|
||||
}
|
||||
if (ctx.hasMedia) {
|
||||
required.push("AttachFiles");
|
||||
}
|
||||
missing = required.filter((permission) => !current.has(permission));
|
||||
} catch {
|
||||
/* ignore permission probe errors */
|
||||
}
|
||||
|
||||
const missingLabel = missing.length
|
||||
? `missing permissions in channel ${ctx.channelId}: ${missing.join(", ")}`
|
||||
: `missing permissions in channel ${ctx.channelId}`;
|
||||
return new DiscordSendError(
|
||||
`${missingLabel}. bot might be muted or blocked by role/channel overrides`,
|
||||
{
|
||||
kind: "missing-permissions",
|
||||
channelId: ctx.channelId,
|
||||
missingPermissions: missing,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveChannelId(
|
||||
rest: RequestClient,
|
||||
recipient: DiscordRecipient,
|
||||
request: DiscordRequest,
|
||||
): Promise<{ channelId: string; dm?: boolean }> {
|
||||
if (recipient.kind === "channel") {
|
||||
return { channelId: recipient.id };
|
||||
}
|
||||
const dmChannel = (await request(
|
||||
() =>
|
||||
rest.post(Routes.userChannels(), {
|
||||
body: { recipient_id: recipient.id },
|
||||
}) as Promise<{ id: string }>,
|
||||
"dm-channel",
|
||||
)) as { id: string };
|
||||
if (!dmChannel?.id) {
|
||||
throw new Error("Failed to create Discord DM channel");
|
||||
}
|
||||
return { channelId: dmChannel.id, dm: true };
|
||||
}
|
||||
|
||||
async function sendDiscordText(
|
||||
rest: RequestClient,
|
||||
channelId: string,
|
||||
text: string,
|
||||
replyTo: string | undefined,
|
||||
request: DiscordRequest,
|
||||
maxLinesPerMessage?: number,
|
||||
) {
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Discord sends");
|
||||
}
|
||||
const messageReference = replyTo
|
||||
? { message_id: replyTo, fail_if_not_exists: false }
|
||||
: undefined;
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
});
|
||||
if (chunks.length === 1) {
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: { content: chunks[0], message_reference: messageReference },
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"text",
|
||||
)) as { id: string; channel_id: string };
|
||||
return res;
|
||||
}
|
||||
let last: { id: string; channel_id: string } | null = null;
|
||||
let isFirst = true;
|
||||
for (const chunk of chunks) {
|
||||
last = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: chunk,
|
||||
message_reference: isFirst ? messageReference : undefined,
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"text",
|
||||
)) as { id: string; channel_id: string };
|
||||
isFirst = false;
|
||||
}
|
||||
if (!last) {
|
||||
throw new Error("Discord send failed (empty chunk result)");
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function sendDiscordMedia(
|
||||
rest: RequestClient,
|
||||
channelId: string,
|
||||
text: string,
|
||||
mediaUrl: string,
|
||||
replyTo: string | undefined,
|
||||
request: DiscordRequest,
|
||||
maxLinesPerMessage?: number,
|
||||
) {
|
||||
const media = await loadWebMedia(mediaUrl);
|
||||
const chunks = text
|
||||
? chunkDiscordText(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
})
|
||||
: [];
|
||||
const caption = chunks[0] ?? "";
|
||||
const messageReference = replyTo
|
||||
? { message_id: replyTo, fail_if_not_exists: false }
|
||||
: undefined;
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: caption || undefined,
|
||||
message_reference: messageReference,
|
||||
files: [
|
||||
{
|
||||
data: media.buffer,
|
||||
name: media.fileName ?? "upload",
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"media",
|
||||
)) as { id: string; channel_id: string };
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) continue;
|
||||
await sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
chunk,
|
||||
undefined,
|
||||
request,
|
||||
maxLinesPerMessage,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function buildReactionIdentifier(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
if (emoji.id && emoji.name) {
|
||||
return `${emoji.name}:${emoji.id}`;
|
||||
}
|
||||
return emoji.name ?? "";
|
||||
}
|
||||
|
||||
function formatReactionEmoji(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
return buildReactionIdentifier(emoji);
|
||||
}
|
||||
|
||||
export {
|
||||
buildDiscordSendError,
|
||||
buildReactionIdentifier,
|
||||
createDiscordClient,
|
||||
formatReactionEmoji,
|
||||
normalizeDiscordPollInput,
|
||||
normalizeEmojiName,
|
||||
normalizeReactionEmoji,
|
||||
normalizeStickerIds,
|
||||
parseRecipient,
|
||||
resolveChannelId,
|
||||
resolveDiscordRest,
|
||||
sendDiscordMedia,
|
||||
sendDiscordText,
|
||||
};
|
||||
Reference in New Issue
Block a user