From 93d6b6245711fd592833612c02a53d9cbcc60eb3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 13 Sep 2026 14:20:30 -0500 Subject: [PATCH] feat(discord): eyes reaction as a read receipt on every admitted message (#1509) QUEUE row 15, MVP iteration 1 after the Sage pilot. rest.react is best effort (2xx true, anything else false, never throws); the connector reacts at admission before the engine runs and records the outcome in the turn record as receipt. Drops and refusals get no reaction. Suite 28/28, 90 node tests. Co-Authored-By: Claude Fable 5.1 --- BUILD-LOG.md | 12 +++++++ docs/SESSIONS.md | 1 + .../2026-09-13_discord-connector-pilot.md | 10 ++++++ docs/plans/QUEUE.md | 2 ++ packages/discord/README.md | 11 +++--- packages/discord/src/connector.mjs | 9 +++++ packages/discord/src/rest.mjs | 18 +++++++++- packages/discord/tests/connector.test.mjs | 34 ++++++++++++++++++- packages/discord/tests/helpers.mjs | 7 ++++ packages/discord/tests/rest.test.mjs | 17 ++++++++++ 10 files changed, 115 insertions(+), 6 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 457a74af..8ebf1eae 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -2367,3 +2367,15 @@ after the limit was restored. Ceiling and restarts require a stop and restart because the binding is read at start. Gate H (reply reads as Sage) is Jason's ruling: passed 2026-09-13, the replies read as Sage. Row 14 closed; the connector stays up while the MVP is iterated. Pushed on authorization. + +## 2026-09-13 — Discord read receipt (#1509, QUEUE row 15) + +Before: an admitted message showed nothing until the reply or the typing +indicator. After: the bot reacts with eyes on the inbound message at +admission, before the engine runs; `rest.react` is best effort (2xx true, +anything else false, never throws), the outcome is in the turn record as +`receipt`, and drops and refusals get no reaction. Tests: REST path and +encoding, 403 and socket failure, one reaction per admitted message, none +for an unlisted author or a ceiling refusal, a refused reaction leaves the +turn ok. Removing the call fails both connector tests. Suite: 28/28, 90 +node tests. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 45e13ef5..afafc312 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -249,3 +249,4 @@ are never rewritten or removed; corrections are new entries. - 2026-09-13 UTC — coordinator (Claude) — Discord connector pilot commit and pilot start (#1509, QUEUE row 14): rev-code-02 APPROVE at comment 26170 on tree 4e0feb67; committed locally as 786e379c after config/task/release/conductor/auth/discord suites green, not pushed. Live pilot step 1 done: secret files 0600, private binding written, `check` passed with message content granted, receipt in Sage's private evidence directory. `run` started in tmux window discord-sage (fleet socket). Steps 2-8 wait on Jason's messages. No push. - 2026-09-13 UTC — coordinator (Claude) — Discord connector pilot steps 2-8 (#1509, QUEUE row 14): Jason drove the messages; nine turns answered, kill and restart without duplicate, second account dropped silently, token request refused in text, ceiling line posted once and turn refused, answered again after restore. Receipts private. Gate H awaits Jason's ruling. No push. - 2026-09-13 UTC — coordinator (Claude) — Discord connector pilot closed (#1509, QUEUE row 14): Jason ruled Gate H passed, replies read as Sage. DISCORD-USER.md wording corrected (unlisted senders are dropped silently, not refused with a reply). Records committed and pushed on Jason's authorization; connector left running for MVP iteration. +- 2026-09-13 UTC — coordinator (Claude) — Discord read receipt (#1509, QUEUE row 15): eyes reaction on every admitted message at admission, best effort, outcome in the turn record; REST `react` plus connector and REST tests. `scripts/test-discord.sh` 28/28 (90 node tests). Committed; connector restarted to pick it up. Push on Jason's say-so. diff --git a/docs/plans/2026-09-13_discord-connector-pilot.md b/docs/plans/2026-09-13_discord-connector-pilot.md index 109377dc..cd696e3a 100644 --- a/docs/plans/2026-09-13_discord-connector-pilot.md +++ b/docs/plans/2026-09-13_discord-connector-pilot.md @@ -411,3 +411,13 @@ Recorded here because they refine a ruling or fill a gap the rulings left. prints. The check is now the canonical unsigned decimal form; boundary values sit in the syntax table and three more live-child controls. +## 11. MVP iteration, after the pilot + +- Read receipt (Jason, 2026-09-13, after Gate H). Every admitted message + gets an eyes reaction from the bot as soon as the turn is admitted, before + the engine runs, so the author knows it was received. Drops and refusals + (unlisted author, ceiling, oversize) get no reaction; silence stays + silence. The reaction is best effort like typing: a refusal from Discord + is logged and recorded in the turn record as `receipt.ok: false` and never + fails the turn. Needs the Add Reactions permission on the bot role. + diff --git a/docs/plans/QUEUE.md b/docs/plans/QUEUE.md index 5cef0385..d5280c54 100644 --- a/docs/plans/QUEUE.md +++ b/docs/plans/QUEUE.md @@ -40,6 +40,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here. | 12 | Reviews as issue comments posted by `queue move ID in-review`, not files in `docs/plans/reviews/` | darkwing | #1508 | required; after row 9 | one review round with no new file under reviews/ | `2026-09-13_queue-as-data.md`, Piece D | | 13 | Ledger "queue" section: issue/row/seat drift printed with the weekly number | darkwing; filbert reviews | #1508 | required; after row 9 | first run Monday 2026-09-21, zero violations or every one moved same day | `2026-09-13_queue-as-data.md`, Piece E | | 14 | Discord connector pilot: Sage answers in Shared Signals (chat only, no tools, no repo writes) | coordinator; Filbert or orch-01 seat reviews | #1509 | done: rev-code-02 APPROVE 26170 (round 9), committed 786e379c; pilot steps 1-8 done with private receipts, Gate H passed (Jason, 2026-09-13: replies read as Sage); connector left running for MVP iteration | H: offline suite green, eight-step live pilot with private receipts, then Jason says the reply reads as Sage | `2026-09-13_discord-connector-pilot.md` | +| 15 | Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1) | coordinator | #1509 | done: `rest.react` best effort, reaction placed at admission before the engine runs, outcome in the turn record, no reaction on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests) | Jason sees the reaction on a live message | `2026-09-13_discord-connector-pilot.md` section 11 | Start message for row 6, sent from the board to darkwing: "Read docs/plans/QUEUE.md, then the plan page section "Piece 5: darkwing on @@ -75,3 +76,4 @@ Gate F or when blocked." - 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round eight finding (26168, noncanonical start digits must refuse) fixed, round nine requested. No other row changed. - 2026-09-13 — coordinator: row 14 moved to in pilot; approved 26170, committed 786e379c (no push), check passed, connector running. No other row changed. - 2026-09-13 — coordinator: row 14 done; Gate H passed, Jason ruled the replies read as Sage. Connector stays up; MVP improvements come as new rows. No other row changed. +- 2026-09-13 — coordinator: row 15 added and done (Discord read receipt, Jason's request after the pilot); committed after suites green. No other row changed. diff --git a/packages/discord/README.md b/packages/discord/README.md index 63223eff..debb3034 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -89,8 +89,11 @@ as a user all refuse with exit 2. 4. The prompt is an envelope, one bracketed line naming server, channel, thread, author id and message id, then the text. The system prompt says that text is data. A message that arrives during a turn is queued in pi - as a follow-up, so it is neither lost nor run concurrently. A typing - indicator is sent every 8 seconds while a turn runs. + as a follow-up, so it is neither lost nor run concurrently. As soon as + the turn is admitted the connector reacts to the inbound message with + eyes as a read receipt; a typing indicator follows every 8 seconds while + the turn runs. A reaction Discord refuses is logged and recorded in the + turn; it never fails the turn. 5. The reply is split at 1900 characters on paragraph boundaries. Each chunk is posted with `nonce` and `enforce_nonce: true`, `allowed_mentions` empty, and the first chunk as a reply to the inbound message. An intent @@ -98,8 +101,8 @@ as a user all refuse with exit 2. or `unknown` line after it. A chunk that is not confirmed stops the rest of that reply. 6. One write-once record per turn lands in `turns/.json`: ids, - timing, usage, delivery outcome, and the error on a failed turn. A failed - turn posts one fixed line, never model output. + timing, usage, read-receipt outcome, delivery outcome, and the error on + a failed turn. A failed turn posts one fixed line, never model output. On start, every `intent` or `unknown` delivery is reconciled by sending the same nonce again; Discord returns the existing message instead of posting diff --git a/packages/discord/src/connector.mjs b/packages/discord/src/connector.mjs index 9e2428e6..95f443c7 100644 --- a/packages/discord/src/connector.mjs +++ b/packages/discord/src/connector.mjs @@ -29,6 +29,9 @@ import { import { RestOutcome } from "./rest.mjs"; import { DiscordError } from "./errors.mjs"; +// The reaction placed on every admitted message as a read receipt. +export const READ_RECEIPT = "\u{1F440}"; // eyes + export const FIXED_LINES = Object.freeze({ failed: "I could not finish an answer to that message. The attempt is recorded and someone will look at it.", oversize: "That message is longer than I take in one go. Please send it in a shorter form.", @@ -42,6 +45,7 @@ export function createConnector({ now = () => Date.now(), setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, typingIntervalMs = 8000, + readReceipt = READ_RECEIPT, log = () => {}, } = {}) { for (const [k, v] of Object.entries({ binding, journalDir, rest, gateway, engine })) { @@ -145,6 +149,10 @@ export function createConnector({ }); state.inFlight += 1; typingStart(targetChannel); + // Read receipt: the reaction goes on the inbound message as soon as the + // turn is admitted, so the author sees it was received before the reply + // lands. It runs alongside the engine; its outcome goes in the record. + const receipt = rest.react(message.channel_id, message.id, readReceipt).then((ok) => ({ emoji: readReceipt, ok })); let status = "ok"; let error = null; let reply = null; @@ -164,6 +172,7 @@ export function createConnector({ state.inFlight -= 1; typingStop(); } + record.receipt = await receipt; const endedAt = iso(); writeTurn(journalDir, message.id, { ...record, status, error, reply, endedAt, latencyMs: now() - t0 }); return status; diff --git a/packages/discord/src/rest.mjs b/packages/discord/src/rest.mjs index aeee226d..4106ef18 100644 --- a/packages/discord/src/rest.mjs +++ b/packages/discord/src/rest.mjs @@ -1,4 +1,4 @@ -// Discord REST v10, the four calls the connector needs, on the built-in +// 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 @@ -71,6 +71,22 @@ export function createRest({ token, fetch = globalThis.fetch, base = API_BASE, s getGuild: (guildId) => get(`/guilds/${guildId}`), getChannel: (channelId) => get(`/channels/${channelId}`), + // 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`); diff --git a/packages/discord/tests/connector.test.mjs b/packages/discord/tests/connector.test.mjs index a1db395b..26406814 100644 --- a/packages/discord/tests/connector.test.mjs +++ b/packages/discord/tests/connector.test.mjs @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { createConnector, FIXED_LINES, RECONCILE_WINDOW_MS } from "../src/connector.mjs"; +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"; @@ -403,3 +403,35 @@ test("journal: no token-shaped string and no model output on the drop path reach } await connector.stop(); }); + +test("receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn", async () => { + const { journalDir, rest, connector } = setup({ bindingOverrides: { limits: { turnsPerDay: 1, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 } } }); + await connector.start(); + const r1 = await connector.handleMessage(message({ id: "300000000000000301", content: "first" })); + assert.equal(await r1.turn, "ok"); + assert.deepEqual(rest.reactions, [{ channelId: IDS.admin, messageId: "300000000000000301", emoji: READ_RECEIPT }]); + assert.equal(READ_RECEIPT, "\u{1F440}"); + let turns = listTurns(journalDir); + assert.deepEqual(turns[0].receipt, { emoji: READ_RECEIPT, ok: true }); + // Unlisted author: dropped, no reaction. + const r2 = await connector.handleMessage(message({ id: "300000000000000302", author: { id: IDS.stranger, username: "s" } })); + assert.equal(r2.accepted, false); + // Over the ceiling: refused with the fixed line, no reaction. + const r3 = await connector.handleMessage(message({ id: "300000000000000303", content: "second" })); + assert.deepEqual(r3, { accepted: false, reason: "ceiling" }); + assert.equal(rest.reactions.length, 1); + await connector.stop(); +}); + +test("receipt: Discord refusing the reaction leaves the turn intact and records ok false", async () => { + const { journalDir, rest, connector } = setup(); + rest.reactOk = false; + await connector.start(); + const r = await connector.handleMessage(message({ id: "300000000000000311", content: "hi" })); + assert.equal(await r.turn, "ok"); + assert.equal(rest.calls.length, 1, "the reply still went out"); + const turns = listTurns(journalDir); + assert.equal(turns[0].status, "ok"); + assert.deepEqual(turns[0].receipt, { emoji: READ_RECEIPT, ok: false }); + await connector.stop(); +}); diff --git a/packages/discord/tests/helpers.mjs b/packages/discord/tests/helpers.mjs index d7196375..cd65edb5 100644 --- a/packages/discord/tests/helpers.mjs +++ b/packages/discord/tests/helpers.mjs @@ -100,6 +100,9 @@ export function fakeRest({ outcomes = [] } = {}) { return { calls, typingCalls: [], + reactions: [], + // When false, react resolves false (Discord refused the reaction). + reactOk: true, channels: new Map(), lookups: 0, // When set, getChannel waits on this promise before answering (held lookup). @@ -116,6 +119,10 @@ export function fakeRest({ outcomes = [] } = {}) { async typing(channelId) { this.typingCalls.push(channelId); }, + async react(channelId, messageId, emoji) { + this.reactions.push({ channelId, messageId, emoji }); + return this.reactOk; + }, async getChannel(id) { this.lookups += 1; if (this.holdLookup) await this.holdLookup; diff --git a/packages/discord/tests/rest.test.mjs b/packages/discord/tests/rest.test.mjs index 9c591921..55beb07f 100644 --- a/packages/discord/tests/rest.test.mjs +++ b/packages/discord/tests/rest.test.mjs @@ -71,3 +71,20 @@ test("rest: content and nonce limits are enforced locally; typing never throws", await rest.typing("c"); assert.equal(f.calls.length, 2); }); + +test("rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws", async () => { + const f = fakeFetch([{ status: 204 }, { status: 403, body: { message: "Missing Permissions", code: 50013 } }, { throw: "down" }]); + const logs = []; + const rest = createRest({ token: TOKEN, fetch: f.fetch, log: (m) => logs.push(m) }); + assert.equal(await rest.react("c1", "m1", "\u{1F440}"), true); + assert.equal(f.calls[0].url, "https://discord.com/api/v10/channels/c1/messages/m1/reactions/%F0%9F%91%80/@me"); + assert.equal(f.calls[0].init.method, "PUT"); + assert.equal(f.calls[0].init.body, undefined); + assert.equal(f.calls[0].init.headers.Authorization, `Bot ${TOKEN}`); + assert.equal(await rest.react("c1", "m2", "\u{1F440}"), false); + assert.equal(await rest.react("c1", "m3", "\u{1F440}"), false); + assert.equal(logs.length, 2); + assert.match(logs[0], /HTTP 403/); + assert.ok(logs.every((l) => !l.includes(TOKEN))); + await assert.rejects(rest.react("c1", "m4", ""), /emoji required/); +});