Skip to main content

botkit_core/
response.rs

1use std::fmt;
2
3use crate::types::component::Component;
4use crate::types::embed::Embed;
5
6/// Unified bot response builder
7///
8/// Represents a response that can be sent back to any platform.
9/// Each platform adapter converts this to platform-specific format.
10///
11/// The builder methods (`with_embed`, `ephemeral`, `with_caption`, ...) apply
12/// to whichever kind of response they make sense for. Calling a message builder
13/// on an empty response promotes it to a message rather than silently dropping
14/// the value, so `Response::empty().with_embed(e)` sends the embed.
15#[derive(Default)]
16pub struct Response {
17    kind: ResponseKind,
18}
19
20#[derive(Default)]
21enum ResponseKind {
22    /// Empty response (no reply)
23    #[default]
24    Empty,
25    /// Text message, optionally with embeds and components
26    Message(Message),
27    /// Acknowledge without visible response (for deferred responses)
28    Acknowledge,
29    /// File attachment
30    File(FileResponse),
31}
32
33#[derive(Default)]
34struct Message {
35    content: String,
36    embeds: Vec<Embed>,
37    components: Vec<Component>,
38    ephemeral: bool,
39}
40
41/// Bytes to upload, plus how to present them
42pub struct FileResponse {
43    /// The file contents
44    pub file: FileSource,
45    /// Optional filename (used for content-disposition)
46    pub filename: Option<String>,
47    /// Optional caption to accompany the file
48    pub caption: Option<String>,
49}
50
51/// Where a file response's bytes come from
52///
53/// Adapters read this to completion before upload, so a `Path` is opened lazily
54/// and only once.
55pub enum FileSource {
56    /// An already-open file handle
57    File(async_fs::File),
58    /// Bytes held in memory
59    Bytes(Vec<u8>),
60    /// A path to read at send time
61    Path(std::path::PathBuf),
62}
63
64impl FileSource {
65    /// Read the whole source into memory
66    pub async fn read(self) -> std::io::Result<Vec<u8>> {
67        use futures_lite::io::AsyncReadExt;
68
69        match self {
70            Self::Bytes(bytes) => Ok(bytes),
71            Self::Path(path) => async_fs::read(path).await,
72            Self::File(mut file) => {
73                let mut bytes = Vec::new();
74                file.read_to_end(&mut bytes).await?;
75                Ok(bytes)
76            }
77        }
78    }
79
80    /// The filename implied by the source, if it has one
81    fn implied_filename(&self) -> Option<String> {
82        match self {
83            Self::Path(path) => Some(path.file_name()?.to_string_lossy().into_owned()),
84            _ => None,
85        }
86    }
87}
88
89impl fmt::Debug for FileSource {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::File(_) => f.write_str("FileSource::File(..)"),
93            Self::Bytes(bytes) => write!(f, "FileSource::Bytes({} bytes)", bytes.len()),
94            Self::Path(path) => write!(f, "FileSource::Path({})", path.display()),
95        }
96    }
97}
98
99impl Response {
100    /// Create an empty response (no reply)
101    pub fn empty() -> Self {
102        Self {
103            kind: ResponseKind::Empty,
104        }
105    }
106
107    /// Create a text response
108    pub fn text(content: impl Into<String>) -> Self {
109        Self {
110            kind: ResponseKind::Message(Message {
111                content: content.into(),
112                ..Message::default()
113            }),
114        }
115    }
116
117    /// Create an acknowledgement response (deferred)
118    pub fn acknowledge() -> Self {
119        Self {
120            kind: ResponseKind::Acknowledge,
121        }
122    }
123
124    /// Create a response with an embed
125    pub fn embed(embed: Embed) -> Self {
126        Self::empty().with_embed(embed)
127    }
128
129    /// Create a file response from an open file handle
130    pub fn file(file: async_fs::File) -> Self {
131        Self::from_source(FileSource::File(file))
132    }
133
134    /// Create a file response from bytes already in memory
135    pub fn bytes(bytes: impl Into<Vec<u8>>) -> Self {
136        Self::from_source(FileSource::Bytes(bytes.into()))
137    }
138
139    /// Create a file response that reads `path` when the response is sent
140    ///
141    /// The filename defaults to the path's final component.
142    pub fn path(path: impl Into<std::path::PathBuf>) -> Self {
143        Self::from_source(FileSource::Path(path.into()))
144    }
145
146    fn from_source(file: FileSource) -> Self {
147        Self {
148            kind: ResponseKind::File(FileResponse {
149                filename: file.implied_filename(),
150                file,
151                caption: None,
152            }),
153        }
154    }
155
156    /// Message parts of this response, promoting `Empty` to a message
157    ///
158    /// Returns `None` for acknowledge and file responses, which have no
159    /// embeds or components to carry.
160    fn message_mut(&mut self) -> Option<&mut Message> {
161        if matches!(self.kind, ResponseKind::Empty) {
162            self.kind = ResponseKind::Message(Message::default());
163        }
164        match &mut self.kind {
165            ResponseKind::Message(message) => Some(message),
166            _ => None,
167        }
168    }
169
170    /// Add an embed to this response
171    pub fn with_embed(mut self, embed: Embed) -> Self {
172        if let Some(message) = self.message_mut() {
173            message.embeds.push(embed);
174        }
175        self
176    }
177
178    /// Add components (buttons, select menus) to this response
179    pub fn with_components(mut self, components: Vec<Component>) -> Self {
180        if let Some(message) = self.message_mut() {
181            message.components = components;
182        }
183        self
184    }
185
186    /// Make this response ephemeral (only visible to the user)
187    ///
188    /// Platforms without ephemeral messages (Telegram, Matrix) ignore this.
189    pub fn ephemeral(mut self) -> Self {
190        if let Some(message) = self.message_mut() {
191            message.ephemeral = true;
192        }
193        self
194    }
195
196    /// Set the filename for a file response
197    pub fn with_filename(mut self, name: impl Into<String>) -> Self {
198        if let ResponseKind::File(file) = &mut self.kind {
199            file.filename = Some(name.into());
200        }
201        self
202    }
203
204    /// Set the caption for a file response
205    pub fn with_caption(mut self, caption: impl Into<String>) -> Self {
206        if let ResponseKind::File(file) = &mut self.kind {
207            file.caption = Some(caption.into());
208        }
209        self
210    }
211
212    /// Check if this response carries nothing to send
213    pub fn is_empty(&self) -> bool {
214        match &self.kind {
215            ResponseKind::Empty => true,
216            ResponseKind::Message(message) => {
217                message.content.is_empty()
218                    && message.embeds.is_empty()
219                    && message.components.is_empty()
220            }
221            _ => false,
222        }
223    }
224
225    /// Check if this is an acknowledge response
226    pub fn is_acknowledge(&self) -> bool {
227        matches!(self.kind, ResponseKind::Acknowledge)
228    }
229
230    /// Get response content if this is a message response
231    pub fn content(&self) -> Option<&str> {
232        match &self.kind {
233            ResponseKind::Message(message) => Some(&message.content),
234            _ => None,
235        }
236    }
237
238    /// Get embeds if this is a message response
239    pub fn embeds(&self) -> &[Embed] {
240        match &self.kind {
241            ResponseKind::Message(message) => &message.embeds,
242            _ => &[],
243        }
244    }
245
246    /// Get components if this is a message response
247    pub fn components(&self) -> &[Component] {
248        match &self.kind {
249            ResponseKind::Message(message) => &message.components,
250            _ => &[],
251        }
252    }
253
254    /// Check if this response is ephemeral
255    pub fn is_ephemeral(&self) -> bool {
256        match &self.kind {
257            ResponseKind::Message(message) => message.ephemeral,
258            _ => false,
259        }
260    }
261
262    /// Check if this is a file response
263    pub fn is_file(&self) -> bool {
264        matches!(self.kind, ResponseKind::File(_))
265    }
266
267    /// Take the file response data, leaving an empty response in its place
268    ///
269    /// This consumes the file data, so it can only be called once.
270    pub fn take_file(&mut self) -> Option<FileResponse> {
271        match std::mem::take(&mut self.kind) {
272            ResponseKind::File(file) => Some(file),
273            other => {
274                self.kind = other;
275                None
276            }
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use futures_lite::future::block_on;
285
286    #[test]
287    fn text_carries_content() {
288        let response = Response::text("hi");
289        assert_eq!(response.content(), Some("hi"));
290        assert!(!response.is_empty());
291        assert!(!response.is_file());
292    }
293
294    #[test]
295    fn empty_response_is_empty() {
296        assert!(Response::empty().is_empty());
297        assert!(Response::text("").is_empty());
298        assert!(!Response::acknowledge().is_empty());
299    }
300
301    #[test]
302    fn builders_promote_empty_instead_of_dropping_values() {
303        let response = Response::empty()
304            .with_embed(Embed::new().title("t"))
305            .ephemeral();
306        assert_eq!(response.embeds().len(), 1);
307        assert!(response.is_ephemeral());
308        assert!(!response.is_empty());
309    }
310
311    #[test]
312    fn embed_constructor_matches_builder() {
313        let response = Response::embed(Embed::new().title("t"));
314        assert_eq!(response.embeds().len(), 1);
315        assert_eq!(response.embeds()[0].title.as_deref(), Some("t"));
316    }
317
318    #[test]
319    fn message_builders_leave_file_responses_alone() {
320        let response = Response::bytes(b"data".to_vec())
321            .with_embed(Embed::new())
322            .ephemeral();
323        assert!(response.is_file());
324        assert!(response.embeds().is_empty());
325        assert!(!response.is_ephemeral());
326    }
327
328    #[test]
329    fn path_responses_default_their_filename() {
330        let response = Response::path("/tmp/report.pdf");
331        let mut response = response;
332        let file = response.take_file().unwrap();
333        assert_eq!(file.filename.as_deref(), Some("report.pdf"));
334    }
335
336    #[test]
337    fn take_file_yields_the_payload_once() {
338        let mut response = Response::bytes(b"hello".to_vec()).with_caption("cap");
339
340        let file = response.take_file().expect("first take yields the file");
341        assert_eq!(file.caption.as_deref(), Some("cap"));
342        assert_eq!(block_on(file.file.read()).unwrap(), b"hello");
343
344        assert!(response.take_file().is_none());
345        assert!(response.is_empty());
346    }
347
348    #[test]
349    fn take_file_preserves_non_file_responses() {
350        let mut response = Response::text("hi");
351        assert!(response.take_file().is_none());
352        assert_eq!(response.content(), Some("hi"));
353    }
354}