- Media: postimg.cc is now the primary clearnet host for image attachments and Sneedchat-relayed image links (litterbox stays as fallback for video, oversized files, or if postimg.cc itself fails). All image-hosting uploads are clearnet-only, never routed through Tor. - Reconnects: the client now re-authenticates proactively before every dial, and watches the server's own permissions.can_send frame to detect a stale session silently downgrading us to guest (view-only) after a reconnect. The outage notice now waits for confirmed authorization before flushing queued Discord messages, and shows whether embedded Tor itself is unhealthy vs. the outage being specific to Sneedchat. - BBCode: fixed [url=...] tags left unrendered when missing a closing [/url], and a bug where [img] nested inside [url=] (Sneedchat's own attachment-link shape) broke the URL pattern entirely due to a newline inserted between passes. - Docker workflow: clarified the two-secret (DOCKER/CI_TOKEN) split in comments and error messages.
8.3 KiB
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.