Spaces:
Paused
Paused
| import type { RESTAPIPoll } from "discord-api-types/rest/v10"; | |
| import { RequestClient } from "@buape/carbon"; | |
| import { PollLayoutType } from "discord-api-types/payloads/v10"; | |
| import { Routes } from "discord-api-types/v10"; | |
| import type { ChunkMode } from "../auto-reply/chunk.js"; | |
| import type { RetryConfig } from "../infra/retry.js"; | |
| import { loadConfig } from "../config/config.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 { chunkDiscordTextWithMode } from "./chunk.js"; | |
| import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; | |
| import { DiscordSendError } from "./send.types.js"; | |
| import { parseDiscordTarget, resolveDiscordTarget } from "./targets.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 target = parseDiscordTarget(raw, { | |
| ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`, | |
| }); | |
| if (!target) { | |
| throw new Error("Recipient is required for Discord sends"); | |
| } | |
| return { kind: target.kind, id: target.id }; | |
| } | |
| /** | |
| * Parse and resolve Discord recipient, including username lookup. | |
| * This enables sending DMs by username (e.g., "john.doe") by querying | |
| * the Discord directory to resolve usernames to user IDs. | |
| * | |
| * @param raw - The recipient string (username, ID, or known format) | |
| * @param accountId - Discord account ID to use for directory lookup | |
| * @returns Parsed DiscordRecipient with resolved user ID if applicable | |
| */ | |
| export async function parseAndResolveRecipient( | |
| raw: string, | |
| accountId?: string, | |
| ): Promise<DiscordRecipient> { | |
| const cfg = loadConfig(); | |
| const accountInfo = resolveDiscordAccount({ cfg, accountId }); | |
| // First try to resolve using directory lookup (handles usernames) | |
| const trimmed = raw.trim(); | |
| const parseOptions = { | |
| ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, | |
| }; | |
| const resolved = await resolveDiscordTarget( | |
| raw, | |
| { | |
| cfg, | |
| accountId: accountInfo.accountId, | |
| }, | |
| parseOptions, | |
| ); | |
| if (resolved) { | |
| return { kind: resolved.kind, id: resolved.id }; | |
| } | |
| // Fallback to standard parsing (for channels, etc.) | |
| const parsed = parseDiscordTarget(raw, parseOptions); | |
| if (!parsed) { | |
| throw new Error("Recipient is required for Discord sends"); | |
| } | |
| return { kind: parsed.kind, id: parsed.id }; | |
| } | |
| 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, | |
| embeds?: unknown[], | |
| chunkMode?: ChunkMode, | |
| ) { | |
| 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 = chunkDiscordTextWithMode(text, { | |
| maxChars: DISCORD_TEXT_LIMIT, | |
| maxLines: maxLinesPerMessage, | |
| chunkMode, | |
| }); | |
| if (!chunks.length && text) { | |
| chunks.push(text); | |
| } | |
| if (chunks.length === 1) { | |
| const res = (await request( | |
| () => | |
| rest.post(Routes.channelMessages(channelId), { | |
| body: { | |
| content: chunks[0], | |
| message_reference: messageReference, | |
| ...(embeds?.length ? { embeds } : {}), | |
| }, | |
| }) 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, | |
| ...(isFirst && embeds?.length ? { embeds } : {}), | |
| }, | |
| }) 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, | |
| embeds?: unknown[], | |
| chunkMode?: ChunkMode, | |
| ) { | |
| const media = await loadWebMedia(mediaUrl); | |
| const chunks = text | |
| ? chunkDiscordTextWithMode(text, { | |
| maxChars: DISCORD_TEXT_LIMIT, | |
| maxLines: maxLinesPerMessage, | |
| chunkMode, | |
| }) | |
| : []; | |
| if (!chunks.length && text) { | |
| chunks.push(text); | |
| } | |
| 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, | |
| ...(embeds?.length ? { embeds } : {}), | |
| 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, | |
| undefined, | |
| chunkMode, | |
| ); | |
| } | |
| 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, | |
| }; | |