feat(discord): connector pilot for the Sage seat, reviewed candidate (#1509)
Zero-dependency Discord connector under packages/discord: binding
validation, REST and gateway clients, pi engine adapter, journal with
append-only inbox, outbox, admissions and notices, and a run.lock
ownership record {pid, start, boot} whose identity is checked three ways
and whose cleanup is gated by STOP. CLI check|run|stop|unlock via
scripts/discord.sh; offline suite scripts/test-discord.sh (28 checks,
87 node tests).
Reviewed by rev-code-02 on #1509 over nine rounds; approved exact tree
4e0feb6758c0a7e4a71483912a8e0d3e3ec95aef at comment 26170. Corrections
(1) to (12) recorded in BUILD-LOG. No listener started, no token read,
no Discord write; the live pilot follows this commit per the brief.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// Shared fakes for the offline suite. No network, no token, no model.
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after } from "node:test";
|
||||
import { validateBinding } from "../src/binding.mjs";
|
||||
import { RestOutcome } from "../src/rest.mjs";
|
||||
|
||||
const roots = [];
|
||||
export function makeRoot() {
|
||||
const root = mkdtempSync(join(tmpdir(), "mosaic-discord-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
after(() => {
|
||||
for (const r of roots) rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
export const IDS = Object.freeze({
|
||||
guild: "100000000000000001",
|
||||
bot: "100000000000000002",
|
||||
admin: "100000000000000010",
|
||||
general: "100000000000000011",
|
||||
other: "100000000000000012",
|
||||
owner: "100000000000000100",
|
||||
stranger: "100000000000000101",
|
||||
threadOfAdmin: "100000000000000020",
|
||||
threadOfOther: "100000000000000021",
|
||||
});
|
||||
|
||||
export function rawBinding(overrides = {}) {
|
||||
return {
|
||||
bindingVersion: 1,
|
||||
name: "test-seat",
|
||||
seat: "sage",
|
||||
guildId: IDS.guild,
|
||||
guildName: "Test Server",
|
||||
botUserId: IDS.bot,
|
||||
tokenFile: "/nonexistent/token",
|
||||
channels: [
|
||||
{ id: IDS.admin, name: "seat-admin", mode: "open" },
|
||||
{ id: IDS.general, name: "general", mode: "mention" },
|
||||
],
|
||||
users: [{ id: IDS.owner, name: "owner" }],
|
||||
engine: { provider: "zai", model: "glm-5.3", thinking: "high" },
|
||||
limits: { turnsPerDay: 200, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 },
|
||||
context: { files: ["contracts/CONSTITUTION.md"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function binding(overrides = {}) {
|
||||
return validateBinding(rawBinding(overrides));
|
||||
}
|
||||
|
||||
// A fake repository with the files prepare() looks for.
|
||||
export function makeRepo(root) {
|
||||
const repo = join(root, "repo");
|
||||
mkdirSync(join(repo, "node_modules", ".bin"), { recursive: true });
|
||||
mkdirSync(join(repo, "contracts"), { recursive: true });
|
||||
writeFileSync(join(repo, "node_modules", ".bin", "pi"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
writeFileSync(join(repo, "contracts", "CONSTITUTION.md"), "# constitution\nbe good\n");
|
||||
return repo;
|
||||
}
|
||||
|
||||
// A data root with config and one binding on disk. Returns paths.
|
||||
export function makeDeployment(root, overrides = {}, { tokenMode = 0o600, tokenContent = "MTAw.abcdefghijklmnopqrstuvwxyz0123456789" } = {}) {
|
||||
const dataRoot = join(root, "data");
|
||||
const secrets = join(root, "secrets");
|
||||
mkdirSync(join(dataRoot, "discord"), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||||
const tokenFile = join(secrets, "bot.token");
|
||||
writeFileSync(tokenFile, tokenContent + "\n", { mode: 0o600 });
|
||||
chmodSync(tokenFile, tokenMode);
|
||||
const raw = rawBinding({ tokenFile, ...overrides });
|
||||
const bindingFile = join(dataRoot, "discord", `${raw.name}.json`);
|
||||
writeFileSync(bindingFile, JSON.stringify(raw, null, 2), { mode: 0o600 });
|
||||
const config = join(root, "config.json");
|
||||
writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
return { dataRoot, config, bindingFile, tokenFile, raw, journalDir: join(dataRoot, "discord", raw.name) };
|
||||
}
|
||||
|
||||
export function message(overrides = {}) {
|
||||
return {
|
||||
id: overrides.id || "200000000000000001",
|
||||
channel_id: IDS.admin,
|
||||
guild_id: IDS.guild,
|
||||
author: { id: IDS.owner, username: "owner" },
|
||||
content: "hello",
|
||||
mentions: [],
|
||||
mention_everyone: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Fake REST: scripted outcomes for createMessage, records every call.
|
||||
export function fakeRest({ outcomes = [] } = {}) {
|
||||
const calls = [];
|
||||
let n = 0;
|
||||
return {
|
||||
calls,
|
||||
typingCalls: [],
|
||||
channels: new Map(),
|
||||
lookups: 0,
|
||||
// When set, getChannel waits on this promise before answering (held lookup).
|
||||
holdLookup: null,
|
||||
push(outcome) {
|
||||
outcomes.push(outcome);
|
||||
},
|
||||
async createMessage(channelId, body) {
|
||||
calls.push({ channelId, ...body });
|
||||
const o = outcomes.length > 0 ? outcomes.shift() : { ok: true };
|
||||
if (o.ok) return { messageId: o.messageId || `m${++n}`, status: 200 };
|
||||
throw new RestOutcome(o.kind, o.message || `fake ${o.kind}`);
|
||||
},
|
||||
async typing(channelId) {
|
||||
this.typingCalls.push(channelId);
|
||||
},
|
||||
async getChannel(id) {
|
||||
this.lookups += 1;
|
||||
if (this.holdLookup) await this.holdLookup;
|
||||
const c = this.channels.get(id);
|
||||
if (!c) throw new RestOutcome("refused", "fake 404");
|
||||
return c;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeGateway() {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
connected: false,
|
||||
closed: null,
|
||||
on(name, fn) {
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
listeners.get(name).add(fn);
|
||||
},
|
||||
emit(name, payload) {
|
||||
for (const fn of listeners.get(name) || []) fn(payload);
|
||||
},
|
||||
connect() {
|
||||
this.connected = true;
|
||||
},
|
||||
close(code, reason) {
|
||||
this.closed = { code, reason };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fake engine: each prompt resolves with a scripted reply, or rejects.
|
||||
// Replies come back in prompt order, as pi's turn_end events do.
|
||||
// hold: true keeps every prompt pending until release() is called, so tests
|
||||
// can observe the connector's state while turns are in flight.
|
||||
export function fakeEngine({ replies = [], delayMs = 0, hold = false } = {}) {
|
||||
const prompts = [];
|
||||
let chain = Promise.resolve();
|
||||
let release = () => {};
|
||||
const gate = hold ? new Promise((resolve) => { release = resolve; }) : Promise.resolve();
|
||||
return {
|
||||
prompts,
|
||||
started: false,
|
||||
stopped: false,
|
||||
release() {
|
||||
release();
|
||||
},
|
||||
start() {
|
||||
this.started = true;
|
||||
},
|
||||
prompt(text, opts) {
|
||||
prompts.push({ text, opts });
|
||||
const r = replies.length > 0 ? replies.shift() : { text: "ok" };
|
||||
const run = () => gate.then(() => new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (r.error) reject(Object.assign(new Error(r.error), { details: { code: r.code || "fake" } }));
|
||||
else resolve({ text: r.text, message: null, usage: r.usage || { input: 1, output: 1 }, model: null, provider: null });
|
||||
}, r.delayMs ?? delayMs);
|
||||
}));
|
||||
const p = chain.then(run, run);
|
||||
chain = p.catch(() => {});
|
||||
return p;
|
||||
},
|
||||
async stop() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Manual timers for gateway and engine tests.
|
||||
export function fakeTimers() {
|
||||
let now = 0;
|
||||
let seq = 0;
|
||||
const timers = new Map();
|
||||
return {
|
||||
setTimeout(fn, ms) {
|
||||
const id = ++seq;
|
||||
timers.set(id, { at: now + ms, fn });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
get pending() {
|
||||
return timers.size;
|
||||
},
|
||||
// Advance the clock, running timers in order.
|
||||
advance(ms) {
|
||||
const target = now + ms;
|
||||
for (;;) {
|
||||
let next = null;
|
||||
for (const [id, t] of timers) if (t.at <= target && (next === null || t.at < next.t.at)) next = { id, t };
|
||||
if (!next) break;
|
||||
timers.delete(next.id);
|
||||
now = next.t.at;
|
||||
next.t.fn();
|
||||
}
|
||||
now = target;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function flush() {
|
||||
return new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
Reference in New Issue
Block a user