Skip to main content

bob_chat/
adapter.rs

1//! The core [`ChatAdapter`] trait that each chat platform implements.
2
3use crate::{
4    card::CardElement,
5    emoji::EmojiValue,
6    error::ChatError,
7    event::ChatEvent,
8    file::FileUpload,
9    message::{AdapterPostableMessage, EphemeralMessage, SentMessage},
10    modal::ModalElement,
11    stream::{StreamOptions, TextStream},
12};
13
14/// Extension point for chat platforms (Slack, Discord, CLI, etc.).
15///
16/// Each platform provides a concrete implementation.  Required methods
17/// must be implemented; optional methods have sensible defaults that
18/// return [`ChatError::NotSupported`].
19///
20/// # Object safety
21///
22/// The trait is object-safe and can be used as `Box<dyn ChatAdapter>`.
23/// Note that [`recv_event`](Self::recv_event) takes `&mut self`, so a
24/// shared reference (`&dyn ChatAdapter`) is insufficient for receiving
25/// events.
26#[async_trait::async_trait]
27pub trait ChatAdapter: Send + Sync {
28    // -----------------------------------------------------------------
29    // Identity
30    // -----------------------------------------------------------------
31
32    /// Short, unique name of this adapter (e.g. `"slack"`, `"discord"`).
33    fn name(&self) -> &str;
34
35    // -----------------------------------------------------------------
36    // Required – message lifecycle
37    // -----------------------------------------------------------------
38
39    /// Post a new message to the given thread.
40    async fn post_message(
41        &self,
42        thread_id: &str,
43        message: &AdapterPostableMessage,
44    ) -> Result<SentMessage, ChatError>;
45
46    /// Edit an existing message in the given thread.
47    async fn edit_message(
48        &self,
49        thread_id: &str,
50        message_id: &str,
51        message: &AdapterPostableMessage,
52    ) -> Result<SentMessage, ChatError>;
53
54    /// Delete a message from the given thread.
55    async fn delete_message(&self, thread_id: &str, message_id: &str) -> Result<(), ChatError>;
56
57    // -----------------------------------------------------------------
58    // Required – rendering
59    // -----------------------------------------------------------------
60
61    /// Render a [`CardElement`] into the adapter's native format string.
62    fn render_card(&self, card: &CardElement) -> String;
63
64    /// Render an [`AdapterPostableMessage`] into a plain-text or
65    /// markup representation suitable for the adapter.
66    fn render_message(&self, message: &AdapterPostableMessage) -> String;
67
68    // -----------------------------------------------------------------
69    // Required – event reception
70    // -----------------------------------------------------------------
71
72    /// Wait for the next inbound [`ChatEvent`].
73    ///
74    /// Returns `None` when the event source is exhausted.
75    async fn recv_event(&mut self) -> Option<ChatEvent>;
76
77    // -----------------------------------------------------------------
78    // Optional – streaming
79    // -----------------------------------------------------------------
80
81    /// Stream text progressively to a thread.
82    ///
83    /// The default implementation uses [`fallback_stream`](crate::stream::fallback_stream)
84    /// which posts a placeholder and then repeatedly edits it as chunks
85    /// arrive from the stream.
86    async fn stream(
87        &self,
88        thread_id: &str,
89        text_stream: TextStream,
90        options: &StreamOptions,
91    ) -> Result<SentMessage, ChatError> {
92        crate::stream::fallback_stream(self, thread_id, text_stream, options).await
93    }
94
95    // -----------------------------------------------------------------
96    // Optional – reactions
97    // -----------------------------------------------------------------
98
99    /// Add a reaction emoji to a message.
100    async fn add_reaction(
101        &self,
102        _thread_id: &str,
103        _message_id: &str,
104        _emoji: &EmojiValue,
105    ) -> Result<(), ChatError> {
106        Err(ChatError::NotSupported("reactions".into()))
107    }
108
109    /// Remove a reaction emoji from a message.
110    async fn remove_reaction(
111        &self,
112        _thread_id: &str,
113        _message_id: &str,
114        _emoji: &EmojiValue,
115    ) -> Result<(), ChatError> {
116        Err(ChatError::NotSupported("reactions".into()))
117    }
118
119    // -----------------------------------------------------------------
120    // Optional – direct messages
121    // -----------------------------------------------------------------
122
123    /// Open (or return an existing) direct-message channel with a user.
124    ///
125    /// Returns the thread/channel ID of the DM.
126    async fn open_dm(&self, _user_id: &str) -> Result<String, ChatError> {
127        Err(ChatError::NotSupported("direct messages".into()))
128    }
129
130    // -----------------------------------------------------------------
131    // Optional – ephemeral messages
132    // -----------------------------------------------------------------
133
134    /// Post a message visible only to the specified user.
135    async fn post_ephemeral(
136        &self,
137        _thread_id: &str,
138        _user_id: &str,
139        _message: &AdapterPostableMessage,
140    ) -> Result<EphemeralMessage, ChatError> {
141        Err(ChatError::NotSupported("ephemeral messages".into()))
142    }
143
144    // -----------------------------------------------------------------
145    // Optional – modals
146    // -----------------------------------------------------------------
147
148    /// Open a modal dialog tied to the given trigger interaction.
149    async fn open_modal(
150        &self,
151        _trigger_id: &str,
152        _modal: &ModalElement,
153        _context_id: Option<&str>,
154    ) -> Result<String, ChatError> {
155        Err(ChatError::NotSupported("modals".into()))
156    }
157
158    // -----------------------------------------------------------------
159    // Optional – typing indicators
160    // -----------------------------------------------------------------
161
162    /// Show a typing / status indicator in the given thread.
163    async fn start_typing(&self, _thread_id: &str, _status: Option<&str>) -> Result<(), ChatError> {
164        Ok(())
165    }
166
167    // -----------------------------------------------------------------
168    // Optional – file uploads
169    // -----------------------------------------------------------------
170
171    /// Upload a file to the given thread.
172    async fn upload_file(
173        &self,
174        _thread_id: &str,
175        _file: &FileUpload,
176    ) -> Result<SentMessage, ChatError> {
177        Err(ChatError::NotSupported("file uploads".into()))
178    }
179}
180
181// =========================================================================
182// Tests
183// =========================================================================
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::event::ChatEvent;
189
190    /// Minimal adapter that implements only required methods.
191    /// Verifies the trait is implementable and object-safe.
192    struct StubAdapter;
193
194    #[async_trait::async_trait]
195    impl ChatAdapter for StubAdapter {
196        fn name(&self) -> &str {
197            "stub"
198        }
199
200        async fn post_message(
201            &self,
202            _thread_id: &str,
203            _message: &AdapterPostableMessage,
204        ) -> Result<SentMessage, ChatError> {
205            Ok(SentMessage {
206                id: "m1".into(),
207                thread_id: "t1".into(),
208                adapter_name: "stub".into(),
209                raw: None,
210            })
211        }
212
213        async fn edit_message(
214            &self,
215            _thread_id: &str,
216            _message_id: &str,
217            _message: &AdapterPostableMessage,
218        ) -> Result<SentMessage, ChatError> {
219            Ok(SentMessage {
220                id: "m1".into(),
221                thread_id: "t1".into(),
222                adapter_name: "stub".into(),
223                raw: None,
224            })
225        }
226
227        async fn delete_message(
228            &self,
229            _thread_id: &str,
230            _message_id: &str,
231        ) -> Result<(), ChatError> {
232            Ok(())
233        }
234
235        fn render_card(&self, _card: &CardElement) -> String {
236            String::new()
237        }
238
239        fn render_message(&self, _message: &AdapterPostableMessage) -> String {
240            String::new()
241        }
242
243        async fn recv_event(&mut self) -> Option<ChatEvent> {
244            None
245        }
246    }
247
248    /// `Box<dyn ChatAdapter>` compiles – trait is object-safe.
249    #[test]
250    fn object_safe() {
251        let _adapter: Box<dyn ChatAdapter> = Box::new(StubAdapter);
252    }
253
254    /// Default optional methods return the expected errors / values.
255    #[tokio::test]
256    async fn default_optional_methods() {
257        let adapter = StubAdapter;
258
259        let emoji = EmojiValue::from_well_known(crate::emoji::WellKnownEmoji::ThumbsUp);
260
261        let err = adapter.add_reaction("t1", "m1", &emoji).await.unwrap_err();
262        assert!(matches!(err, ChatError::NotSupported(_)));
263
264        let err = adapter.remove_reaction("t1", "m1", &emoji).await.unwrap_err();
265        assert!(matches!(err, ChatError::NotSupported(_)));
266
267        let err = adapter.open_dm("u1").await.unwrap_err();
268        assert!(matches!(err, ChatError::NotSupported(_)));
269
270        let err = adapter
271            .post_ephemeral("t1", "u1", &AdapterPostableMessage::Text("hi".into()))
272            .await
273            .unwrap_err();
274        assert!(matches!(err, ChatError::NotSupported(_)));
275
276        let err = adapter
277            .open_modal(
278                "trigger",
279                &ModalElement {
280                    callback_id: "cb".into(),
281                    title: "test".into(),
282                    submit_label: None,
283                    children: vec![],
284                    private_metadata: None,
285                    notify_on_close: false,
286                },
287                None,
288            )
289            .await
290            .unwrap_err();
291        assert!(matches!(err, ChatError::NotSupported(_)));
292
293        // start_typing defaults to Ok
294        adapter.start_typing("t1", None).await.expect("start_typing should default to Ok");
295
296        let err = adapter
297            .upload_file(
298                "t1",
299                &crate::file::FileUpload {
300                    filename: "f.txt".into(),
301                    mime_type: None,
302                    data: bytes::Bytes::new(),
303                },
304            )
305            .await
306            .unwrap_err();
307        assert!(matches!(err, ChatError::NotSupported(_)));
308    }
309
310    /// Required methods work on the stub.
311    #[tokio::test]
312    async fn stub_required_methods() {
313        let mut adapter = StubAdapter;
314
315        let sent = adapter
316            .post_message("t1", &AdapterPostableMessage::Text("hello".into()))
317            .await
318            .expect("post_message");
319        assert_eq!(sent.id, "m1");
320
321        let sent = adapter
322            .edit_message("t1", "m1", &AdapterPostableMessage::Text("edited".into()))
323            .await
324            .expect("edit_message");
325        assert_eq!(sent.id, "m1");
326
327        adapter.delete_message("t1", "m1").await.expect("delete_message");
328
329        assert_eq!(adapter.name(), "stub");
330        assert!(
331            adapter
332                .render_card(&CardElement { title: None, children: vec![], fallback_text: None })
333                .is_empty()
334        );
335        assert!(adapter.render_message(&AdapterPostableMessage::Text("x".into())).is_empty());
336
337        let event = adapter.recv_event().await;
338        assert!(event.is_none());
339    }
340}