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_config());
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_propagates_handler_error() {
253 let mut handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
254 handler.on_update(|_| async move {
255 Err(CryptoBotError::WebhookError {
256 kind: WebhookErrorKind::InvalidPayload,
257 message: "handler error".to_string(),
258 })
259 });
260
261 let json = json!({
262 "update_id": 1,
263 "update_type": "invoice_paid",
264 "request_date": Utc::now().to_rfc3339(),
265 "payload": {
266 "invoice_id": 528890,
267 "hash": "IVDoTcNBYEfk",
268 "currency_type": "crypto",
269 "asset": "TON",
270 "amount": "10.5",
271 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
272 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
273 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
274 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
275 "description": "Test invoice",
276 "status": "paid",
277 "created_at": "2025-02-08T12:11:01.341Z",
278 "allow_comments": true,
279 "allow_anonymous": true
280 }
281 })
282 .to_string();
283
284 let result = handler.handle_update(&json).await;
285 assert!(matches!(
286 result,
287 Err(CryptoBotError::WebhookError {
288 kind: WebhookErrorKind::InvalidPayload,
289 message
290 }) if message == "handler error"
291 ));
292 }
293
294 #[tokio::test]
295 async fn test_webhook_handler_invalid_request_date() {
296 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
297
298 let json = json!({
299 "update_id": 1,
300 "update_type": "invoice_paid",
301 "request_date": "invalid_date",
302 "payload": {
303 "invoice_id": 528890,
304 "hash": "IVDoTcNBYEfk",
305 "currency_type": "crypto",
306 "asset": "TON",
307 "amount": "10.5",
308 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
309 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
310 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
311 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
312 "description": "Test invoice",
313 "status": "paid",
314 "created_at": "2025-02-08T12:11:01.341Z",
315 "allow_comments": true,
316 "allow_anonymous": true
317 }
318 });
319
320 let result = handler.handle_update(&json.to_string()).await;
321
322 assert!(matches!(
323 result,
324 Err(CryptoBotError::WebhookError {
325 kind: WebhookErrorKind::InvalidPayload,
326 message,
327 }) if message == "Invalid request date"
328 ));
329 }
330
331 #[tokio::test]
332 async fn test_webhook_handler_with_disabled_expiration() {
333 let handler = WebhookHandler::with_config(
334 "test_token",
335 WebhookHandlerConfigBuilder::new().disable_expiration().build_config(),
336 );
337
338 let json = json!({
339 "update_id": 1,
340 "update_type": "invoice_paid",
341 "request_date": Utc::now().to_rfc3339(),
342 "payload": {
343 "invoice_id": 528890,
344 "hash": "IVDoTcNBYEfk",
345 "currency_type": "crypto",
346 "asset": "TON",
347 "amount": "10.5",
348 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
349 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
350 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
351 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
352 "description": "Test invoice",
353 "status": "paid",
354 "created_at": "2025-02-08T12:11:01.341Z",
355 "allow_comments": true,
356 "allow_anonymous": true
357 }
358 });
359
360 let result = handler.handle_update(&json.to_string()).await;
361 assert!(result.is_ok());
362 }
363
364 #[tokio::test]
365 async fn test_default_webhook_expiration() {
366 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
367
368 let date = (Utc::now() - chrono::Duration::minutes(3)).to_rfc3339();
369
370 let json = json!({
371 "update_id": 1,
372 "update_type": "invoice_paid",
373 "request_date": date,
374 "payload": {
375 "invoice_id": 528890,
376 "hash": "IVDoTcNBYEfk",
377 "currency_type": "crypto",
378 "asset": "TON",
379 "amount": "10.5",
380 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
381 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
382 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
383 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
384 "description": "Test invoice",
385 "status": "paid",
386 "created_at": "2025-02-08T12:11:01.341Z",
387 "allow_comments": true,
388 "allow_anonymous": true
389 }
390 })
391 .to_string();
392
393 let result = handler.handle_update(&json).await;
394 assert!(result.is_ok());
395 }
396
397 #[tokio::test]
398 async fn test_custom_webhook_expiration() {
399 let handler = WebhookHandler::with_config(
400 "test_token",
401 WebhookHandlerConfigBuilder::new()
402 .expiration_time(Duration::from_secs(60))
403 .build_config(),
404 );
405
406 let old_date = (Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
407
408 let json = json!({
409 "update_id": 1,
410 "update_type": "invoice_paid",
411 "request_date": old_date,
412 "payload": {
413 "invoice_id": 528890,
414 "hash": "IVDoTcNBYEfk",
415 "currency_type": "crypto",
416 "asset": "TON",
417 "amount": "10.5",
418 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
419 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
420 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
421 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
422 "description": "Test invoice",
423 "status": "paid",
424 "created_at": "2025-02-08T12:11:01.341Z",
425 "allow_comments": true,
426 "allow_anonymous": true
427 }
428 })
429 .to_string();
430
431 let result = handler.handle_update(&json).await;
432 assert!(matches!(
433 result,
434 Err(CryptoBotError::WebhookError {
435 kind: WebhookErrorKind::Expired,
436 ..
437 })
438 ));
439 }
440
441 #[test]
442 fn test_webhook_signature_verification() {
443 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
444 let body = json!({
445 "update_id": 1,
446 "update_type": "invoice_paid",
447 "request_date": "2024-01-01T12:00:00Z",
448 "payload": {
449 "invoice_id": 528890,
450 "hash": "IVDoTcNBYEfk",
451 "status": "paid",
452 }
454 })
455 .to_string();
456
457 let secret = Sha256::digest(b"test_token");
459 let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
460 mac.update(body.as_bytes());
461 let signature = hex::encode(mac.finalize().into_bytes());
462
463 assert!(handler.verify_signature(&body, &signature));
464 assert!(!handler.verify_signature(&body, "invalid_signature"));
465 }
466
467 #[test]
468 fn test_parse_webhook_update() {
469 let json = json!({
470 "update_id": 1,
471 "update_type": "invoice_paid",
472 "request_date": "2024-02-02T12:11:02Z",
473 "payload": {
474 "invoice_id": 528890,
475 "hash": "IVDoTcNBYEfk",
476 "currency_type": "crypto",
477 "asset": "TON",
478 "amount": "10.5",
479 "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
480 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
481 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
482 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
483 "description": "Test invoice",
484 "status": "paid",
485 "created_at": "2025-02-08T12:11:01.341Z",
486 "allow_comments": true,
487 "allow_anonymous": true
488 }
489 });
490
491 let result = WebhookHandler::parse_update(&json.to_string());
492 assert!(result.is_ok());
493
494 let update = result.unwrap();
495 assert_eq!(update.update_id, 1);
496 assert_eq!(update.update_type, UpdateType::InvoicePaid);
497 assert_eq!(update.request_date, "2024-02-02T12:11:02Z");
498
499 match update.payload {
500 WebhookPayload::InvoicePaid(invoice) => {
501 assert_eq!(invoice.invoice_id, 528890);
502 assert_eq!(invoice.status, InvoiceStatus::Paid);
503 }
504 }
505 }
506
507 #[test]
508 fn test_parse_invalid_webhook_update() {
509 let invalid_json = r#"{"invalid": "json"}"#;
510
511 let result = WebhookHandler::parse_update(invalid_json);
512 assert!(matches!(
513 result,
514 Err(CryptoBotError::WebhookError {
515 kind: WebhookErrorKind::InvalidPayload,
516 ..
517 })
518 ));
519 }
520
521 #[tokio::test]
522 async fn test_handle_update_with_missing_handler_ok() {
523 let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfig::default());
524
525 let json = json!({
526 "update_id": 1,
527 "update_type": "invoice_paid",
528 "request_date": Utc::now().to_rfc3339(),
529 "payload": {
530 "invoice_id": 1,
531 "hash": "hash",
532 "status": "paid",
533 "currency_type": "crypto",
534 "asset": "TON",
535 "amount": "1",
536 "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=hash",
537 "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-hash",
538 "web_app_invoice_url": "https://testnet-app.send.tg/invoices/hash",
539 "created_at": "2025-02-08T12:11:01.341Z",
540 "allow_comments": true,
541 "allow_anonymous": true
542 }
543 })
544 .to_string();
545
546 let result = handler.handle_update(&json).await;
547 assert!(result.is_ok());
548 }
549}