Files
stack/packages/discord/src/rest.mjs
T
jason.woltjeandClaude Opus 5.5 2f5303c1c7 feat(cli): the mosaic CLI, broker host and decision notifier (row 39, S4, rocko)
packages/cli adds mosaic inbox, decide, tasks, agents and trail over the
human-cli transport, and mosaic bus start, stop and status as the trusted
host (unit mosaic-bus@<business>, scripts/bus-service.sh). The host boots
packages/bus/src/process.mjs, passes config.trackers from the tracker.*
variables (lead decision 70), and runs a notifier child. The notifier DMs
each open blocking decision once and sends an 08:00 America/Chicago
digest, journaled in notify/<business>/sent.jsonl at 0600 with no Discord
ids. A torn journal tail is copied aside and truncated; a malformed line,
a directory looser than 0700 or a symlinked journal refuses (lead
decision 71). packages/discord gains dmRecipient, createDm and notify.mjs.

Candidate agents/rocko/work/slice1-s4, base b9b6cf00, build.patch
b52f7d68, manifest e858504e (29 files). Darkwing approved round 2 on
#1521 (comment 26855), Filbert approved round 2 (comment 26856). The
packet's mutant table lists M28 as killed; it survived, and BUILD-LOG
records the correction.

Integration gate in a worktree on 2557e29d with the patch applied:
bus 67, business 60, cli 49, control-board 124, discord 178, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3, the same K1, K3 and K10 cases that fail
on the base; S4 doesn't touch the package. Every scripts/test-*.sh is
green, with test-release 14/14 and test-task 98/98 on the existing gate2
compose network. A scratch test, not in this commit, booted the real
host with trackers against S3's fake Vikunja: the adapter went ready and
a task.close on a missing task answered task-not-found after a Vikunja
read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:49:38 -05:00

171 lines
8.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}`),
// 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 });
},
};
}