Compare commits
3 Commits
b0236ee52b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78fec2946c
|
||
|
|
1af9bd1def
|
||
|
|
e2fbd52009
|
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1127,6 +1127,7 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.8.23",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -29,3 +29,4 @@ futures-util = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
13
README.md
13
README.md
@@ -29,6 +29,7 @@ Or create a `.env` file (git-ignored):
|
||||
|
||||
```
|
||||
OWNCAST_ACCESS_TOKEN=your-token-here
|
||||
WEBHOOK_SECRET=some-random-secret
|
||||
```
|
||||
|
||||
**4. Configure the Owncast webhook**
|
||||
@@ -36,9 +37,11 @@ OWNCAST_ACCESS_TOKEN=your-token-here
|
||||
In your Owncast admin, go to **Integrations > Webhooks** and add a webhook pointing to:
|
||||
|
||||
```
|
||||
http://<bridge-host>:9078/webhook
|
||||
http://<bridge-host>:9078/webhook?secret=some-random-secret
|
||||
```
|
||||
|
||||
If `WEBHOOK_SECRET` is set, the bridge rejects any request that doesn't include a matching `?secret=` query parameter. If unset, all requests are accepted (a warning is logged at startup).
|
||||
|
||||
Select the events: **Chat Message**, **Stream Started**, **Stream Stopped**.
|
||||
|
||||
**5. Run it**
|
||||
@@ -82,7 +85,13 @@ See [`config.example.toml`](config.example.toml) for all options. The only requi
|
||||
| `bridge` | `owncast_prefix` | `[OC]` | Prefix for Owncast messages in IRC |
|
||||
| `control` | `socket_path` | `/tmp/owncast-irc-bridge.sock` | Unix socket for `bridge-ctl` |
|
||||
|
||||
The access token is always read from the `OWNCAST_ACCESS_TOKEN` environment variable (not the config file).
|
||||
Secrets are always read from environment variables (not the config file):
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OWNCAST_ACCESS_TOKEN` | Yes | Owncast integration API token |
|
||||
| `IRC_PASSWORD` | No | IRC server password (PASS command) |
|
||||
| `WEBHOOK_SECRET` | No | Shared secret for webhook authentication |
|
||||
|
||||
## Runtime Control
|
||||
|
||||
|
||||
20
chat-summaries/2026-03-12_16-45-summary.md
Normal file
20
chat-summaries/2026-03-12_16-45-summary.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# IRC ACTION Message Formatting Fix
|
||||
|
||||
## Task
|
||||
Fix IRC CTCP ACTION messages (`/me` commands) displaying as `[IRC] <cottongin> ☒ACTION sniffs☒` in OwnCast. The `\x01` CTCP delimiters were rendering as boxed X characters, and the ACTION keyword was not being parsed.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- **`src/events.rs`**: Added `is_action: bool` field to `BridgeEvent::ChatMessage` variant.
|
||||
- **`src/irc_task.rs`**: Added `parse_ctcp_action()` function to detect `\x01ACTION ...\x01` pattern, extract the action body, and set `is_action: true`. Added 5 unit tests.
|
||||
- **`src/router.rs`**: Updated formatting for both IRC-to-OwnCast and OwnCast-to-IRC directions to use `* username body *` format when `is_action` is true.
|
||||
- **`src/irc_format.rs`**: Added `\x01` to the stripped control codes as a safety net for any non-ACTION CTCP leakage. Added 2 unit tests.
|
||||
- **`src/webhook.rs`**: Added `is_action: false` to OwnCast-origin ChatMessage constructor; updated test destructure.
|
||||
- **`src/websocket.rs`**: Added `is_action: false` to OwnCast-origin ChatMessage constructor.
|
||||
|
||||
## Result
|
||||
- Before: `[IRC] <cottongin> ☒ACTION sniffs☒`
|
||||
- After: `[IRC] * cottongin sniffs *`
|
||||
|
||||
## Follow-up
|
||||
- None identified. All 55 tests pass.
|
||||
17
chat-summaries/2026-03-12_17-00-summary.md
Normal file
17
chat-summaries/2026-03-12_17-00-summary.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# OwnCast `/me` to IRC CTCP ACTION
|
||||
|
||||
## Task
|
||||
Detect when OwnCast users type `/me does something` and relay the message to IRC as a proper CTCP ACTION so IRC clients render it natively as an action, with bold attribution on the username.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- **`src/router.rs`**: Extracted `format_owncast_to_irc()` function. When the body starts with `/me `, strips the prefix and wraps the outbound string as `\x01ACTION {prefix} \x02{username}\x02 {action_body}\x01`. Added 5 unit tests covering: action formatting, multi-word actions, normal passthrough, bare `/me` without space, and `/me` mid-message.
|
||||
- **`src/irc_task.rs`**: Updated outbound handler to detect strings starting with `\x01` and send them as raw `Command::PRIVMSG` (preserving the CTCP wrapper) instead of using `send_privmsg` which would double-escape.
|
||||
|
||||
## Result
|
||||
- OwnCast user sends: `/me claps`
|
||||
- IRC clients see: `* bridge-bot [OC] **viewer42** claps` (bold username via mIRC formatting)
|
||||
- Normal messages are unaffected.
|
||||
|
||||
## Follow-up
|
||||
- None identified. All 60 tests pass.
|
||||
17
chat-summaries/2026-03-12_22-00-summary.md
Normal file
17
chat-summaries/2026-03-12_22-00-summary.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# IRC Server Password Support
|
||||
|
||||
## Task
|
||||
Add support for connecting to password-protected IRC servers by sending the PASS command during connection, and allow setting a custom IRC username.
|
||||
|
||||
## Changes
|
||||
|
||||
- **src/config.rs**: Added `username: Option<String>` field to `IrcConfig`. Added `BridgeConfig::irc_server_password()` method that reads `IRC_PASSWORD` from the environment (returns `Option<String>`). Updated `default_for_test()` and tests for the new field and method.
|
||||
- **src/irc_task.rs**: Set `password` and `username` on the `irc` crate's `Config` when building the IRC connection, sourcing the password from `BridgeConfig::irc_server_password()` and username from `IrcConfig.username`.
|
||||
- **config.toml**: Removed stale `server_password` field, added comment about `IRC_PASSWORD` env var.
|
||||
- **config.example.toml**: Added commented-out `username` field and `IRC_PASSWORD` env var documentation.
|
||||
|
||||
## Usage
|
||||
Set the `IRC_PASSWORD` environment variable before running the bridge to authenticate with the IRC server. Optionally set `username` in `[irc]` config for a custom ident.
|
||||
|
||||
## Follow-up
|
||||
- None identified.
|
||||
40
chat-summaries/2026-03-13_12-00-summary.md
Normal file
40
chat-summaries/2026-03-13_12-00-summary.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Webhook Authentication Guard
|
||||
|
||||
**Date:** 2026-03-13
|
||||
|
||||
## Task
|
||||
|
||||
Add a shared secret (`WEBHOOK_SECRET`) to the webhook endpoint so only requests with a matching `?secret=` query parameter are accepted. This prevents unauthorized parties from injecting events into the bridge.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### `src/config.rs`
|
||||
- Added `webhook_secret()` static method to `BridgeConfig` — reads `WEBHOOK_SECRET` env var, returns `Option<String>`.
|
||||
|
||||
### `src/webhook.rs`
|
||||
- Added `WebhookQuery` struct for axum query parameter extraction.
|
||||
- Added `secret: Option<String>` field to `WebhookState`.
|
||||
- Updated `handle_webhook` to validate the secret before processing: returns 401 if configured secret doesn't match.
|
||||
- Updated `run_webhook_server` signature to accept `secret: Option<String>`; logs a warning at startup if unset.
|
||||
- Added 4 integration tests using `tower::ServiceExt::oneshot`: correct secret (200), wrong secret (401), missing secret (401), no secret configured (200).
|
||||
|
||||
### `src/main.rs`
|
||||
- Reads `WEBHOOK_SECRET` via `config::BridgeConfig::webhook_secret()`.
|
||||
- Passes the secret to `webhook::run_webhook_server()`.
|
||||
|
||||
### `docker-compose.yml`
|
||||
- Added `WEBHOOK_SECRET=${WEBHOOK_SECRET}` to environment section.
|
||||
|
||||
### `config.example.toml`
|
||||
- Added comment documenting the `WEBHOOK_SECRET` env var.
|
||||
|
||||
### `README.md`
|
||||
- Updated webhook URL example to include `?secret=` parameter.
|
||||
- Added environment variables table documenting all three secrets.
|
||||
|
||||
### `Cargo.toml`
|
||||
- Added `tower` (0.5, `util` feature) as dev dependency for handler tests.
|
||||
|
||||
## Follow-up Items
|
||||
|
||||
- None. All 65 tests pass.
|
||||
@@ -1,5 +1,7 @@
|
||||
[irc]
|
||||
server = "irc.zeronode.net"
|
||||
# Set IRC_PASSWORD env var for server password (PASS command)
|
||||
# username = "myuser"
|
||||
port = 6667
|
||||
tls = false
|
||||
nick = "owncast-bridge"
|
||||
@@ -8,6 +10,7 @@ channel = "#BowlAfterBowl"
|
||||
[owncast]
|
||||
url = "https://owncast.bowlafterbowl.com"
|
||||
# Set OWNCAST_ACCESS_TOKEN env var for the token
|
||||
# Set WEBHOOK_SECRET env var to require ?secret=<token> on incoming webhooks
|
||||
webhook_port = 9078
|
||||
websocket_enabled = true
|
||||
health_poll_interval_secs = 30
|
||||
|
||||
@@ -5,8 +5,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- OWNCAST_ACCESS_TOKEN=${OWNCAST_ACCESS_TOKEN}
|
||||
- IRC_PASSWORD=${IRC_PASSWORD}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||
- RUST_LOG=info
|
||||
volumes:
|
||||
- ./config.toml:/etc/owncast-irc-bridge/config.toml:ro
|
||||
- ./config.toml:/etc/owncast-irc-bridge/config.toml:ro,z
|
||||
ports:
|
||||
- "9078:9078"
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct IrcConfig {
|
||||
pub tls: bool,
|
||||
#[serde(default = "default_nick")]
|
||||
pub nick: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
pub channel: String,
|
||||
}
|
||||
|
||||
@@ -60,10 +62,18 @@ impl BridgeConfig {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn irc_server_password() -> Option<String> {
|
||||
std::env::var("IRC_PASSWORD").ok()
|
||||
}
|
||||
|
||||
pub fn owncast_access_token(&self) -> anyhow::Result<String> {
|
||||
std::env::var("OWNCAST_ACCESS_TOKEN")
|
||||
.map_err(|_| anyhow::anyhow!("OWNCAST_ACCESS_TOKEN env var not set"))
|
||||
}
|
||||
|
||||
pub fn webhook_secret() -> Option<String> {
|
||||
std::env::var("WEBHOOK_SECRET").ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_irc_port() -> u16 { 6667 }
|
||||
@@ -100,6 +110,7 @@ impl BridgeConfig {
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "test-bot".to_string(),
|
||||
username: None,
|
||||
channel: "#test".to_string(),
|
||||
},
|
||||
owncast: OwncastConfig {
|
||||
@@ -134,6 +145,7 @@ url = "https://owncast.example.com"
|
||||
assert_eq!(config.irc.port, 6667);
|
||||
assert_eq!(config.irc.tls, false);
|
||||
assert_eq!(config.irc.nick, "owncast-bridge");
|
||||
assert!(config.irc.username.is_none());
|
||||
assert_eq!(config.irc.channel, "#test");
|
||||
assert_eq!(config.owncast.url, "https://owncast.example.com");
|
||||
assert_eq!(config.owncast.webhook_port, 9078);
|
||||
@@ -153,6 +165,7 @@ server = "irc.example.com"
|
||||
port = 6697
|
||||
tls = true
|
||||
nick = "mybot"
|
||||
username = "myident"
|
||||
channel = "#mychan"
|
||||
|
||||
[owncast]
|
||||
@@ -173,12 +186,21 @@ socket_path = "/var/run/bridge.sock"
|
||||
assert_eq!(config.irc.port, 6697);
|
||||
assert!(config.irc.tls);
|
||||
assert_eq!(config.irc.nick, "mybot");
|
||||
assert_eq!(config.irc.username.as_deref(), Some("myident"));
|
||||
assert_eq!(config.owncast.webhook_port, 8888);
|
||||
assert!(config.owncast.websocket_enabled);
|
||||
assert_eq!(config.bridge.message_buffer_size, 50);
|
||||
assert_eq!(config.control.socket_path, "/var/run/bridge.sock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_irc_server_password_from_env() {
|
||||
std::env::set_var("IRC_PASSWORD", "secret123");
|
||||
assert_eq!(BridgeConfig::irc_server_password().as_deref(), Some("secret123"));
|
||||
std::env::remove_var("IRC_PASSWORD");
|
||||
assert!(BridgeConfig::irc_server_password().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_access_token_from_env() {
|
||||
std::env::set_var("OWNCAST_ACCESS_TOKEN", "test-token-123");
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum BridgeEvent {
|
||||
username: String,
|
||||
body: String,
|
||||
id: Option<String>,
|
||||
is_action: bool,
|
||||
},
|
||||
StreamStarted {
|
||||
title: String,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// Strip mIRC-style formatting control codes from a string.
|
||||
///
|
||||
/// Removes bold (\x02), color (\x03 + optional fg[,bg] digits), reset (\x0F),
|
||||
/// monospace (\x11), reverse (\x16), italic (\x1D), strikethrough (\x1E),
|
||||
/// and underline (\x1F).
|
||||
/// Removes CTCP delimiter (\x01), bold (\x02), color (\x03 + optional fg[,bg]
|
||||
/// digits), reset (\x0F), monospace (\x11), reverse (\x16), italic (\x1D),
|
||||
/// strikethrough (\x1E), and underline (\x1F).
|
||||
pub fn strip_formatting(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let len = bytes.len();
|
||||
@@ -11,7 +11,7 @@ pub fn strip_formatting(input: &str) -> String {
|
||||
|
||||
while i < len {
|
||||
match bytes[i] {
|
||||
b'\x02' | b'\x0F' | b'\x11' | b'\x16' | b'\x1D' | b'\x1E' | b'\x1F' => {
|
||||
b'\x01' | b'\x02' | b'\x0F' | b'\x11' | b'\x16' | b'\x1D' | b'\x1E' | b'\x1F' => {
|
||||
i += 1;
|
||||
}
|
||||
b'\x03' => {
|
||||
@@ -156,4 +156,17 @@ mod tests {
|
||||
fn preserves_cjk_and_accented_chars() {
|
||||
assert_eq!(strip_formatting("\x02café\x02 日本語"), "café 日本語");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_ctcp_delimiter() {
|
||||
assert_eq!(strip_formatting("\x01ACTION sniffs\x01"), "ACTION sniffs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_ctcp_delimiter_mixed_with_formatting() {
|
||||
assert_eq!(
|
||||
strip_formatting("\x01\x02ACTION bold\x02\x01"),
|
||||
"ACTION bold"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use irc::client::prelude::*;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::config::IrcConfig;
|
||||
use crate::config::{BridgeConfig, IrcConfig};
|
||||
use crate::events::{BridgeEvent, Source};
|
||||
|
||||
pub async fn run_irc_task(
|
||||
@@ -52,6 +52,8 @@ async fn connect_and_run(
|
||||
port: Some(config.port),
|
||||
use_tls: Some(config.tls),
|
||||
channels: vec![config.channel.clone()],
|
||||
password: BridgeConfig::irc_server_password(),
|
||||
username: config.username.clone(),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
@@ -71,11 +73,13 @@ async fn connect_and_run(
|
||||
if let Command::PRIVMSG(ref target, ref text) = message.command {
|
||||
if target == &config.channel {
|
||||
let nick = message.source_nickname().unwrap_or("unknown").to_string();
|
||||
let (body, is_action) = parse_ctcp_action(text);
|
||||
let event = BridgeEvent::ChatMessage {
|
||||
source: Source::Irc,
|
||||
username: nick,
|
||||
body: crate::irc_format::strip_formatting(text),
|
||||
body: crate::irc_format::strip_formatting(&body),
|
||||
id: None,
|
||||
is_action,
|
||||
};
|
||||
if event_tx.send(event).await.is_err() {
|
||||
return Ok(());
|
||||
@@ -88,8 +92,12 @@ async fn connect_and_run(
|
||||
}
|
||||
}
|
||||
Some(text) = outbound_rx.recv() => {
|
||||
if text.starts_with('\x01') {
|
||||
sender.send(Command::PRIVMSG(config.channel.clone(), text))?;
|
||||
} else {
|
||||
sender.send_privmsg(&config.channel, &text)?;
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
let _ = sender.send_quit("Bridge shutting down");
|
||||
return Ok(());
|
||||
@@ -97,3 +105,60 @@ async fn connect_and_run(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CTCP_DELIM: u8 = b'\x01';
|
||||
const ACTION_PREFIX: &str = "ACTION ";
|
||||
|
||||
/// If `text` is a CTCP ACTION (`\x01ACTION ...\x01`), returns the inner
|
||||
/// action body and `true`. Otherwise returns the original text and `false`.
|
||||
fn parse_ctcp_action(text: &str) -> (String, bool) {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() >= 9
|
||||
&& bytes[0] == CTCP_DELIM
|
||||
&& bytes[bytes.len() - 1] == CTCP_DELIM
|
||||
&& text[1..].starts_with(ACTION_PREFIX)
|
||||
{
|
||||
let action_body = &text[1 + ACTION_PREFIX.len()..text.len() - 1];
|
||||
(action_body.to_string(), true)
|
||||
} else {
|
||||
(text.to_string(), false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_action_message() {
|
||||
let (body, is_action) = parse_ctcp_action("\x01ACTION sniffs\x01");
|
||||
assert!(is_action);
|
||||
assert_eq!(body, "sniffs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_action_with_multiple_words() {
|
||||
let (body, is_action) = parse_ctcp_action("\x01ACTION waves hello\x01");
|
||||
assert!(is_action);
|
||||
assert_eq!(body, "waves hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_message_unchanged() {
|
||||
let (body, is_action) = parse_ctcp_action("just a regular message");
|
||||
assert!(!is_action);
|
||||
assert_eq!(body, "just a regular message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_ctcp_not_treated_as_action() {
|
||||
let (_, is_action) = parse_ctcp_action("\x01ACTION sniffs");
|
||||
assert!(!is_action);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_action_ctcp_not_treated_as_action() {
|
||||
let (_, is_action) = parse_ctcp_action("\x01VERSION\x01");
|
||||
assert!(!is_action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config = config::BridgeConfig::load(&cli.config)?;
|
||||
let access_token = config.owncast_access_token()?;
|
||||
let webhook_secret = config::BridgeConfig::webhook_secret();
|
||||
|
||||
info!("Starting owncast-irc-bridge");
|
||||
|
||||
@@ -64,7 +65,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let webhook_port = config.owncast.webhook_port;
|
||||
let webhook_event_tx = event_tx.clone();
|
||||
let _webhook_handle = tokio::spawn(async move {
|
||||
if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx).await {
|
||||
if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx, webhook_secret).await {
|
||||
tracing::error!(error = %e, "Webhook server failed");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -141,7 +141,7 @@ async fn handle_event(
|
||||
owncast_state: &OwncastState,
|
||||
) {
|
||||
match event {
|
||||
BridgeEvent::ChatMessage { source, username, body, id } => {
|
||||
BridgeEvent::ChatMessage { source, username, body, id, is_action } => {
|
||||
if let Some(ref msg_id) = id {
|
||||
if dedup.is_duplicate(msg_id) {
|
||||
return;
|
||||
@@ -153,7 +153,11 @@ async fn handle_event(
|
||||
if *owncast_state == OwncastState::Unavailable {
|
||||
return;
|
||||
}
|
||||
let formatted = format!("{} <{}> {}", settings.irc_prefix, username, body);
|
||||
let formatted = if is_action {
|
||||
format!("{} * {} {} *", settings.irc_prefix, username, body)
|
||||
} else {
|
||||
format!("{} <{}> {}", settings.irc_prefix, username, body)
|
||||
};
|
||||
echo.record_sent(&body);
|
||||
if let Err(e) = api_client.send_chat_message(&formatted).await {
|
||||
warn!(error = %e, "Failed to send to Owncast");
|
||||
@@ -163,7 +167,9 @@ async fn handle_event(
|
||||
if echo.is_echo(&body) {
|
||||
return;
|
||||
}
|
||||
let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body);
|
||||
let formatted = format_owncast_to_irc(
|
||||
&settings.owncast_prefix, &username, &body,
|
||||
);
|
||||
if irc_tx.send(formatted).await.is_err() {
|
||||
warn!("IRC outbound channel closed");
|
||||
}
|
||||
@@ -200,6 +206,14 @@ async fn handle_state_change(
|
||||
}
|
||||
}
|
||||
|
||||
fn format_owncast_to_irc(prefix: &str, username: &str, body: &str) -> String {
|
||||
if let Some(action_body) = body.strip_prefix("/me ") {
|
||||
format!("\x01ACTION {} \x02{}\x02 {}\x01", prefix, username, action_body)
|
||||
} else {
|
||||
format!("{} <{}> {}", prefix, username, body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -233,4 +247,34 @@ mod tests {
|
||||
assert!(suppressor.is_echo("hello from IRC"));
|
||||
assert!(!suppressor.is_echo("different message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_owncast_me_formats_as_ctcp_action() {
|
||||
let result = format_owncast_to_irc("[OC]", "viewer42", "/me claps");
|
||||
assert_eq!(result, "\x01ACTION [OC] \x02viewer42\x02 claps\x01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_owncast_me_multi_word_action() {
|
||||
let result = format_owncast_to_irc("[OC]", "viewer42", "/me claps loudly");
|
||||
assert_eq!(result, "\x01ACTION [OC] \x02viewer42\x02 claps loudly\x01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_owncast_normal_message_unchanged() {
|
||||
let result = format_owncast_to_irc("[OC]", "viewer42", "hello world");
|
||||
assert_eq!(result, "[OC] <viewer42> hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_owncast_me_without_space_is_normal() {
|
||||
let result = format_owncast_to_irc("[OC]", "viewer42", "/me");
|
||||
assert_eq!(result, "[OC] <viewer42> /me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_owncast_me_mid_message_is_normal() {
|
||||
let result = format_owncast_to_irc("[OC]", "viewer42", "hello /me claps");
|
||||
assert_eq!(result, "[OC] <viewer42> hello /me claps");
|
||||
}
|
||||
}
|
||||
|
||||
114
src/webhook.rs
114
src/webhook.rs
@@ -1,4 +1,4 @@
|
||||
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use axum::{extract::{Query, State}, http::StatusCode, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
@@ -52,6 +52,7 @@ impl WebhookPayload {
|
||||
username: data.user.display_name,
|
||||
body: strip_html(&data.body),
|
||||
id: Some(data.id),
|
||||
is_action: false,
|
||||
})
|
||||
}
|
||||
"STREAM_STARTED" => {
|
||||
@@ -66,15 +67,32 @@ impl WebhookPayload {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebhookQuery {
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WebhookState {
|
||||
event_tx: mpsc::Sender<BridgeEvent>,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
async fn handle_webhook(
|
||||
State(state): State<WebhookState>,
|
||||
Query(query): Query<WebhookQuery>,
|
||||
Json(payload): Json<WebhookPayload>,
|
||||
) -> StatusCode {
|
||||
if let Some(ref expected) = state.secret {
|
||||
match query.secret {
|
||||
Some(ref provided) if provided == expected => {}
|
||||
_ => {
|
||||
warn!("Webhook request rejected: invalid or missing secret");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(event_type = %payload.event_type, "Received webhook");
|
||||
|
||||
match payload.into_bridge_event() {
|
||||
@@ -92,8 +110,13 @@ async fn handle_webhook(
|
||||
pub async fn run_webhook_server(
|
||||
port: u16,
|
||||
event_tx: mpsc::Sender<BridgeEvent>,
|
||||
secret: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = WebhookState { event_tx };
|
||||
if secret.is_none() {
|
||||
warn!("WEBHOOK_SECRET is not set — webhook endpoint accepts all requests");
|
||||
}
|
||||
|
||||
let state = WebhookState { event_tx, secret };
|
||||
let app = Router::new()
|
||||
.route("/webhook", post(handle_webhook))
|
||||
.with_state(state);
|
||||
@@ -109,6 +132,91 @@ pub async fn run_webhook_server(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn build_app(secret: Option<String>) -> (Router, mpsc::Receiver<BridgeEvent>) {
|
||||
let (event_tx, rx) = mpsc::channel(16);
|
||||
let state = WebhookState { event_tx, secret };
|
||||
let app = Router::new()
|
||||
.route("/webhook", post(handle_webhook))
|
||||
.with_state(state);
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
fn chat_body() -> String {
|
||||
serde_json::json!({
|
||||
"type": "CHAT",
|
||||
"eventData": {
|
||||
"user": { "displayName": "test", "isBot": false },
|
||||
"body": "hi",
|
||||
"id": "msg1",
|
||||
"visible": true
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_correct_secret_returns_200() {
|
||||
let (app, _rx) = build_app(Some("mysecret".to_string()));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::post("/webhook?secret=mysecret")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(chat_body()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_wrong_secret_returns_401() {
|
||||
let (app, _rx) = build_app(Some("mysecret".to_string()));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::post("/webhook?secret=wrong")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(chat_body()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_missing_secret_returns_401() {
|
||||
let (app, _rx) = build_app(Some("mysecret".to_string()));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::post("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(chat_body()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_no_secret_configured_accepts_all() {
|
||||
let (app, _rx) = build_app(None);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::post("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(chat_body()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_chat_event() {
|
||||
@@ -124,7 +232,7 @@ mod tests {
|
||||
let payload: WebhookPayload = serde_json::from_value(json).unwrap();
|
||||
let event = payload.into_bridge_event();
|
||||
assert!(event.is_some());
|
||||
if let Some(BridgeEvent::ChatMessage { source, username, body, id }) = event {
|
||||
if let Some(BridgeEvent::ChatMessage { source, username, body, id, .. }) = event {
|
||||
assert_eq!(source, Source::Owncast);
|
||||
assert_eq!(username, "viewer42");
|
||||
assert_eq!(body, "hello world");
|
||||
|
||||
@@ -148,6 +148,7 @@ fn parse_ws_message(text: &str) -> Option<BridgeEvent> {
|
||||
username: display_name,
|
||||
body: strip_html(body),
|
||||
id,
|
||||
is_action: false,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
|
||||
Reference in New Issue
Block a user