use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use hooksmith_core::{HttpClient, WebhookSender};
use crate::{WebhookError, WebhookMessage};
fn validate_url(url: &str) -> Result<(), WebhookError> {
if !url.starts_with("https://") {
return Err(WebhookError::InvalidUrl {
reason: "webhook URL must use HTTPS",
});
}
if !url.contains("discord.com/api/webhooks/") {
return Err(WebhookError::InvalidUrl {
reason: "URL must target discord.com/api/webhooks/",
});
}
Ok(())
}
pub struct WebhookClient {
url: SecretString,
client: HttpClient,
}
impl WebhookClient {
pub fn new(url: impl Into<String>) -> Result<Self, WebhookError> {
let url = url.into();
validate_url(&url)?;
Ok(Self { url: SecretString::from(url), client: HttpClient::new() })
}
pub fn with_client(url: impl Into<String>, client: Client) -> Result<Self, WebhookError> {
let url = url.into();
validate_url(&url)?;
Ok(Self { url: SecretString::from(url), client: HttpClient::with_reqwest(client) })
}
pub async fn send(&self, message: &WebhookMessage) -> Result<(), WebhookError> {
self.execute(message, None).await
}
pub async fn send_to_thread(
&self,
message: &WebhookMessage,
thread_id: &str,
) -> Result<(), WebhookError> {
self.execute(message, Some(thread_id)).await
}
async fn execute(
&self,
message: &WebhookMessage,
thread_id: Option<&str>,
) -> Result<(), WebhookError> {
let mut url = format!("{}?wait=true", self.url.expose_secret());
if let Some(tid) = thread_id {
url.push_str("&thread_id=");
url.push_str(tid);
}
let response = self.client.post_json(&url, message).await?;
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after_ms = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<f64>().ok())
.map(|secs| (secs * 1000.0) as u64)
.unwrap_or(1_000);
return Err(WebhookError::RateLimited { retry_after_ms });
}
if !status.is_success() {
let body = response.text().await.unwrap_or_else(|_| status.to_string());
return Err(WebhookError::ApiError { status: status.as_u16(), message: body });
}
Ok(())
}
}
impl std::fmt::Debug for WebhookClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let raw = self.url.expose_secret();
let redacted = match raw.rfind('/') {
Some(idx) => format!("{}/", &raw[..idx]),
None => String::new(),
};
f.debug_struct("WebhookClient")
.field("url", &format_args!("{}<REDACTED>", redacted))
.finish_non_exhaustive()
}
}
impl WebhookSender for WebhookClient {
type Message = WebhookMessage;
type Error = WebhookError;
fn send(
&self,
message: &WebhookMessage,
) -> impl std::future::Future<Output = Result<(), WebhookError>> + Send {
self.execute(message, None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_http_url() {
let err = validate_url("http://discord.com/api/webhooks/123/abc").unwrap_err();
assert!(matches!(err, WebhookError::InvalidUrl { .. }));
}
#[test]
fn rejects_non_discord_url() {
let err = validate_url("https://example.com/webhook").unwrap_err();
assert!(matches!(err, WebhookError::InvalidUrl { .. }));
}
#[test]
fn accepts_valid_discord_url() {
assert!(validate_url("https://discord.com/api/webhooks/123456789/abcdef").is_ok());
}
#[test]
fn client_new_propagates_invalid_url() {
let err = WebhookClient::new("not-a-url").unwrap_err();
assert!(matches!(err, WebhookError::InvalidUrl { .. }));
}
#[test]
fn debug_output_redacts_token() {
let client =
WebhookClient::new("https://discord.com/api/webhooks/123456789/SECRET_TOKEN")
.unwrap();
let debug = format!("{client:?}");
assert!(!debug.contains("SECRET_TOKEN"), "token must not appear in debug output");
assert!(debug.contains("123456789"), "webhook id should be visible");
}
}