1use 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#[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#[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#[derive(Debug, Clone)]
32pub struct WebhookIncoming {
33 pub id: String,
34 pub source: String,
35 pub body: String,
36}
37
38pub 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
148pub 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 Ok(vec![])
192 }
193}
194
195pub 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}