Skip to main content

construct/channels/
traits.rs

1use async_trait::async_trait;
2use tokio_util::sync::CancellationToken;
3
4/// A message received from or sent to a channel
5#[derive(Debug, Clone)]
6pub struct ChannelMessage {
7    pub id: String,
8    pub sender: String,
9    pub reply_target: String,
10    pub content: String,
11    pub channel: String,
12    pub timestamp: u64,
13    /// Platform thread identifier (e.g. Slack `ts`, Discord thread ID).
14    /// When set, replies should be posted as threaded responses.
15    pub thread_ts: Option<String>,
16    /// Thread scope identifier for interruption/cancellation grouping.
17    /// Distinct from `thread_ts` (reply anchor): this is `Some` only when the message
18    /// is genuinely inside a reply thread and should be isolated from other threads.
19    /// `None` means top-level — scope is sender+channel only.
20    pub interruption_scope_id: Option<String>,
21    /// Media attachments (audio, images, video) for the media pipeline.
22    /// Channels populate this when they receive media alongside a text message.
23    /// Defaults to empty — existing channels are unaffected.
24    pub attachments: Vec<super::media_pipeline::MediaAttachment>,
25}
26
27/// Message to send through a channel
28#[derive(Debug, Clone)]
29pub struct SendMessage {
30    pub content: String,
31    pub recipient: String,
32    pub subject: Option<String>,
33    /// Platform thread identifier for threaded replies (e.g. Slack `thread_ts`).
34    pub thread_ts: Option<String>,
35    /// Optional cancellation token for interruptible delivery (e.g. multi-message mode).
36    pub cancellation_token: Option<CancellationToken>,
37}
38
39impl SendMessage {
40    /// Create a new message with content and recipient
41    pub fn new(content: impl Into<String>, recipient: impl Into<String>) -> Self {
42        Self {
43            content: content.into(),
44            recipient: recipient.into(),
45            subject: None,
46            thread_ts: None,
47            cancellation_token: None,
48        }
49    }
50
51    /// Create a new message with content, recipient, and subject
52    pub fn with_subject(
53        content: impl Into<String>,
54        recipient: impl Into<String>,
55        subject: impl Into<String>,
56    ) -> Self {
57        Self {
58            content: content.into(),
59            recipient: recipient.into(),
60            subject: Some(subject.into()),
61            thread_ts: None,
62            cancellation_token: None,
63        }
64    }
65
66    /// Set the thread identifier for threaded replies.
67    pub fn in_thread(mut self, thread_ts: Option<String>) -> Self {
68        self.thread_ts = thread_ts;
69        self
70    }
71
72    /// Attach a cancellation token for interruptible delivery.
73    pub fn with_cancellation(mut self, token: CancellationToken) -> Self {
74        self.cancellation_token = Some(token);
75        self
76    }
77}
78
79/// Core channel trait — implement for any messaging platform
80#[async_trait]
81pub trait Channel: Send + Sync {
82    /// Human-readable channel name
83    fn name(&self) -> &str;
84
85    /// Send a message through this channel
86    async fn send(&self, message: &SendMessage) -> anyhow::Result<()>;
87
88    /// Start listening for incoming messages (long-running)
89    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()>;
90
91    /// Check if channel is healthy
92    async fn health_check(&self) -> bool {
93        true
94    }
95
96    /// Signal that the bot is processing a response (e.g. "typing" indicator).
97    /// Implementations should repeat the indicator as needed for their platform.
98    async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
99        Ok(())
100    }
101
102    /// Stop any active typing indicator.
103    async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
104        Ok(())
105    }
106
107    /// Whether this channel supports progressive message updates via draft edits.
108    fn supports_draft_updates(&self) -> bool {
109        false
110    }
111
112    /// Whether this channel supports multi-message streaming delivery, where
113    /// the response is sent as multiple separate messages at paragraph
114    /// boundaries as tokens arrive from the provider.
115    fn supports_multi_message_streaming(&self) -> bool {
116        false
117    }
118
119    /// Minimum delay (ms) between sending each paragraph in multi-message mode.
120    /// Channels should override this to avoid platform rate limits.
121    fn multi_message_delay_ms(&self) -> u64 {
122        800
123    }
124
125    /// Send an initial draft message. Returns a platform-specific message ID for later edits.
126    async fn send_draft(&self, _message: &SendMessage) -> anyhow::Result<Option<String>> {
127        Ok(None)
128    }
129
130    /// Update a previously sent draft message with new accumulated content.
131    async fn update_draft(
132        &self,
133        _recipient: &str,
134        _message_id: &str,
135        _text: &str,
136    ) -> anyhow::Result<()> {
137        Ok(())
138    }
139
140    /// Show a progress/status update (e.g. tool execution status).
141    /// Channels can display this in a status bar rather than in the message body.
142    /// Default: no-op (progress is ignored).
143    async fn update_draft_progress(
144        &self,
145        _recipient: &str,
146        _message_id: &str,
147        _text: &str,
148    ) -> anyhow::Result<()> {
149        Ok(())
150    }
151
152    /// Finalize a draft with the complete response (e.g. apply Markdown formatting).
153    async fn finalize_draft(
154        &self,
155        _recipient: &str,
156        _message_id: &str,
157        _text: &str,
158    ) -> anyhow::Result<()> {
159        Ok(())
160    }
161
162    /// Cancel and remove a previously sent draft message if the channel supports it.
163    async fn cancel_draft(&self, _recipient: &str, _message_id: &str) -> anyhow::Result<()> {
164        Ok(())
165    }
166
167    /// Add a reaction (emoji) to a message.
168    ///
169    /// `channel_id` is the platform channel/conversation identifier (e.g. Discord channel ID).
170    /// `message_id` is the platform-scoped message identifier (e.g. `discord_<snowflake>`).
171    /// `emoji` is the Unicode emoji to react with (e.g. "👀", "✅").
172    async fn add_reaction(
173        &self,
174        _channel_id: &str,
175        _message_id: &str,
176        _emoji: &str,
177    ) -> anyhow::Result<()> {
178        Ok(())
179    }
180
181    /// Remove a reaction (emoji) from a message previously added by this bot.
182    async fn remove_reaction(
183        &self,
184        _channel_id: &str,
185        _message_id: &str,
186        _emoji: &str,
187    ) -> anyhow::Result<()> {
188        Ok(())
189    }
190
191    /// Pin a message in the channel.
192    async fn pin_message(&self, _channel_id: &str, _message_id: &str) -> anyhow::Result<()> {
193        Ok(())
194    }
195
196    /// Unpin a previously pinned message.
197    async fn unpin_message(&self, _channel_id: &str, _message_id: &str) -> anyhow::Result<()> {
198        Ok(())
199    }
200
201    /// Redact (delete) a message from the channel.
202    ///
203    /// `channel_id` is the platform channel/conversation identifier.
204    /// `message_id` is the platform-scoped message identifier.
205    /// `reason` is an optional reason for the redaction (may be visible in audit logs).
206    async fn redact_message(
207        &self,
208        _channel_id: &str,
209        _message_id: &str,
210        _reason: Option<String>,
211    ) -> anyhow::Result<()> {
212        Ok(())
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    struct DummyChannel;
221
222    #[async_trait]
223    impl Channel for DummyChannel {
224        fn name(&self) -> &str {
225            "dummy"
226        }
227
228        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
229            Ok(())
230        }
231
232        async fn listen(
233            &self,
234            tx: tokio::sync::mpsc::Sender<ChannelMessage>,
235        ) -> anyhow::Result<()> {
236            tx.send(ChannelMessage {
237                id: "1".into(),
238                sender: "tester".into(),
239                reply_target: "tester".into(),
240                content: "hello".into(),
241                channel: "dummy".into(),
242                timestamp: 123,
243                thread_ts: None,
244                interruption_scope_id: None,
245                attachments: vec![],
246            })
247            .await
248            .map_err(|e| anyhow::anyhow!(e.to_string()))
249        }
250    }
251
252    #[test]
253    fn channel_message_clone_preserves_fields() {
254        let message = ChannelMessage {
255            id: "42".into(),
256            sender: "alice".into(),
257            reply_target: "alice".into(),
258            content: "ping".into(),
259            channel: "dummy".into(),
260            timestamp: 999,
261            thread_ts: None,
262            interruption_scope_id: None,
263            attachments: vec![],
264        };
265
266        let cloned = message.clone();
267        assert_eq!(cloned.id, "42");
268        assert_eq!(cloned.sender, "alice");
269        assert_eq!(cloned.reply_target, "alice");
270        assert_eq!(cloned.content, "ping");
271        assert_eq!(cloned.channel, "dummy");
272        assert_eq!(cloned.timestamp, 999);
273    }
274
275    #[tokio::test]
276    async fn default_trait_methods_return_success() {
277        let channel = DummyChannel;
278
279        assert!(channel.health_check().await);
280        assert!(channel.start_typing("bob").await.is_ok());
281        assert!(channel.stop_typing("bob").await.is_ok());
282        assert!(
283            channel
284                .send(&SendMessage::new("hello", "bob"))
285                .await
286                .is_ok()
287        );
288    }
289
290    #[tokio::test]
291    async fn default_reaction_methods_return_success() {
292        let channel = DummyChannel;
293
294        assert!(
295            channel
296                .add_reaction("chan_1", "msg_1", "\u{1F440}")
297                .await
298                .is_ok()
299        );
300        assert!(
301            channel
302                .remove_reaction("chan_1", "msg_1", "\u{1F440}")
303                .await
304                .is_ok()
305        );
306    }
307
308    #[tokio::test]
309    async fn default_draft_methods_return_success() {
310        let channel = DummyChannel;
311
312        assert!(!channel.supports_draft_updates());
313        assert!(
314            channel
315                .send_draft(&SendMessage::new("draft", "bob"))
316                .await
317                .unwrap()
318                .is_none()
319        );
320        assert!(channel.update_draft("bob", "msg_1", "text").await.is_ok());
321        assert!(
322            channel
323                .finalize_draft("bob", "msg_1", "final text")
324                .await
325                .is_ok()
326        );
327        assert!(channel.cancel_draft("bob", "msg_1").await.is_ok());
328    }
329
330    #[tokio::test]
331    async fn listen_sends_message_to_channel() {
332        let channel = DummyChannel;
333        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
334
335        channel.listen(tx).await.unwrap();
336
337        let received = rx.recv().await.expect("message should be sent");
338        assert_eq!(received.sender, "tester");
339        assert_eq!(received.content, "hello");
340        assert_eq!(received.channel, "dummy");
341    }
342
343    #[tokio::test]
344    async fn default_redact_message_returns_success() {
345        let channel = DummyChannel;
346
347        assert!(
348            channel
349                .redact_message("chan_1", "msg_1", Some("spam".to_string()))
350                .await
351                .is_ok()
352        );
353        assert!(
354            channel
355                .redact_message("chan_1", "msg_2", None)
356                .await
357                .is_ok()
358        );
359    }
360}