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 <[email protected]>
This commit is contained in:
2026-09-13 14:20:30 -05:00
co-authored by Claude Fable 5.1
parent 788515dc69
commit 93d6b62457
10 changed files with 115 additions and 6 deletions
+7 -4
View File
@@ -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/<message id>.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
+9
View File
@@ -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;
+17 -1
View File
@@ -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`);
+33 -1
View File
@@ -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();
});
+7
View File
@@ -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;
+17
View File
@@ -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/);
});