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, ¶ms)
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}