agent-notify-core 0.1.0

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

use crate::{
    Attachment, NotifyMessage, Result,
    config::{DiscordBotConfig, DiscordWebhookConfig},
};

use super::{
    SendResult, StoredAttachment,
    common::{ensure_success, multipart_part, render_message, resolve_required_secret},
};

pub(super) async fn send_webhook(
    client: &Client,
    channel_name: &str,
    config: &DiscordWebhookConfig,
    message: &NotifyMessage,
) -> Result<SendResult> {
    let url = resolve_required_secret(
        channel_name,
        "webhook_url",
        &config.webhook_url,
        &config.webhook_url_env,
    )?;
    let payload = discord_payload(
        message,
        config.allow_mentions.unwrap_or(false),
        config.username.as_deref(),
        config.avatar_url.as_deref(),
    );

    let response = if message.attachments.is_empty() {
        client.post(&url).json(&payload).send().await?
    } else {
        client
            .post(&url)
            .multipart(discord_multipart_form(&payload, &message.attachments)?)
            .send()
            .await?
    };
    let status = response.status();
    let response_text = response.text().await?;
    ensure_success("discord-webhook", status, response_text)?;

    Ok(SendResult {
        id: message.id.clone(),
        attachments: discord_sent_attachments(&message.attachments),
    })
}

pub(super) async fn send_bot(
    client: &Client,
    channel_name: &str,
    config: &DiscordBotConfig,
    message: &NotifyMessage,
) -> Result<SendResult> {
    let bot_token = resolve_required_secret(
        channel_name,
        "bot_token",
        &config.bot_token,
        &config.bot_token_env,
    )?;
    let channel_id = resolve_required_secret(
        channel_name,
        "channel_id",
        &config.channel_id,
        &config.channel_id_env,
    )?;
    let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages");
    let payload = discord_payload(message, config.allow_mentions.unwrap_or(false), None, None);

    let request = client
        .post(url)
        .header(reqwest::header::AUTHORIZATION, format!("Bot {bot_token}"));
    let response = if message.attachments.is_empty() {
        request.json(&payload).send().await?
    } else {
        request
            .multipart(discord_multipart_form(&payload, &message.attachments)?)
            .send()
            .await?
    };
    let status = response.status();
    let response_text = response.text().await?;
    ensure_success("discord-bot", status, response_text)?;

    Ok(SendResult {
        id: message.id.clone(),
        attachments: discord_sent_attachments(&message.attachments),
    })
}

fn discord_payload(
    message: &NotifyMessage,
    allow_mentions: bool,
    username: Option<&str>,
    avatar_url: Option<&str>,
) -> serde_json::Value {
    let mut payload = json!({
        "content": render_message(message),
    });
    if !allow_mentions {
        payload["allowed_mentions"] = json!({ "parse": [] });
    }
    if let Some(username) = username {
        payload["username"] = json!(username);
    }
    if let Some(avatar_url) = avatar_url {
        payload["avatar_url"] = json!(avatar_url);
    }
    if !message.attachments.is_empty() {
        payload["attachments"] = json!(
            message
                .attachments
                .iter()
                .enumerate()
                .map(|(index, attachment)| json!({
                    "id": index,
                    "filename": attachment.name,
                }))
                .collect::<Vec<_>>()
        );
    }
    payload
}

fn discord_multipart_form(
    payload: &serde_json::Value,
    attachments: &[Attachment],
) -> Result<multipart::Form> {
    let mut form = multipart::Form::new().text("payload_json", serde_json::to_string(payload)?);
    for (index, attachment) in attachments.iter().enumerate() {
        form = form.part(
            format!("files[{index}]"),
            multipart_part(attachment, &attachment.name)?,
        );
    }
    Ok(form)
}

fn discord_sent_attachments(attachments: &[Attachment]) -> Vec<StoredAttachment> {
    attachments
        .iter()
        .enumerate()
        .map(|(index, attachment)| {
            StoredAttachment::sent(Some(format!("files[{index}]")), attachment)
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use crate::{MessageFormat, Priority};

    use super::*;

    #[test]
    fn discord_payload_disables_mentions_by_default() {
        let message = NotifyMessage::new(
            "Hello".to_string(),
            Some("@everyone".to_string()),
            MessageFormat::Markdown,
            Priority::Info,
            Vec::new(),
            Vec::new(),
        )
        .unwrap();

        let payload = discord_payload(&message, false, Some("Agent Notify"), None);

        assert_eq!(payload["allowed_mentions"], json!({ "parse": [] }));
        assert_eq!(payload["username"], json!("Agent Notify"));
        assert!(payload["content"].as_str().unwrap().contains("@everyone"));
    }
}