titanium_rs/
context.rs

1//! Context module for Discord interaction handling.
2//!
3//! This module provides the [`Context`] struct which is passed to event handlers
4//! and provides convenient methods for responding to Discord interactions.
5//!
6//! # Features
7//!
8//! - **Automatic defer/reply handling**: The context tracks whether you've already
9//!   responded to an interaction and automatically uses the correct Discord API
10//!   (initial response vs. edit).
11//!
12//! - **Thread-safe**: Uses `AtomicBool` for response tracking, avoiding locks during
13//!   async HTTP operations.
14//!
15//! - **JS/discord.py-like ergonomics**: Methods like `reply()`, `reply_embed()`,
16//!   `success()`, `error()` for quick responses.
17//!
18//! # Example
19//!
20//! ```no_run
21//! use titanium_rs::prelude::*;
22//!
23//! async fn handle_command(ctx: Context) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
24//!     // For long operations, defer first
25//!     ctx.defer(false).await?;
26//!     
27//!     // Do some work...
28//!     tokio::time::sleep(std::time::Duration::from_secs(2)).await;
29//!     
30//!     // Then reply (automatically uses edit since we deferred)
31//!     ctx.reply("Done!").await?;
32//!     Ok(())
33//! }
34//! ```
35
36use crate::error::{ContextError, TitaniumError};
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::Arc;
39use titanium_gateway::Shard;
40use titanium_http::HttpClient;
41use titanium_model::{Embed, Interaction, Message, Snowflake, User};
42
43/// Context for Discord interaction handling.
44///
45/// This struct is passed to event handlers and provides access to:
46/// - The HTTP client for making API requests
47/// - The cache for looking up cached entities
48/// - The shard that received the event
49/// - The interaction data (for slash commands, buttons, etc.)
50///
51/// # Thread Safety
52///
53/// `Context` is `Clone` and can be safely shared between tasks. The response
54/// tracking uses `AtomicBool` to avoid holding locks during async operations.
55///
56/// # Response Flow
57///
58/// Discord interactions must receive a response within 3 seconds. If your
59/// operation takes longer, use [`Context::defer`] first, then [`Context::reply`].
60/// The context automatically tracks this and uses the correct API endpoint.
61#[derive(Clone)]
62pub struct Context {
63    /// HTTP client for making Discord API requests.
64    pub http: Arc<HttpClient>,
65    /// In-memory cache for guilds, channels, users, etc.
66    pub cache: Arc<titanium_cache::InMemoryCache>,
67    /// The shard that received this event.
68    pub shard: Arc<Shard>,
69    /// The interaction data (if this context is for an interaction).
70    pub interaction: Option<Arc<Interaction<'static>>>,
71    /// Whether the interaction has been deferred or replied to.
72    /// Uses AtomicBool to avoid holding locks during async HTTP calls.
73    has_responded: Arc<AtomicBool>,
74}
75
76impl Context {
77    pub fn new(
78        http: Arc<HttpClient>,
79        cache: Arc<titanium_cache::InMemoryCache>,
80        shard: Arc<Shard>,
81        interaction: Option<Interaction<'static>>,
82    ) -> Self {
83        Self {
84            http,
85            cache,
86            shard,
87            interaction: interaction.map(Arc::new),
88            has_responded: Arc::new(AtomicBool::new(false)),
89        }
90    }
91
92    /// Defer the interaction response.
93    ///
94    /// This sends a "Thinking..." state to Discord, giving you up to 15 minutes
95    /// to send the actual response. You must call this within 3 seconds if your
96    /// operation takes longer.
97    ///
98    /// # Arguments
99    ///
100    /// * `ephemeral` - If true, the "Thinking..." and subsequent reply will only
101    ///   be visible to the user who invoked the command.
102    ///
103    /// # Example
104    ///
105    /// ```no_run
106    /// # use titanium_rs::prelude::*;
107    /// # async fn example(ctx: Context) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
108    /// // Defer for a long operation
109    /// ctx.defer(false).await?;
110    ///
111    /// // Do expensive work...
112    /// tokio::time::sleep(std::time::Duration::from_secs(5)).await;
113    ///
114    /// // Reply (automatically edits the deferred message)
115    /// ctx.reply("Done!").await?;
116    /// # Ok(())
117    /// # }
118    /// ```
119    pub async fn defer(&self, ephemeral: bool) -> Result<(), TitaniumError> {
120        // Use compare_exchange to atomically check and set - no lock held during HTTP call
121        if self
122            .has_responded
123            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
124            .is_err()
125        {
126            return Ok(()); // Already responded
127        }
128
129        let interaction = self
130            .interaction
131            .as_ref()
132            .ok_or(ContextError::NoInteraction)?;
133
134        let response = titanium_model::builder::InteractionResponseBuilder::new()
135            .deferred(ephemeral)
136            .build();
137
138        // If HTTP fails, we should reset the flag, but for defer this is acceptable
139        // since Discord will timeout the interaction anyway
140        self.http
141            .create_interaction_response(interaction.id, &interaction.token, &response)
142            .await?;
143
144        Ok(())
145    }
146
147    /// Reply to the command.
148    ///
149    /// This smarter method checks if we have deferred.
150    /// If NOT deferred -> calls `create_interaction_response`
151    /// If DEFERRED -> calls `edit_original_interaction_response`
152    ///
153    /// This solves the "3 second rule" complexity for the user!
154    /// Reply to the interaction or message.
155    ///
156    /// # Errors
157    /// Returns `TitaniumError` if the HTTP request fails.
158    pub async fn reply(
159        &self,
160        content: impl Into<String>,
161    ) -> Result<Message<'static>, TitaniumError> {
162        let content = content.into();
163        let interaction = self
164            .interaction
165            .as_ref()
166            .ok_or(ContextError::NoInteraction)?;
167
168        // Check if already responded (atomically)
169        if self.has_responded.load(Ordering::SeqCst) {
170            // We already deferred (or replied), so we must EDIT the original
171            #[derive(serde::Serialize)]
172            struct EditBody {
173                content: String,
174            }
175
176            let message = self
177                .http
178                .edit_original_interaction_response(
179                    interaction.application_id,
180                    &interaction.token,
181                    EditBody { content },
182                )
183                .await?;
184
185            Ok(message)
186        } else {
187            // Initial response - try to claim the first response
188            if self
189                .has_responded
190                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
191                .is_err()
192            {
193                // Lost race, someone else responded first - use edit instead
194                #[derive(serde::Serialize)]
195                struct EditBody {
196                    content: String,
197                }
198                let message = self
199                    .http
200                    .edit_original_interaction_response(
201                        interaction.application_id,
202                        &interaction.token,
203                        EditBody { content },
204                    )
205                    .await?;
206                return Ok(message);
207            }
208
209            let response = titanium_model::builder::InteractionResponseBuilder::new()
210                .content(content.clone())
211                .build();
212
213            self.http
214                .create_interaction_response(interaction.id, &interaction.token, &response)
215                .await?;
216
217            // Fetch the interaction response to return a full Message object.
218            let msg = self
219                .http
220                .get_original_interaction_response(interaction.application_id, &interaction.token)
221                .await?;
222            Ok(msg)
223        }
224    }
225
226    /// Reply with an embed.
227    ///
228    /// Works like [`Context::reply`] but sends an embed instead of text.
229    /// Automatically handles deferred interactions.
230    ///
231    /// # Example
232    ///
233    /// ```no_run
234    /// # use titanium_rs::prelude::*;
235    /// # async fn example(ctx: Context) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
236    /// let embed = EmbedBuilder::new()
237    ///     .title("Hello!")
238    ///     .description("This is an embed")
239    ///     .color(0x5865F2)
240    ///     .build();
241    /// ctx.reply_embed(embed).await?;
242    /// # Ok(())
243    /// # }
244    /// ```
245    /// Reply with an embed.
246    ///
247    /// # Errors
248    /// Returns `TitaniumError` if the HTTP request fails.
249    pub async fn reply_embed(
250        &self,
251        embed: impl Into<Embed<'static>>,
252    ) -> Result<Message<'static>, TitaniumError> {
253        let embed = embed.into();
254        let interaction = self
255            .interaction
256            .as_ref()
257            .ok_or(ContextError::NoInteraction)?;
258
259        if self.has_responded.load(Ordering::SeqCst) {
260            // Edit original response
261            #[derive(serde::Serialize)]
262            struct EditBody {
263                embeds: Vec<Embed<'static>>,
264            }
265
266            let message = self
267                .http
268                .edit_original_interaction_response(
269                    interaction.application_id,
270                    &interaction.token,
271                    EditBody {
272                        embeds: vec![embed],
273                    },
274                )
275                .await?;
276            Ok(message)
277        } else {
278            // Try to claim first response
279            if self
280                .has_responded
281                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
282                .is_err()
283            {
284                // Lost race - use edit
285                #[derive(serde::Serialize)]
286                struct EditBody {
287                    embeds: Vec<Embed<'static>>,
288                }
289                let message = self
290                    .http
291                    .edit_original_interaction_response(
292                        interaction.application_id,
293                        &interaction.token,
294                        EditBody {
295                            embeds: vec![embed],
296                        },
297                    )
298                    .await?;
299                return Ok(message);
300            }
301
302            let response = titanium_model::builder::InteractionResponseBuilder::new()
303                .embed(embed.clone())
304                .build();
305
306            self.http
307                .create_interaction_response(interaction.id, &interaction.token, &response)
308                .await?;
309
310            // Fetch the interaction response
311            let msg = self
312                .http
313                .get_original_interaction_response(interaction.application_id, &interaction.token)
314                .await?;
315            Ok(msg)
316        }
317    }
318
319    /// Reply with an ephemeral message (only visible to user).
320    /// Reply with an ephemeral message (only visible to user).
321    ///
322    /// # Errors
323    /// Returns `TitaniumError` if the HTTP request fails.
324    pub async fn reply_ephemeral(&self, content: impl Into<String>) -> Result<(), TitaniumError> {
325        let content = content.into();
326        let interaction = self
327            .interaction
328            .as_ref()
329            .ok_or(ContextError::NoInteraction)?;
330
331        // Ephemeral can only be set on initial response
332        if self
333            .has_responded
334            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
335            .is_err()
336        {
337            return Err(ContextError::AlreadyResponded.into());
338        }
339
340        let response = titanium_model::builder::InteractionResponseBuilder::new()
341            .content(content)
342            .ephemeral(true)
343            .build();
344
345        self.http
346            .create_interaction_response(interaction.id, &interaction.token, &response)
347            .await?;
348
349        Ok(())
350    }
351
352    /// Edit the original interaction response.
353    pub async fn edit_reply(
354        &self,
355        content: impl Into<String>,
356    ) -> Result<Message<'static>, TitaniumError> {
357        let interaction = self
358            .interaction
359            .as_ref()
360            .ok_or(ContextError::NoInteraction)?;
361        #[derive(serde::Serialize)]
362        #[allow(clippy::items_after_statements)]
363        struct EditBody {
364            content: String,
365        }
366
367        let message = self
368            .http
369            .edit_original_interaction_response(
370                interaction.application_id,
371                &interaction.token,
372                EditBody {
373                    content: content.into(),
374                },
375            )
376            .await?;
377
378        Ok(message)
379    }
380
381    /// Send a follow-up message.
382    pub async fn followup(
383        &self,
384        content: impl Into<String>,
385    ) -> Result<Message<'static>, TitaniumError> {
386        use titanium_model::builder::ExecuteWebhook;
387        let interaction = self
388            .interaction
389            .as_ref()
390            .ok_or(ContextError::NoInteraction)?;
391
392        let params = ExecuteWebhook {
393            content: Some(content.into()),
394            ..Default::default()
395        };
396
397        // Interaction followups use the webhook endpoint with application_id as webhook_id
398        let msg = self
399            .http
400            .execute_webhook(interaction.application_id, &interaction.token, &params)
401            .await?;
402
403        // execute_webhook returns Option<Message>, but with wait=true it should return Some
404        msg.ok_or(TitaniumError::Other(
405            "Failed to get followup message".into(),
406        ))
407    }
408
409    /// Get the user who triggered the interaction.
410    #[inline]
411    pub fn user(&self) -> Option<&User<'static>> {
412        self.interaction.as_ref().and_then(|i| {
413            i.member
414                .as_ref()
415                .and_then(|m| m.user.as_ref())
416                .or(i.user.as_ref())
417        })
418    }
419
420    /// Get the guild ID if in a guild.
421    #[inline]
422    #[must_use]
423    pub fn guild_id(&self) -> Option<Snowflake> {
424        self.interaction.as_ref().and_then(|i| i.guild_id)
425    }
426
427    /// Get the channel ID.
428    #[inline]
429    #[must_use]
430    pub fn channel_id(&self) -> Option<Snowflake> {
431        self.interaction.as_ref().and_then(|i| i.channel_id)
432    }
433
434    // =========================================================================
435    // Quick Reply Helpers (JS-like ergonomics)
436    // =========================================================================
437
438    /// Reply with a success embed (green).
439    #[inline]
440    pub async fn success(
441        &self,
442        title: impl Into<String>,
443        description: impl Into<String>,
444    ) -> Result<Message<'static>, TitaniumError> {
445        let embed =
446            titanium_model::builder::EmbedBuilder::success(title.into(), description.into())
447                .build();
448        self.reply_embed(embed).await
449    }
450
451    /// Reply with an error embed (red).
452    #[inline]
453    pub async fn error(
454        &self,
455        title: impl Into<String>,
456        description: impl Into<String>,
457    ) -> Result<Message<'static>, TitaniumError> {
458        let embed =
459            titanium_model::builder::EmbedBuilder::error(title.into(), description.into()).build();
460        self.reply_embed(embed).await
461    }
462
463    /// Reply with an info embed (blurple).
464    #[inline]
465    pub async fn info(
466        &self,
467        title: impl Into<String>,
468        description: impl Into<String>,
469    ) -> Result<Message<'static>, TitaniumError> {
470        let embed =
471            titanium_model::builder::EmbedBuilder::info(title.into(), description.into()).build();
472        self.reply_embed(embed).await
473    }
474
475    /// Reply with a warning embed (yellow).
476    #[inline]
477    pub async fn warning(
478        &self,
479        title: impl Into<String>,
480        description: impl Into<String>,
481    ) -> Result<Message<'static>, TitaniumError> {
482        let embed =
483            titanium_model::builder::EmbedBuilder::warning(title.into(), description.into())
484                .build();
485        self.reply_embed(embed).await
486    }
487
488    /// Send a message to a specific channel (bypass interaction).
489    #[inline]
490    pub async fn send(
491        &self,
492        channel_id: impl Into<Snowflake>,
493        content: impl Into<String>,
494    ) -> Result<Message<'static>, titanium_http::HttpError> {
495        self.http.send_message(channel_id.into(), content).await
496    }
497
498    /// Send an embed to a specific channel.
499    #[inline]
500    pub async fn send_embed(
501        &self,
502        channel_id: impl Into<Snowflake>,
503        embed: impl Into<Embed<'static>>,
504    ) -> Result<Message<'static>, titanium_http::HttpError> {
505        let msg = titanium_model::builder::MessageBuilder::new()
506            .embed(embed)
507            .build();
508        self.http
509            .create_message_struct(channel_id.into(), &msg)
510            .await
511    }
512}