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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user