Skip to main content

botkit_core/
action.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::time::Duration;
5
6#[cfg(not(target_arch = "wasm32"))]
7use async_io::Timer;
8#[cfg(target_arch = "wasm32")]
9use gloo_timers::future::sleep;
10
11use crate::BotError;
12
13#[cfg(not(target_arch = "wasm32"))]
14type ChatActionFuture<'a> = Pin<Box<dyn Future<Output = Result<(), BotError>> + Send + 'a>>;
15#[cfg(target_arch = "wasm32")]
16type ChatActionFuture<'a> = Pin<Box<dyn Future<Output = Result<(), BotError>> + 'a>>;
17
18#[cfg(not(target_arch = "wasm32"))]
19pub trait ChatActionSenderBounds: Send + Sync {}
20#[cfg(not(target_arch = "wasm32"))]
21impl<T: Send + Sync + ?Sized> ChatActionSenderBounds for T {}
22
23#[cfg(target_arch = "wasm32")]
24pub trait ChatActionSenderBounds {}
25#[cfg(target_arch = "wasm32")]
26impl<T: ?Sized> ChatActionSenderBounds for T {}
27
28#[cfg(not(target_arch = "wasm32"))]
29pub trait ChatActionFutureBounds: Future + Send {}
30#[cfg(not(target_arch = "wasm32"))]
31impl<T: Future + Send + ?Sized> ChatActionFutureBounds for T {}
32
33#[cfg(target_arch = "wasm32")]
34pub trait ChatActionFutureBounds: Future {}
35#[cfg(target_arch = "wasm32")]
36impl<T: Future + ?Sized> ChatActionFutureBounds for T {}
37
38/// Chat action types for platform indicators
39///
40/// Used by the framework to show the appropriate indicator (typing, uploading,
41/// and so on). Platforms that only support typing map every variant onto it.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ChatAction {
44    /// User is typing a message
45    Typing,
46    /// User is uploading a photo
47    UploadPhoto,
48    /// User is recording a video
49    RecordVideo,
50    /// User is uploading a video
51    UploadVideo,
52    /// User is recording audio/voice
53    RecordVoice,
54    /// User is uploading audio/voice
55    UploadVoice,
56    /// User is uploading a document
57    UploadDocument,
58    /// User is choosing a sticker
59    ChooseSticker,
60    /// User is finding a location
61    FindLocation,
62    /// User is recording a video note
63    RecordVideoNote,
64    /// User is uploading a video note
65    UploadVideoNote,
66}
67
68/// Trait for sending chat actions to a channel
69///
70/// Platform implementations define how to send actions and their expiration
71/// times. Use [`AnyChatActionSender`] where a sender must be stored behind
72/// type erasure.
73pub trait ChatActionSender: ChatActionSenderBounds + 'static {
74    /// Send a chat action to the specified channel
75    fn send_action(
76        &self,
77        action: ChatAction,
78    ) -> impl ChatActionFutureBounds<Output = Result<(), BotError>> + '_;
79
80    /// Duration after which the action indicator expires
81    ///
82    /// Used for auto-renewal: renew at 80% of this duration.
83    fn action_expiry(&self) -> Duration;
84
85    /// Clear the indicator early
86    ///
87    /// Called when the guard drops. Platforms that expire indicators on a timer
88    /// (Discord, Telegram) need nothing here; Matrix uses it to retract the
89    /// typing notice immediately.
90    fn clear_action(&self) -> impl ChatActionFutureBounds<Output = Result<(), BotError>> + '_ {
91        async { Ok(()) }
92    }
93}
94
95/// Object-safe twin of [`ChatActionSender`] backing [`AnyChatActionSender`].
96trait ChatActionSenderImpl: ChatActionSenderBounds {
97    fn send_action_boxed<'a>(&'a self, action: ChatAction) -> ChatActionFuture<'a>;
98    fn action_expiry(&self) -> Duration;
99    fn clear_action_boxed<'a>(&'a self) -> ChatActionFuture<'a>;
100}
101
102impl<T: ChatActionSender> ChatActionSenderImpl for T {
103    fn send_action_boxed<'a>(&'a self, action: ChatAction) -> ChatActionFuture<'a> {
104        Box::pin(ChatActionSender::send_action(self, action))
105    }
106
107    fn action_expiry(&self) -> Duration {
108        ChatActionSender::action_expiry(self)
109    }
110
111    fn clear_action_boxed<'a>(&'a self) -> ChatActionFuture<'a> {
112        Box::pin(ChatActionSender::clear_action(self))
113    }
114}
115
116/// Type-erased [`ChatActionSender`] handle
117///
118/// Cloning shares the underlying sender. This is what the framework stores
119/// and hands to [`ChatActionGuard`]; platform `ContextData` implementations
120/// produce one via [`AnyChatActionSender::new`].
121#[derive(Clone)]
122pub struct AnyChatActionSender {
123    inner: Arc<dyn ChatActionSenderImpl>,
124}
125
126impl AnyChatActionSender {
127    /// Erase `sender` into a shareable handle.
128    pub fn new(sender: impl ChatActionSender) -> Self {
129        Self {
130            inner: Arc::new(sender),
131        }
132    }
133
134    /// Send a chat action to the channel this sender is bound to
135    pub fn send_action(
136        &self,
137        action: ChatAction,
138    ) -> impl ChatActionFutureBounds<Output = Result<(), BotError>> + '_ {
139        self.inner.send_action_boxed(action)
140    }
141
142    /// Duration after which the action indicator expires
143    pub fn action_expiry(&self) -> Duration {
144        self.inner.action_expiry()
145    }
146
147    /// Clear the indicator early
148    pub fn clear_action(&self) -> impl ChatActionFutureBounds<Output = Result<(), BotError>> + '_ {
149        self.inner.clear_action_boxed()
150    }
151}
152
153/// RAII guard that keeps a chat action active until dropped
154///
155/// When created, immediately sends the action and starts auto-renewal.
156/// When dropped, the renewal task stops promptly and clears the indicator.
157///
158/// # Example
159/// ```ignore
160/// async fn slow_command(ctx: Context) -> String {
161///     let _typing = ctx.typing();  // Starts typing indicator
162///     expensive_work().await;
163///     "Done!"
164/// }  // Typing stops when _typing is dropped
165/// ```
166pub struct ChatActionGuard {
167    /// Closed on drop; the renewal task selects on it so it wakes immediately
168    /// instead of sleeping out the rest of its interval.
169    stop: async_channel::Sender<()>,
170}
171
172impl ChatActionGuard {
173    /// Create and start a chat action indicator
174    ///
175    /// The action is sent immediately and renewed automatically until
176    /// the guard is dropped.
177    pub fn start(sender: AnyChatActionSender, action: ChatAction) -> Self {
178        let (stop, stopped) = async_channel::bounded::<()>(1);
179
180        // Renew ahead of expiry so the indicator never visibly flickers. A
181        // sender that reports no expiry still gets one initial send.
182        let renewal_interval = sender.action_expiry().mul_f32(0.8);
183
184        spawn_renewal(async move {
185            let _ = sender.send_action(action).await;
186
187            while !renewal_interval.is_zero() {
188                // Whichever comes first: the renewal deadline, or the guard
189                // dropping and closing the channel.
190                let on_stop = async {
191                    // Resolves once the guard drops and closes the channel.
192                    while stopped.recv().await.is_ok() {}
193                };
194
195                if race(sleep_for(renewal_interval), on_stop).await.is_err() {
196                    break;
197                }
198
199                if sender.send_action(action).await.is_err() {
200                    return;
201                }
202            }
203
204            let _ = sender.clear_action().await;
205        });
206
207        Self { stop }
208    }
209}
210
211impl Drop for ChatActionGuard {
212    fn drop(&mut self) {
213        self.stop.close();
214    }
215}
216
217/// Resolve to `Ok` if `left` finishes first, `Err` if `right` does.
218async fn race<L: Future<Output = ()>, R: Future<Output = ()>>(left: L, right: R) -> Result<(), ()> {
219    futures_lite::future::or(
220        async {
221            left.await;
222            Ok(())
223        },
224        async {
225            right.await;
226            Err(())
227        },
228    )
229    .await
230}
231
232#[cfg(not(target_arch = "wasm32"))]
233async fn sleep_for(duration: Duration) {
234    Timer::after(duration).await;
235}
236
237#[cfg(not(target_arch = "wasm32"))]
238fn spawn_renewal(task: impl Future<Output = ()> + Send + 'static) {
239    executor_core::spawn(task).detach();
240}
241
242#[cfg(target_arch = "wasm32")]
243async fn sleep_for(duration: Duration) {
244    sleep(duration).await;
245}
246
247#[cfg(target_arch = "wasm32")]
248fn spawn_renewal(task: impl Future<Output = ()> + 'static) {
249    executor_core::spawn_local(task).detach();
250}