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