11 KiB
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
-
IRC Task — Connects to the IRC server, joins channel, listens for PRIVMSG events. Sends incoming messages as
BridgeEvent::ChatMessageto the router. Receives outbound messages from the router to relay Owncast → IRC. -
Webhook HTTP Task — Runs an axum server on a configurable port. Exposes
POST /webhookthat Owncast hits. Parses CHAT, STREAM_STARTED, STREAM_STOPPED events and forwards them asBridgeEventvariants. -
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.
-
Router Task — Central fan-in/fan-out. Receives
BridgeEventfrom all sources, deduplicates (if both webhook and WS are active), applies echo suppression, and dispatches to the appropriate output. -
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
enum BridgeEvent {
ChatMessage {
source: Source,
username: String,
body: String,
id: Option<String>, // Owncast message ID for dedup
},
StreamStarted { title: String },
StreamStopped,
}
enum Source {
Irc,
Owncast,
}
IRC → Owncast
- IRC task receives
PRIVMSG #BowlAfterBowl :hello everyone - Extracts nick + body, sends
BridgeEvent::ChatMessage { source: Irc, username: "somenick", body: "hello everyone" }to router - Router calls Owncast API:
POST /api/integrations/chat/sendwith body{"body": "[IRC] <somenick> hello everyone"}
Owncast → IRC
- Webhook receives POST with
{ "type": "CHAT", "eventData": { "user": { "displayName": "viewer42" }, "body": "hey chat" } } - Strips HTML (Owncast sends emoji as
<img>tags), extracts display name + raw text - Sends
BridgeEvent::ChatMessage { source: Owncast, ... }to router - Router sends to IRC:
PRIVMSG #BowlAfterBowl :[OC] <viewer42> hey chat
Stream announcements (Owncast → IRC)
STREAM_STARTED→PRIVMSG #BowlAfterBowl :Stream started: <title> — https://owncast.bowlafterbowl.comSTREAM_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.
[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:
tracingwith structured JSON output. Level configurable viaRUST_LOG.
5. Owncast Lifecycle Handling
Three states
enum OwncastState {
Online, // Stream active, chat available
OfflineChatOpen, // Stream offline, chat still accepting messages
Unavailable, // Chat unreachable or disabled
}
State detection
- Webhook signals —
STREAM_STARTED→Online,STREAM_STOPPED→ probe chat availability. - Health polling — Background task polls
GET /api/statusat a configurable interval (default 30s). This is the ground truth. - API response codes —
POST /api/integrations/chat/sendreturning 400/503 marks chat asUnavailable. - 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
- Hit
GET /api/statusto determine initial state. - Connect to IRC regardless.
- Start webhook listener regardless.
- Only attempt WS connection if status shows chat is available.
- 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:
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:
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 sidesSIGTERM/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.