use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::future::Future;
use std::pin::Pin;
use crate::{
error::{CryptoBotError, WebhookErrorKind},
models::{WebhookResponse, WebhookUpdate},
};
use super::WebhookHandlerConfig;
pub type WebhookHandlerFn =
Box<dyn Fn(WebhookUpdate) -> Pin<Box<dyn Future<Output = Result<(), CryptoBotError>> + Send>> + Send + Sync>;
pub struct WebhookHandler {
pub(crate) api_token: String,
pub(crate) config: WebhookHandlerConfig,
pub(crate) update_handler: Option<WebhookHandlerFn>,
}
impl WebhookHandler {
pub(crate) fn with_config(api_token: impl Into<String>, config: WebhookHandlerConfig) -> Self {
Self {
api_token: api_token.into(),
config,
update_handler: None,
}
}
pub fn parse_update(json: &str) -> Result<WebhookUpdate, CryptoBotError> {
serde_json::from_str(json).map_err(|e| CryptoBotError::WebhookError {
kind: WebhookErrorKind::InvalidPayload,
message: e.to_string(),
})
}
pub fn verify_signature(&self, body: &str, signature: &str) -> bool {
let secret = Sha256::digest(self.api_token.as_bytes());
let mut mac = Hmac::<Sha256>::new_from_slice(&secret).expect("HMAC can take key of any size");
mac.update(body.as_bytes());
if let Ok(hex_signature) = hex::decode(signature) {
mac.verify_slice(&hex_signature).is_ok()
} else {
false
}
}
pub async fn handle_update(&self, body: &str) -> Result<WebhookResponse, CryptoBotError> {
let update: WebhookUpdate = Self::parse_update(body)?;
if let Some(expiration_time) = self.config.expiration_time {
let request_date =
DateTime::parse_from_rfc3339(&update.request_date).map_err(|_| CryptoBotError::WebhookError {
kind: WebhookErrorKind::InvalidPayload, message: "Invalid request date".to_string(),
})?;
let age = Utc::now().signed_duration_since(request_date.with_timezone(&Utc));
let webhook_expiration_time = expiration_time.as_secs();
let webhook_expiration = chrono::Duration::seconds(webhook_expiration_time as i64);
if age > webhook_expiration {
return Err(CryptoBotError::WebhookError {
kind: WebhookErrorKind::Expired,
message: "Webhook request too old".to_string(),
});
}
}
if let Some(handler) = &self.update_handler {
handler(update).await?;
}
Ok(WebhookResponse::ok())
}
pub fn on_update<F, Fut>(&mut self, handler: F)
where
F: Fn(WebhookUpdate) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), CryptoBotError>> + Send + 'static,
{
self.update_handler = Some(Box::new(move |update| Box::pin(handler(update))));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
models::{InvoiceStatus, UpdateType, WebhookPayload},
webhook::WebhookHandlerConfigBuilder,
};
use chrono::Utc;
use serde_json::json;
use std::{sync::Arc, time::Duration};
use tokio::sync::Mutex;
#[tokio::test]
async fn test_webhook_handler() {
let mut handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
let received = Arc::new(Mutex::new(None));
let received_clone = received.clone();
handler.on_update(move |update| {
let received = received_clone.clone();
async move {
let mut guard = received.lock().await;
*guard = Some(update);
Ok(())
}
});
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": Utc::now().to_rfc3339(),
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
})
.to_string();
let result = handler.handle_update(&json).await;
assert!(result.is_ok());
let update = received.lock().await.take().expect("Should have received update");
assert_eq!(update.update_type, UpdateType::InvoicePaid);
match update.payload {
WebhookPayload::InvoicePaid(invoice) => {
assert_eq!(invoice.invoice_id, 528890);
assert_eq!(invoice.status, InvoiceStatus::Paid);
}
}
}
#[tokio::test]
async fn test_webhook_handler_propagates_handler_error() {
let mut handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
handler.on_update(|_| async move {
Err(CryptoBotError::WebhookError {
kind: WebhookErrorKind::InvalidPayload,
message: "handler error".to_string(),
})
});
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": Utc::now().to_rfc3339(),
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
})
.to_string();
let result = handler.handle_update(&json).await;
assert!(matches!(
result,
Err(CryptoBotError::WebhookError {
kind: WebhookErrorKind::InvalidPayload,
message
}) if message == "handler error"
));
}
#[tokio::test]
async fn test_webhook_handler_invalid_request_date() {
let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": "invalid_date",
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
});
let result = handler.handle_update(&json.to_string()).await;
assert!(matches!(
result,
Err(CryptoBotError::WebhookError {
kind: WebhookErrorKind::InvalidPayload,
message,
}) if message == "Invalid request date"
));
}
#[tokio::test]
async fn test_webhook_handler_with_disabled_expiration() {
let handler = WebhookHandler::with_config(
"test_token",
WebhookHandlerConfigBuilder::new().disable_expiration().build_config(),
);
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": Utc::now().to_rfc3339(),
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
});
let result = handler.handle_update(&json.to_string()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_default_webhook_expiration() {
let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
let date = (Utc::now() - chrono::Duration::minutes(3)).to_rfc3339();
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": date,
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
})
.to_string();
let result = handler.handle_update(&json).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_custom_webhook_expiration() {
let handler = WebhookHandler::with_config(
"test_token",
WebhookHandlerConfigBuilder::new()
.expiration_time(Duration::from_secs(60))
.build_config(),
);
let old_date = (Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": old_date,
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
})
.to_string();
let result = handler.handle_update(&json).await;
assert!(matches!(
result,
Err(CryptoBotError::WebhookError {
kind: WebhookErrorKind::Expired,
..
})
));
}
#[test]
fn test_webhook_signature_verification() {
let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
let body = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": "2024-01-01T12:00:00Z",
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"status": "paid",
}
})
.to_string();
let secret = Sha256::digest(b"test_token");
let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
mac.update(body.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
assert!(handler.verify_signature(&body, &signature));
assert!(!handler.verify_signature(&body, "invalid_signature"));
}
#[test]
fn test_parse_webhook_update() {
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": "2024-02-02T12:11:02Z",
"payload": {
"invoice_id": 528890,
"hash": "IVDoTcNBYEfk",
"currency_type": "crypto",
"asset": "TON",
"amount": "10.5",
"pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
"description": "Test invoice",
"status": "paid",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
});
let result = WebhookHandler::parse_update(&json.to_string());
assert!(result.is_ok());
let update = result.unwrap();
assert_eq!(update.update_id, 1);
assert_eq!(update.update_type, UpdateType::InvoicePaid);
assert_eq!(update.request_date, "2024-02-02T12:11:02Z");
match update.payload {
WebhookPayload::InvoicePaid(invoice) => {
assert_eq!(invoice.invoice_id, 528890);
assert_eq!(invoice.status, InvoiceStatus::Paid);
}
}
}
#[test]
fn test_parse_invalid_webhook_update() {
let invalid_json = r#"{"invalid": "json"}"#;
let result = WebhookHandler::parse_update(invalid_json);
assert!(matches!(
result,
Err(CryptoBotError::WebhookError {
kind: WebhookErrorKind::InvalidPayload,
..
})
));
}
#[tokio::test]
async fn test_handle_update_with_missing_handler_ok() {
let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfig::default());
let json = json!({
"update_id": 1,
"update_type": "invoice_paid",
"request_date": Utc::now().to_rfc3339(),
"payload": {
"invoice_id": 1,
"hash": "hash",
"status": "paid",
"currency_type": "crypto",
"asset": "TON",
"amount": "1",
"bot_invoice_url": "https://t.me/CryptoTestnetBot?start=hash",
"mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-hash",
"web_app_invoice_url": "https://testnet-app.send.tg/invoices/hash",
"created_at": "2025-02-08T12:11:01.341Z",
"allow_comments": true,
"allow_anonymous": true
}
})
.to_string();
let result = handler.handle_update(&json).await;
assert!(result.is_ok());
}
}