feat(discord): binding reload without a restart, and a per-user channel allowlist (#1509)

`reload` validates the binding file and sends SIGHUP to the live owner;
the running connector re-reads it and swaps guildName, channels, users
and limits in place. name, seat, guildId, botUserId, tokenFile, engine
and context are fixed for the life of the process; a change there, an
invalid file or a channel outside the guild refuses the reload and keeps
the old binding. Every attempt is one line in reloads.jsonl. The service
unit maps `systemctl --user reload` to the same signal.

A user entry may carry `channels`, an allowlist of listed channel ids;
absent means every listed channel. Outside the list the message is
dropped as channel-not-for-user; threads count as their parent.

Suite 41/41, 101 node tests. QUEUE rows 19 and 20 opened.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-13 18:59:31 -05:00
co-authored by Claude Fable 5.1
parent d9745a4510
commit caaef941e6
16 changed files with 326 additions and 21 deletions
+41
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { createConnector, FIXED_LINES, RECONCILE_WINDOW_MS, READ_RECEIPT } from "../src/connector.mjs";
import { readOutbox, readDrops, listTurns, readInboxIds, appendOutbox, appendInbox, ensureJournal, requestStop, writeTurn, countAdmissionsOn, noticeOn } from "../src/journal.mjs";
import { makeRoot, binding, message, IDS, fakeRest, fakeGateway, fakeEngine } from "./helpers.mjs";
import { DiscordError } from "../src/errors.mjs";
function setup({ bindingOverrides = {}, replies = [], outcomes = [], now, engineOverrides = {} } = {}) {
const root = makeRoot();
@@ -435,3 +436,43 @@ test("receipt: Discord refusing the reaction leaves the turn intact and records
assert.deepEqual(turns[0].receipt, { emoji: READ_RECEIPT, ok: false });
await connector.stop();
});
test("reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once", async () => {
const { journalDir, engine, connector } = setup();
await connector.start();
const r0 = await connector.handleMessage(message({ id: "300000000000000090", author: { id: IDS.stranger } }));
assert.equal(r0.reason, "user-unlisted");
const diff = connector.reload(binding({
channels: [{ id: IDS.admin, name: "seat-admin", mode: "open" }],
users: [{ id: IDS.owner, name: "owner" }, { id: IDS.stranger, name: "guest", channels: [IDS.admin] }],
limits: { turnsPerDay: 2, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 },
}));
assert.deepEqual(diff.users.added, [IDS.stranger]);
assert.deepEqual(diff.channels.removed, [IDS.general]);
assert.deepEqual(diff.limits, ["turnsPerDay"]);
const r1 = await connector.handleMessage(message({ id: "300000000000000091", author: { id: IDS.stranger } }));
assert.equal(r1.accepted, true);
assert.equal(await r1.turn, "ok");
const r2 = await connector.handleMessage(message({ id: "300000000000000092", channel_id: IDS.general, mentions: [{ id: IDS.bot }] }));
assert.equal(r2.reason, "channel-unlisted");
const r3 = await connector.handleMessage(message({ id: "300000000000000093" }));
assert.equal(await r3.turn, "ok");
const r4 = await connector.handleMessage(message({ id: "300000000000000094" }));
assert.equal(r4.reason, "ceiling");
assert.equal(engine.prompts.length, 2);
assert.equal(readDrops(journalDir).map((d) => d.reason).join(","), "user-unlisted,channel-unlisted,ceiling");
await connector.stop();
});
test("reload: a fixed key refuses with exit 2 and the old binding stays in force", async () => {
const { connector } = setup();
await connector.start();
const before = connector.binding;
assert.throws(() => connector.reload(binding({ seat: "other" })), (err) => err instanceof DiscordError && err.exitCode === 2 && /seat cannot change/.test(err.message));
assert.throws(() => connector.reload(binding({ engine: { provider: "zai", model: "glm-5.3", thinking: "low" } })), /engine cannot change/);
assert.equal(connector.binding, before);
const r = await connector.handleMessage(message({ id: "300000000000000095" }));
assert.equal(r.accepted, true);
assert.equal(await r.turn, "ok");
await connector.stop();
});