Files
owncast-IRC-bridge/docs/plans/2026-03-10-owncast-irc-bridge-design.md
2026-03-10 21:49:42 -04:00

11 KiB
Raw Permalink Blame History

OwncastIRC 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

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

  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] <somenick> 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 <img> tags), extracts display name + raw text
  3. Sends BridgeEvent::ChatMessage { source: Owncast, ... } to router
  4. Router sends to IRC: PRIVMSG #BowlAfterBowl :[OC] <viewer42> hey chat

Stream announcements (Owncast → IRC)

  • STREAM_STARTEDPRIVMSG #BowlAfterBowl :Stream started: <title> — https://owncast.bowlafterbowl.com
  • STREAM_STOPPEDPRIVMSG #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: tracing with structured JSON output. Level configurable via RUST_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

  1. Webhook signalsSTREAM_STARTEDOnline, 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 codesPOST /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:

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 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.