# Owncast–IRC Bridge Design Bidirectional chat bridge between an Owncast instance and an IRC channel. - **IRC**: `irc.zeronode.net` / `#BowlAfterBowl` - **Owncast**: `https://owncast.bowlafterbowl.com` - **Language**: Rust - **Deployment**: Compiled binary + Docker ## 1. Architecture Single async Rust binary (tokio runtime) with four concurrent tasks communicating through `mpsc` channels: ``` ┌──────────────────────────────────────────────────────────┐ │ Bridge Process │ │ │ │ ┌─────────────┐ BridgeEvent ┌──────────────┐ │ │ │ IRC Task │────────────────────▶│ Router │ │ │ │ (irc crate) │◀────────────────────│ Task │ │ │ └─────────────┘ IrcOutbound └──────┬───────┘ │ │ │ ▲ │ │ ┌─────────────┐ BridgeEvent │ │ │ │ │ Webhook │────────────────────────────┘ │ │ │ │ HTTP Server │ (axum, POST /webhook) │ │ │ └─────────────┘ │ │ │ │ │ │ ┌─────────────┐ BridgeEvent │ │ │ │ WebSocket │───────────────────────────────┘ │ │ │ Task (opt) │ (tokio-tungstenite) │ │ └─────────────┘ │ │ │ │ ┌─────────────┐ │ │ │ Owncast API │◀── Router sends relayed IRC messages │ │ │ Sender │ via reqwest POST │ │ └─────────────┘ │ │ │ │ ┌─────────────┐ │ │ │ Control │◀── Unix socket, receives commands │ │ │ Task │ from bridge-ctl CLI │ │ └─────────────┘ │ └──────────────────────────────────────────────────────────┘ ``` ### Tasks 1. **IRC Task** — Connects to the IRC server, joins channel, listens for PRIVMSG events. Sends incoming messages as `BridgeEvent::ChatMessage` to the router. Receives outbound messages from the router to relay Owncast → IRC. 2. **Webhook HTTP Task** — Runs an axum server on a configurable port. Exposes `POST /webhook` that Owncast hits. Parses CHAT, STREAM_STARTED, STREAM_STOPPED events and forwards them as `BridgeEvent` variants. 3. **WebSocket Task** (optional, enabled via config) — Connects to Owncast's internal WebSocket. Parses chat messages from the WS stream. Acts as a fallback if webhooks aren't reachable. 4. **Router Task** — Central fan-in/fan-out. Receives `BridgeEvent` from all sources, deduplicates (if both webhook and WS are active), applies echo suppression, and dispatches to the appropriate output. 5. **Control Task** — Listens on a Unix domain socket for runtime commands from `bridge-ctl`. **Owncast API Sender** — Async function called by the router. Uses reqwest to `POST /api/integrations/chat/send` with Bearer token auth. ## 2. Data Flow & Message Types ### Core types ```rust enum BridgeEvent { ChatMessage { source: Source, username: String, body: String, id: Option, // Owncast message ID for dedup }, StreamStarted { title: String }, StreamStopped, } enum Source { Irc, Owncast, } ``` ### IRC → Owncast 1. IRC task receives `PRIVMSG #BowlAfterBowl :hello everyone` 2. Extracts nick + body, sends `BridgeEvent::ChatMessage { source: Irc, username: "somenick", body: "hello everyone" }` to router 3. Router calls Owncast API: `POST /api/integrations/chat/send` with body `{"body": "[IRC] hello everyone"}` ### Owncast → IRC 1. Webhook receives POST with `{ "type": "CHAT", "eventData": { "user": { "displayName": "viewer42" }, "body": "hey chat" } }` 2. Strips HTML (Owncast sends emoji as `` tags), extracts display name + raw text 3. Sends `BridgeEvent::ChatMessage { source: Owncast, ... }` to router 4. Router sends to IRC: `PRIVMSG #BowlAfterBowl :[OC] hey chat` ### Stream announcements (Owncast → IRC) - `STREAM_STARTED` → `PRIVMSG #BowlAfterBowl :Stream started: — https://owncast.bowlafterbowl.com` - `STREAM_STOPPED` → `PRIVMSG #BowlAfterBowl :Stream ended.` ### Echo suppression The bridge's own messages sent via the Owncast API come back through webhook/WS. The router keeps a short-lived set of recently sent message bodies and uses the Owncast `user.isBot` field or known bot display name to suppress echoes. ### Deduplication (webhook + WS both active) The router tracks seen Owncast message IDs in a bounded `HashSet` with TTL, dropping duplicates. ## 3. Configuration TOML config file with environment variable overrides for secrets. ```toml [irc] server = "irc.zeronode.net" port = 6667 tls = false nick = "owncast-bridge" channel = "#BowlAfterBowl" [owncast] url = "https://owncast.bowlafterbowl.com" # access_token loaded from OWNCAST_ACCESS_TOKEN env var webhook_port = 9078 websocket_enabled = true health_poll_interval_secs = 30 [bridge] irc_prefix = "[IRC]" owncast_prefix = "[OC]" message_buffer_size = 0 # 0 = drop messages when Owncast unavailable [control] socket_path = "/tmp/owncast-irc-bridge.sock" ``` The access token is never stored in the config file. ## 4. Error Handling & Reconnection - **IRC**: Exponential backoff reconnect loop (1s → 2s → 4s → ... capped at 60s). Transient send failures logged and retried once; persistent failures trigger reconnect. - **Webhook HTTP server**: Bind failure exits the process. Individual request parse failures return 400 and log a warning. - **WebSocket**: Same exponential backoff reconnect. If optional and failing repeatedly, bridge continues on webhooks alone. - **Owncast API sends**: Failed POSTs logged. No retry on 4xx. Single retry with 1s delay on 5xx/network errors. - **Logging**: `tracing` with structured JSON output. Level configurable via `RUST_LOG`. ## 5. Owncast Lifecycle Handling ### Three states ```rust enum OwncastState { Online, // Stream active, chat available OfflineChatOpen, // Stream offline, chat still accepting messages Unavailable, // Chat unreachable or disabled } ``` ### State detection 1. **Webhook signals** — `STREAM_STARTED` → `Online`, `STREAM_STOPPED` → probe chat availability. 2. **Health polling** — Background task polls `GET /api/status` at a configurable interval (default 30s). This is the ground truth. 3. **API response codes** — `POST /api/integrations/chat/send` returning 400/503 marks chat as `Unavailable`. 4. **WebSocket disconnects** — Check health endpoint before deciding state. ### Behavior per state | State | IRC → Owncast | Owncast → IRC | IRC notification | |---|---|---|---| | `Online` | Relay | Relay | (stream start already announced) | | `OfflineChatOpen` | Relay | Relay | None | | `Unavailable` | Buffer or drop (configurable) | No messages arriving | Single notice: "Owncast chat is currently unavailable" | ### Startup 1. Hit `GET /api/status` to determine initial state. 2. Connect to IRC regardless. 3. Start webhook listener regardless. 4. Only attempt WS connection if status shows chat is available. 5. Post current stream status to IRC once connected. IRC is the anchor — always stays up. Owncast is the side that comes and goes. ## 6. Runtime Control ### Unix socket control interface The bridge listens on a Unix domain socket for line-based text commands. | Command | Effect | |---|---| | `irc connect` | Initiate IRC connection (if disconnected) | | `irc disconnect` | Gracefully QUIT from IRC | | `irc reconnect` | Disconnect then connect | | `owncast connect` | Connect WS + start health polling + resume API sends | | `owncast disconnect` | Drop WS, stop polling, stop sends | | `owncast reconnect` | Disconnect then connect | | `status` | Returns JSON with state of both sides | | `quit` | Graceful shutdown | Internal type: ```rust enum ControlCommand { IrcConnect, IrcDisconnect, IrcReconnect, OwncastConnect, OwncastDisconnect, OwncastReconnect, Status { reply: oneshot::Sender<BridgeStatus> }, Quit, } ``` ### `bridge-ctl` CLI Separate binary in the same Cargo workspace using `clap`: ```bash bridge-ctl status bridge-ctl irc connect|disconnect|reconnect bridge-ctl owncast connect|disconnect|reconnect bridge-ctl quit bridge-ctl --socket /var/run/bridge.sock status ``` Connects to the Unix socket, sends command, reads response, pretty-prints, exits. ### Signal handling - `SIGHUP` → reconnect both sides - `SIGTERM` / `SIGINT` → graceful quit ## 7. Project Structure ``` owncast-irc-bridge/ ├── Cargo.toml # workspace root ├── config.example.toml ├── Dockerfile ├── src/ │ ├── main.rs # bridge daemon entry point │ ├── config.rs # TOML + env var loading │ ├── events.rs # BridgeEvent, Source, OwncastState enums │ ├── router.rs # central routing, dedup, echo suppression │ ├── irc_task.rs # IRC connection, send/recv │ ├── webhook.rs # axum HTTP server for Owncast webhooks │ ├── websocket.rs # optional Owncast WS client │ ├── owncast_api.rs # reqwest client for sending to Owncast │ ├── health.rs # Owncast /api/status poller │ ├── control.rs # Unix socket listener, ControlCommand handling │ └── html.rs # strip HTML/emoji img tags from Owncast messages ├── src/bin/ │ └── bridge_ctl.rs # CLI tool └── tests/ └── integration.rs ``` ### Key dependencies | Crate | Purpose | |---|---| | `tokio` | Async runtime | | `axum` | Webhook HTTP server | | `irc` | IRC protocol client | | `tokio-tungstenite` | Owncast WebSocket client | | `reqwest` | HTTP client for Owncast API + health checks | | `serde` / `toml` | Config deserialization | | `tracing` / `tracing-subscriber` | Structured logging | | `clap` | CLI argument parsing (bridge-ctl) | ### Dockerfile Multi-stage build: `rust:slim` for compilation, `debian:bookworm-slim` for runtime. Copies both binaries into the final image.