Files
stack/packages/discord/README.md
T
jason.woltjeandClaude Fable 5.1 93d6b62457 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]>
2026-09-13 14:20:30 -05:00

147 lines
8.0 KiB
Markdown

# discord
The Discord connector: one seat's conversation reachable from listed
channels of one Discord server, chat only. Issue #1509, brief
`docs/plans/2026-09-13_discord-connector-pilot.md`. Plain ESM, no
dependencies, Node 24 or newer, built-in WebSocket and fetch.
A Discord channel is one more interface onto a seat's conversation, the same
class of thing as the terminal and the WebUI session chat. The connector owns
a `pi --mode rpc` engine today; that module is the one to swap when the
CHAT-03 conversation controller exists.
## Commands
```
scripts/discord.sh check <binding>
scripts/discord.sh run <binding>
scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
```
`<binding>` names `<dataRoot>/discord/<binding>.json`. The wrapper passes
`--repo` for you; the CLI also takes `--config PATH`.
- `check` validates the binding, the token file (0600, regular, not a
symlink), the context files and the pi install; reads the bot, the guild and
every listed channel over REST; opens one gateway connection, waits for
READY, closes it. Nothing is sent to a channel. Close code 4014 is reported
as the message-content intent not being granted in the developer portal.
- `run` refuses when `STOP` exists or an unresolved delivery cannot be
reconciled. Otherwise it starts pi, connects, and serves turns until
SIGTERM, SIGINT or `stop`. Run it in a tmux window; there is no service unit.
- `stop` writes `STOP` and sends SIGTERM to the process in `run.lock`, only
when that pid is alive and both its start time and the boot id match the
recorded ones; a reused pid, a pid from a previous boot, or a pid whose
identity cannot be read right now is never signaled. The current turn finishes or times out, then
the process exits. Remove `STOP` to run again.
- `unlock` writes `STOP`, then removes a `run.lock` whose owner is gone
(crash, reboot, a start interrupted before it published its record). It
refuses while the owner is running; use `stop` for that. It also refuses,
removing nothing, when the pid is alive and its identity cannot be
established: the record predates the boot id (an upgrade over a running
connector), a recorded value is not a start tick or a boot id, or /proc
cannot be read right now. Once that pid is dead,
`unlock` clears it. A record file that exists but cannot be parsed is
never removed or claimed over; inspect it by hand. `STOP` is the gate that serializes
cleanup with starts: a `run` re-checks `STOP` after publishing its record
and releases itself if it is there, so nothing that starts during an
unlock can hold the binding. `run` never reclaims a stale lock on its own;
it refuses and names this command. Remove `STOP` to run again.
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 4 usage.
## The binding
Deployment policy for one seat on one server. It carries Discord ids of real
people, so it lives under the data root at mode 0600 and is never committed.
`fixtures/binding.example.json` is the shape with placeholder ids; the schema
is `src/binding.mjs`.
| Field | Meaning |
|---|---|
| `bindingVersion` | 1 |
| `name`, `seat` | binding name (matches the file name) and the seat it serves |
| `guildId`, `guildName`, `botUserId` | the one server and the bot identity `check` confirms |
| `tokenFile` | absolute path to the bot token, 0600, read into memory at start, never printed or journaled |
| `channels[]` | `{id, name, mode}`; `open` answers every message, `mention` only when the bot is mentioned; threads inherit the parent's mode |
| `users[]` | `{id, name}`; the only authors that get a turn |
| `engine` | `provider`, `model`, `thinking` for pi |
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
Unknown keys, missing fields, wrong types, empty allowlists and a bot listed
as a user all refuse with exit 2.
## What happens to a message
1. The gateway delivers `MESSAGE_CREATE`. `authorize` drops it unless the
guild matches, the author is listed and is not a bot, webhook or the bot
itself, the channel or the thread's parent is listed, and in `mention`
mode the bot is in `mentions` (`@everyone` does not count). A drop is one
line in `drops.jsonl` and no reply.
2. The message id is appended to `inbox.jsonl` before anything else. On
start the inbox is read back; a replayed id is dropped as `duplicate`.
That is the restart guard.
3. `STOP`, an oversize message and the daily ceiling are checked next. Over
size gets one fixed line. Over the ceiling gets one fixed line per UTC
day, then silence until midnight UTC; the process stays up.
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. 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
line goes to `outbox.jsonl` before the POST and a `confirmed`, `refused`
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, 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
twice. An intent older than five minutes is outside Discord's dedupe window
and is marked `refused` rather than re-sent, because a re-send could post a
second reply. If anything is still `unknown` after that, `run` refuses to
start and names the nonces.
## Runtime data
```
<dataRoot>/discord/<binding>.json the binding, 0600
<dataRoot>/discord/<binding>/inbox.jsonl accepted message ids
<dataRoot>/discord/<binding>/outbox.jsonl delivery intent and receipts, by nonce
<dataRoot>/discord/<binding>/drops.jsonl one line per dropped or refused message
<dataRoot>/discord/<binding>/admissions.jsonl one line per admitted turn, before the engine runs
<dataRoot>/discord/<binding>/turns/<id>.json write-once turn records
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
<dataRoot>/discord/<binding>/STOP stop switch
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
```
Directories are 0700, files 0600. Logs are append-only; turn records are
written with `O_EXCL` and never rewritten.
## Tests
`scripts/test-discord.sh` or `node --test packages/discord/tests/`. All
offline: fake WebSocket and timers for the gateway, fake fetch for REST, a
scripted stand-in for pi over stdio, a disposable data root. Groups: binding,
authorization table, gateway (hello, identify, heartbeat, missed ack, op 7,
op 9, close 4014), delivery and reconcile, engine (follow-up, timeout,
malformed line), restart replay, stop and ceiling.
## Not in this piece
Tools, repository writes, announcements, attachments, slash commands, DMs,
per-thread sessions, more than one server or seat, a service unit, a
control-board row. Section 8 of the brief keeps the list.