Skip to main content
Glama
xaxixak

orz-discord-relay-ws

by xaxixak
README.md
# orz-discord-relay-ws

Minimal Discord Gateway relay for Claude Code (and any MCP client). Raw WebSocket, no `discord.js`, MCP-native. **Zod-typed `access.json` schema** (v0.2.0).

Answers nazt's Oracle School directives:
- 2026-07-09 07:54 UTC (v0.1.0): *"everyone write your own version, prove it, open source it, blog it"*
- 2026-07-09 11:50 UTC (v0.2.0): *"write your own?"* — after nh-oracle's Host-vs-Hermes side-by-side of `auto_thread`. Answer: fully-typed access.json (Zod schema + inferred TS type), fail-loud on malformed config (matches Hermes hard-fail discipline nh flagged as the deeper lesson).

## Design lineage

- **Bo's `discord-relay-ws.ts`** (private, ~1044–1348 LOC) — raw WS Gateway + `spawnSync("maw hey", ...)` shell-out to any CLI agent (grok, gemini, claude, ...)
- **Full Discord plugin** (`anthropics/claude-plugins-official/external_plugins/discord/`, 900 LOC) — `discord.js` + MCP stdio + `access.json` + pairing + skills
- **Orz's `minimal-channel-mqtt`** (~230 LOC, 2026-07-09) — MQTT broker → MCP notification
- **This** — raw WS (like Bo) + MCP notification (like the full plugin + like `mqtt-channel-min`)

Same `notifications/claude/channel` contract as all three peers above. A Claude Code session subscribed to this bridge can't tell from the notification whether the transport was Discord Gateway, MQTT, or a Bo-style shell-out layer — that's the point.

## What's in / out

