discord-bridge
Work on the Discord-as-client bridge (`packages/discord-bridge`) — the WebSocket-proxy bot that lets a Discord user play MUDdown from their DM thread. Covers the renderer invariants (envelope → embed + components), button `custom_id` encoding, the no-auto-message rule, the multi-character picker flow, and the public `/feed` subscriber.
- License 许可
- MIT
- 所属仓库
- MUDdown/MUDdown
- 最近更新
- 2026-08-06
- Stars
- ★ 2
- Forks
- 2
01 前言 Front Matter
- name
- discord-bridge
- description
- Work on the Discord-as-client bridge (`packages/discord-bridge`) — the WebSocket-proxy bot that lets a Discord user play MUDdown from their DM thread. Covers the renderer invariants (envelope → embed + components), button `custom_id` encoding, the no-auto-message rule, the multi-character picker flow, and the public `/feed` subscriber.
03 SKILL.md 正文
Discord Bridge Skill
You are working on packages/discord-bridge, a stateless proxy that holds one WebSocket session per Discord user and translates between Discord messages and MUDdown wire envelopes. It is parallel in shape to packages/bridge (telnet) but speaks Discord embeds + components instead of ANSI/OSC 8.
The primary play surface is the bot's DM with each player. Public-channel slash commands (/play, /who, /switch, /quit) are convenience entry points that funnel the player into their DM session. A second, optional surface is the public feed channel — a one-way mirror of :::system{scope="world"} envelopes posted by feed-subscriber.ts.
Files at a glance
| File | Responsibility |
|---|---|
src/main.ts |
Entry point — dynamic import of bridge.ts |
src/bridge.ts |
Discord client lifecycle, interaction handlers, DM intake, slash commands, feedSubscriber wiring |
src/bridge-policy.ts |
Pure policy functions extracted from bridge.ts for unit testability: resolveGameplayInteractionCommand, dispatchGameplayCommand, formatWhoStatus, recordActivityIfDispatched, refreshReconnectTicket, handleSocketClose, handleReconnectError |
src/render.ts |
Pure ServerMessage → RenderedMessage (embeds + components). No discord.js coupling — exercised in unit tests by inspecting the returned shape |
src/sessions.ts |
Discord user ID → DiscordSession registry (open/close/touch/values). Connection objects are held separately in bridge.ts |
src/commands.ts |
Slash-command registration (/play, /who, /switch, /quit) |
src/idle-policy.ts |
Idle-sweep logic and tunable defaults (IDLE_TIMEOUT_MS, IDLE_CHECK_INTERVAL_MS, findIdleSessions) |
src/delivery-policy.ts |
Envelope delivery retry logic and defaults (gameplayDeliveryBackoffMs, nextDeliveryFailure) |
src/reconnect-notifier.ts |
Per-user reconnect state tracking |
src/feed.ts |
isWorldScopeEnvelope() defense-in-depth filter and stripInteractiveLinks() |
src/feed-subscriber.ts |
Read-only WS client to /feed, full-jitter backoff, posts world-scope envelopes to feedChannelId |
src/config.ts |
Env parsing, snowflake validation, feedChannelId opt-in |
Renderer invariants
render.ts is the single source of truth for envelope → Discord shape. Keep it pure (no discord.js client, no I/O) so tests assert on the returned object directly.
- One embed per envelope, except long bodies split across multiple embeds in the same message.
- Embed color by block type, mirroring the spec's ARIA-role intent:
room→ blue0x3b82f6system→ red0xef4444combat→ orange0xf97316dialogue→ green0x22c55enarrative→ neutral gray0x9ca3af
- Hard limits (from Discord's API; codified in
DISCORD_LIMITS):- Embed description: 4096 chars (chunk via
chunkDescription, prefer paragraph breaks then whitespace; falls back to a hard cut at 4096 when neither a\n\nnor a space boundary exists past the midpoint) - Buttons per action row: 5
- Action rows per message: 5 (25 buttons total; overflow → select menu)
- Button
custom_id: ≤ 100 chars
- Embed description: 4096 chars (chunk via
- Frontmatter and outer
:::fences are stripped (stripContainerScaffolding). Anchors are start-/end-of-string, not line, so an inner:::fence isn't matched as the outer close. - Interactive links become components, not embedded URLs. External URLs (http(s)) stay inline as Markdown links. Game schemes (
go:,cmd:,item:,npc:,player:,help:) are pulled out and re-emitted as buttons viaresolveGameLink. - De-duplicate same-label/same-target buttons (
label|customIdkey) so a list of exits doesn't render the same button twice.
Button custom_id encoding
The wire format for an interactive-link button:
muddown-link:<percent-encoded command line>
Encode with encodeLinkCustomId(command) and decode with decodeLinkCustomId(customId). The encoder rejects results longer than DISCORD_LIMITS.customIdLength (100) by returning undefined — callers must skip those links rather than truncate.
The character picker uses a separate, non-collision-prone literal: muddown-character-select. Don't reuse muddown-link: for non-game-link interactions.
When more than 25 game links are present, the renderer places the first 20 as buttons (4 rows × 5) and the next up to 25 in a string-select component with customId = "muddown-link-select" and the same muddown-link:<…> value per option. Links beyond 45 total are dropped with a console.warn. Both shapes are resolved via resolveGameplayInteractionCommand (defined in bridge-policy.ts, called from bridge.ts).
No-auto-message rule
The bridge never sends a DM the user did not ask for.
- A new player gets their first DM only after invoking
/playin the public hub channel — never as a side effect of joining the server, posting elsewhere, etc. - Reconnect / idle-eviction notices go to the user only when there is an active session that the user opened.
- Slash commands answer in the channel they were invoked in (or as an ephemeral reply); the bot doesn't follow up by DM unless the command's contract says so.
- The public feed channel is the only surface that posts without a per-user trigger, and it never @-mentions or pings.
This is a hard constraint, not a courtesy. Discord aggressively rate-limits and eventually disables bots that DM unprompted.
Multi-character picker flow
The bridge supports a multi-character account model via CharacterEntry (local interface in bridge.ts). Characters are fetched from the server's /auth/characters endpoint.
- First DM: bridge sends a "Select the character to play:" embed with a string-select menu, one option per character on the linked account. Selecting one opens the WebSocket session.
/switch(or sendingquitthen re-DMing): tears down the active session, returns to the picker, and starts a new session under the chosen character.- Single-active invariant: only one active character per Discord user at a time, matching the WebSocket "one session per connection" rule on the server.
- Single-character accounts skip the picker entirely and open the session directly.
The picker uses CHARACTER_SELECT_CUSTOM_ID = "muddown-character-select". Character IDs travel as the option value, not encoded into the custom_id, because Discord's 100-char custom_id limit is too tight for multi-character UUIDs.
Public feed channel
feed-subscriber.ts opens a dedicated WS to ${MUDDOWN_SERVER_URL} with the path overwritten to /feed. It only activates when config.feedChannelId !== undefined.
- Read-only by construction: the server never delivers
scope="player"traffic to/feed, but the subscriber also runs every envelope throughisWorldScopeEnvelope()as defense-in-depth. Anything else is dropped. - Interactive links are stripped by
stripInteractiveLinks()before the embed is built — the public channel has no per-user session, so buttons would resolve to nothing. Visible link text is retained; thecomponentsarray is discarded. - Backoff: exponential 1s → 30s with full jitter. The client never spams reconnects.
- URL hygiene:
deriveFeedUrl()clearssearch(prevents?ticket=…leakage to the unauthenticated endpoint per spec §6.3.1) andhash, and rewrites pathname to/feed. - Lifecycle race guard: in
bridge.ts, afterawait client.channels.fetch(config.feedChannelId), checkif (this.client !== client)— if true, bail out without creating the subscriber. This guards against ashutdown()orreset()that ran during the fetch.
Testing
Vitest. 200+ tests across 11 files.
tests/render.test.ts— fixture-driven envelope → expected embed/components shapetests/sessions.test.ts— connection manager, idle evictiontests/feed.test.ts—isWorldScopeEnvelopeandstripInteractiveLinks(coversscope="player"rejection, mixed link cases, case-sensitive scheme matching)tests/feed-subscriber.test.ts— reconnect/backoff, URL hygiene, lifecycle
Run from the repo root:
npx turbo run test --filter=@muddown/discord-bridge
Renderer fixtures should compare the structural shape, not stringified discord.js builders. The renderer never imports a discord.js builder for this reason.
What NOT to do
- Don't import discord.js into
render.ts. The renderer stays pure. - Don't auto-DM users who haven't opened a session.
- Don't widen
isWorldScopeEnvelopeto acceptscope="player". The defense-in-depth check is the second wall; relaxing it is the bug. - Don't reuse
muddown-link:for non-game-link interactions. Pick a new literal. - Don't truncate a too-long
custom_id— returnundefinedand skip the button. - Don't ship changes to bridge DM-session behaviour or
discord_rich_presencewithout updatingMUDdown.wiki/Discord-Bridge.mdandMUDdown.wiki/Desktop-App.md(per AGENTS.md).
04 同仓库更多 Skills
testing
Write and maintain tests for MUDdown packages. Covers vitest for the server and parser, fixture helpers, world integrity tests, and testing conventions.
room-creation
Create and modify MUDdown room files. Covers the Markdown+YAML structure, frontmatter fields, container blocks, bidirectional exits, item/NPC references, lighting, and region organization.
privacy
Audit and maintain privacy compliance for MUDdown. Covers the privacy policy, data collection practices, cookie/storage usage, and compliance testing.
persistence
Work with the database abstraction layer and account/character/world persistence. Covers the GameDatabase interface, SQLite adapter, save/load lifecycle, auth sessions, and testing patterns.
osc8-bridge
Add or modify OSC 8 hyperlink capabilities in the telnet bridge — NEW-ENVIRON (RFC 1572) negotiation, Mudlet/Fado/MudForge `send:` URIs, and per-scheme tooltip/menu metadata. Covers the capability pipeline from telnet negotiation through renderer output, the sanitization contract, and the word-wrap invariant.
oauth-provider
Add a new OAuth/OIDC identity provider to MUDdown. Covers the shared type, auth switch statements, server config, login page button, env vars, tests, and local verification.