A production deployment ran two bridges against one shared state volume.
That is a configuration error, but the way the bridge failed under it turned
a misconfiguration into an unrecoverable crash loop, and none of the
machinery built to report problems ever got a chance to run.
main() used to `?` straight out of the Tor bootstrap and the initial login,
so any transient Tor failure killed the process. Under Docker's
`restart: unless-stopped` that is not resilience but a thrash loop, and a
self-sustaining one: the log showed five process starts in 75 seconds, each
discarding the bootstrapped client and its guard state, re-contending the
Arti state lock on the way back up, and then dying on the "database is
locked" the previous restart had just caused.
Dying at startup is also the worst possible moment. The reconnect loop --
which treats these exact errors ("Onion Service not found", "operation
timed out", a stale directory) as entirely routine -- does not exist yet,
and neither does the Discord side, so no outage notice is posted and the
bridge is simply invisible while broken. startup_step now retries both steps
with the same 7s->120s backoff as reconnects, holds one Tor client across
attempts, and stays responsive to SIGTERM while waiting. A bad
KIWI_TOR_PROXY still fails fast: that is a typo, not a transient fault, and
retrying it would loop forever.
The state-directory explanation moves to net::explain_if_state_dir_contended
so both the startup path and the reconnect loop emit it, and it now also
matches "database is locked" and the bootstrap failure -- neither of which
the reconnect-loop-only version would have caught, because the process never
survived long enough to reach it.
docker-compose.yml documents why the volume must not be shared, since the
example in this repo is where the shared mount came from. It also records
that sharing one account across instances is fine: session.json is only
rewritten after a full password login (store::save has exactly one caller),
so two bridges on one account never contend over it.
153 tests pass. Verified: cargo clippy --all-targets clean, release build
links no libsqlite3.
Co-Authored-By: Claude Opus 5 <[email protected]>
Sneedchat-Discord Bridge (Rust)
A bridge that synchronizes messages between Kiwi Farms' Sneedchat and Discord — edits, deletes, attachments, BBCode formatting, MOTD sync, and outage queueing — with full bidirectional sync.
This is a Rust rewrite of a previous Go implementation. The connection/login/proof-of-work machinery is adapted from sockchat-rs. The core change from the Go version: kiwifarms is reached exclusively over Tor (embedded in-process via Arti — no external tor binary to install or manage), while Discord and media uploads stay on clearnet, since neither allows Tor traffic.
Features
- Bidirectional message sync (Sneedchat ↔ Discord)
- Edit and delete synchronization
- Attachment uploads: images go to postimg.cc, everything else (video, oversized files, or if postimg.cc itself fails) falls back to litterbox.catbox.moe — both wrapped in the BBCode Sneedchat needs to render them
- BBCode → Discord Markdown conversion
- Message queueing during Sneedchat outages, with a live-updating outage notice embed
- Sneedchat MOTD mirrored to a pinned Discord message, auto-updated when it changes
- Direct username/password/TOTP login — no more pasting a
Cookie:header out of a browser - Automatic KiwiFlare/Tartarus proof-of-work gate solving
- Per-user Discord avatars — Sneedchat's
avatar_urlis resolved against the clearnetkiwifarms.sthost and handed to Discord directly (Discord's own servers fetch it; the bridge never does)
Requirements
- Rust (edition 2024 — a recent stable toolchain;
rustup updateifcargo buildcomplains) - A Discord bot token and webhook URL
- A Kiwi Farms account with Sneedchat access
No tor binary, Docker, or external dependency is required — Tor is embedded in the binary via Arti.
Building
cargo build --release
The binary is target/release/sneedchat-discord-bridge.
Discord setup
1. Create a Discord application
- Go to the Discord Developer Portal
- New Application, name it (e.g. "Sneedchat Bridge"), Create
2. Create the bot
- Bot tab → Add Bot
- Under Token, Reset Token → copy it — this is
DISCORD_BOT_TOKEN - Under Privileged Gateway Intents, enable Message Content Intent, Save Changes
3. Invite the bot to your server
- OAuth2 → URL Generator
- Scopes:
bot - Bot permissions:
Read Messages/View Channels,Send Messages,Manage Messages(edits/deletes/MOTD pinning),Embed Links,Attach Files,Read Message History - Open the generated URL, pick your server, Authorize
4. Create a webhook
- Right-click the channel to bridge → Edit Channel → Integrations → Webhooks → New Webhook
- Name it, Copy Webhook URL — this is
DISCORD_WEBHOOK_URL
5. Get the channel (and optionally guild/your own user) ID
- Enable Developer Mode (User Settings → Advanced)
- Right-click the bridge channel → Copy Channel ID — this is
DISCORD_CHANNEL_ID - Right-click the server name → Copy Server ID —
DISCORD_GUILD_ID(currently unused by bridge logic, kept for parity) - Right-click yourself → Copy User ID — optional, for
DISCORD_PING_USER_ID
Configuration
cp .env.example .env
chmod 600 .env
Fill in .env — see the comments in .env.example for what each variable does and which are optional. At minimum you need DISCORD_BOT_TOKEN, DISCORD_CHANNEL_ID, DISCORD_WEBHOOK_URL, SNEEDCHAT_ROOM_ID, BRIDGE_USERNAME, and BRIDGE_PASSWORD.
Run it:
./target/release/sneedchat-discord-bridge
First run bootstraps embedded Tor (fetches a consensus, builds circuits) — this can take about a minute. Watch for:
authenticated as YourKiwiUsername
joined Sneedchat room 1
Discord bot ready: Sneedchat Bridge (...)
Ctrl+C to stop.
Systemd service
# /etc/systemd/system/sneedchat-bridge.service
[Unit]
Description=Sneedchat-Discord Bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/sneedchat-bridge
ExecStart=/opt/sneedchat-bridge/sneedchat-discord-bridge
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now sneedchat-bridge
sudo systemctl status sneedchat-bridge
The bridge reads .env from its working directory (set WorkingDirectory above accordingly), or from real environment variables if you'd rather set them in the unit file directly.
Docker
A prebuilt image is published by this repo's Gitea Actions workflow (.gitea/workflows/docker.yml) to this Gitea instance's own container registry on every push to master: git.salastil.com/salastil/sneedchat-discord-bridge-rs:latest (linux/amd64 only). There's no version tagging — latest is always whatever's currently on master.
cp docker-compose.yml docker-compose.local.yml # or edit docker-compose.yml directly
# fill in the environment: block with your real values
docker compose up -d
docker compose logs -f
All configuration is environment variables passed via docker-compose.yml's environment: block (see .env.example for what each one does) — nothing is read from a .env file inside the container. The compose file also mounts a named volume over /home/bridge/.config/sneedchat-bridge, where Arti's Tor cache/state and the saved kiwifarms login session live; without it, every container restart re-bootstraps Tor from scratch and logs in again instead of reusing the saved session.
Volume permissions: the container starts as root just long enough to chown that volume to a bridge user (UID/GID 1000 by default, overridable via PUID/PGID) and then drops privileges to run the actual binary — this is what entrypoint.sh does. It exists because a freshly created bind mount or named volume (particularly common on Unraid, where the default docker user is often 99:100 rather than 1000:1000) is otherwise owned by whatever the host happened to create it as, which doesn't match a fixed build-time UID and fails with a permission error the moment Arti tries to create its Tor state directory. You shouldn't need to do anything for this to work; set PUID/PGID only if you specifically want the volume's files owned by a particular host user.
To build the image yourself instead of pulling a published one:
docker build -t sneedchat-discord-bridge-rs .
Testing
cargo test --lib # unit tests, no network
Two #[ignore]d integration tests hit the real kiwifarms .onion over Tor (login + gate-solving, and a full join + live-frame check). They need your own credentials in the environment and should be run sparingly — see the doc comments at the top of tests/login_probe.rs and tests/join_probe.rs:
export BRIDGE_TEST_USER=... BRIDGE_TEST_PASSWORD=... # BRIDGE_TEST_TOTP_SECRET too, if 2FA is on
cargo test --test login_probe -- --ignored --nocapture
cargo test --test join_probe -- --ignored --nocapture
Troubleshooting
Login fails / 2FA errors on startup — if the account has two-factor authentication enabled, BRIDGE_TOTP_SECRET must be set (base32 secret from the "can't scan the QR code?" link during 2FA setup). This is a headless service — unlike a browser login, there's no prompt to answer, so a missing secret is a hard startup failure with a clear error rather than a hang.
Messages aren't appearing in Discord — check the bot has Read Messages and Send Messages in the channel, and that the webhook was created for the same channel DISCORD_CHANNEL_ID points at.
First startup is slow — that's Tor bootstrapping (consensus fetch + circuit building), not a hang. Subsequent restarts are faster since Arti caches state under ~/.config/sneedchat-bridge/.
Security notes
chmod 600 .env— it holds your Kiwi Farms password and Discord bot token- Rotate the Discord bot token or Kiwi Farms password if either leaks
- The saved login session (
~/.config/sneedchat-bridge/session.json) is written mode 0600 and never contains the password itself, only session cookies
License
Provided as-is. Use responsibly and in accordance with Kiwi Farms' and Discord's Terms of Service.