Skip to main content

telegram_webapp_sdk/core/
context.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use once_cell::unsync::OnceCell;
5use percent_encoding::{percent_decode, percent_decode_str};
6use wasm_bindgen::JsValue;
7
8use super::types::{
9    init_data::TelegramInitData, launch_params::LaunchParams, theme_params::TelegramThemeParams
10};
11
12/// Global context of the Telegram Mini App, initialized once per app session.
13#[derive(Clone)]
14pub struct TelegramContext {
15    /// Parsed and validated `initData` describing the current user, chat and
16    /// session of the Mini App.
17    pub init_data:     TelegramInitData,
18    /// Theme parameters reported by `Telegram.WebApp.themeParams` at
19    /// initialization time.
20    pub theme_params:  TelegramThemeParams,
21    /// Original URL-encoded `initData` string, retained for server-side
22    /// signature validation.
23    pub raw_init_data: String
24}
25
26thread_local! {
27    /// Thread-local global TelegramContext instance.
28    static CONTEXT: OnceCell<TelegramContext> = const { OnceCell::new() };
29}
30
31impl TelegramContext {
32    /// Initializes the global Telegram context.
33    ///
34    /// # Errors
35    /// Returns an error if the context was already initialized.
36    pub fn init(
37        init_data: TelegramInitData,
38        theme_params: TelegramThemeParams,
39        raw_init_data: String
40    ) -> Result<(), &'static str> {
41        CONTEXT.with(|cell| {
42            cell.set(TelegramContext {
43                init_data,
44                theme_params,
45                raw_init_data
46            })
47            .map_err(|_| "TelegramContext already initialized")
48        })
49    }
50
51    /// Access the global context if it has been initialized.
52    ///
53    /// Accepts a closure and returns the result of applying it to the context.
54    pub fn get<F, R>(f: F) -> Option<R>
55    where
56        F: FnOnce(&TelegramContext) -> R
57    {
58        CONTEXT.with(|cell| cell.get().map(f))
59    }
60
61    /// Returns the raw initData string as provided by Telegram.
62    ///
63    /// This is the URL-encoded initData string suitable for server-side
64    /// signature validation. The string is captured during SDK initialization
65    /// and remains unchanged.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the SDK has not been initialized via
70    /// [`crate::core::init::init_sdk`].
71    ///
72    /// # Examples
73    ///
74    /// ```no_run
75    /// use telegram_webapp_sdk::core::context::TelegramContext;
76    ///
77    /// match TelegramContext::get_raw_init_data() {
78    ///     Ok(raw) => {
79    ///         // Send to backend for validation
80    ///         println!("Raw initData: {}", raw);
81    ///     }
82    ///     Err(e) => eprintln!("Error: {}", e)
83    /// }
84    /// ```
85    pub fn get_raw_init_data() -> Result<String, &'static str> {
86        Self::get(|ctx| ctx.raw_init_data.clone()).ok_or("TelegramContext not initialized")
87    }
88}
89
90/// Returns launch parameters parsed from the current window location.
91///
92/// The `tg_web_app_platform` entry is read from the `tgWebAppPlatform`
93/// query parameter and falls back to `"web"` when it is absent.
94///
95/// # Errors
96/// Returns a [`JsValue`] if the global window object is unavailable.
97///
98/// # Examples
99/// ```no_run
100/// # use telegram_webapp_sdk::core::context::get_launch_params;
101/// let _ = get_launch_params();
102/// ```
103pub fn get_launch_params() -> Result<LaunchParams, JsValue> {
104    let _ = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
105
106    Ok(LaunchParams {
107        tg_web_app_platform:      get_param("tgWebAppPlatform")
108            .or_else(|| Some(String::from("web"))),
109        tg_web_app_version:       get_param("tgWebAppVersion"),
110        tg_web_app_start_param:   get_param("tgWebAppStartParam"),
111        tg_web_app_show_settings: get_param("tgWebAppShowSettings").map(|s| s == "1"),
112        tg_web_app_bot_inline:    get_param("tgWebAppBotInline").map(|s| s == "1")
113    })
114}
115
116fn get_param(key: &str) -> Option<String> {
117    let search = web_sys::window()?.document()?.location()?.search().ok()?;
118
119    let query = search.strip_prefix('?').unwrap_or(search.as_str());
120    extract_param(query, key)
121}
122
123fn extract_param(query: &str, key: &str) -> Option<String> {
124    query.split('&').find_map(|pair| {
125        if pair.is_empty() {
126            return None;
127        }
128
129        let mut parts = pair.splitn(2, '=');
130        let current_key = parts.next()?;
131        if current_key != key {
132            return None;
133        }
134
135        let raw_value = parts.next()?;
136        decode_query_value(raw_value)
137    })
138}
139
140fn decode_query_value(raw_value: &str) -> Option<String> {
141    if raw_value.contains('+') {
142        let mut buffer = Vec::with_capacity(raw_value.len());
143        for byte in raw_value.as_bytes() {
144            if *byte == b'+' {
145                buffer.push(b' ');
146            } else {
147                buffer.push(*byte);
148            }
149        }
150
151        return percent_decode(buffer.as_slice())
152            .decode_utf8()
153            .ok()
154            .map(|cow| cow.into_owned());
155    }
156
157    percent_decode_str(raw_value)
158        .decode_utf8()
159        .ok()
160        .map(|cow| cow.into_owned())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn extract_param_returns_first_entry() {
169        let query = "tgWebAppPlatform=android&tgWebAppVersion=9.2";
170        let platform = extract_param(query, "tgWebAppPlatform");
171        assert_eq!(platform.as_deref(), Some("android"));
172    }
173
174    #[test]
175    fn decode_query_value_handles_plus_and_percent_sequences() {
176        let query = "tgWebAppStartParam=hello%2Bworld+test";
177        let value = extract_param(query, "tgWebAppStartParam");
178        assert_eq!(value.as_deref(), Some("hello+world test"));
179    }
180
181    #[cfg(target_arch = "wasm32")]
182    mod wasm {
183        use wasm_bindgen::JsValue;
184        use wasm_bindgen_test::wasm_bindgen_test;
185
186        use super::super::get_launch_params;
187
188        #[allow(dead_code)]
189        #[wasm_bindgen_test]
190        fn get_launch_params_returns_error_without_window() {
191            let err = get_launch_params().unwrap_err();
192            assert_eq!(err, JsValue::from_str("no window"));
193        }
194
195        #[wasm_bindgen_test]
196        fn get_launch_params_reads_first_query_parameter() -> Result<(), JsValue> {
197            let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
198            let location = window.location();
199            let original_search = location.search().unwrap_or_default();
200
201            location.set_search(
202                "?tgWebAppPlatform=android&tgWebAppVersion=9.2&tgWebAppStartParam=hello%2Bworld+test&tgWebAppShowSettings=1&tgWebAppBotInline=0"
203            )?;
204
205            let params = get_launch_params()?;
206            assert_eq!(params.tg_web_app_platform.as_deref(), Some("android"));
207            assert_eq!(params.tg_web_app_version.as_deref(), Some("9.2"));
208            assert_eq!(
209                params.tg_web_app_start_param.as_deref(),
210                Some("hello+world test")
211            );
212            assert_eq!(params.tg_web_app_show_settings, Some(true));
213            assert_eq!(params.tg_web_app_bot_inline, Some(false));
214
215            location.set_search(&original_search)?;
216            Ok(())
217        }
218    }
219}