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]>
157 lines
7.7 KiB
JavaScript
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 });
|
|
},
|
|
};
|
|
}
|