use crate::config::{
DiscordConfig, DiscordEventsConfig, DiscordNotifyEvent, ServiceDiscordConfig, SshConfig,
};
use crate::strategies::{ServiceConfig, XbpConfig};
use crate::utils::resolve_env_placeholders;
use serde_json::{json, Value as JsonValue};
use std::collections::HashMap;
use std::env;
use std::path::Path;
use std::time::Duration;
const DEFAULT_USERNAME: &str = "XBP";
const COLOR_SUCCESS: u32 = 0x57F2_87;
const COLOR_FAILURE: u32 = 0xED42_45;
const COLOR_INFO: u32 = 0x5865_F2;
const COLOR_WARNING: u32 = 0xFEE7_5C;
const WEBHOOK_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscordStatus {
Success,
Failure,
Info,
Warning,
}
impl DiscordStatus {
fn color(self) -> u32 {
match self {
Self::Success => COLOR_SUCCESS,
Self::Failure => COLOR_FAILURE,
Self::Info => COLOR_INFO,
Self::Warning => COLOR_WARNING,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Failure => "failure",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
#[derive(Debug, Clone)]
pub struct DiscordField {
pub name: String,
pub value: String,
pub inline: bool,
}
impl DiscordField {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
inline: true,
}
}
pub fn block(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
inline: false,
}
}
}
#[derive(Debug, Clone)]
pub struct DiscordNotification {
pub event: DiscordNotifyEvent,
pub title: String,
pub description: Option<String>,
pub status: DiscordStatus,
pub url: Option<String>,
pub fields: Vec<DiscordField>,
pub service: Option<String>,
pub footer: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedDiscordWebhook {
pub webhook_url: String,
pub username: String,
pub avatar_url: Option<String>,
pub config: DiscordConfig,
pub service_override: Option<ServiceDiscordConfig>,
}
impl ResolvedDiscordWebhook {
pub fn event_enabled(&self, event: DiscordNotifyEvent) -> bool {
let project_default = self.config.event_enabled(event);
match &self.service_override {
Some(service) => service.event_enabled(event, project_default),
None => project_default,
}
}
}
pub fn resolve_discord_webhook(
project_root: Option<&Path>,
project_config: Option<&XbpConfig>,
service: Option<&ServiceConfig>,
) -> Option<ResolvedDiscordWebhook> {
let global = SshConfig::load().ok().and_then(|cfg| cfg.discord);
let project = project_config.and_then(|cfg| cfg.discord.clone());
let mut config = merge_discord_config(global, project).unwrap_or_default();
if let Some(root) = project_root {
if let Some(url) = config.webhook_url.as_ref() {
let mut env_map = HashMap::new();
env_map.insert("webhook_url".to_string(), url.clone());
let resolved = resolve_env_placeholders(root, &env_map);
config.webhook_url = resolved.get("webhook_url").cloned();
}
}
if config
.webhook_url
.as_ref()
.map(|u| u.trim().is_empty())
.unwrap_or(true)
{
if let Some(env_url) = env_discord_webhook_url() {
config.webhook_url = Some(env_url);
}
}
let webhook_url = config
.webhook_url
.as_ref()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())?;
if !looks_like_discord_webhook(&webhook_url) {
eprintln!(
"Discord webhook URL does not look valid (expected https://discord.com/api/webhooks/...); skipping notify"
);
return None;
}
if !config.is_master_enabled() {
return None;
}
let username = config
.username
.as_ref()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.unwrap_or_else(|| DEFAULT_USERNAME.to_string());
Some(ResolvedDiscordWebhook {
webhook_url,
username,
avatar_url: config
.avatar_url
.as_ref()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty()),
config,
service_override: service.and_then(|s| s.discord.clone()),
})
}
fn merge_discord_config(
global: Option<DiscordConfig>,
project: Option<DiscordConfig>,
) -> Option<DiscordConfig> {
match (global, project) {
(None, None) => None,
(Some(g), None) => Some(g),
(None, Some(p)) => Some(p),
(Some(g), Some(p)) => Some(DiscordConfig {
enabled: p.enabled.or(g.enabled),
webhook_url: p
.webhook_url
.filter(|u| !u.trim().is_empty())
.or(g.webhook_url),
username: p
.username
.filter(|u| !u.trim().is_empty())
.or(g.username),
avatar_url: p
.avatar_url
.filter(|u| !u.trim().is_empty())
.or(g.avatar_url),
events: merge_events(g.events, p.events),
}),
}
}
fn merge_events(
global: Option<DiscordEventsConfig>,
project: Option<DiscordEventsConfig>,
) -> Option<DiscordEventsConfig> {
match (global, project) {
(None, None) => None,
(Some(g), None) => Some(g),
(None, Some(p)) => Some(p),
(Some(g), Some(p)) => Some(DiscordEventsConfig {
version_release: p.version_release.or(g.version_release),
crates_io: p.crates_io.or(g.crates_io),
npm: p.npm.or(g.npm),
linear: p.linear.or(g.linear),
cargo_dist: p.cargo_dist.or(g.cargo_dist),
oci: p.oci.or(g.oci),
}),
}
}
fn env_discord_webhook_url() -> Option<String> {
for key in [
"DISCORD_WEBHOOK_URL",
"XBP_DISCORD_WEBHOOK_URL",
"DISCORD_WEBHOOK",
] {
if let Ok(value) = env::var(key) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
pub fn looks_like_discord_webhook(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
(lower.starts_with("https://discord.com/api/webhooks/")
|| lower.starts_with("https://discordapp.com/api/webhooks/")
|| lower.starts_with("https://canary.discord.com/api/webhooks/")
|| lower.starts_with("https://ptb.discord.com/api/webhooks/"))
&& lower.matches('/').count() >= 5
}
pub async fn notify_discord(resolved: &ResolvedDiscordWebhook, notification: &DiscordNotification) {
if !resolved.event_enabled(notification.event) {
return;
}
if let Err(error) = post_discord_webhook(resolved, notification).await {
eprintln!(
"Discord webhook notify failed ({}): {}",
notification.event.as_key(),
error
);
}
}
pub async fn notify_discord_for_project(
project_root: &Path,
project_config: Option<&XbpConfig>,
service: Option<&ServiceConfig>,
notification: DiscordNotification,
) {
let Some(resolved) = resolve_discord_webhook(Some(project_root), project_config, service)
else {
return;
};
notify_discord(&resolved, ¬ification).await;
}
pub async fn post_discord_webhook(
resolved: &ResolvedDiscordWebhook,
notification: &DiscordNotification,
) -> Result<(), String> {
let mut embed = json!({
"title": truncate(¬ification.title, 256),
"color": notification.status.color(),
"timestamp": chrono::Utc::now().to_rfc3339(),
});
if let Some(description) = ¬ification.description {
let trimmed = description.trim();
if !trimmed.is_empty() {
embed["description"] = json!(truncate(trimmed, 4000));
}
}
if let Some(url) = ¬ification.url {
let trimmed = url.trim();
if !trimmed.is_empty() {
embed["url"] = json!(trimmed);
}
}
let mut fields = Vec::new();
fields.push(json!({
"name": "Event",
"value": notification.event.label(),
"inline": true,
}));
fields.push(json!({
"name": "Status",
"value": notification.status.as_str(),
"inline": true,
}));
if let Some(service) = ¬ification.service {
if !service.trim().is_empty() {
fields.push(json!({
"name": "Service",
"value": truncate(service, 1024),
"inline": true,
}));
}
}
for field in ¬ification.fields {
if field.name.trim().is_empty() || field.value.trim().is_empty() {
continue;
}
fields.push(json!({
"name": truncate(&field.name, 256),
"value": truncate(&field.value, 1024),
"inline": field.inline,
}));
}
embed["fields"] = JsonValue::Array(fields);
let footer_text = notification
.footer
.clone()
.unwrap_or_else(|| format!("XBP {}", env!("CARGO_PKG_VERSION")));
embed["footer"] = json!({ "text": truncate(&footer_text, 2048) });
let mut body = json!({
"username": truncate(&resolved.username, 80),
"embeds": [embed],
});
if let Some(avatar) = &resolved.avatar_url {
body["avatar_url"] = json!(avatar);
}
let client = reqwest::Client::builder()
.timeout(WEBHOOK_TIMEOUT)
.user_agent(format!("xbp/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let post_url = append_webhook_wait_query(&resolved.webhook_url);
let response = client
.post(&post_url)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("Discord webhook request failed: {e}"))?;
let status = response.status();
if status.is_success() || status.as_u16() == 204 {
return Ok(());
}
let body_text = response.text().await.unwrap_or_default();
Err(format!(
"Discord webhook returned HTTP {}{}",
status.as_u16(),
if body_text.trim().is_empty() {
String::new()
} else {
format!(": {}", truncate(&body_text, 300))
}
))
}
pub fn version_release_notification(
tag_name: &str,
version: &str,
release_url: &str,
release_title: &str,
scope_label: &str,
dry_run: bool,
) -> DiscordNotification {
let status = if dry_run {
DiscordStatus::Info
} else {
DiscordStatus::Success
};
DiscordNotification {
event: DiscordNotifyEvent::VersionRelease,
title: if dry_run {
format!("Dry-run release planned: {tag_name}")
} else {
format!("Released {tag_name}")
},
description: Some(release_title.to_string()),
status,
url: Some(release_url.to_string()).filter(|u| !u.is_empty()),
fields: vec![
DiscordField::new("Version", version),
DiscordField::new("Scope", scope_label),
DiscordField::new("Tag", tag_name),
],
service: None,
footer: None,
}
}
pub fn registry_publish_notification(
kind: &str,
package: &str,
version: &str,
status_label: &str,
message: Option<&str>,
) -> DiscordNotification {
let event = if kind.eq_ignore_ascii_case("npm") || kind.eq_ignore_ascii_case("npmjs") {
DiscordNotifyEvent::Npm
} else {
DiscordNotifyEvent::CratesIo
};
let registry = if event == DiscordNotifyEvent::Npm {
"npmjs"
} else {
"crates.io"
};
let success = status_label == "published" || status_label == "skipped_already_visible";
let mut fields = vec![
DiscordField::new("Package", package),
DiscordField::new("Version", version),
DiscordField::new("Registry", registry),
DiscordField::new("Outcome", status_label),
];
if let Some(msg) = message {
if !msg.trim().is_empty() {
fields.push(DiscordField::block("Detail", msg));
}
}
let package_url = match event {
DiscordNotifyEvent::Npm => format!("https://www.npmjs.com/package/{package}/v/{version}"),
DiscordNotifyEvent::CratesIo => {
format!("https://crates.io/crates/{package}/{version}")
}
_ => String::new(),
};
DiscordNotification {
event,
title: format!("{registry}: {package}@{version}"),
description: Some(if success {
format!("Published to {registry}")
} else {
format!("Publish {status_label} on {registry}")
}),
status: if success {
DiscordStatus::Success
} else {
DiscordStatus::Failure
},
url: Some(package_url).filter(|u| !u.is_empty()),
fields,
service: None,
footer: None,
}
}
pub fn linear_post_notification(
initiative_name: &str,
initiative_url: Option<&str>,
release_tag: &str,
release_url: &str,
) -> DiscordNotification {
DiscordNotification {
event: DiscordNotifyEvent::Linear,
title: format!("Linear release post: {initiative_name}"),
description: Some(format!("Published release update for `{release_tag}`")),
status: DiscordStatus::Success,
url: initiative_url
.map(str::to_string)
.or_else(|| Some(release_url.to_string()))
.filter(|u| !u.is_empty()),
fields: vec![
DiscordField::new("Initiative", initiative_name),
DiscordField::new("Release tag", release_tag),
],
service: None,
footer: None,
}
}
pub fn cargo_dist_notification(
phase: &str,
tag_name: &str,
status: DiscordStatus,
detail: &str,
asset_count: Option<usize>,
) -> DiscordNotification {
let mut fields = vec![
DiscordField::new("Phase", phase),
DiscordField::new("Tag", tag_name),
DiscordField::new("Status", status.as_str()),
];
if let Some(count) = asset_count {
fields.push(DiscordField::new("Assets", count.to_string()));
}
if !detail.trim().is_empty() {
fields.push(DiscordField::block("Detail", detail));
}
DiscordNotification {
event: DiscordNotifyEvent::CargoDist,
title: format!("cargo-dist {phase}: {tag_name}"),
description: Some(detail.to_string()).filter(|d| !d.trim().is_empty()),
status,
url: None,
fields,
service: None,
footer: None,
}
}
pub fn oci_notification(
service: &str,
image_ref: &str,
status: DiscordStatus,
detail: &str,
) -> DiscordNotification {
DiscordNotification {
event: DiscordNotifyEvent::Oci,
title: format!("OCI: {service}"),
description: Some(detail.to_string()).filter(|d| !d.trim().is_empty()),
status,
url: None,
fields: vec![
DiscordField::new("Service", service),
DiscordField::block("Image", image_ref),
DiscordField::new("Status", status.as_str()),
],
service: Some(service.to_string()),
footer: None,
}
}
fn truncate(value: &str, max: usize) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= max {
return trimmed.to_string();
}
let mut out: String = trimmed.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
fn append_webhook_wait_query(url: &str) -> String {
if url.contains('?') {
if url.contains("wait=") {
url.to_string()
} else {
format!("{url}&wait=true")
}
} else {
format!("{url}?wait=true")
}
}
pub fn deploy_notification(
target: &str,
env: &str,
mode: &str,
status: DiscordStatus,
detail: &str,
) -> DiscordNotification {
DiscordNotification {
event: DiscordNotifyEvent::Oci,
title: format!("Deploy {mode}: {target}"),
description: Some(detail.to_string()).filter(|d| !d.trim().is_empty()),
status,
url: None,
fields: vec![
DiscordField::new("Target", target),
DiscordField::new("Env", env),
DiscordField::new("Mode", mode),
DiscordField::new("Status", status.as_str()),
],
service: Some(target.to_string()),
footer: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DiscordEventsConfig;
#[test]
fn looks_like_discord_webhook_accepts_standard_urls() {
assert!(looks_like_discord_webhook(
"https://discord.com/api/webhooks/123/abc-token"
));
assert!(looks_like_discord_webhook(
"https://discordapp.com/api/webhooks/123/abc"
));
assert!(!looks_like_discord_webhook("https://example.com/hooks/1"));
assert!(!looks_like_discord_webhook("not-a-url"));
}
#[test]
fn event_defaults_are_enabled() {
let events = DiscordEventsConfig::default();
for event in DiscordNotifyEvent::all() {
assert!(events.is_enabled(*event));
}
}
#[test]
fn merge_prefers_project_webhook() {
let global = DiscordConfig {
enabled: Some(true),
webhook_url: Some("https://discord.com/api/webhooks/1/global".into()),
username: Some("Global".into()),
avatar_url: None,
events: Some(DiscordEventsConfig {
npm: Some(false),
..Default::default()
}),
};
let project = DiscordConfig {
enabled: None,
webhook_url: Some("https://discord.com/api/webhooks/2/project".into()),
username: None,
avatar_url: None,
events: Some(DiscordEventsConfig {
crates_io: Some(false),
..Default::default()
}),
};
let merged = merge_discord_config(Some(global), Some(project)).expect("merged");
assert_eq!(
merged.webhook_url.as_deref(),
Some("https://discord.com/api/webhooks/2/project")
);
assert_eq!(merged.username.as_deref(), Some("Global"));
let events = merged.events.expect("events");
assert_eq!(events.npm, Some(false));
assert_eq!(events.crates_io, Some(false));
}
#[test]
fn registry_notification_maps_kinds() {
let npm = registry_publish_notification("npm", "@scope/pkg", "1.0.0", "published", None);
assert_eq!(npm.event, DiscordNotifyEvent::Npm);
let crates =
registry_publish_notification("crates", "xbp", "1.0.0", "published", None);
assert_eq!(crates.event, DiscordNotifyEvent::CratesIo);
}
#[test]
fn service_override_can_disable_oci() {
let project = DiscordConfig {
enabled: Some(true),
webhook_url: Some("https://discord.com/api/webhooks/1/x".into()),
username: None,
avatar_url: None,
events: None,
};
let service = ServiceDiscordConfig {
enabled: None,
events: Some(DiscordEventsConfig {
oci: Some(false),
..Default::default()
}),
};
let resolved = ResolvedDiscordWebhook {
webhook_url: project.webhook_url.clone().unwrap(),
username: "XBP".into(),
avatar_url: None,
config: project,
service_override: Some(service),
};
assert!(resolved.event_enabled(DiscordNotifyEvent::VersionRelease));
assert!(!resolved.event_enabled(DiscordNotifyEvent::Oci));
}
#[test]
fn env_only_webhook_resolves_without_yaml() {
let mut config = DiscordConfig::default();
config.webhook_url = Some("https://discord.com/api/webhooks/9/env-only".into());
assert!(config.is_master_enabled());
assert!(looks_like_discord_webhook(config.webhook_url.as_deref().unwrap()));
let merged = merge_discord_config(None, None);
assert!(merged.is_none());
let mut from_default = DiscordConfig::default();
from_default.webhook_url = Some("https://discord.com/api/webhooks/9/env-only".into());
assert!(from_default.is_master_enabled());
}
#[test]
fn wait_query_is_appended_once() {
assert_eq!(
append_webhook_wait_query("https://discord.com/api/webhooks/1/x"),
"https://discord.com/api/webhooks/1/x?wait=true"
);
assert_eq!(
append_webhook_wait_query("https://discord.com/api/webhooks/1/x?wait=true"),
"https://discord.com/api/webhooks/1/x?wait=true"
);
assert_eq!(
append_webhook_wait_query("https://discord.com/api/webhooks/1/x?thread_id=1"),
"https://discord.com/api/webhooks/1/x?thread_id=1&wait=true"
);
}
#[test]
fn looks_like_discord_webhook_accepts_canary_and_ptb() {
assert!(looks_like_discord_webhook(
"https://canary.discord.com/api/webhooks/1/token"
));
assert!(looks_like_discord_webhook(
"https://ptb.discord.com/api/webhooks/1/token"
));
}
#[test]
fn looks_like_discord_webhook_rejects_http_and_short_paths() {
assert!(!looks_like_discord_webhook(
"http://discord.com/api/webhooks/1/token"
));
assert!(!looks_like_discord_webhook("https://discord.com/api/webhooks"));
assert!(!looks_like_discord_webhook(""));
}
#[test]
fn merge_none_none_is_none() {
assert!(merge_discord_config(None, None).is_none());
}
#[test]
fn merge_global_only() {
let global = DiscordConfig {
enabled: Some(true),
webhook_url: Some("https://discord.com/api/webhooks/1/g".into()),
username: Some("G".into()),
avatar_url: None,
events: None,
};
let merged = merge_discord_config(Some(global), None).expect("global");
assert_eq!(merged.username.as_deref(), Some("G"));
}
#[test]
fn merge_project_only() {
let project = DiscordConfig {
enabled: Some(false),
webhook_url: Some("https://discord.com/api/webhooks/2/p".into()),
username: None,
avatar_url: None,
events: None,
};
let merged = merge_discord_config(None, Some(project)).expect("project");
assert!(!merged.is_master_enabled());
}
#[test]
fn master_disabled_blocks_events() {
let cfg = DiscordConfig {
enabled: Some(false),
webhook_url: Some("https://discord.com/api/webhooks/1/x".into()),
username: None,
avatar_url: None,
events: None,
};
assert!(!cfg.event_enabled(DiscordNotifyEvent::VersionRelease));
}
#[test]
fn event_keys_roundtrip() {
for event in DiscordNotifyEvent::all() {
let key = event.as_key();
assert_eq!(DiscordNotifyEvent::parse(key), Some(*event));
assert!(!event.label().is_empty());
}
assert_eq!(DiscordNotifyEvent::parse("ghcr"), Some(DiscordNotifyEvent::Oci));
assert_eq!(DiscordNotifyEvent::parse("not-an-event"), None);
}
#[test]
fn version_release_notification_dry_run_is_info() {
let n = version_release_notification(
"v1.0.0",
"1.0.0",
"https://example.com",
"title",
"repo",
true,
);
assert_eq!(n.status, DiscordStatus::Info);
assert_eq!(n.event, DiscordNotifyEvent::VersionRelease);
assert!(n.title.contains("Dry-run"));
}
#[test]
fn version_release_notification_success() {
let n = version_release_notification(
"v1.0.0",
"1.0.0",
"https://example.com",
"title",
"repo",
false,
);
assert_eq!(n.status, DiscordStatus::Success);
assert!(!n.title.contains("Dry-run"));
}
#[test]
fn registry_notification_failure_status() {
let n = registry_publish_notification("crates", "xbp", "1.0.0", "failed", Some("boom"));
assert_eq!(n.status, DiscordStatus::Failure);
assert!(n.fields.iter().any(|f| f.name == "Detail"));
}
#[test]
fn deploy_notification_uses_oci_event() {
let n = deploy_notification("svc", "prod", "run", DiscordStatus::Success, "ok");
assert_eq!(n.event, DiscordNotifyEvent::Oci);
assert_eq!(n.service.as_deref(), Some("svc"));
assert!(n.title.contains("svc"));
}
#[test]
fn oci_notification_fields() {
let n = oci_notification("api", "ghcr.io/x/y:1", DiscordStatus::Failure, "err");
assert_eq!(n.event, DiscordNotifyEvent::Oci);
assert_eq!(n.status, DiscordStatus::Failure);
}
#[test]
fn linear_post_notification_prefers_initiative_url() {
let n = linear_post_notification(
"Init",
Some("https://linear.app/i"),
"v1",
"https://github.com/r",
);
assert_eq!(n.url.as_deref(), Some("https://linear.app/i"));
}
#[test]
fn cargo_dist_notification_includes_assets() {
let n = cargo_dist_notification("build", "v1", DiscordStatus::Success, "done", Some(3));
assert!(n.fields.iter().any(|f| f.name == "Assets" && f.value == "3"));
}
#[test]
fn discord_status_colors_and_strings() {
assert_eq!(DiscordStatus::Success.as_str(), "success");
assert_eq!(DiscordStatus::Failure.as_str(), "failure");
assert_eq!(DiscordStatus::Info.as_str(), "info");
assert_eq!(DiscordStatus::Warning.as_str(), "warning");
assert_ne!(DiscordStatus::Success.color(), DiscordStatus::Failure.color());
}
#[test]
fn truncate_short_and_long() {
assert_eq!(truncate(" hi ", 10), "hi");
let long = "a".repeat(20);
let t = truncate(&long, 10);
assert!(t.chars().count() <= 10);
assert!(t.ends_with('…'));
}
#[test]
fn discord_field_helpers() {
let inline = DiscordField::new("A", "B");
assert!(inline.inline);
let block = DiscordField::block("A", "B");
assert!(!block.inline);
}
}