// Discord REST v10, the calls the connector needs, on the built-in // fetch. The token goes in the Authorization header and nowhere else. // // Outcome vocabulary for createMessage matches the outbox: a 2xx is // `confirmed` with the message id; a 4xx other than 429 is `refused`; a 429 // waits `retry_after` and retries a bounded number of times; a 5xx or a // socket error is `unknown`, because the message may or may not exist. The // caller reconciles `unknown` by sending the same nonce again with // enforce_nonce, which makes Discord return the existing message instead of // posting a second one. import { DiscordError } from "./errors.mjs"; export const API_BASE = "https://discord.com/api/v10"; export const USER_AGENT = "DiscordBot (https://git.mosaicstack.dev/mosaicstack/stack, 0.1.0)"; const MAX_429_RETRIES = 3; const MAX_RETRY_AFTER_MS = 30000; export class RestOutcome extends Error { constructor(kind, message, details = {}) { super(message); this.name = "RestOutcome"; this.kind = kind; // refused | unknown this.details = details; } } function redact(text) { return typeof text === "string" ? text.slice(0, 300).replace(/\n/g, " ") : ""; } export function createRest({ token, fetch = globalThis.fetch, base = API_BASE, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = () => {} } = {}) { if (typeof token !== "string" || token.length === 0) throw new DiscordError("rest: token required", 1); if (typeof fetch !== "function") throw new DiscordError("rest: fetch required", 1); async function call(method, path, body) { const headers = { Authorization: `Bot ${token}`, "User-Agent": USER_AGENT }; const init = { method, headers }; if (body !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } let res; try { res = await fetch(`${base}${path}`, init); } catch (err) { throw new RestOutcome("unknown", `${method} ${path}: ${err.message}`, { cause: err.code || err.name }); } const text = await res.text(); let json = null; if (text.length > 0) { try { json = JSON.parse(text); } catch { json = null; } } return { status: res.status, json, text }; } // A read that must succeed. Anything but 2xx is a refusal with the status. async function get(path) { const r = await call("GET", path); if (r.status >= 200 && r.status < 300) return r.json; throw new RestOutcome(r.status >= 500 ? "unknown" : "refused", `GET ${path}: HTTP ${r.status} ${redact(r.text)}`, { status: r.status }); } return { getMe: () => get("/users/@me"), getGatewayBot: () => get("/gateway/bot"), getGuild: (guildId) => get(`/guilds/${guildId}`), getChannel: (channelId) => get(`/channels/${channelId}`), // Open (or fetch) the DM channel with one user; Discord returns the // same channel each time. Resolves the channel id. Throws RestOutcome: // refused on a 4xx, unknown on a 5xx, a socket error or a 2xx without // an id. Only the bus notifier calls it (packages/discord/src/notify.mjs). async createDm(recipientId) { if (typeof recipientId !== "string" || !/^[0-9]{17,20}$/.test(recipientId)) throw new DiscordError("createDm: recipient must be a snowflake id", 1); const r = await call("POST", "/users/@me/channels", { recipient_id: recipientId }); if (r.status >= 200 && r.status < 300) { if (!r.json || typeof r.json.id !== "string") throw new RestOutcome("unknown", "createDm: 2xx without a channel id", { status: r.status }); return r.json.id; } throw new RestOutcome(r.status >= 500 ? "unknown" : "refused", `createDm: HTTP ${r.status} ${redact(r.text)}`, { status: r.status }); }, // Read receipt: one reaction on the inbound message. Best effort like // typing: resolves true on 2xx, false otherwise, never throws. A // reaction that fails must not fail the turn. async react(channelId, messageId, emoji) { if (typeof emoji !== "string" || emoji.length === 0) throw new DiscordError("react: emoji required", 1); try { const r = await call("PUT", `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`); if (r.status >= 200 && r.status < 300) return true; log(`react: HTTP ${r.status} ${redact(r.text)}`); return false; } catch (err) { log(`react: ${err.message}`); return false; } }, async typing(channelId) { try { const r = await call("POST", `/channels/${channelId}/typing`); if (r.status < 200 || r.status >= 300) log(`typing: HTTP ${r.status}`); } catch (err) { log(`typing: ${err.message}`); } }, // Resolves {messageId, status} on confirmation. Throws RestOutcome with // kind refused or unknown. Never throws anything else for HTTP outcomes. async createMessage(channelId, { content, nonce, replyTo = null, components = null }) { if (typeof nonce !== "string" || nonce.length === 0 || nonce.length > 25) throw new DiscordError("createMessage: nonce must be 1..25 chars", 1); if (typeof content !== "string" || content.length === 0 || content.length > 2000) throw new DiscordError("createMessage: content must be 1..2000 chars", 1); const body = { content, nonce, enforce_nonce: true, allowed_mentions: { parse: [], replied_user: false } }; if (replyTo) body.message_reference = { message_id: replyTo, fail_if_not_exists: false }; if (components) body.components = components; for (let attempt = 0; ; attempt++) { const r = await call("POST", `/channels/${channelId}/messages`, body); if (r.status >= 200 && r.status < 300) { if (!r.json || typeof r.json.id !== "string") throw new RestOutcome("unknown", `createMessage: 2xx without a message id`, { status: r.status }); return { messageId: r.json.id, status: r.status }; } if (r.status === 429 && attempt < MAX_429_RETRIES) { const after = r.json && typeof r.json.retry_after === "number" ? r.json.retry_after : 1; const ms = Math.min(Math.max(Math.ceil(after * 1000), 0), MAX_RETRY_AFTER_MS); log(`createMessage: rate limited, waiting ${ms} ms`); await sleep(ms); continue; } if (r.status >= 500) throw new RestOutcome("unknown", `createMessage: HTTP ${r.status} ${redact(r.text)}`, { status: r.status }); throw new RestOutcome("refused", `createMessage: HTTP ${r.status} ${redact(r.text)}`, { status: r.status, code: r.json && r.json.code }); } }, // Answer a component interaction within Discord's window. The body is // the callback object ({type, data}). A 2xx resolves true; anything // else resolves false and is logged, because an interaction that was // not answered in time cannot be answered later anyway. async interactionCallback(interactionId, interactionToken, callback) { if (typeof interactionId !== "string" || typeof interactionToken !== "string" || !callback) throw new DiscordError("interactionCallback: id, token and callback required", 1); try { const r = await call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, callback); if (r.status >= 200 && r.status < 300) return true; log(`interactionCallback: HTTP ${r.status} ${redact(r.text)}`); return false; } catch (err) { log(`interactionCallback: ${err.message}`); return false; } }, // Edit the message a deferred interaction points at. Same outcome // vocabulary as createMessage: 2xx confirmed, 4xx refused, 5xx or a // socket error unknown. async editInteractionMessage(applicationId, interactionToken, { content, components = null }) { if (typeof applicationId !== "string" || typeof interactionToken !== "string") throw new DiscordError("editInteractionMessage: application id and token required", 1); if (typeof content !== "string" || content.length === 0 || content.length > 2000) throw new DiscordError("editInteractionMessage: content must be 1..2000 chars", 1); const body = { content, allowed_mentions: { parse: [], replied_user: false } }; if (components) body.components = components; const r = await call("PATCH", `/webhooks/${applicationId}/${interactionToken}/messages/@original`, body); if (r.status >= 200 && r.status < 300) return { status: r.status }; if (r.status >= 500) throw new RestOutcome("unknown", `editInteractionMessage: HTTP ${r.status} ${redact(r.text)}`, { status: r.status }); throw new RestOutcome("refused", `editInteractionMessage: HTTP ${r.status} ${redact(r.text)}`, { status: r.status, code: r.json && r.json.code }); }, }; }