Files
stack/packages/discord/src/rest.mjs
T
jason.woltjeandClaude Fable 5.1 43d7574d6a feat(discord): SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (#1509)
Row 25, parts 2a and 2b, against the shared-signals contract a5425a2.

Model side: eight fixed verbs in the pi extension (record_list, record_get,
record_create, record_update, resolve_id, open_approval_request,
get_approval_request, create_document), each one HTTP call with arguments
checked before any request. Writes carry an idempotency key
<principal>:<message id>:<call index> and an audit context. The seat key is
read from a 0600 file on every call and never cached, printed or journaled.

Connector side: append-only approval ledger, Approve button and exact
"approve" reply resolved by the connector against the required approvers,
confirmation message posted as button evidence, bind and add_approval through
the service under connector keys, retry of unknown entries on start.

Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by
rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-22 12:59:39 -05:00

157 lines
7.7 KiB
JavaScript

// 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}`),
// 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 });
},
};
}