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