1use chrono::{DateTime, Utc};
2use hmac::{Hmac, Mac};
3use sha2::{Digest, Sha256};
4use std::future::Future;
5use std::pin::Pin;
6
7use crate::{
8 error::{CryptoBotError, WebhookErrorKind},
9 models::{WebhookResponse, WebhookUpdate},
10};
11
12use super::WebhookHandlerConfig;
13
14pub type WebhookHandlerFn =
15 Box<dyn Fn(WebhookUpdate) -> Pin<Box<dyn Future<Output = Result<(), CryptoBotError>> + Send>> + Send + Sync>;
16
17pub struct WebhookHandler {
18 pub(crate) api_token: String,
19 pub(crate) config: WebhookHandlerConfig,
20 pub(crate) update_handler: Option<WebhookHandlerFn>,
21}
22
23impl WebhookHandler {
24 pub(crate) fn with_config(api_token: impl Into<String>, config: WebhookHandlerConfig) -> Self {
25 Self {
26 api_token: api_token.into(),
27 config,
28 update_handler: None,
29 }
30 }
31
32 pub fn parse_update(json: &str) -> Result<WebhookUpdate, CryptoBotError> {
33 serde_json::from_str(json).map_err(|e| CryptoBotError::WebhookError {
34 kind: WebhookErrorKind::InvalidPayload,
35 message: e.to_string(),
36 })
37 }
38
39 pub fn verify_signature(&self, body: &str, signature: &str) -> bool {
73 let secret = Sha256::digest(self.api_token.as_bytes());
74 let mut mac = Hmac::<Sha256>::new_from_slice(&secret).expect("HMAC can take key of any size");
75
76 mac.update(body.as_bytes());
77
78 if let Ok(hex_signature) = hex::decode(signature) {
79 mac.verify_slice(&hex_signature).is_ok()
80 } else {
81 false
82 }
83 }
84
85 pub async fn handle_update(&self, body: &str) -> Result<WebhookResponse, CryptoBotError> {
104 let update: WebhookUpdate = Self::parse_update(body)?;
105
106 if let Some(expiration_time) = self.config.expiration_time {
107 let request_date =
108 DateTime::parse_from_rfc3339(&update.request_date).map_err(|_| CryptoBotError::WebhookError {
109 kind: WebhookErrorKind::InvalidPayload, message: "Invalid request date".to_string(),
111 })?;
112
113 let age = Utc::now().signed_duration_since(request_date.with_timezone(&Utc));
114
115 let webhook_expiration_time = expiration_time.as_secs();
116
117 let webhook_expiration = chrono::Duration::seconds(webhook_expiration_time as i64);
118
119 if age > webhook_expiration {
120 return Err(CryptoBotError::WebhookError {
121 kind: WebhookErrorKind::Expired,
122 message: "Webhook request too old".to_string(),
123 });
124 }
125 }
126
127 if let Some(handler) = &self.update_handler {
128 handler(update).await?;
129 }
130
131 Ok(WebhookResponse::ok())
132 }
133
134 pub fn on_update<F, Fut>(&mut self, handler: F)
178 where
179 F: Fn(WebhookUpdate) -> Fut + Send + Sync + 'static,
180 Fut: Future<Output = Result<(), CryptoBotError>> + Send + 'static,
181 {
182 self.update_handler = Some(Box::new(move |update| Box::pin(handler(update))));
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::{
190 models::{InvoiceStatus, UpdateType, WebhookPayload},
191 webhook::WebhookHandlerConfigBuilder,
192 };
193 use chrono::Utc;
194 use serde_json::json;
195
196 use std::{sync::Arc, time::Duration};
197 use tokio::sync::Mutex;
198
199 #[tokio::test]
200 async fn test_webhook_handler() {
201 let mut handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build());
202
203 let received = Arc::new(Mutex::new(None));
204 let received_clone = received.clone();
205
206 handler.on_update(move |update| {
207 let received = received_clone.clone();
208 async move {
209 let mut guard = received.lock().await;
210 *guard = Some(update);
211 Ok(())
212 }
213 });
214
215 let json = json!({
216 "update_id": 1,
217 "update_type": "invoice_paid",
218 "request_date": Utc::now().to_rfc3339(),
219 "payload": {
220 "invoice_id": 528890,
221 "hash": "IVDoTcNBYEfk",
222 "currency_type": "crypto",
223 "asset": "TON",
224 "amount": "10.5",
225 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
226 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
227 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
228 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
229 "description": "Test invoice",
230 "status": "paid",
231 "created_at": "2025-02-08T12:11:01.341Z",
232 "allow_comments": true,
233 "allow_anonymous": true
234 }
235 })
236 .to_string();
237
238 let result = handler.handle_update(&json).await;
239 assert!(result.is_ok());
240
241 let update = received.lock().await.take().expect("Should have received update");
242 assert_eq!(update.update_type, UpdateType::InvoicePaid);
243 match update.payload {
244 WebhookPayload::InvoicePaid(invoice) => {
245 assert_eq!(invoice.invoice_id, 528890);
246 assert_eq!(invoice.status, InvoiceStatus::Paid);
247 }
248 }
249 }
250
251 #[tokio::test]
252 async fn test_webhook_handler_invalid_request_date() {
253 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build());
254
255 let json = json!({
256 "update_id": 1,
257 "update_type": "invoice_paid",
258 "request_date": "invalid_date",
259 "payload": {
260 "invoice_id": 528890,
261 "hash": "IVDoTcNBYEfk",
262 "currency_type": "crypto",
263 "asset": "TON",
264 "amount": "10.5",
265 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
266 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
267 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
268 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
269 "description": "Test invoice",
270 "status": "paid",
271 "created_at": "2025-02-08T12:11:01.341Z",
272 "allow_comments": true,
273 "allow_anonymous": true
274 }
275 });
276
277 let result = handler.handle_update(&json.to_string()).await;
278
279 assert!(matches!(
280 result,
281 Err(CryptoBotError::WebhookError {
282 kind: WebhookErrorKind::InvalidPayload,
283 message,
284 }) if message == "Invalid request date"
285 ));
286 }
287
288 #[tokio::test]
289 async fn test_webhook_handler_with_disabled_expiration() {
290 let handler = WebhookHandler::with_config(
291 "test_token",
292 WebhookHandlerConfigBuilder::new().disable_expiration().build(),
293 );
294
295 let json = json!({
296 "update_id": 1,
297 "update_type": "invoice_paid",
298 "request_date": Utc::now().to_rfc3339(),
299 "payload": {
300 "invoice_id": 528890,
301 "hash": "IVDoTcNBYEfk",
302 "currency_type": "crypto",
303 "asset": "TON",
304 "amount": "10.5",
305 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
306 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
307 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
308 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
309 "description": "Test invoice",
310 "status": "paid",
311 "created_at": "2025-02-08T12:11:01.341Z",
312 "allow_comments": true,
313 "allow_anonymous": true
314 }
315 });
316
317 let result = handler.handle_update(&json.to_string()).await;
318 assert!(result.is_ok());
319 }
320
321 #[tokio::test]
322 async fn test_default_webhook_expiration() {
323 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build());
324
325 let date = (Utc::now() - chrono::Duration::minutes(3)).to_rfc3339();
326
327 let json = json!({
328 "update_id": 1,
329 "update_type": "invoice_paid",
330 "request_date": date,
331 "payload": {
332 "invoice_id": 528890,
333 "hash": "IVDoTcNBYEfk",
334 "currency_type": "crypto",
335 "asset": "TON",
336 "amount": "10.5",
337 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
338 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
339 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
340 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
341 "description": "Test invoice",
342 "status": "paid",
343 "created_at": "2025-02-08T12:11:01.341Z",
344 "allow_comments": true,
345 "allow_anonymous": true
346 }
347 })
348 .to_string();
349
350 let result = handler.handle_update(&json).await;
351 assert!(result.is_ok());
352 }
353
354 #[tokio::test]
355 async fn test_custom_webhook_expiration() {
356 let handler = WebhookHandler::with_config(
357 "test_token",
358 WebhookHandlerConfigBuilder::new()
359 .expiration_time(Duration::from_secs(60))
360 .build(),
361 );
362
363 let old_date = (Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
364
365 let json = json!({
366 "update_id": 1,
367 "update_type": "invoice_paid",
368 "request_date": old_date,
369 "payload": {
370 "invoice_id": 528890,
371 "hash": "IVDoTcNBYEfk",
372 "currency_type": "crypto",
373 "asset": "TON",
374 "amount": "10.5",
375 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
376 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
377 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
378 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
379 "description": "Test invoice",
380 "status": "paid",
381 "created_at": "2025-02-08T12:11:01.341Z",
382 "allow_comments": true,
383 "allow_anonymous": true
384 }
385 })
386 .to_string();
387
388 let result = handler.handle_update(&json).await;
389 assert!(matches!(
390 result,
391 Err(CryptoBotError::WebhookError {
392 kind: WebhookErrorKind::Expired,
393 ..
394 })
395 ));
396 }
397
398 #[test]
399 fn test_webhook_signature_verification() {
400 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfig::default());
401 let body = json!({
402 "update_id": 1,
403 "update_type": "invoice_paid",
404 "request_date": "2024-01-01T12:00:00Z",
405 "payload": {
406 "invoice_id": 528890,
407 "hash": "IVDoTcNBYEfk",
408 "status": "paid",
409 }
411 })
412 .to_string();
413
414 let secret = Sha256::digest(b"test_token");
416 let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
417 mac.update(body.as_bytes());
418 let signature = hex::encode(mac.finalize().into_bytes());
419
420 assert!(handler.verify_signature(&body, &signature));
421 assert!(!handler.verify_signature(&body, "invalid_signature"));
422 }
423
424 #[test]
425 fn test_parse_webhook_update() {
426 let json = json!({
427 "update_id": 1,
428 "update_type": "invoice_paid",
429 "request_date": "2024-02-02T12:11:02Z",
430 "payload": {
431 "invoice_id": 528890,
432 "hash": "IVDoTcNBYEfk",
433 "currency_type": "crypto",
434 "asset": "TON",
435 "amount": "10.5",
436 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
437 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
438 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
439 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
440 "description": "Test invoice",
441 "status": "paid",
442 "created_at": "2025-02-08T12:11:01.341Z",
443 "allow_comments": true,
444 "allow_anonymous": true
445 }
446 });
447
448 let result = WebhookHandler::parse_update(&json.to_string());
449 assert!(result.is_ok());
450
451 let update = result.unwrap();
452 assert_eq!(update.update_id, 1);
453 assert_eq!(update.update_type, UpdateType::InvoicePaid);
454 assert_eq!(update.request_date, "2024-02-02T12:11:02Z");
455
456 match update.payload {
457 WebhookPayload::InvoicePaid(invoice) => {
458 assert_eq!(invoice.invoice_id, 528890);
459 assert_eq!(invoice.status, InvoiceStatus::Paid);
460 }
461 }
462 }
463
464 #[test]
465 fn test_parse_invalid_webhook_update() {
466 let invalid_json = r#"{"invalid": "json"}"#;
467
468 let result = WebhookHandler::parse_update(invalid_json);
469 assert!(matches!(
470 result,
471 Err(CryptoBotError::WebhookError {
472 kind: WebhookErrorKind::InvalidPayload,
473 ..
474 })
475 ));
476 }
477}