telers/client/session/
base.rs

1//! This module contains [`Session`] trait that needs to be implemented for sending requests to Telegram Bot API.
2//!
3//! Supported implementations:
4//! - [`Reqwest`] - uses reqwest client. Check [module docs](crate::client::session::reqwest) for more information.
5//!
6//! [`Reqwest`]: telers::client::session::reqwest::Reqwest
7
8use crate::{
9    client::{telegram::APIServer, Bot},
10    errors::{SessionErrorKind, TelegramErrorKind},
11    methods::{Response, TelegramMethod},
12};
13
14use serde::de::DeserializeOwned;
15use std::{
16    fmt::{self, Display, Formatter},
17    future::Future,
18    ops::RangeInclusive,
19};
20use tracing::{debug_span, event, instrument, Level, Span};
21
22pub const DEFAULT_TIMEOUT: f32 = 60.0;
23
24#[derive(Debug)]
25pub struct StatusCode(u16);
26
27impl StatusCode {
28    const SUCESS_STATUS_CODE_RANGE: RangeInclusive<u16> = 200..=226;
29
30    #[must_use]
31    pub fn new(status_code: u16) -> Self {
32        Self(status_code)
33    }
34
35    #[must_use]
36    pub fn is_success(&self) -> bool {
37        Self::SUCESS_STATUS_CODE_RANGE.contains(&self.0)
38    }
39
40    #[must_use]
41    pub fn is_error(&self) -> bool {
42        !self.is_success()
43    }
44
45    #[must_use]
46    pub const fn as_u16(&self) -> u16 {
47        self.0
48    }
49}
50
51impl Display for StatusCode {
52    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
53        write!(f, "{}", self.0)
54    }
55}
56
57impl PartialEq<u16> for StatusCode {
58    fn eq(&self, other: &u16) -> bool {
59        self.0 == *other
60    }
61}
62
63impl From<u16> for StatusCode {
64    fn from(status_code: u16) -> Self {
65        Self::new(status_code)
66    }
67}
68
69#[derive(Debug)]
70pub struct ClientResponse {
71    pub status_code: StatusCode,
72    pub content: Box<str>,
73}
74
75impl ClientResponse {
76    #[must_use]
77    pub fn new(status_code: impl Into<StatusCode>, content: impl Into<Box<str>>) -> Self {
78        Self {
79            status_code: status_code.into(),
80            content: content.into(),
81        }
82    }
83}
84
85pub trait Session: Send + Sync {
86    /// Get configuration of Telegram Bot API server endpoints and local mode
87    #[must_use]
88    fn api(&self) -> &APIServer;
89
90    /// Makes a request to Telegram API
91    /// # Arguments
92    /// * `bot` - Bot instance for building request, it is mainly used for getting bot token
93    /// * `method` - Telegram method for building request
94    /// * `timeout` - Request timeout.
95    ///   If `None`, then client timeout will be used, which is [`DEFAULT_TIMEOUT`] by default.
96    /// # Errors
97    /// If the request cannot be send or decoded
98    #[must_use]
99    fn send_request<Client, T>(
100        &self,
101        bot: &Bot<Client>,
102        method: &T,
103        timeout: Option<f32>,
104    ) -> impl Future<Output = Result<ClientResponse, anyhow::Error>> + Send
105    where
106        Client: Session,
107        T: TelegramMethod + Send + Sync,
108        T::Method: Send + Sync;
109
110    /// Checks a response from Telegram API
111    /// # Arguments
112    /// * `method` - Telegram method
113    /// * `status_code` - HTTP status code
114    /// * `content` - Response content
115    /// # Errors
116    /// If the response represents an telegram api error
117    #[allow(clippy::redundant_else)]
118    #[instrument(name = "check", skip_all, fields(ok, error_message))]
119    fn check_response(
120        &self,
121        response: &Response<impl DeserializeOwned>,
122        status_code: StatusCode,
123    ) -> Result<(), TelegramErrorKind> {
124        if status_code.is_success() && response.ok {
125            Span::current().record("ok", true);
126
127            if response.result.is_none() {
128                event!(
129                    Level::ERROR,
130                    "Contract violation: result is empty in success response"
131                );
132
133                return Err(anyhow::Error::msg(
134                    "Contract violation: result is empty in success response",
135                )
136                .into());
137            }
138
139            return Ok(());
140        } else {
141            Span::current().record("ok", false);
142        }
143
144        let Some(message) = response.description.clone() else {
145            // Descriptions for every error mentioned in errors (https://core.telegram.org/api/errors)
146            event!(
147                Level::ERROR,
148                error_code = ?response.error_code,
149                parameters = ?response.parameters,
150                "Contract violation: description is empty in error response",
151            );
152
153            return Err(anyhow::Error::msg(
154                "Contract violation: description is empty in error response",
155            )
156            .into());
157        };
158
159        Span::current().record("error_message", message.as_ref());
160
161        if let Some(ref parameters) = response.parameters {
162            if let Some(retry_after) = parameters.retry_after {
163                let err = TelegramErrorKind::RetryAfter {
164                    url: "https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this",
165                    message,
166                    retry_after,
167                };
168
169                event!(Level::ERROR, error = %err);
170
171                return Err(err);
172            }
173            if let Some(migrate_to_chat_id) = parameters.migrate_to_chat_id {
174                let err = TelegramErrorKind::MigrateToChat {
175                    url: "https://core.telegram.org/bots/api#responseparameters",
176                    message,
177                    migrate_to_chat_id,
178                };
179
180                event!(Level::ERROR, error = %err);
181
182                return Err(err);
183            }
184        }
185
186        let err = match status_code.as_u16() {
187            400 => TelegramErrorKind::BadRequest { message },
188            401 => TelegramErrorKind::Unauthorized { message },
189            403 => TelegramErrorKind::Forbidden { message },
190            404 => TelegramErrorKind::NotFound { message },
191            409 => TelegramErrorKind::ConflictError { message },
192            413 => TelegramErrorKind::EntityTooLarge {
193                url: "https://core.telegram.org/bots/api#sending-files",
194                message,
195            },
196            500 => {
197                if message.contains("restart") {
198                    TelegramErrorKind::RestartingTelegram { message }
199                } else {
200                    TelegramErrorKind::ServerError { message }
201                }
202            }
203            _ => {
204                event!(
205                    Level::ERROR,
206                    %status_code,
207                    message,
208                    "Error with unknown status code",
209                );
210
211                return Err(anyhow::Error::msg(message).into());
212            }
213        };
214
215        event!(Level::ERROR, error = %err);
216
217        Err(err)
218    }
219
220    /// Makes a request to Telegram API
221    /// # Arguments
222    /// * `bot` - Bot instance for building and sending request, it is mainly used for getting bot token
223    /// * `method` - Telegram method for building and sending request
224    /// * `timeout` - Request timeout.
225    ///   If [`None`], then client timeout will be used, which is [`DEFAULT_TIMEOUT`] by default.
226    /// # Errors
227    /// - If the request cannot be send or decoded
228    /// - If the response cannot be parsed
229    /// - If the response represents an Telegram API error
230    fn make_request<Client, T>(
231        &self,
232        bot: &Bot<Client>,
233        method: &T,
234        timeout: Option<f32>,
235    ) -> impl Future<Output = Result<Response<T::Return>, SessionErrorKind>> + Send
236    where
237        Client: Session,
238        T: TelegramMethod + Send + Sync,
239        T::Method: Send + Sync,
240    {
241        async move {
242            let response = self.send_request(bot, method, timeout).await?;
243
244            debug_span!("response", status_code = response.status_code.as_u16()).in_scope(|| {
245                let resp = method.build_response(&response.content)?;
246                self.check_response(&resp, response.status_code)?;
247
248                Ok(resp)
249            })
250        }
251    }
252
253    /// Makes a request to Telegram API and get result from it
254    /// # Arguments
255    /// * `bot` - Bot instance for building and sending request, it is mainly used for getting bot token
256    /// * `method` - Telegram method for building and sending request
257    /// * `timeout` - Request timeout.
258    ///   If `None`, then client timeout will be used, which is [`DEFAULT_TIMEOUT`] by default.
259    /// # Errors
260    /// - If the request cannot be send or decoded
261    /// - If the response cannot be parsed
262    /// - If the response represents an telegram api error
263    fn make_request_and_get_result<Client, T>(
264        &self,
265        bot: &Bot<Client>,
266        method: &T,
267        timeout: Option<f32>,
268    ) -> impl Future<Output = Result<T::Return, SessionErrorKind>> + Send
269    where
270        Client: Session,
271        T: TelegramMethod + Send + Sync,
272        T::Method: Send + Sync,
273    {
274        async move {
275            let resp = self.make_request(bot, method, timeout).await?;
276
277            // Unwrap safe because we checked it in `check_response`
278            Ok(resp.result.unwrap())
279        }
280    }
281
282    /// Close client session. Default implementation does nothing.
283    fn close(&self) -> impl Future<Output = Result<(), anyhow::Error>> + Send {
284        async move { Ok(()) }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::methods::SendMessage;
292
293    use serde_json::json;
294
295    #[test]
296    fn build_response() {
297        let method = SendMessage::new(810646651, "Hello, abc!");
298
299        let content = json!(
300        {
301            "ok": true,
302            "result": {
303                "message_id": 423,
304                "from": {
305                    "id": 1i64,
306                    "is_bot": true,
307                    "first_name": "test",
308                    "username": "test"
309                },
310                "chat": {
311                    "id": 1,
312                    "first_name": "test",
313                    "username": "test",
314                    "type": "private",
315                },
316                "date": 1706267365,
317                "reply_to_message": {
318                    "message_id": 422,
319                    "from": {
320                        "id": 1,
321                        "is_bot": false,
322                        "first_name": "test",
323                        "username": "test",
324                        "language_code": "ru",
325                        "is_premium": true,
326                    },
327                    "chat":{
328                        "id": 1,
329                        "first_name": "test",
330                        "username": "test",
331                        "type": "private",
332                    },
333                    "date": 1,
334                    "text": "/start",
335                    "entities":[
336                        {
337                            "offset": 0,
338                            "length": 6,
339                            "type": "bot_command",
340                        },
341                    ],
342                },
343                "text": "test",
344            },
345            "statud_code": 200,
346        });
347
348        let result = method
349            .build_response(&content.to_string())
350            .unwrap()
351            .result
352            .unwrap();
353
354        assert_eq!(result.id(), 423);
355    }
356}