Live product

Stoatcord

stoatcord.com

Shipped Chat infrastructureProtocol translation
Stoatcord screenshot

Stoatcord moves a Discord community onto Stoat, the open-source Revolt fork, and keeps both halves in sync while people finish leaving. The part worth looking at is the consent protocol. A Discord admin can't just point /migrate at somebody else's Stoat server: the bot posts an approval request into that server and the Discord slash command blocks on a promise until a Stoat admin with ManageServer replies approve, or five minutes run out. The alternative path is a one-time six-character claim code, crypto-random, consumed atomically, expiring in an hour, generated by an admin on the Stoat side — and it works in reverse too, a Stoat admin can push a migration request into Discord. Re-running a migration re-authorizes from scratch; there is deliberately no "already linked" shortcut.

Cloning the server means compiling one permission model into another. Twenty-four Discord permission flags become thirty Revolt bits, and the mapping isn't 1:1 — ManageRoles fans out to three separate Revolt bits, ManageWebhooks implies Revolt's masquerade bit, roles map allow-only while per-channel overrides carry independent allow and deny bitfields. Everything Stoat can't represent — slowmode, mentionable roles, role icons, over-length names — is collected into a warnings list and shown to you rather than dropped. --dry_run prints an itemized create/update/skip plan without touching either API, and include_snapshot posts a complete forensic record of the Discord server — every role, channel, permission overwrite, ban, pin, emoji and sticker — into a #migration-log channel, so you keep a readable copy of what you left.

Then it has to survive being on for a year. The live bridge carries messages, edits, deletes, reactions, typing and channel metadata both ways, with four separate echo-suppression sets on different TTLs because an edit you relay comes straight back as an event you must not relay again. Every message writes an ID-pair row, which is how an edit finds its counterpart months later. After a disconnect the bot diffs its last-bridged cursors against both platforms and replays the gap. And because Stoat's own WebSocket doesn't deliver other users' server messages to bot connections, there's a REST poller rotating ten channels every five seconds, synthesizing the events the platform won't send and dispatching them through the same handler as the real ones. Off to the side sits the push stack — FCM with RS256 service-account JWTs, plus VAPID WebPush and UnifiedPush behind an SSRF filter that blocks cloud-metadata addresses — which is the only reason the sibling Stoatally Android app gets notifications at all.

  • Two-party migration consent: bot posts an approval request into the target Stoat server and a Discord slash command blocks on it for 5 minutes, or a crypto-random 6-char single-use claim code with a 1-hour expiry — and every re-run re-authorizes (src/migration/approval.ts:26, src/migration/wizard.ts:41-53).
  • 24 Discord permission flags compiled into 30 Revolt bits, one-to-many where the models diverge, with allow/deny handled separately for per-channel overrides (src/migration/roles.ts:79-164).
  • Stoat's WebSocket silently drops other users' server messages to bots, so a REST poller covers 10 channels every 5s and dispatches synthesized events through the real handler path, deduped against a self-trimming 10k-ID set (src/stoat/websocket.ts:338-434).
  • Four echo-suppression windows — 60s for messages, 10s each for edits, deletes, reactions and metadata — plus reconnect gap-replay off per-channel last-bridged cursors (src/bridge/relay.ts:31-53, src/bridge/recovery.ts:18).
  • History replay runs Discord → SQLite → Stoat, resumable on both legs, rebuilding author identity via masquerade, reply chains via a job-scoped ID map, timestamps, attachments and embeds at ~0.9 msg/s inside Stoat's 10-per-10s limit (src/archive/export.ts:40, src/archive/import.ts:16-288).
  • Full push backend for a platform that has none: FCM HTTP v1 with RS256 service-account JWTs and cached tokens, plus RFC 8291 WebPush/UnifiedPush behind an SSRF filter rejecting loopback, RFC1918 and 169.254.x metadata endpoints (src/push/fcm.ts:52, src/push/relay.ts:26-51).