feat: scaffold project with dependencies and config

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 21:49:42 -04:00
commit ba4e2e1df2
9 changed files with 5048 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
.env
config.toml

2632
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "owncast-irc-bridge"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "owncast-irc-bridge"
path = "src/main.rs"
[[bin]]
name = "bridge-ctl"
path = "src/bin/bridge_ctl.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
irc = "1"
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
clap = { version = "4", features = ["derive"] }
thiserror = "2"
anyhow = "1"
futures-util = "0.3"
[dev-dependencies]
tokio-test = "0.4"

View File

@@ -0,0 +1,24 @@
# OwncastIRC Bridge: Brainstorm & Design Session
**Date:** 2026-03-10
## Task
Design a bidirectional chat bridge between Owncast (`https://owncast.bowlafterbowl.com`) and IRC (`irc.zeronode.net` / `#BowlAfterBowl`).
## Decisions Made
- **Language:** Rust
- **Architecture:** Tokio async runtime with concurrent tasks (IRC, Webhook, WebSocket, Health Poller, Router, Control Socket) communicating via mpsc channels
- **Owncast ingest:** Webhooks (primary) + WebSocket (fallback)
- **Identity:** Prefixed messages — `[IRC] <nick>` / `[OC] <user>`
- **Extras:** Stream start/stop announcements in IRC, auto-reconnect with exponential backoff
- **Owncast lifecycle:** Three-state model (Online, OfflineChatOpen, Unavailable) with health polling
- **Runtime control:** Unix domain socket + `bridge-ctl` CLI tool
- **Deployment:** Binary + Dockerfile (multi-stage)
## Files Created
- `docs/plans/2026-03-10-owncast-irc-bridge-design.md` — Full design document
- `docs/plans/2026-03-10-owncast-irc-bridge-impl.md` — 15-task implementation plan
## Follow-up
- Open a new session and execute the implementation plan using `superpowers:executing-plans` skill
- Plan file: `docs/plans/2026-03-10-owncast-irc-bridge-impl.md`

21
config.example.toml Normal file
View File

@@ -0,0 +1,21 @@
[irc]
server = "irc.zeronode.net"
port = 6667
tls = false
nick = "owncast-bridge"
channel = "#BowlAfterBowl"
[owncast]
url = "https://owncast.bowlafterbowl.com"
# Set OWNCAST_ACCESS_TOKEN env var for the token
webhook_port = 9078
websocket_enabled = true
health_poll_interval_secs = 30
[bridge]
irc_prefix = "[IRC]"
owncast_prefix = "[OC]"
message_buffer_size = 0
[control]
socket_path = "/tmp/owncast-irc-bridge.sock"

View File

@@ -0,0 +1,273 @@
# 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
```rust
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_STARTED``PRIVMSG #BowlAfterBowl :Stream started: <title> — 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.

File diff suppressed because it is too large Load Diff

3
src/bin/bridge_ctl.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
println!("bridge-ctl");
}

3
src/main.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
println!("owncast-irc-bridge");
}