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
+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`);