Files
Salastil 372c6ff393
Build and publish / docker (push) Successful in 11m24s
Build and publish / release-binary (push) Successful in 6m51s
Add postimg.cc image hosting, fix guest-state outages, fix BBCode link bugs
- 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.
2026-08-19 20:56:40 -04:00

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_url is resolved against the clearnet kiwifarms.st host and handed to Discord directly (Discord's own servers fetch it; the bridge never does)

Requirements

  • Rust (edition 2024 — a recent stable toolchain; rustup update if cargo build complains)
  • 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

  1. Go to the Discord Developer Portal
  2. New Application, name it (e.g. "Sneedchat Bridge"), Create

2. Create the bot

  1. Bot tab → Add Bot
  2. Under Token, Reset Token → copy it — this is DISCORD_BOT_TOKEN
  3. Under Privileged Gateway Intents, enable Message Content Intent, Save Changes

3. Invite the bot to your server

  1. OAuth2 → URL Generator
  2. Scopes: bot
  3. Bot permissions: Read Messages/View Channels, Send Messages, Manage Messages (edits/deletes/MOTD pinning), Embed Links, Attach Files, Read Message History
  4. Open the generated URL, pick your server, Authorize

4. Create a webhook

  1. Right-click the channel to bridge → Edit Channel → Integrations → Webhooks → New Webhook
  2. Name it, Copy Webhook URL — this is DISCORD_WEBHOOK_URL

5. Get the channel (and optionally guild/your own user) ID

  1. Enable Developer Mode (User Settings → Advanced)
  2. Right-click the bridge channel → Copy Channel ID — this is DISCORD_CHANNEL_ID
  3. Right-click the server name → Copy Server ID — DISCORD_GUILD_ID (currently unused by bridge logic, kept for parity)
  4. 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.