feat: add webhook body template and headers support for channels

This commit is contained in:
2026-03-16 01:10:26 +01:00
parent db461db270
commit e76167134b
12 changed files with 366 additions and 23 deletions

155
k-tv-backend/Cargo.lock generated
View File

@@ -78,6 +78,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"dotenvy", "dotenvy",
"handlebars",
"infra", "infra",
"k-core", "k-core",
"rand 0.8.5", "rand 0.8.5",
@@ -539,14 +540,38 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.21.3",
"darling_macro", "darling_macro 0.21.3",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
] ]
[[package]] [[package]]
@@ -563,13 +588,24 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn",
]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.21.3" version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.21.3",
"quote", "quote",
"syn", "syn",
] ]
@@ -601,6 +637,37 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -1030,6 +1097,22 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "handlebars"
version = "6.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e"
dependencies = [
"derive_builder",
"log",
"num-order",
"pest",
"pest_derive",
"serde",
"serde_json",
"thiserror 2.0.17",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1756,6 +1839,21 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-modular"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
[[package]]
name = "num-order"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
dependencies = [
"num-modular",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1989,6 +2087,49 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.12.1" version = "0.12.1"
@@ -2816,7 +2957,7 @@ version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
dependencies = [ dependencies = [
"darling", "darling 0.21.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
@@ -3572,6 +3713,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"

View File

@@ -58,6 +58,7 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] }
tracing = "0.1" tracing = "0.1"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
handlebars = "6"
async-trait = "0.1" async-trait = "0.1"
dotenvy = "0.15.7" dotenvy = "0.15.7"
time = "0.3" time = "0.3"

View File

@@ -74,6 +74,8 @@ pub struct CreateChannelRequest {
pub access_password: Option<String>, pub access_password: Option<String>,
pub webhook_url: Option<String>, pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: Option<u32>, pub webhook_poll_interval_secs: Option<u32>,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
} }
/// All fields are optional — only provided fields are updated. /// All fields are optional — only provided fields are updated.
@@ -96,6 +98,10 @@ pub struct UpdateChannelRequest {
/// `Some(None)` = clear, `Some(Some(url))` = set, `None` = unchanged. /// `Some(None)` = clear, `Some(Some(url))` = set, `None` = unchanged.
pub webhook_url: Option<Option<String>>, pub webhook_url: Option<Option<String>>,
pub webhook_poll_interval_secs: Option<u32>, pub webhook_poll_interval_secs: Option<u32>,
/// `Some(None)` = clear, `Some(Some(tmpl))` = set, `None` = unchanged.
pub webhook_body_template: Option<Option<String>>,
/// `Some(None)` = clear, `Some(Some(json))` = set, `None` = unchanged.
pub webhook_headers: Option<Option<String>>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -114,6 +120,8 @@ pub struct ChannelResponse {
pub logo_opacity: f32, pub logo_opacity: f32,
pub webhook_url: Option<String>, pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: u32, pub webhook_poll_interval_secs: u32,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -135,6 +143,8 @@ impl From<domain::Channel> for ChannelResponse {
logo_opacity: c.logo_opacity, logo_opacity: c.logo_opacity,
webhook_url: c.webhook_url, webhook_url: c.webhook_url,
webhook_poll_interval_secs: c.webhook_poll_interval_secs, webhook_poll_interval_secs: c.webhook_poll_interval_secs,
webhook_body_template: c.webhook_body_template,
webhook_headers: c.webhook_headers,
created_at: c.created_at, created_at: c.created_at,
updated_at: c.updated_at, updated_at: c.updated_at,
} }

View File

@@ -56,6 +56,14 @@ pub(super) async fn create_channel(
channel.webhook_poll_interval_secs = interval; channel.webhook_poll_interval_secs = interval;
changed = true; changed = true;
} }
if let Some(tmpl) = payload.webhook_body_template {
channel.webhook_body_template = Some(tmpl);
changed = true;
}
if let Some(headers) = payload.webhook_headers {
channel.webhook_headers = Some(headers);
changed = true;
}
if changed { if changed {
channel = state.channel_service.update(channel).await?; channel = state.channel_service.update(channel).await?;
} }
@@ -126,6 +134,12 @@ pub(super) async fn update_channel(
if let Some(interval) = payload.webhook_poll_interval_secs { if let Some(interval) = payload.webhook_poll_interval_secs {
channel.webhook_poll_interval_secs = interval; channel.webhook_poll_interval_secs = interval;
} }
if let Some(tmpl) = payload.webhook_body_template {
channel.webhook_body_template = tmpl;
}
if let Some(headers) = payload.webhook_headers {
channel.webhook_headers = headers;
}
channel.updated_at = Utc::now(); channel.updated_at = Utc::now();
let channel = state.channel_service.update(channel).await?; let channel = state.channel_service.update(channel).await?;

View File

@@ -4,6 +4,7 @@
//! webhook_url, and fires HTTP POST requests (fire-and-forget). //! webhook_url, and fires HTTP POST requests (fire-and-forget).
use chrono::Utc; use chrono::Utc;
use handlebars::Handlebars;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -30,8 +31,10 @@ pub async fn run_webhook_consumer(
Ok(Some(channel)) => { Ok(Some(channel)) => {
if let Some(url) = channel.webhook_url { if let Some(url) = channel.webhook_url {
let client = client.clone(); let client = client.clone();
let template = channel.webhook_body_template.clone();
let headers = channel.webhook_headers.clone();
tokio::spawn(async move { tokio::spawn(async move {
post_webhook(&client, &url, payload).await; post_webhook(&client, &url, payload, template.as_deref(), headers.as_deref()).await;
}); });
} }
// No webhook_url configured — skip silently // No webhook_url configured — skip silently
@@ -146,15 +149,60 @@ fn build_payload(event: &DomainEvent) -> Value {
} }
/// Fire-and-forget HTTP POST to a webhook URL. /// Fire-and-forget HTTP POST to a webhook URL.
async fn post_webhook(client: &reqwest::Client, url: &str, payload: Value) { ///
match client.post(url).json(&payload).send().await { /// If `template` is provided it is rendered with `payload` as context via Handlebars.
/// `headers_json` is a JSON object string of extra HTTP headers (e.g. `{"Authorization":"Bearer x"}`).
/// Content-Type defaults to `application/json` unless overridden in `headers_json`.
async fn post_webhook(
client: &reqwest::Client,
url: &str,
payload: Value,
template: Option<&str>,
headers_json: Option<&str>,
) {
let body = if let Some(tmpl) = template {
let hbs = Handlebars::new();
match hbs.render_template(tmpl, &payload) {
Ok(rendered) => rendered,
Err(e) => {
warn!("webhook template render failed for {}: {}", url, e);
return;
}
}
} else {
match serde_json::to_string(&payload) {
Ok(s) => s,
Err(e) => {
warn!("webhook payload serialize failed: {}", e);
return;
}
}
};
let mut req = client.post(url).body(body);
let mut has_content_type = false;
if let Some(h) = headers_json {
if let Ok(map) = serde_json::from_str::<serde_json::Map<String, Value>>(h) {
for (k, v) in &map {
if k.to_lowercase() == "content-type" {
has_content_type = true;
}
if let Some(v_str) = v.as_str() {
req = req.header(k.as_str(), v_str);
}
}
}
}
if !has_content_type {
req = req.header("Content-Type", "application/json");
}
match req.send().await {
Ok(resp) => { Ok(resp) => {
if !resp.status().is_success() { if !resp.status().is_success() {
warn!( warn!("webhook POST to {} returned status {}", url, resp.status());
"webhook POST to {} returned status {}",
url,
resp.status()
);
} }
} }
Err(e) => { Err(e) => {

View File

@@ -90,6 +90,8 @@ pub struct Channel {
pub logo_opacity: f32, pub logo_opacity: f32,
pub webhook_url: Option<String>, pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: u32, pub webhook_poll_interval_secs: u32,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -117,6 +119,8 @@ impl Channel {
logo_opacity: 1.0, logo_opacity: 1.0,
webhook_url: None, webhook_url: None,
webhook_poll_interval_secs: 5, webhook_poll_interval_secs: 5,
webhook_body_template: None,
webhook_headers: None,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }

View File

@@ -21,6 +21,8 @@ pub(super) struct ChannelRow {
pub logo_opacity: f32, pub logo_opacity: f32,
pub webhook_url: Option<String>, pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: i64, pub webhook_poll_interval_secs: i64,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -77,6 +79,8 @@ impl TryFrom<ChannelRow> for Channel {
logo_opacity: row.logo_opacity, logo_opacity: row.logo_opacity,
webhook_url: row.webhook_url, webhook_url: row.webhook_url,
webhook_poll_interval_secs: row.webhook_poll_interval_secs as u32, webhook_poll_interval_secs: row.webhook_poll_interval_secs as u32,
webhook_body_template: row.webhook_body_template,
webhook_headers: row.webhook_headers,
created_at: parse_dt(&row.created_at)?, created_at: parse_dt(&row.created_at)?,
updated_at: parse_dt(&row.updated_at)?, updated_at: parse_dt(&row.updated_at)?,
}) })
@@ -84,4 +88,4 @@ impl TryFrom<ChannelRow> for Channel {
} }
pub(super) const SELECT_COLS: &str = pub(super) const SELECT_COLS: &str =
"id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, created_at, updated_at"; "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at";

View File

@@ -71,8 +71,8 @@ impl ChannelRepository for SqliteChannelRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, created_at, updated_at) (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
description = excluded.description, description = excluded.description,
@@ -87,6 +87,8 @@ impl ChannelRepository for SqliteChannelRepository {
logo_opacity = excluded.logo_opacity, logo_opacity = excluded.logo_opacity,
webhook_url = excluded.webhook_url, webhook_url = excluded.webhook_url,
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs, webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
webhook_body_template = excluded.webhook_body_template,
webhook_headers = excluded.webhook_headers,
updated_at = excluded.updated_at updated_at = excluded.updated_at
"#, "#,
) )
@@ -105,6 +107,8 @@ impl ChannelRepository for SqliteChannelRepository {
.bind(channel.logo_opacity) .bind(channel.logo_opacity)
.bind(&channel.webhook_url) .bind(&channel.webhook_url)
.bind(channel.webhook_poll_interval_secs as i64) .bind(channel.webhook_poll_interval_secs as i64)
.bind(&channel.webhook_body_template)
.bind(&channel.webhook_headers)
.bind(channel.created_at.to_rfc3339()) .bind(channel.created_at.to_rfc3339())
.bind(channel.updated_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339())
.execute(&self.pool) .execute(&self.pool)

View File

@@ -0,0 +1,2 @@
ALTER TABLE channels ADD COLUMN webhook_body_template TEXT;
ALTER TABLE channels ADD COLUMN webhook_headers TEXT;

View File

@@ -23,6 +23,26 @@ import type {
RecyclePolicy, RecyclePolicy,
} from "@/lib/types"; } from "@/lib/types";
// ---------------------------------------------------------------------------
// Webhook preset templates (frontend-only, zero backend changes needed)
// ---------------------------------------------------------------------------
const WEBHOOK_PRESETS = {
discord: `{
"embeds": [{
"title": "📺 {{event}}",
"description": "{{#if data.item.title}}Now playing: **{{data.item.title}}**{{else}}No signal{{/if}}",
"color": 3447003,
"timestamp": "{{timestamp}}"
}]
}`,
slack: `{
"text": "📺 *{{event}}*{{#if data.item.title}} — {{data.item.title}}{{/if}}"
}`,
} as const;
type WebhookFormat = "default" | "discord" | "slack" | "custom";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Zod schemas // Zod schemas
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -758,6 +778,8 @@ interface EditChannelSheetProps {
logo_opacity?: number; logo_opacity?: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
}, },
) => void; ) => void;
isPending: boolean; isPending: boolean;
@@ -791,6 +813,9 @@ export function EditChannelSheet({
const [logoOpacity, setLogoOpacity] = useState(100); const [logoOpacity, setLogoOpacity] = useState(100);
const [webhookUrl, setWebhookUrl] = useState(""); const [webhookUrl, setWebhookUrl] = useState("");
const [webhookPollInterval, setWebhookPollInterval] = useState<number | "">(5); const [webhookPollInterval, setWebhookPollInterval] = useState<number | "">(5);
const [webhookFormat, setWebhookFormat] = useState<WebhookFormat>("default");
const [webhookBodyTemplate, setWebhookBodyTemplate] = useState("");
const [webhookHeaders, setWebhookHeaders] = useState("");
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null); const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -810,6 +835,18 @@ export function EditChannelSheet({
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100)); setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
setWebhookUrl(channel.webhook_url ?? ""); setWebhookUrl(channel.webhook_url ?? "");
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5); setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
const tmpl = channel.webhook_body_template ?? "";
setWebhookBodyTemplate(tmpl);
setWebhookHeaders(channel.webhook_headers ?? "");
if (!tmpl) {
setWebhookFormat("default");
} else if (tmpl === WEBHOOK_PRESETS.discord) {
setWebhookFormat("discord");
} else if (tmpl === WEBHOOK_PRESETS.slack) {
setWebhookFormat("slack");
} else {
setWebhookFormat("custom");
}
setSelectedBlockId(null); setSelectedBlockId(null);
setFieldErrors({}); setFieldErrors({});
} }
@@ -846,6 +883,8 @@ export function EditChannelSheet({
...(webhookUrl ...(webhookUrl
? { webhook_poll_interval_secs: webhookPollInterval === "" ? 5 : webhookPollInterval } ? { webhook_poll_interval_secs: webhookPollInterval === "" ? 5 : webhookPollInterval }
: {}), : {}),
webhook_body_template: webhookBodyTemplate || null,
webhook_headers: webhookHeaders || null,
}); });
}; };
@@ -1107,6 +1146,7 @@ export function EditChannelSheet({
/> />
</Field> </Field>
{webhookUrl && ( {webhookUrl && (
<>
<Field label="Poll interval (seconds)" hint="How often to check for broadcast changes"> <Field label="Poll interval (seconds)" hint="How often to check for broadcast changes">
<NumberInput <NumberInput
value={webhookPollInterval} value={webhookPollInterval}
@@ -1115,6 +1155,63 @@ export function EditChannelSheet({
placeholder="5" placeholder="5"
/> />
</Field> </Field>
<div className="space-y-2">
<p className="text-xs text-zinc-400">Payload format</p>
<div className="flex gap-1.5 flex-wrap">
{(["default", "discord", "slack", "custom"] as WebhookFormat[]).map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => {
setWebhookFormat(fmt);
if (fmt === "discord") setWebhookBodyTemplate(WEBHOOK_PRESETS.discord);
else if (fmt === "slack") setWebhookBodyTemplate(WEBHOOK_PRESETS.slack);
else if (fmt === "default") setWebhookBodyTemplate("");
}}
className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
webhookFormat === fmt
? "bg-zinc-600 text-zinc-100"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{fmt === "default" ? "K-TV default" : fmt}
</button>
))}
</div>
</div>
{webhookFormat !== "default" && (
<Field
label="Body template (Handlebars)"
hint="Context: event, timestamp, channel_id, data.item.title, data.item.duration_secs, …"
>
<textarea
rows={6}
value={webhookBodyTemplate}
onChange={(e) => {
setWebhookBodyTemplate(e.target.value);
setWebhookFormat("custom");
}}
placeholder={'{\n "text": "Now playing: {{data.item.title}}"\n}'}
className="w-full resize-y rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>
)}
<Field
label="Extra headers (JSON)"
hint={'e.g. {"Authorization": "Bearer token"}'}
>
<textarea
rows={2}
value={webhookHeaders}
onChange={(e) => setWebhookHeaders(e.target.value)}
placeholder={'{"Authorization": "Bearer xxx"}'}
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>
</>
)} )}
</section> </section>
</div> </div>

View File

@@ -158,6 +158,10 @@ export default function DashboardPage() {
logo?: string | null; logo?: string | null;
logo_position?: import("@/lib/types").LogoPosition; logo_position?: import("@/lib/types").LogoPosition;
logo_opacity?: number; logo_opacity?: number;
webhook_url?: string | null;
webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
}, },
) => { ) => {
updateChannel.mutate( updateChannel.mutate(

View File

@@ -149,6 +149,8 @@ export interface ChannelResponse {
logo_opacity: number; logo_opacity: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -161,6 +163,8 @@ export interface CreateChannelRequest {
access_password?: string; access_password?: string;
webhook_url?: string; webhook_url?: string;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
webhook_body_template?: string;
webhook_headers?: string;
} }
export interface UpdateChannelRequest { export interface UpdateChannelRequest {
@@ -180,6 +184,10 @@ export interface UpdateChannelRequest {
/** null = clear webhook */ /** null = clear webhook */
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
/** null = clear template */
webhook_body_template?: string | null;
/** null = clear headers */
webhook_headers?: string | null;
} }
// Media & Schedule // Media & Schedule