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, baseb9b6cf00, 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 on2557e29dwith 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]>
81 lines
4.7 KiB
JavaScript
81 lines
4.7 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { openDirect } from "../src/notify.mjs";
|
|
import { RestOutcome } from "../src/rest.mjs";
|
|
import { DiscordError } from "../src/errors.mjs";
|
|
import { makeRoot, makeDeployment, IDS } from "./helpers.mjs";
|
|
|
|
const CHANNEL = "100000000000000900";
|
|
const TOKEN = "MTAw.abcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
|
function fakeFetch(script) {
|
|
const calls = [];
|
|
const fetch = async (url, init) => {
|
|
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null, auth: init.headers.Authorization });
|
|
const next = script.shift();
|
|
if (!next) throw new Error("fake fetch: no scripted response");
|
|
return { status: next.status, text: async () => (next.body === undefined ? "" : JSON.stringify(next.body)) };
|
|
};
|
|
return { fetch, calls };
|
|
}
|
|
|
|
const secretFree = (err) => ![IDS.owner, CHANNEL, TOKEN].some((s) => err.message.includes(s) || JSON.stringify(err.details ?? {}).includes(s));
|
|
|
|
test("notify: the DM channel opens once, every send carries the nonce, and only the message id comes back", async () => {
|
|
const { dataRoot } = makeDeployment(makeRoot(), { dmRecipient: IDS.owner });
|
|
const f = fakeFetch([
|
|
{ status: 200, body: { id: CHANNEL, type: 1 } },
|
|
{ status: 200, body: { id: "300000000000000001" } },
|
|
{ status: 200, body: { id: "300000000000000002" } },
|
|
]);
|
|
const dm = openDirect({ dataRoot, name: "test-seat", fetch: f.fetch, base: "http://fake" });
|
|
assert.deepEqual(await dm.send({ content: "one", nonce: "dm1" }), { messageId: "300000000000000001" });
|
|
assert.deepEqual(await dm.send({ content: "two", nonce: "dm2" }), { messageId: "300000000000000002" });
|
|
assert.deepEqual(f.calls.map((c) => `${c.method} ${c.url}`), [
|
|
"POST http://fake/users/@me/channels",
|
|
`POST http://fake/channels/${CHANNEL}/messages`,
|
|
`POST http://fake/channels/${CHANNEL}/messages`,
|
|
]);
|
|
assert.deepEqual(f.calls[0].body, { recipient_id: IDS.owner });
|
|
assert.equal(f.calls[1].body.nonce, "dm1");
|
|
assert.equal(f.calls[1].body.enforce_nonce, true);
|
|
assert.deepEqual(f.calls[1].body.allowed_mentions, { parse: [], replied_user: false });
|
|
assert.ok(f.calls.every((c) => c.auth === `Bot ${TOKEN}`));
|
|
assert.equal(JSON.stringify(dm).includes(TOKEN) || JSON.stringify(dm).includes(IDS.owner), false);
|
|
});
|
|
|
|
test("notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time", async () => {
|
|
const { dataRoot } = makeDeployment(makeRoot(), { dmRecipient: IDS.owner });
|
|
const f = fakeFetch([
|
|
{ status: 403, body: { code: 50007, message: `Cannot send messages to ${IDS.owner}` } },
|
|
{ status: 200, body: { id: CHANNEL } },
|
|
{ status: 502, body: { message: `channel ${CHANNEL} upstream` } },
|
|
{ status: 200, body: { id: "300000000000000003" } },
|
|
{ status: 403, body: { code: 50007, message: "Cannot send messages to this user" } },
|
|
{ status: 200, body: { id: CHANNEL } },
|
|
{ status: 200, body: { id: "300000000000000004" } },
|
|
]);
|
|
const dm = openDirect({ dataRoot, name: "test-seat", fetch: f.fetch, base: "http://fake" });
|
|
await assert.rejects(dm.send({ content: "x", nonce: "n1" }), (e) => e instanceof RestOutcome && e.kind === "refused" && e.details.status === 403 && secretFree(e));
|
|
await assert.rejects(dm.send({ content: "x", nonce: "n1" }), (e) => e instanceof RestOutcome && e.kind === "unknown" && secretFree(e));
|
|
// unknown keeps the channel; the retry reuses it with the same nonce.
|
|
assert.deepEqual(await dm.send({ content: "x", nonce: "n1" }), { messageId: "300000000000000003" });
|
|
await assert.rejects(dm.send({ content: "y", nonce: "n2" }), (e) => e.kind === "refused" && secretFree(e));
|
|
assert.deepEqual(await dm.send({ content: "y", nonce: "n2" }), { messageId: "300000000000000004" });
|
|
assert.equal(f.calls.filter((c) => c.url.endsWith("/users/@me/channels")).length, 3);
|
|
});
|
|
|
|
test("notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use", async () => {
|
|
let calls = 0;
|
|
const fetch = async () => {
|
|
calls++;
|
|
throw new Error("network");
|
|
};
|
|
const none = makeDeployment(makeRoot());
|
|
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && e.exitCode === 2 && /no dmRecipient/.test(e.message));
|
|
const loose = makeDeployment(makeRoot(), { dmRecipient: IDS.owner }, { tokenMode: 0o644 });
|
|
assert.throws(() => openDirect({ dataRoot: loose.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && /mode 0600/.test(e.message) && !e.message.includes(TOKEN));
|
|
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding not found/.test(e.message));
|
|
assert.equal(calls, 0);
|
|
});
|