1use 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#[async_trait::async_trait]
27pub trait ChatAdapter: Send + Sync {
28 fn name(&self) -> &str;
34
35 async fn post_message(
41 &self,
42 thread_id: &str,
43 message: &AdapterPostableMessage,
44 ) -> Result<SentMessage, ChatError>;
45
46 async fn edit_message(
48 &self,
49 thread_id: &str,
50 message_id: &str,
51 message: &AdapterPostableMessage,
52 ) -> Result<SentMessage, ChatError>;
53
54 async fn delete_message(&self, thread_id: &str, message_id: &str) -> Result<(), ChatError>;
56
57 fn render_card(&self, card: &CardElement) -> String;
63
64 fn render_message(&self, message: &AdapterPostableMessage) -> String;
67
68 async fn recv_event(&mut self) -> Option<ChatEvent>;
76
77 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 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 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 async fn open_dm(&self, _user_id: &str) -> Result<String, ChatError> {
127 Err(ChatError::NotSupported("direct messages".into()))
128 }
129
130 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 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 async fn start_typing(&self, _thread_id: &str, _status: Option<&str>) -> Result<(), ChatError> {
164 Ok(())
165 }
166
167 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#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::event::ChatEvent;
189
190 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 #[test]
250 fn object_safe() {
251 let _adapter: Box<dyn ChatAdapter> = Box::new(StubAdapter);
252 }
253
254 #[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 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 #[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}