Skip to main content

stakpak_gateway/channels/
mod.rs

1pub mod discord;
2pub mod slack;
3pub mod telegram;
4
5use anyhow::{Result, anyhow};
6use async_trait::async_trait;
7use tokio::sync::mpsc;
8use tokio_util::sync::CancellationToken;
9
10use crate::types::{ChannelId, InboundMessage, OutboundReply};
11
12#[derive(Debug, Clone)]
13pub struct ApprovalButton {
14    pub label: String,
15    pub callback_data: String,
16    pub style: ButtonStyle,
17}
18
19#[derive(Debug, Clone, Copy, Eq, PartialEq)]
20pub enum ButtonStyle {
21    Success,
22    Danger,
23}
24
25#[derive(Debug, Clone, Default)]
26pub struct DeliveryReceipt {
27    pub message_id: Option<String>,
28    pub thread_id: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32pub struct ChannelTestResult {
33    pub channel: String,
34    pub identity: String,
35    pub details: String,
36}
37
38#[async_trait]
39pub trait Channel: Send + Sync + 'static {
40    fn id(&self) -> &ChannelId;
41
42    fn display_name(&self) -> &str;
43
44    async fn start(
45        &self,
46        inbound_tx: mpsc::Sender<InboundMessage>,
47        cancel: CancellationToken,
48    ) -> Result<()>;
49
50    async fn send(&self, reply: OutboundReply) -> Result<()>;
51
52    async fn send_with_receipt(&self, reply: OutboundReply) -> Result<DeliveryReceipt> {
53        self.send(reply).await?;
54        Ok(DeliveryReceipt::default())
55    }
56
57    async fn send_with_buttons(
58        &self,
59        _reply: OutboundReply,
60        _buttons: Vec<ApprovalButton>,
61    ) -> Result<String> {
62        Err(anyhow!(
63            "channel '{}' does not support interactive approval buttons",
64            self.display_name()
65        ))
66    }
67
68    async fn edit_message(&self, _message_id: &str, _new_text: &str) -> Result<()> {
69        Err(anyhow!(
70            "channel '{}' does not support editing messages",
71            self.display_name()
72        ))
73    }
74
75    async fn test(&self) -> Result<ChannelTestResult>;
76}
77
78pub fn parse_approval_callback(data: &str) -> Option<(&str, &str)> {
79    let rest = data.strip_prefix("a:")?;
80    let (approval_id, decision) = rest.split_once(':')?;
81    if approval_id.is_empty() || !matches!(decision, "allow" | "deny") {
82        return None;
83    }
84
85    Some((approval_id, decision))
86}
87
88#[cfg(test)]
89mod tests {
90    use anyhow::Result;
91    use async_trait::async_trait;
92    use tokio::sync::mpsc;
93    use tokio_util::sync::CancellationToken;
94
95    use super::{Channel, ChannelId, ChannelTestResult, parse_approval_callback};
96    use crate::types::{ChatType, InboundMessage, OutboundReply, PeerId};
97
98    #[derive(Clone)]
99    struct DefaultBehaviorChannel {
100        id: ChannelId,
101    }
102
103    impl DefaultBehaviorChannel {
104        fn new() -> Self {
105            Self {
106                id: ChannelId("default-test".to_string()),
107            }
108        }
109    }
110
111    #[async_trait]
112    impl Channel for DefaultBehaviorChannel {
113        fn id(&self) -> &ChannelId {
114            &self.id
115        }
116
117        fn display_name(&self) -> &str {
118            "DefaultOnly"
119        }
120
121        async fn start(
122            &self,
123            _inbound_tx: mpsc::Sender<InboundMessage>,
124            _cancel: CancellationToken,
125        ) -> Result<()> {
126            Ok(())
127        }
128
129        async fn send(&self, _reply: OutboundReply) -> Result<()> {
130            Ok(())
131        }
132
133        async fn test(&self) -> Result<ChannelTestResult> {
134            Ok(ChannelTestResult {
135                channel: self.id.0.clone(),
136                identity: "default-only".to_string(),
137                details: "ok".to_string(),
138            })
139        }
140    }
141
142    fn outbound_reply() -> OutboundReply {
143        OutboundReply {
144            channel: ChannelId("default-test".to_string()),
145            peer_id: PeerId("peer-1".to_string()),
146            chat_type: ChatType::Direct,
147            text: "hello".to_string(),
148            metadata: serde_json::json!({}),
149        }
150    }
151
152    #[test]
153    fn parse_approval_callback_accepts_valid_payloads() {
154        assert_eq!(
155            parse_approval_callback("a:a3f0c92d:allow"),
156            Some(("a3f0c92d", "allow"))
157        );
158        assert_eq!(
159            parse_approval_callback("a:a3f0c92d:deny"),
160            Some(("a3f0c92d", "deny"))
161        );
162    }
163
164    #[test]
165    fn parse_approval_callback_rejects_invalid_payloads() {
166        assert_eq!(parse_approval_callback(""), None);
167        assert_eq!(parse_approval_callback("a::allow"), None);
168        assert_eq!(parse_approval_callback("a:a3f0c92d:maybe"), None);
169        assert_eq!(parse_approval_callback("x:a3f0c92d:allow"), None);
170        assert_eq!(parse_approval_callback("a:a3f0c92d"), None);
171    }
172
173    #[tokio::test]
174    async fn channel_default_send_with_buttons_returns_error() {
175        let channel = DefaultBehaviorChannel::new();
176        let result = channel
177            .send_with_buttons(outbound_reply(), Vec::new())
178            .await;
179        assert!(result.is_err());
180        let error = match result {
181            Ok(_) => String::new(),
182            Err(error) => error.to_string(),
183        };
184        assert!(error.contains("does not support interactive approval buttons"));
185        assert!(error.contains("DefaultOnly"));
186    }
187
188    #[tokio::test]
189    async fn channel_default_edit_message_returns_error() {
190        let channel = DefaultBehaviorChannel::new();
191        let result = channel.edit_message("msg-1", "updated").await;
192        assert!(result.is_err());
193        let error = match result {
194            Ok(_) => String::new(),
195            Err(error) => error.to_string(),
196        };
197        assert!(error.contains("does not support editing messages"));
198        assert!(error.contains("DefaultOnly"));
199    }
200}