feat: add webhook body template and headers support for channels
This commit is contained in:
@@ -74,6 +74,8 @@ pub struct CreateChannelRequest {
|
||||
pub access_password: Option<String>,
|
||||
pub webhook_url: Option<String>,
|
||||
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.
|
||||
@@ -96,6 +98,10 @@ pub struct UpdateChannelRequest {
|
||||
/// `Some(None)` = clear, `Some(Some(url))` = set, `None` = unchanged.
|
||||
pub webhook_url: Option<Option<String>>,
|
||||
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)]
|
||||
@@ -114,6 +120,8 @@ pub struct ChannelResponse {
|
||||
pub logo_opacity: f32,
|
||||
pub webhook_url: Option<String>,
|
||||
pub webhook_poll_interval_secs: u32,
|
||||
pub webhook_body_template: Option<String>,
|
||||
pub webhook_headers: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -135,6 +143,8 @@ impl From<domain::Channel> for ChannelResponse {
|
||||
logo_opacity: c.logo_opacity,
|
||||
webhook_url: c.webhook_url,
|
||||
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,
|
||||
updated_at: c.updated_at,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ pub(super) async fn create_channel(
|
||||
channel.webhook_poll_interval_secs = interval;
|
||||
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 {
|
||||
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 {
|
||||
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();
|
||||
|
||||
let channel = state.channel_service.update(channel).await?;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! webhook_url, and fires HTTP POST requests (fire-and-forget).
|
||||
|
||||
use chrono::Utc;
|
||||
use handlebars::Handlebars;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
@@ -30,8 +31,10 @@ pub async fn run_webhook_consumer(
|
||||
Ok(Some(channel)) => {
|
||||
if let Some(url) = channel.webhook_url {
|
||||
let client = client.clone();
|
||||
let template = channel.webhook_body_template.clone();
|
||||
let headers = channel.webhook_headers.clone();
|
||||
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
|
||||
@@ -146,15 +149,60 @@ fn build_payload(event: &DomainEvent) -> Value {
|
||||
}
|
||||
|
||||
/// 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) => {
|
||||
if !resp.status().is_success() {
|
||||
warn!(
|
||||
"webhook POST to {} returned status {}",
|
||||
url,
|
||||
resp.status()
|
||||
);
|
||||
warn!("webhook POST to {} returned status {}", url, resp.status());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user