feat: scaffold project with dependencies and config
Made-with: Cursor
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
config.toml
|
||||||
2632
Cargo.lock
generated
Normal file
2632
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal 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"
|
||||||
24
chat-summaries/2026-03-10_brainstorm-summary.md
Normal file
24
chat-summaries/2026-03-10_brainstorm-summary.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Owncast–IRC 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
21
config.example.toml
Normal 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"
|
||||||
273
docs/plans/2026-03-10-owncast-irc-bridge-design.md
Normal file
273
docs/plans/2026-03-10-owncast-irc-bridge-design.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# 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<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.
|
||||||
2058
docs/plans/2026-03-10-owncast-irc-bridge-impl.md
Normal file
2058
docs/plans/2026-03-10-owncast-irc-bridge-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
3
src/bin/bridge_ctl.rs
Normal file
3
src/bin/bridge_ctl.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("bridge-ctl");
|
||||||
|
}
|
||||||
3
src/main.rs
Normal file
3
src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("owncast-irc-bridge");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user