active_call/useragent/
webhook.rs

1use crate::{call::RoutingState, useragent::invitation::InvitationHandler};
2use anyhow::Result;
3use async_trait::async_trait;
4use chrono::Utc;
5use reqwest::Client;
6use rsip::prelude::{HasHeaders, HeadersExt};
7use rsipstack::dialog::server_dialog::ServerInviteDialog;
8use serde_json::json;
9use std::{sync::Arc, time::Instant};
10use tokio_util::sync::CancellationToken;
11use tracing::info;
12
13pub struct WebhookInvitationHandler {
14    urls: Vec<String>,
15    method: Option<String>,
16    headers: Option<Vec<(String, String)>>,
17}
18
19impl WebhookInvitationHandler {
20    pub fn new(
21        urls: Vec<String>,
22        method: Option<String>,
23        headers: Option<Vec<(String, String)>>,
24    ) -> Self {
25        Self {
26            urls,
27            method,
28            headers,
29        }
30    }
31}
32
33#[async_trait]
34impl InvitationHandler for WebhookInvitationHandler {
35    async fn on_invite(
36        &self,
37        dialog_id: String,
38        _cancel_token: CancellationToken,
39        dialog: ServerInviteDialog,
40        routing_state: Arc<RoutingState>,
41    ) -> Result<()> {
42        let client = Client::new();
43        let create_time = Utc::now().to_rfc3339();
44
45        let invite_request = dialog.initial_request();
46        let caller = invite_request.from_header()?.uri()?.to_string();
47        let callee = invite_request.to_header()?.uri()?.to_string();
48        let headers = invite_request
49            .headers()
50            .clone()
51            .into_iter()
52            .map(|h| h.to_string())
53            .collect::<Vec<_>>();
54
55        let payload = json!({
56            "dialogId": dialog_id,
57            "createdAt": create_time,
58            "caller": caller,
59            "callee": callee,
60            "event": "invite",
61            "headers": headers,
62            "offer": String::from_utf8_lossy(invite_request.body()),
63        });
64        // TODO: better load balancing strategy
65        // just use round-robin for now
66        let idx = routing_state.next_round_robin_index("useragent_webhook", self.urls.len());
67        let url = match self.urls.get(idx) {
68            Some(u) => u,
69            None => {
70                return Err(anyhow::anyhow!("no webhook URL configured"));
71            }
72        };
73
74        let method = self.method.as_deref().unwrap_or("POST");
75        let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes())?, url);
76
77        if let Some(headers) = &self.headers {
78            for (key, value) in headers {
79                request = request.header(key, value);
80            }
81        }
82
83        let start_time = Instant::now();
84        match request.json(&payload).send().await {
85            Ok(response) => {
86                info!(
87                    dialog_id,
88                    url,
89                    caller,
90                    callee,
91                    elapsed = start_time.elapsed().as_millis(),
92                    status = ?response.status(),
93                    "invite to webhook"
94                );
95                if !response.status().is_success() {
96                    return Err(anyhow::anyhow!("failed to send invite to webhook"));
97                }
98            }
99            Err(e) => {
100                return Err(anyhow::anyhow!("failed to send invite to webhook: {}", e));
101            }
102        }
103        Ok(())
104    }
105}