Skip to main content

rustant_core/channels/
webhook.rs

1//! Webhook channel — generic HTTP webhook (inbound + outbound).
2//!
3//! Inbound messages arrive via an HTTP endpoint; outbound messages are
4//! sent via POST to a configured URL. In tests, traits abstract HTTP calls.
5
6use super::{
7    Channel, ChannelCapabilities, ChannelMessage, ChannelStatus, ChannelType, ChannelUser,
8    MessageId, StreamingMode,
9};
10use crate::error::{ChannelError, RustantError};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13
14/// Configuration for a Webhook channel.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct WebhookConfig {
17    pub enabled: bool,
18    pub listen_path: String,
19    pub outbound_url: String,
20    pub secret: String,
21}
22
23/// Trait for webhook HTTP interactions.
24#[async_trait]
25pub trait WebhookHttpClient: Send + Sync {
26    async fn post_outbound(&self, url: &str, payload: &str) -> Result<String, String>;
27    async fn get_inbound(&self) -> Result<Vec<WebhookIncoming>, String>;
28}
29
30/// An incoming webhook payload.
31#[derive(Debug, Clone)]
32pub struct WebhookIncoming {
33    pub id: String,
34    pub source: String,
35    pub body: String,
36}
37
38/// Webhook channel.
39pub struct WebhookChannel {
40    config: WebhookConfig,
41    status: ChannelStatus,
42    http_client: Box<dyn WebhookHttpClient>,
43    name: String,
44}
45
46impl WebhookChannel {
47    pub fn new(config: WebhookConfig, http_client: Box<dyn WebhookHttpClient>) -> Self {
48        Self {
49            config,
50            status: ChannelStatus::Disconnected,
51            http_client,
52            name: "webhook".to_string(),
53        }
54    }
55
56    pub fn with_name(mut self, name: impl Into<String>) -> Self {
57        self.name = name.into();
58        self
59    }
60}
61
62#[async_trait]
63impl Channel for WebhookChannel {
64    fn name(&self) -> &str {
65        &self.name
66    }
67
68    fn channel_type(&self) -> ChannelType {
69        ChannelType::Webhook
70    }
71
72    async fn connect(&mut self) -> Result<(), RustantError> {
73        if self.config.outbound_url.is_empty() && self.config.listen_path.is_empty() {
74            return Err(RustantError::Channel(ChannelError::ConnectionFailed {
75                name: self.name.clone(),
76                message: "No outbound URL or listen path configured".into(),
77            }));
78        }
79        self.status = ChannelStatus::Connected;
80        Ok(())
81    }
82
83    async fn disconnect(&mut self) -> Result<(), RustantError> {
84        self.status = ChannelStatus::Disconnected;
85        Ok(())
86    }
87
88    async fn send_message(&self, msg: ChannelMessage) -> Result<MessageId, RustantError> {
89        if self.status != ChannelStatus::Connected {
90            return Err(RustantError::Channel(ChannelError::NotConnected {
91                name: self.name.clone(),
92            }));
93        }
94        let text = msg.content.as_text().unwrap_or("");
95        self.http_client
96            .post_outbound(&self.config.outbound_url, text)
97            .await
98            .map(MessageId::new)
99            .map_err(|e| {
100                RustantError::Channel(ChannelError::SendFailed {
101                    name: self.name.clone(),
102                    message: e,
103                })
104            })
105    }
106
107    async fn receive_messages(&self) -> Result<Vec<ChannelMessage>, RustantError> {
108        let incoming = self.http_client.get_inbound().await.map_err(|e| {
109            RustantError::Channel(ChannelError::ConnectionFailed {
110                name: self.name.clone(),
111                message: e,
112            })
113        })?;
114
115        let messages = incoming
116            .into_iter()
117            .map(|m| {
118                let sender = ChannelUser::new(&m.source, ChannelType::Webhook);
119                ChannelMessage::text(ChannelType::Webhook, &m.source, sender, &m.body)
120            })
121            .collect();
122
123        Ok(messages)
124    }
125
126    fn status(&self) -> ChannelStatus {
127        self.status
128    }
129
130    fn capabilities(&self) -> ChannelCapabilities {
131        ChannelCapabilities {
132            supports_threads: false,
133            supports_reactions: false,
134            supports_files: true,
135            supports_voice: false,
136            supports_video: false,
137            max_message_length: None,
138            supports_editing: false,
139            supports_deletion: false,
140        }
141    }
142
143    fn streaming_mode(&self) -> StreamingMode {
144        StreamingMode::ServerSentEvents
145    }
146}
147
148/// Real Webhook HTTP client using reqwest.
149pub struct RealWebhookHttp {
150    client: reqwest::Client,
151}
152
153impl Default for RealWebhookHttp {
154    fn default() -> Self {
155        Self {
156            client: reqwest::Client::new(),
157        }
158    }
159}
160
161impl RealWebhookHttp {
162    pub fn new() -> Self {
163        Self::default()
164    }
165}
166
167#[async_trait]
168impl WebhookHttpClient for RealWebhookHttp {
169    async fn post_outbound(&self, url: &str, payload: &str) -> Result<String, String> {
170        let resp = self
171            .client
172            .post(url)
173            .header("Content-Type", "application/json")
174            .body(payload.to_string())
175            .send()
176            .await
177            .map_err(|e| format!("HTTP error: {e}"))?;
178
179        let status = resp.status();
180        let body = resp.text().await.map_err(|e| format!("Read error: {e}"))?;
181
182        if !status.is_success() {
183            return Err(format!("Webhook POST failed ({}): {}", status, body));
184        }
185
186        Ok(body)
187    }
188
189    async fn get_inbound(&self) -> Result<Vec<WebhookIncoming>, String> {
190        // Inbound webhooks are received via HTTP server, not polled.
191        Ok(vec![])
192    }
193}
194
195/// Create a Webhook channel with a real HTTP client.
196pub fn create_webhook_channel(config: WebhookConfig) -> WebhookChannel {
197    WebhookChannel::new(config, Box::new(RealWebhookHttp::new()))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    struct MockWebhookHttp;
205
206    #[async_trait]
207    impl WebhookHttpClient for MockWebhookHttp {
208        async fn post_outbound(&self, _url: &str, _payload: &str) -> Result<String, String> {
209            Ok("wh-msg-1".into())
210        }
211        async fn get_inbound(&self) -> Result<Vec<WebhookIncoming>, String> {
212            Ok(vec![])
213        }
214    }
215
216    #[test]
217    fn test_webhook_channel_creation() {
218        let ch = WebhookChannel::new(WebhookConfig::default(), Box::new(MockWebhookHttp));
219        assert_eq!(ch.name(), "webhook");
220        assert_eq!(ch.channel_type(), ChannelType::Webhook);
221    }
222
223    #[test]
224    fn test_webhook_capabilities() {
225        let ch = WebhookChannel::new(WebhookConfig::default(), Box::new(MockWebhookHttp));
226        let caps = ch.capabilities();
227        assert!(caps.supports_files);
228        assert!(!caps.supports_threads);
229        assert!(caps.max_message_length.is_none());
230    }
231
232    #[test]
233    fn test_webhook_streaming_mode() {
234        let ch = WebhookChannel::new(WebhookConfig::default(), Box::new(MockWebhookHttp));
235        assert_eq!(ch.streaming_mode(), StreamingMode::ServerSentEvents);
236    }
237
238    #[test]
239    fn test_webhook_status_disconnected() {
240        let ch = WebhookChannel::new(WebhookConfig::default(), Box::new(MockWebhookHttp));
241        assert_eq!(ch.status(), ChannelStatus::Disconnected);
242    }
243
244    #[tokio::test]
245    async fn test_webhook_send_without_connect() {
246        let ch = WebhookChannel::new(WebhookConfig::default(), Box::new(MockWebhookHttp));
247        let sender = ChannelUser::new("ext", ChannelType::Webhook);
248        let msg = ChannelMessage::text(ChannelType::Webhook, "ext-sys", sender, "data");
249        assert!(ch.send_message(msg).await.is_err());
250    }
251}