Skip to main content

botkit_core/
extractor.rs

1use std::future::Future;
2
3use crate::action::ChatActionGuard;
4use crate::context::Context;
5
6#[cfg(not(target_arch = "wasm32"))]
7pub trait FromContextBounds: Send {}
8#[cfg(not(target_arch = "wasm32"))]
9impl<T: Send> FromContextBounds for T {}
10
11#[cfg(target_arch = "wasm32")]
12pub trait FromContextBounds {}
13#[cfg(target_arch = "wasm32")]
14impl<T> FromContextBounds for T {}
15
16#[cfg(not(target_arch = "wasm32"))]
17pub trait ContextFutureBounds: Future + Send {}
18#[cfg(not(target_arch = "wasm32"))]
19impl<T: Future + Send + ?Sized> ContextFutureBounds for T {}
20
21#[cfg(target_arch = "wasm32")]
22pub trait ContextFutureBounds: Future {}
23#[cfg(target_arch = "wasm32")]
24impl<T: Future + ?Sized> ContextFutureBounds for T {}
25
26/// Trait for extracting typed data from bot context
27///
28/// Similar to skyzen's `Extractor` trait, this allows handlers to
29/// declare what data they need as function parameters.
30///
31/// # Example
32/// ```ignore
33/// // Built-in extractors
34/// async fn greet(user: User) -> String {
35///     format!("Hello, {}!", user.name)
36/// }
37///
38/// async fn echo(args: CommandArgs) -> String {
39///     args.0
40/// }
41/// ```
42pub trait FromContext: Sized + FromContextBounds {
43    /// Extract data from the context
44    fn from_context(ctx: &Context) -> impl ContextFutureBounds<Output = Self>;
45}
46
47/// Extract the full context (for advanced use cases)
48impl FromContext for Context {
49    async fn from_context(ctx: &Context) -> Self {
50        ctx.clone()
51    }
52}
53
54/// User information extractor
55#[derive(Debug, Clone)]
56pub struct User {
57    pub id: String,
58    pub name: String,
59}
60
61impl FromContext for User {
62    async fn from_context(ctx: &Context) -> Self {
63        Self {
64            id: ctx.user_id().to_string(),
65            name: ctx.user_name().to_string(),
66        }
67    }
68}
69
70/// Channel information extractor
71#[derive(Debug, Clone)]
72pub struct Channel {
73    pub id: String,
74}
75
76impl FromContext for Channel {
77    async fn from_context(ctx: &Context) -> Self {
78        Self {
79            id: ctx.channel_id().to_string(),
80        }
81    }
82}
83
84/// Command name extractor
85#[derive(Debug, Clone)]
86pub struct CommandName(pub String);
87
88impl FromContext for CommandName {
89    async fn from_context(ctx: &Context) -> Self {
90        Self(ctx.command_name().unwrap_or_default().to_string())
91    }
92}
93
94/// Command arguments extractor (Telegram-style string args)
95#[derive(Debug, Clone)]
96pub struct CommandArgs(pub String);
97
98impl FromContext for CommandArgs {
99    async fn from_context(ctx: &Context) -> Self {
100        Self(ctx.command_args().unwrap_or_default().to_string())
101    }
102}
103
104/// Button/callback ID extractor
105#[derive(Debug, Clone)]
106pub struct ButtonId(pub String);
107
108impl FromContext for ButtonId {
109    async fn from_context(ctx: &Context) -> Self {
110        Self(ctx.button_id().unwrap_or_default().to_string())
111    }
112}
113
114/// Message content extractor
115#[derive(Debug, Clone)]
116pub struct MessageContent(pub String);
117
118impl FromContext for MessageContent {
119    async fn from_context(ctx: &Context) -> Self {
120        Self(ctx.message_content().unwrap_or_default().to_string())
121    }
122}
123
124// Tuples of extractors extract each element in declaration order.
125macro_rules! impl_from_context_tuple {
126    () => {
127        impl FromContext for () {
128            async fn from_context(_ctx: &Context) -> Self {}
129        }
130    };
131    ($($ty:ident),+) => {
132        impl<$($ty: FromContext,)+> FromContext for ($($ty,)+) {
133            async fn from_context(ctx: &Context) -> Self {
134                ($($ty::from_context(ctx).await,)+)
135            }
136        }
137    };
138}
139
140impl_from_context_tuple!();
141impl_from_context_tuple!(T1);
142impl_from_context_tuple!(T1, T2);
143impl_from_context_tuple!(T1, T2, T3);
144impl_from_context_tuple!(T1, T2, T3, T4);
145
146/// Typing indicator extractor
147///
148/// Automatically starts a typing indicator when extracted.
149/// The indicator stops when the handler completes (guard is dropped).
150///
151/// # Example
152/// ```ignore
153/// async fn slow_handler(_typing: Typing) -> String {
154///     expensive_work().await;
155///     "Done!"
156/// }
157/// ```
158pub struct Typing(pub Option<ChatActionGuard>);
159
160impl FromContext for Typing {
161    async fn from_context(ctx: &Context) -> Self {
162        Self(ctx.typing())
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::test_util::{EmptyData, StubData};
170    use futures_lite::future::block_on;
171
172    fn stub() -> Context {
173        Context::new(StubData)
174    }
175
176    fn empty() -> Context {
177        Context::new(EmptyData)
178    }
179
180    #[test]
181    fn extracts_user_and_channel() {
182        let user = block_on(User::from_context(&stub()));
183        assert_eq!(
184            (user.id.as_str(), user.name.as_str()),
185            ("stub-user", "stub-user")
186        );
187        assert_eq!(block_on(Channel::from_context(&stub())).id, "stub-channel");
188    }
189
190    #[test]
191    fn extracts_command_parts() {
192        assert_eq!(block_on(CommandName::from_context(&stub())).0, "cmd");
193        assert_eq!(block_on(CommandArgs::from_context(&stub())).0, "args");
194        assert_eq!(block_on(ButtonId::from_context(&stub())).0, "stub-button");
195        assert_eq!(
196            block_on(MessageContent::from_context(&stub())).0,
197            "stub message"
198        );
199    }
200
201    #[test]
202    fn absent_fields_extract_as_empty_strings() {
203        assert_eq!(block_on(CommandName::from_context(&empty())).0, "");
204        assert_eq!(block_on(CommandArgs::from_context(&empty())).0, "");
205        assert_eq!(block_on(ButtonId::from_context(&empty())).0, "");
206        assert_eq!(block_on(MessageContent::from_context(&empty())).0, "");
207    }
208
209    #[test]
210    fn tuples_extract_every_element() {
211        let (name, args, user) =
212            block_on(<(CommandName, CommandArgs, User)>::from_context(&stub()));
213        assert_eq!((name.0.as_str(), args.0.as_str()), ("cmd", "args"));
214        assert_eq!(user.id, "stub-user");
215    }
216
217    #[test]
218    fn context_extracts_itself() {
219        let ctx = block_on(Context::from_context(&stub()));
220        assert_eq!(ctx.user_id(), "stub-user");
221    }
222
223    #[test]
224    fn typing_is_absent_without_platform_support() {
225        // `StubData` does not override `action_sender`.
226        assert!(block_on(Typing::from_context(&stub())).0.is_none());
227    }
228}