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(/^]+):(\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: 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, };