stakpak_gateway/channels/
mod.rs1pub 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}