use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use std::time::Duration;
use hooksmith_core::{HttpClient, RetryPolicy, 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(())
}
fn validate_thread_id(thread_id: &str) -> Result<(), WebhookError> {
if thread_id.is_empty() || !thread_id.chars().all(|c| c.is_ascii_digit()) {
return Err(WebhookError::InvalidThreadId);
}
Ok(())
}
#[derive(Clone)]
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> {
validate_thread_id(thread_id)?;
self.execute(message, Some(thread_id)).await
}
pub async fn send_with_retry(
&self,
message: &WebhookMessage,
policy: &RetryPolicy,
) -> Result<(), WebhookError> {
let url = format!("{}?wait=true", self.url.expose_secret());
let response = self
.client
.post_json_with_retry(&url, message, policy)
.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
.bytes()
.await
.map(|b| {
let slice = &b[..b.len().min(4096)];
String::from_utf8_lossy(slice).into_owned()
})
.unwrap_or_else(|_| status.to_string());
return Err(WebhookError::ApiError {
status: status.as_u16(),
message: body,
});
}
Ok(())
}
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
.bytes()
.await
.map(|b| {
let slice = &b[..b.len().min(4096)];
String::from_utf8_lossy(slice).into_owned()
})
.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)
}
}
pub struct WebhookClientBuilder {
url: String,
connect_timeout: Option<Duration>,
request_timeout: Option<Duration>,
}
impl WebhookClientBuilder {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
connect_timeout: None,
request_timeout: Some(Duration::from_secs(30)),
}
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = Some(timeout);
self
}
pub fn build(self) -> Result<WebhookClient, WebhookError> {
validate_url(&self.url)?;
let mut builder = Client::builder();
if let Some(t) = self.connect_timeout {
builder = builder.connect_timeout(t);
}
if let Some(t) = self.request_timeout {
builder = builder.timeout(t);
}
let client = builder.build().map_err(WebhookError::Http)?;
Ok(WebhookClient {
url: SecretString::from(self.url),
client: HttpClient::with_reqwest(client),
})
}
}
#[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");
}
#[test]
fn thread_id_rejects_empty() {
assert!(matches!(
validate_thread_id(""),
Err(WebhookError::InvalidThreadId)
));
}
#[test]
fn thread_id_rejects_non_numeric() {
for bad in &["abc", "123abc", "12&34", "12/34", "12?id=1", " 123"] {
assert!(
matches!(validate_thread_id(bad), Err(WebhookError::InvalidThreadId)),
"expected InvalidThreadId for {:?}",
bad
);
}
}
#[test]
fn thread_id_accepts_valid_snowflake() {
assert!(validate_thread_id("1234567890123456789").is_ok());
}
#[test]
fn builder_accepts_valid_url() {
use std::time::Duration;
let result = WebhookClientBuilder::new("https://discord.com/api/webhooks/123456789/abcdef")
.connect_timeout(Duration::from_secs(5))
.request_timeout(Duration::from_secs(15))
.build();
assert!(result.is_ok());
}
#[test]
fn builder_rejects_invalid_url() {
let result = WebhookClientBuilder::new("http://example.com/webhook").build();
assert!(matches!(result, Err(WebhookError::InvalidUrl { .. })));
}
}