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:
@@ -7,6 +7,11 @@
|
||||
// Loading fails closed: unknown key, missing field, wrong type, bad mode,
|
||||
// symlink, empty allowlist. Nothing is defaulted silently except the limits'
|
||||
// documented defaults below, which the fixture spells out anyway.
|
||||
//
|
||||
// A user entry may carry `channels`, an allowlist of listed channel ids;
|
||||
// absent means every listed channel. A running connector may re-read the
|
||||
// file (`reload`): `reloadDiff` says which keys may change in place and
|
||||
// refuses the rest.
|
||||
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve, sep } from "node:path";
|
||||
@@ -28,7 +33,7 @@ export const LIMIT_DEFAULTS = Object.freeze({
|
||||
|
||||
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context"];
|
||||
const CHANNEL_KEYS = ["id", "name", "mode"];
|
||||
const USER_KEYS = ["id", "name"];
|
||||
const USER_KEYS = ["id", "name", "channels"];
|
||||
const ENGINE_KEYS = ["provider", "model", "thinking"];
|
||||
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
|
||||
const CONTEXT_KEYS = ["files"];
|
||||
@@ -123,7 +128,19 @@ export function validateBinding(raw, where = "binding") {
|
||||
const w = `${where}.users[${i}]`;
|
||||
if (!isObject(u)) throw new DiscordError(`${w}: not an object`);
|
||||
onlyKeys(u, USER_KEYS, w);
|
||||
return Object.freeze({ id: requireSnowflake(u, "id", w), name: requireString(u, "name", w) });
|
||||
const id = requireSnowflake(u, "id", w);
|
||||
const uname = requireString(u, "name", w);
|
||||
let allowed = null;
|
||||
if (u.channels !== undefined) {
|
||||
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
|
||||
allowed = u.channels.map((cid, j) => {
|
||||
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
|
||||
return cid;
|
||||
});
|
||||
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
|
||||
}
|
||||
return Object.freeze({ id, name: uname, channels: allowed === null ? null : Object.freeze(allowed) });
|
||||
});
|
||||
if (new Set(users.map((u) => u.id)).size !== users.length) throw new DiscordError(`${where}: duplicate user id`);
|
||||
if (users.some((u) => u.id === botUserId)) throw new DiscordError(`${where}: the bot cannot be an authorized user`);
|
||||
@@ -166,6 +183,36 @@ export function validateBinding(raw, where = "binding") {
|
||||
});
|
||||
}
|
||||
|
||||
// What a running connector may take from a re-read binding, and what it may
|
||||
// not: the engine and its prompt are launched once, the token is read once,
|
||||
// and the journal directory is named after the binding. A change to a fixed
|
||||
// key needs a stop and a start. Returns a summary of the reloadable
|
||||
// differences or throws with exit 2.
|
||||
export const RELOADABLE_KEYS = Object.freeze(["guildName", "channels", "users", "limits"]);
|
||||
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context"]);
|
||||
|
||||
export function reloadDiff(current, next) {
|
||||
for (const k of FIXED_KEYS) {
|
||||
if (JSON.stringify(current[k]) !== JSON.stringify(next[k])) throw new DiscordError(`reload: ${k} cannot change while running; stop and start instead`);
|
||||
}
|
||||
const byId = (xs) => new Map(xs.map((x) => [x.id, JSON.stringify(x)]));
|
||||
const listDiff = (a, b) => {
|
||||
const A = byId(a);
|
||||
const B = byId(b);
|
||||
return Object.freeze({
|
||||
added: Object.freeze([...B.keys()].filter((id) => !A.has(id))),
|
||||
removed: Object.freeze([...A.keys()].filter((id) => !B.has(id))),
|
||||
changed: Object.freeze([...B.keys()].filter((id) => A.has(id) && A.get(id) !== B.get(id))),
|
||||
});
|
||||
};
|
||||
return Object.freeze({
|
||||
channels: listDiff(current.channels, next.channels),
|
||||
users: listDiff(current.users, next.users),
|
||||
limits: Object.freeze(LIMIT_KEYS.filter((k) => current.limits[k] !== next.limits[k])),
|
||||
guildName: current.guildName !== next.guildName,
|
||||
});
|
||||
}
|
||||
|
||||
// A private file: regular, not a symlink, owner-only (0600), non-empty.
|
||||
export function checkPrivateFile(path, what) {
|
||||
let st;
|
||||
|
||||
Reference in New Issue
Block a user