Skip to main content

telegram_webapp_sdk/webapp/
core.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use js_sys::{Function, Object, Promise, Reflect};
5use wasm_bindgen::{JsCast, JsValue, prelude::Closure};
6use wasm_bindgen_futures::JsFuture;
7use web_sys::window;
8
9use crate::{core::context::TelegramContext, webapp::TelegramWebApp};
10
11/// Build a `Promise` whose executor invokes `f` synchronously with the
12/// `resolve` and `reject` callables. If `f` returns `Err`, the promise is
13/// rejected with that value immediately. Used to wrap one-shot Telegram
14/// callbacks into async-friendly futures.
15pub(super) fn one_shot_promise<F>(f: F) -> Promise
16where
17    F: FnOnce(Function, Function) -> Result<(), JsValue>
18{
19    let mut executor = Some(f);
20    Promise::new(&mut |resolve, reject| {
21        let Some(invoke) = executor.take() else {
22            return;
23        };
24        if let Err(err) = invoke(resolve, reject.clone()) {
25            let _ = reject.call1(&JsValue::NULL, &err);
26        }
27    })
28}
29
30pub(super) async fn await_one_shot(promise: Promise) -> Result<JsValue, JsValue> {
31    JsFuture::from(promise).await
32}
33
34impl TelegramWebApp {
35    /// Get instance of `Telegram.WebApp` or `None` if not present
36    pub fn instance() -> Option<Self> {
37        let win = window()?;
38        let tg = Reflect::get(&win, &"Telegram".into()).ok()?;
39        let webapp = Reflect::get(&tg, &"WebApp".into()).ok()?;
40        webapp.dyn_into::<Object>().ok().map(|inner| Self {
41            inner
42        })
43    }
44
45    /// Try to get instance of `Telegram.WebApp`.
46    ///
47    /// # Errors
48    /// Returns [`JsValue`] if the `Telegram.WebApp` object is missing or
49    /// malformed.
50    pub fn try_instance() -> Result<Self, JsValue> {
51        let win = window().ok_or_else(|| JsValue::from_str("window not available"))?;
52        let tg = Reflect::get(&win, &"Telegram".into())?;
53        let webapp = Reflect::get(&tg, &"WebApp".into())?;
54        let inner = webapp.dyn_into::<Object>()?;
55        Ok(Self {
56            inner
57        })
58    }
59
60    /// Returns the raw initData string as provided by Telegram.
61    ///
62    /// This is the URL-encoded initData string captured during SDK
63    /// initialization, suitable for server-side signature validation.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the SDK has not been initialized via
68    /// [`crate::core::init::init_sdk`].
69    ///
70    /// # Examples
71    ///
72    /// ```no_run
73    /// use telegram_webapp_sdk::TelegramWebApp;
74    ///
75    /// match TelegramWebApp::get_raw_init_data() {
76    ///     Ok(raw) => {
77    ///         // Send to backend for validation
78    ///         println!("Raw initData: {}", raw);
79    ///     }
80    ///     Err(e) => eprintln!("SDK not initialized: {}", e)
81    /// }
82    /// ```
83    pub fn get_raw_init_data() -> Result<String, &'static str> {
84        TelegramContext::get_raw_init_data()
85    }
86
87    /// Call `WebApp.sendData(data)`.
88    ///
89    /// # Errors
90    /// Returns [`JsValue`] if the underlying JS call fails.
91    pub fn send_data(&self, data: &str) -> Result<(), JsValue> {
92        self.call1("sendData", &data.into())
93    }
94
95    /// Returns whether the WebApp version is at least the provided value.
96    ///
97    /// # Examples
98    /// ```no_run
99    /// use telegram_webapp_sdk::webapp::TelegramWebApp;
100    ///
101    /// if let Some(app) = TelegramWebApp::instance() {
102    ///     let _ = app.is_version_at_least("9.0");
103    /// }
104    /// ```
105    pub fn is_version_at_least(&self, version: &str) -> Result<bool, JsValue> {
106        let f = Reflect::get(&self.inner, &"isVersionAtLeast".into())?;
107        let func = f
108            .dyn_ref::<Function>()
109            .ok_or_else(|| JsValue::from_str("isVersionAtLeast is not a function"))?;
110        let result = func.call1(&self.inner, &version.into())?;
111        Ok(result.as_bool().unwrap_or(false))
112    }
113
114    /// Call `WebApp.ready()`.
115    ///
116    /// # Errors
117    /// Returns [`JsValue`] if the underlying JS call fails.
118    pub fn ready(&self) -> Result<(), JsValue> {
119        self.call0("ready")
120    }
121
122    /// Call `WebApp.invokeCustomMethod(method, params, callback)`.
123    ///
124    /// JS callback signature is `(error, result)`. The Rust callback receives a
125    /// `Result<JsValue, JsValue>` — `Ok(result)` when JS passes
126    /// `null`/`undefined` for error, `Err(err)` otherwise.
127    ///
128    /// Prefer the `async` sibling [`Self::invoke_custom_method`] for new code.
129    ///
130    /// # Errors
131    /// Returns [`JsValue`] if the underlying JS call fails.
132    pub fn invoke_custom_method_with_callback<F>(
133        &self,
134        method: &str,
135        params: &JsValue,
136        callback: F
137    ) -> Result<(), JsValue>
138    where
139        F: 'static + FnOnce(Result<JsValue, JsValue>)
140    {
141        let cb = Closure::once_into_js(move |err: JsValue, result: JsValue| {
142            if err.is_null() || err.is_undefined() {
143                callback(Ok(result));
144            } else {
145                callback(Err(err));
146            }
147        });
148        let f = Reflect::get(&self.inner, &"invokeCustomMethod".into())?;
149        let func = f
150            .dyn_ref::<Function>()
151            .ok_or_else(|| JsValue::from_str("invokeCustomMethod is not a function"))?;
152        func.call3(&self.inner, &method.into(), params, &cb)?;
153        Ok(())
154    }
155
156    /// Async wrapper over `WebApp.invokeCustomMethod`.
157    ///
158    /// Resolves with the JS `result` value when Telegram returns a successful
159    /// response and rejects with the JS `error` value otherwise.
160    ///
161    /// # Examples
162    /// ```no_run
163    /// # use js_sys::Object;
164    /// # use telegram_webapp_sdk::webapp::TelegramWebApp;
165    /// # async fn run() -> Result<(), wasm_bindgen::JsValue> {
166    /// let app = TelegramWebApp::try_instance()?;
167    /// let params = Object::new();
168    /// let result = app
169    ///     .invoke_custom_method("getRequestedContact", &params.into())
170    ///     .await?;
171    /// let _ = result;
172    /// # Ok(())
173    /// # }
174    /// ```
175    ///
176    /// # Errors
177    /// Returns [`JsValue`] if Telegram rejects the call or the underlying JS
178    /// invocation fails.
179    pub async fn invoke_custom_method(
180        &self,
181        method: &str,
182        params: &JsValue
183    ) -> Result<JsValue, JsValue> {
184        let webapp = self.inner.clone();
185        let method = method.to_owned();
186        let params = params.clone();
187        let promise = one_shot_promise(move |resolve, reject| {
188            let resolve_for_cb = resolve.clone();
189            let reject_for_cb = reject.clone();
190            let cb = Closure::once_into_js(move |err: JsValue, result: JsValue| {
191                if err.is_null() || err.is_undefined() {
192                    let _ = resolve_for_cb.call1(&JsValue::NULL, &result);
193                } else {
194                    let _ = reject_for_cb.call1(&JsValue::NULL, &err);
195                }
196            });
197            let f = Reflect::get(&webapp, &"invokeCustomMethod".into())?;
198            let func = f
199                .dyn_ref::<Function>()
200                .ok_or_else(|| JsValue::from_str("invokeCustomMethod is not a function"))?;
201            func.call3(&webapp, &method.into(), &params, &cb)?;
202            Ok(())
203        });
204        await_one_shot(promise).await
205    }
206
207    // === Internal helper methods ===
208
209    pub(super) fn call0(&self, method: &str) -> Result<(), JsValue> {
210        let f = Reflect::get(&self.inner, &method.into())?;
211        let func = f
212            .dyn_ref::<Function>()
213            .ok_or_else(|| JsValue::from_str("not a function"))?;
214        func.call0(&self.inner)?;
215        Ok(())
216    }
217
218    pub(super) fn call1(&self, method: &str, arg: &JsValue) -> Result<(), JsValue> {
219        let f = Reflect::get(&self.inner, &method.into())?;
220        let func = f
221            .dyn_ref::<Function>()
222            .ok_or_else(|| JsValue::from_str("not a function"))?;
223        func.call1(&self.inner, arg)?;
224        Ok(())
225    }
226
227    pub(super) fn call_nested0(&self, field: &str, method: &str) -> Result<(), JsValue> {
228        let obj = Reflect::get(&self.inner, &field.into())?;
229        let f = Reflect::get(&obj, &method.into())?;
230        let func = f
231            .dyn_ref::<Function>()
232            .ok_or_else(|| JsValue::from_str("not a function"))?;
233        func.call0(&obj)?;
234        Ok(())
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use std::{cell::RefCell, rc::Rc};
241
242    use js_sys::{Function, Object, Reflect};
243    use wasm_bindgen::JsValue;
244    use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
245    use web_sys::window;
246
247    use crate::webapp::TelegramWebApp;
248
249    wasm_bindgen_test_configure!(run_in_browser);
250
251    fn setup_webapp() -> Object {
252        let win = window().expect("window");
253        let telegram = Object::new();
254        let webapp = Object::new();
255        let _ = Reflect::set(&win, &"Telegram".into(), &telegram);
256        let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp);
257        webapp
258    }
259
260    #[wasm_bindgen_test]
261    #[allow(dead_code, clippy::unused_unit)]
262    fn invoke_custom_method_with_callback_passes_args_and_delivers_result() {
263        let webapp = setup_webapp();
264        let invoke = Function::new_with_args(
265            "method, params, cb",
266            "this.method = method; this.params = params; cb(null, {ok: 1});"
267        );
268        let _ = Reflect::set(&webapp, &"invokeCustomMethod".into(), &invoke);
269
270        let app = TelegramWebApp::instance().expect("instance");
271        let received = Rc::new(RefCell::new(None::<JsValue>));
272        let cap = received.clone();
273        let params = Object::new();
274        let _ = Reflect::set(&params, &"x".into(), &"y".into());
275        app.invoke_custom_method_with_callback("doStuff", &params.into(), move |out| {
276            *cap.borrow_mut() = Some(out.expect("ok"));
277        })
278        .expect("ok");
279
280        assert_eq!(
281            Reflect::get(&webapp, &"method".into())
282                .unwrap()
283                .as_string()
284                .as_deref(),
285            Some("doStuff")
286        );
287        let value = received.borrow().clone().expect("result");
288        let ok_val = Reflect::get(&value, &"ok".into()).expect("ok field");
289        assert_eq!(ok_val.as_f64(), Some(1.0));
290    }
291
292    #[wasm_bindgen_test]
293    #[allow(dead_code, clippy::unused_unit)]
294    fn invoke_custom_method_with_callback_translates_error() {
295        let webapp = setup_webapp();
296        let invoke = Function::new_with_args("_method, _params, cb", "cb('boom', null);");
297        let _ = Reflect::set(&webapp, &"invokeCustomMethod".into(), &invoke);
298
299        let app = TelegramWebApp::instance().expect("instance");
300        let received = Rc::new(RefCell::new(None::<JsValue>));
301        let cap = received.clone();
302        app.invoke_custom_method_with_callback("doStuff", &JsValue::NULL, move |out| {
303            *cap.borrow_mut() = Some(out.expect_err("err"));
304        })
305        .expect("ok");
306
307        let err = received.borrow().clone().expect("err");
308        assert_eq!(err.as_string().as_deref(), Some("boom"));
309    }
310
311    #[wasm_bindgen_test]
312    #[allow(dead_code, clippy::unused_unit)]
313    async fn invoke_custom_method_async_resolves_with_result() {
314        let webapp = setup_webapp();
315        let invoke = Function::new_with_args(
316            "_method, _params, cb",
317            "setTimeout(() => cb(null, {ok: 7}), 0);"
318        );
319        let _ = Reflect::set(&webapp, &"invokeCustomMethod".into(), &invoke);
320
321        let app = TelegramWebApp::instance().expect("instance");
322        let value = app
323            .invoke_custom_method("doStuff", &JsValue::NULL)
324            .await
325            .expect("resolved");
326        let ok = Reflect::get(&value, &"ok".into()).expect("ok field");
327        assert_eq!(ok.as_f64(), Some(7.0));
328    }
329
330    #[wasm_bindgen_test]
331    #[allow(dead_code, clippy::unused_unit)]
332    async fn invoke_custom_method_async_rejects_on_js_error() {
333        let webapp = setup_webapp();
334        let invoke = Function::new_with_args(
335            "_method, _params, cb",
336            "setTimeout(() => cb('boom', null), 0);"
337        );
338        let _ = Reflect::set(&webapp, &"invokeCustomMethod".into(), &invoke);
339
340        let app = TelegramWebApp::instance().expect("instance");
341        let err = app
342            .invoke_custom_method("doStuff", &JsValue::NULL)
343            .await
344            .expect_err("rejected");
345        assert_eq!(err.as_string().as_deref(), Some("boom"));
346    }
347}