use serde::Serialize;
use tracing::{debug, error};
use crate::config::WebhookConfig;
use crate::error::NotificationError;
use crate::hooks::HookEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebhookTarget {
Slack,
Discord,
Generic,
}
impl WebhookTarget {
#[must_use]
pub fn detect(url: &str) -> Self {
if url.contains("hooks.slack.com") {
Self::Slack
} else if url.contains("discord.com/api/webhooks")
|| url.contains("discordapp.com/api/webhooks")
{
Self::Discord
} else {
Self::Generic
}
}
}
fn format_timestamp_iso8601(timestamp: std::time::SystemTime) -> String {
use chrono::{DateTime, SecondsFormat, Utc};
DateTime::<Utc>::from(timestamp).to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[derive(Debug, Serialize)]
pub struct SlackPayload {
text: String,
blocks: Vec<SlackBlock>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum SlackBlock {
Section { text: SlackText },
Context { elements: Vec<SlackText> },
}
#[derive(Debug, Serialize)]
struct SlackText {
#[serde(rename = "type")]
text_type: String,
text: String,
}
impl SlackPayload {
#[must_use]
pub fn from_event(event: &HookEvent, session_name: &str) -> Self {
let message = event
.message()
.unwrap_or_else(|| format!("{:?} event occurred", event.event_type));
let timestamp = format_timestamp_iso8601(event.timestamp);
Self {
text: format!("[tazuna] {session_name}: {message}"),
blocks: vec![
SlackBlock::Section {
text: SlackText {
text_type: "mrkdwn".to_string(),
text: format!("*{message}*"),
},
},
SlackBlock::Context {
elements: vec![SlackText {
text_type: "mrkdwn".to_string(),
text: format!(":bookmark: {session_name} | :clock1: {timestamp}"),
}],
},
],
}
}
}
#[derive(Debug, Serialize)]
pub struct DiscordPayload {
embeds: Vec<DiscordEmbed>,
}
#[derive(Debug, Serialize)]
struct DiscordEmbed {
title: String,
description: String,
color: u32,
fields: Vec<DiscordField>,
timestamp: String,
footer: DiscordFooter,
}
#[derive(Debug, Serialize)]
struct DiscordField {
name: String,
value: String,
inline: bool,
}
#[derive(Debug, Serialize)]
struct DiscordFooter {
text: String,
}
impl DiscordPayload {
#[must_use]
pub fn from_event(event: &HookEvent, session_name: &str) -> Self {
let message = event
.message()
.unwrap_or_else(|| format!("{:?} event occurred", event.event_type));
let timestamp = format_timestamp_iso8601(event.timestamp);
Self {
embeds: vec![DiscordEmbed {
title: "tazuna".to_string(),
description: message,
color: 16_750_848, fields: vec![DiscordField {
name: "Session".to_string(),
value: session_name.to_string(),
inline: true,
}],
timestamp,
footer: DiscordFooter {
text: "tazuna".to_string(),
},
}],
}
}
}
#[derive(Debug, Serialize)]
pub struct GenericPayload {
text: String,
session: String,
timestamp: String,
}
impl GenericPayload {
#[must_use]
pub fn from_event(event: &HookEvent, session_name: &str) -> Self {
let message = event
.message()
.unwrap_or_else(|| format!("{:?} event occurred", event.event_type));
let timestamp = format_timestamp_iso8601(event.timestamp);
Self {
text: format!("[tazuna] {session_name}: {message}"),
session: session_name.to_string(),
timestamp,
}
}
}
pub struct WebhookNotifier {
client: reqwest::Client,
url: String,
target: WebhookTarget,
}
impl WebhookNotifier {
#[must_use]
pub fn new(config: &WebhookConfig) -> Self {
Self {
client: reqwest::Client::new(),
target: WebhookTarget::detect(&config.url),
url: config.url.clone(),
}
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_client(client: reqwest::Client, url: String) -> Self {
let target = WebhookTarget::detect(&url);
Self {
client,
url,
target,
}
}
pub async fn send(
&self,
event: &HookEvent,
session_name: &str,
) -> Result<(), NotificationError> {
debug!(
"Sending webhook to {} (target: {:?})",
self.url, self.target
);
let response = match self.target {
WebhookTarget::Slack => {
let payload = SlackPayload::from_event(event, session_name);
self.client.post(&self.url).json(&payload).send().await
}
WebhookTarget::Discord => {
let payload = DiscordPayload::from_event(event, session_name);
self.client.post(&self.url).json(&payload).send().await
}
WebhookTarget::Generic => {
let payload = GenericPayload::from_event(event, session_name);
self.client.post(&self.url).json(&payload).send().await
}
}
.map_err(NotificationError::WebhookFailed)?;
if !response.status().is_success() {
error!("Webhook failed with status: {}", response.status());
}
Ok(())
}
#[cfg(test)]
#[must_use]
pub(crate) fn url(&self) -> &str {
&self.url
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::session::SessionId;
use rstest::rstest;
use uuid::Uuid;
fn test_session_id() -> SessionId {
SessionId::from(Uuid::new_v4())
}
fn make_event(hook_name: &str, msg_or_tool: &str) -> HookEvent {
let payload = if hook_name == "Notification" {
serde_json::json!({"hook_event_name": hook_name, "message": msg_or_tool})
} else {
serde_json::json!({"hook_event_name": hook_name, "tool_name": msg_or_tool})
};
HookEvent::from_payload(test_session_id(), payload).expect("create event")
}
#[test]
fn url_accessor() {
let config = WebhookConfig {
enabled: true,
url: "https://example.com".to_string(),
};
assert_eq!(WebhookNotifier::new(&config).url(), "https://example.com");
}
#[test]
fn with_client_constructor() {
let notifier =
WebhookNotifier::with_client(reqwest::Client::new(), "http://test".to_string());
assert_eq!(notifier.url(), "http://test");
}
#[rstest]
#[case(200)]
#[case(500)]
#[tokio::test]
async fn send_returns_ok(#[case] status: u16) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/webhook"))
.respond_with(ResponseTemplate::new(status))
.mount(&mock_server)
.await;
let notifier = WebhookNotifier::with_client(
reqwest::Client::new(),
format!("{}/webhook", mock_server.uri()),
);
assert!(
notifier
.send(&make_event("Notification", "Test"), "s")
.await
.is_ok()
);
}
#[rstest]
#[case("https://hooks.slack.com/services/T00/B00/xxx", WebhookTarget::Slack)]
#[case("https://hooks.slack.com/workflows/T00/A00/xxx", WebhookTarget::Slack)]
#[case("https://discord.com/api/webhooks/123/abc", WebhookTarget::Discord)]
#[case("https://discordapp.com/api/webhooks/123/abc", WebhookTarget::Discord)]
#[case("https://example.com/webhook", WebhookTarget::Generic)]
#[case("https://my-server.com/slack-webhook", WebhookTarget::Generic)]
fn detect_webhook_target(#[case] url: &str, #[case] expected: WebhookTarget) {
assert_eq!(WebhookTarget::detect(url), expected);
}
#[test]
fn slack_payload_has_required_structure() {
let event = make_event("Notification", "Permission needed");
let payload = SlackPayload::from_event(&event, "test-session");
let json = serde_json::to_value(&payload).expect("serialize");
assert!(json.get("text").is_some());
assert!(json["text"].as_str().unwrap().contains("test-session"));
assert!(json.get("blocks").is_some());
assert!(json["blocks"].is_array());
let blocks = json["blocks"].as_array().unwrap();
assert!(blocks.len() >= 2);
assert_eq!(blocks[0]["type"], "section");
assert!(
blocks[0]["text"]["text"]
.as_str()
.unwrap()
.contains("Permission needed")
);
assert_eq!(blocks[1]["type"], "context");
}
#[test]
fn discord_payload_has_required_structure() {
let event = make_event("Notification", "Action required");
let payload = DiscordPayload::from_event(&event, "my-session");
let json = serde_json::to_value(&payload).expect("serialize");
assert!(json.get("embeds").is_some());
let embeds = json["embeds"].as_array().unwrap();
assert_eq!(embeds.len(), 1);
let embed = &embeds[0];
assert_eq!(embed["title"], "tazuna");
assert!(
embed["description"]
.as_str()
.unwrap()
.contains("Action required")
);
assert_eq!(embed["color"], 16_750_848);
assert!(embed["fields"].is_array());
assert!(embed.get("timestamp").is_some());
assert_eq!(embed["footer"]["text"], "tazuna");
}
#[test]
fn generic_payload_backward_compat() {
let event = make_event("Notification", "Test message");
let payload = GenericPayload::from_event(&event, "sess-1");
let json = serde_json::to_value(&payload).expect("serialize");
assert!(
json["text"]
.as_str()
.unwrap()
.starts_with("[tazuna] sess-1:")
);
assert!(json["text"].as_str().unwrap().contains("Test message"));
assert_eq!(json["session"], "sess-1");
assert!(json.get("timestamp").is_some());
}
#[tokio::test]
async fn send_slack_payload_has_blocks() {
use wiremock::matchers::{body_partial_json, method};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(serde_json::json!({
"blocks": [{"type": "section"}]
})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let notifier = WebhookNotifier::with_client(
reqwest::Client::new(),
format!("{}?host=hooks.slack.com", mock_server.uri()),
);
notifier
.send(&make_event("Notification", "Test"), "test-session")
.await
.expect("send ok");
}
#[tokio::test]
async fn send_discord_payload_has_embeds() {
use wiremock::matchers::{body_partial_json, method};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(serde_json::json!({
"embeds": [{"title": "tazuna"}]
})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let notifier = WebhookNotifier::with_client(
reqwest::Client::new(),
format!("{}?host=discord.com/api/webhooks", mock_server.uri()),
);
notifier
.send(&make_event("Notification", "Test"), "test-session")
.await
.expect("send ok");
}
#[tokio::test]
async fn send_generic_payload_minimal() {
use wiremock::matchers::{body_partial_json, method};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(serde_json::json!({
"session": "test-session"
})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let notifier = WebhookNotifier::with_client(reqwest::Client::new(), mock_server.uri());
notifier
.send(&make_event("Notification", "Test"), "test-session")
.await
.expect("send ok");
}
}