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]>
147 lines
8.0 KiB
Markdown
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.
|