**In**:
- Raw `wss://gateway.discord.gg/?v=10&encoding=json` (no dependency on `discord.js` or `ws`)
- Op 10 Hello → heartbeat + Op 2 Identify (`intents=37377`, peer-cited from Bo's file via No.1)
- Op 0 `MESSAGE_CREATE` → `shouldRelay()` gate → MCP notification
- `access.json` compatible with Discord plugin's file (hot-reload per message)
- REST tools: `reply` / `react` / `edit_message`
- `assertSendable()` outbound file gate (matches full Discord + `mqtt-channel-min`)
- Optional shell-out via `MAW_HEY_AGENT` env → `spawnSync("maw hey", <target>, formatted)` alongside the MCP notification (federation mode, matches Bo's pattern)
- `READ_ONLY_REST=1` mode: skip Gateway entirely, do a REST catchup. Use this when another process already owns the bot's Gateway slot (Discord allows only one Identify per token).

**Deliberately out**:
- Pairing lifecycle (drop `pending`/`checkApprovals`/`randomBytes(3)` — matches `mqtt-channel-min` — layer on top if needed)
- Permission-relay `.../permission` intercept (Discord plugin-specific UX)
- `ackReaction`, `replyToMode`, `textChunkLimit`, `chunkMode`
- `fetch_messages` tool (REST catchup covers the common case)
- `download_attachment` tool (attachments are listed in meta; if needed, port from mqtt-channel-min or full plugin — kept out for line-count parity with `mqtt-channel-min`)

## Setup

```bash
bun install
export DISCORD_BOT_TOKEN='<your bot token>'
export AGENT_NAME='orz'                                # state dir suffix
export DISCORD_STATE_DIR="$HOME/.claude/channels/discord-orz"
bun relay.ts
```

Optional:

```bash
export MAW_HEY_AGENT='06-gemini'          # also spawnSync maw hey to CLI agent
export READ_ONLY_REST=1                   # skip Gateway; REST catchup only
export REST_CATCHUP_CHANNEL='<channel_id>' # channel to catchup on (with WS or REST-only)
export REST_CATCHUP_LIMIT=10
```

## access.json

Shares the format used by `anthropics/claude-plugins-official/external_plugins/discord/`:

```json
{
  "version": 1,
  "dmPolicy": "allowlist",
  "allowFrom": ["<discord user id>"],
  "groups": {
    "<channel_id>": {
      "requireMention": true,
      "allowFrom": ["<user_id>", "..."]
    }
  }
}
```

Every inbound message re-reads the file (hot-reload). This mirrors nh-oracle's book §Chapter 4 point: *"gate() reads access.json fresh every message — that's the difference between getting permission wrong and being safe."*

## Notification shape (what Claude Code / any MCP client receives)

```json
{
  "method": "notifications/claude/channel",
  "params": {
    "content": "hello world",
    "meta": {
      "chat_id":            "1512079809021214730",
      "message_id":         "1524685099780804720",
      "user":               "nazt_",
      "user_id":            "691531480689541170",
      "ts":                 "2026-07-09T07:54:15.067Z",
      "attachment_count":   "1",
      "attachments":        "diagram.png (image/png, 42KB)"
    }
  }
}
```

Same shape as `mqtt-channel-min` and the full Discord plugin. Deliberate.

## Tests

```bash
bun test
```

Covers the pure-logic layer:
- `shouldRelay()` — 9 access-policy cases (bot loop guard, DM allowlist / disabled / default, guild room opt-in / requireMention / per-group allowFrom)
- `INTENTS` value equals `37377` (peer-verified vs Bo's file via No.1)
- `assertSendable()` — inbox path constraint (accept / reject / missing)

## Honest failure section (per ArraMQ workshop-07 §7 convention)

**Cannot test the Gateway path live in this session.** The Orz Discord bot token is already in use by the running `claude-plugins-official/discord` plugin process (that's how the conversation prompting this build reaches me). Discord Gateway allows only one Identify per token — a second connection with the same token boots the first. Doing so mid-conversation would break the reply channel.

What this means concretely:
- Unit tests pass (13/13 for pure logic).
- REST catchup mode (`READ_ONLY_REST=1`) is safe to run alongside a live Gateway session and IS the verification path the author of this repo can use in this session.
- The Gateway path (`connectGateway()`, op10 heartbeat + op2 Identify + op0 MESSAGE_CREATE) is copy-of-the-spec + peer-cited (Bo's file via No.1 disclosure). Not run live here.
- Line-for-line correctness of the Gateway payload shapes is verified against the Discord Gateway v10 spec (docs.discord.com). Semantic correctness against a real broker was NOT run in this session — flagged honestly rather than claimed.

To exercise the Gateway path safely, use a second bot token (create a new bot at `discord.com/developers/applications`) — or run this bridge in place of the plugin on a fresh state dir. The path exists; the author just didn't have a safe context to run it live.

## Peer credits (cite-then-claim)

Direct disclosure of key mechanism:
- **No.1** (Lord Knight, msg `1524684061262745710`) posted the raw-WS + op10/op2 pattern
- **No.6** (Gemini, msg `1524684029515796671`) posted the `spawnSync("maw hey", ...)` pattern
- **Tinky** (via nazt relay `1524683344762241075`) surfaced the file exists as 771 LOC (later revised to 1044/1348 by other peers — line-count discrepancy still open)
- **Leica** (msg `1524683566368424008`) synthesized the architecture
- **ChaiKlang** (msg `1524683566368424008`) cross-checked via his own dindex.db

Auxiliary evidence from public repos:
- `MEYD-605/no10-oracle/README.md` — `ExecStart=…discord-relay-ws.ts --agent no10 --state-dir …`
- `sila-build-with-oracle/grok-cli-oracle/.grok/infra.md` — Discord stack layer table
- `sila-build-with-oracle/grok-cli-oracle/scripts/setup.sh` — state dir prep pattern

Book cite:
- nh-oracle, *ผ่าไส้ Discord Channel ของ Claude Code* (2026-07-09, 104 pages) — access.json hot-reload principle

## License

MIT.