agent-notify-core 0.3.0

Core notification config and provider implementation for agent-notify.
Documentation
use reqwest::Client;
use serde_json::json;

use crate::{MessageFormat, NotifyError, NotifyMessage, Priority, Result, config::GotifyConfig};

use super::{
    SendResult,
    common::{ensure_success, resolve_required_secret},
};

pub(super) async fn send(
    client: &Client,
    channel_name: &str,
    config: &GotifyConfig,
    message: &NotifyMessage,
) -> Result<SendResult> {
    if !message.attachments.is_empty() {
        return Err(NotifyError::UnsupportedAttachment {
            channel_type: "gotify".to_string(),
        });
    }

    let token = resolve_required_secret(channel_name, "token", &config.token, &config.token_env)?;
    let url = format!("{}/message", config.server.trim_end_matches('/'));
    let payload = gotify_payload(message, config);

    let response = client
        .post(url)
        .query(&[("token", token)])
        .json(&payload)
        .send()
        .await?;
    ensure_success("gotify", response.status(), response.text().await?)?;

    Ok(SendResult {
        id: message.id.clone(),
        attachments: Vec::new(),
    })
}

fn gotify_payload(message: &NotifyMessage, config: &GotifyConfig) -> serde_json::Value {
    let mut payload = json!({
        "title": message.title,
        "message": message.body.as_deref().unwrap_or(&message.title),
        "priority": config.priority.unwrap_or_else(|| gotify_priority(message.priority)),
    });
    if message.format == MessageFormat::Markdown {
        payload["extras"] = json!({
            "client::display": {
                "contentType": "text/markdown"
            }
        });
    }
    payload
}

fn gotify_priority(priority: Priority) -> i64 {
    match priority {
        Priority::Info | Priority::Success => 5,
        Priority::Warning => 6,
        Priority::Error => 8,
        Priority::Critical => 10,
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use reqwest::Client;
    use serde_json::json;
    use tempfile::tempdir;

    use crate::{Attachment, MessageFormat};

    use super::*;

    #[test]
    fn gotify_payload_includes_message_fields() {
        let message = NotifyMessage::new(
            "Done".to_string(),
            Some("**Completed.**".to_string()),
            MessageFormat::Markdown,
            Priority::Critical,
            Vec::new(),
            Vec::new(),
        )
        .unwrap();
        let config = GotifyConfig {
            server: "https://gotify.example.com".to_string(),
            token: Some("app-token".to_string()),
            token_env: None,
            priority: None,
        };

        let payload = gotify_payload(&message, &config);

        assert_eq!(payload["title"], json!("Done"));
        assert_eq!(payload["message"], json!("**Completed.**"));
        assert_eq!(payload["priority"], json!(10));
        assert_eq!(
            payload["extras"]["client::display"]["contentType"],
            json!("text/markdown")
        );
    }

    #[test]
    fn gotify_config_priority_overrides_message_priority() {
        let message = NotifyMessage::new(
            "Done".to_string(),
            Some("Completed.".to_string()),
            MessageFormat::Text,
            Priority::Critical,
            Vec::new(),
            Vec::new(),
        )
        .unwrap();
        let config = GotifyConfig {
            server: "https://gotify.example.com".to_string(),
            token: Some("app-token".to_string()),
            token_env: None,
            priority: Some(3),
        };

        let payload = gotify_payload(&message, &config);

        assert_eq!(payload["priority"], json!(3));
    }

    #[tokio::test]
    async fn gotify_rejects_attachments() {
        let dir = tempdir().unwrap();
        let source = dir.path().join("report.txt");
        fs::write(&source, "hello").unwrap();
        let message = NotifyMessage::new(
            "Done".to_string(),
            Some("Attached.".to_string()),
            MessageFormat::Text,
            Priority::Info,
            Vec::new(),
            vec![Attachment::from_path(&source).unwrap()],
        )
        .unwrap();
        let config = GotifyConfig {
            server: "https://gotify.example.com".to_string(),
            token: Some("app-token".to_string()),
            token_env: None,
            priority: None,
        };

        let error = send(&Client::new(), "phone", &config, &message)
            .await
            .unwrap_err();

        assert_eq!(error.code(), "UNSUPPORTED_ATTACHMENT");
    }

    #[tokio::test]
    async fn gotify_http_errors_do_not_leak_token_url() {
        let message = NotifyMessage::new(
            "Done".to_string(),
            Some("Completed.".to_string()),
            MessageFormat::Text,
            Priority::Info,
            Vec::new(),
            Vec::new(),
        )
        .unwrap();
        let config = GotifyConfig {
            server: "http://127.0.0.1:1/secret-path".to_string(),
            token: Some("secret-token".to_string()),
            token_env: None,
            priority: None,
        };

        let error = send(&Client::new(), "phone", &config, &message)
            .await
            .unwrap_err();
        let error = error.to_string();

        assert!(!error.contains("secret-token"));
        assert!(!error.contains("secret-path"));
    }
}