spdlog_telegram/
lib.rs

1//! Sends logs to Telegram, based on [spdlog-rs].
2//!
3//! This crate provides a sink [`TelegramSink`] which sends logs to Telegram
4//! recipients via Telegram Bot API.
5//!
6//! ## Examples
7//!
8//! See directory [./examples].
9//!
10//! [spdlog-rs]: https://crates.io/crates/spdlog-rs
11//! [./examples]: https://github.com/SpriteOvO/spdlog-telegram/tree/main/examples
12
13#![warn(missing_docs)]
14
15mod error;
16mod recipient;
17mod request;
18
19use std::{convert::Infallible, sync::atomic::Ordering};
20
21use atomic::Atomic;
22pub use error::{Error, Result};
23pub use recipient::Recipient;
24use request::Requester;
25use spdlog::{
26    ErrorHandler, Record, StringBuf,
27    formatter::{Formatter, FormatterContext, PatternFormatter, pattern},
28    prelude::*,
29    sink::{GetSinkProp, Sink, SinkProp},
30};
31use url::Url;
32
33/// A sink with a Telegram recipient as the target via Telegram Bot API.
34///
35/// This sink involves network operations. If you don't want it to block the
36/// thread, you may want to use it in combination with [`AsyncPoolSink`].
37///
38/// [`AsyncPoolSink`]: https://docs.rs/spdlog-rs/0.5.1/spdlog/sink/struct.AsyncPoolSink.html
39pub struct TelegramSink {
40    prop: SinkProp,
41    silence: Atomic<LevelFilter>,
42    requester: Requester,
43}
44
45impl TelegramSink {
46    /// Gets a builder of `TelegramSink` with default parameters:
47    ///
48    /// | Parameter         | Default Value                                                                           |
49    /// |-------------------|-----------------------------------------------------------------------------------------|
50    /// | [level_filter]    | `All`                                                                                   |
51    /// | [formatter]       | pattern `"#log #{level} {payload} {kv}\n@{source}"` or `"#log #{level} {payload} {kv}"` |
52    /// | [error_handler]   | [`ErrorHandler::default()`]                                                             |
53    /// |                   |                                                                                         |
54    /// | [server_url]      | `"https://api.telegram.org"`                                                            |
55    /// | [bot_token]       | *must be specified*                                                                     |
56    /// | [recipient]       | *must be specified*                                                                     |
57    /// | [silence]         | `Off`                                                                                   |
58    ///
59    /// [level_filter]: TelegramSinkBuilder::level_filter
60    /// [formatter]: TelegramSinkBuilder::formatter
61    /// [error_handler]: TelegramSinkBuilder::error_handler
62    /// [`ErrorHandler::default()`]: spdlog::error::ErrorHandler::default()
63    /// [server_url]: TelegramSinkBuilder::server_url
64    /// [bot_token]: TelegramSinkBuilder::bot_token
65    /// [recipient]: TelegramSinkBuilder::recipient
66    /// [silence]: TelegramSinkBuilder::silence
67    #[must_use]
68    pub fn builder() -> TelegramSinkBuilder<(), ()> {
69        let prop = SinkProp::default();
70        if spdlog::source_location_current!().is_some() {
71            prop.set_formatter(PatternFormatter::new(pattern!(
72                "#log #{level} {payload} {kv}\n@{source}"
73            )));
74        } else {
75            prop.set_formatter(PatternFormatter::new(pattern!(
76                "#log #{level} {payload} {kv}"
77            )))
78        };
79        TelegramSinkBuilder {
80            prop,
81            server_url: None,
82            bot_token: (),
83            recipient: (),
84            silence: LevelFilter::Off,
85        }
86    }
87
88    /// Gets the silence level filter.
89    #[must_use]
90    pub fn silence(&self) -> LevelFilter {
91        self.silence.load(Ordering::Relaxed)
92    }
93
94    /// Sets the silence level filter.
95    ///
96    /// Logs with level matching the filter will be sent with
97    /// `disable_notification` set to `true`.
98    pub fn set_silence(&self, silent_if: LevelFilter) {
99        self.silence.store(silent_if, Ordering::Relaxed);
100    }
101}
102
103impl GetSinkProp for TelegramSink {
104    fn prop(&self) -> &SinkProp {
105        &self.prop
106    }
107}
108
109impl Sink for TelegramSink {
110    fn log(&self, record: &Record) -> spdlog::Result<()> {
111        let mut string_buf = StringBuf::new();
112        let mut ctx = FormatterContext::new();
113        self.prop
114            .formatter()
115            .format(record, &mut string_buf, &mut ctx)?;
116
117        self.requester
118            .send_log(string_buf, self.silence().test(record.level()))
119            .map_err(|err| spdlog::Error::Downstream(err.into()))?;
120        Ok(())
121    }
122
123    fn flush(&self) -> spdlog::Result<()> {
124        Ok(())
125    }
126}
127
128/// #
129///
130/// # Note
131///
132/// The generics here are designed to check for required fields at compile time,
133/// users should not specify them manually and/or depend on them. If the generic
134/// concrete types or the number of generic types are changed in the future, it
135/// may not be considered as a breaking change.
136pub struct TelegramSinkBuilder<ArgT, ArgR> {
137    prop: SinkProp,
138    server_url: Option<Url>,
139    bot_token: ArgT,
140    recipient: ArgR,
141    silence: LevelFilter,
142}
143
144impl<ArgT, ArgD> TelegramSinkBuilder<ArgT, ArgD> {
145    /// Specifies the Telegram Bot API server URL.
146    ///
147    /// See [Telegram Bot API: Using a Local Bot API Server][local-srv].
148    ///
149    /// This parameter is **optional**.
150    ///
151    /// [local-srv]: https://core.telegram.org/bots/api#using-a-local-bot-api-server
152    #[must_use]
153    pub fn server_url<S>(mut self, url: S) -> Self
154    where
155        S: Into<Url>,
156    {
157        self.server_url = Some(url.into());
158        self
159    }
160
161    /// Specifies the bot token.
162    ///
163    /// See [Telegram Bot API: Authorizing your bot][token]
164    ///
165    /// [token]: https://core.telegram.org/bots/api#authorizing-your-bot
166    ///
167    /// This parameter is **required**.
168    #[must_use]
169    pub fn bot_token<T>(self, bot_token: T) -> TelegramSinkBuilder<String, ArgD>
170    where
171        T: Into<String>,
172    {
173        TelegramSinkBuilder {
174            prop: self.prop,
175            server_url: self.server_url,
176            bot_token: bot_token.into(),
177            recipient: self.recipient,
178            silence: self.silence,
179        }
180    }
181
182    /// Specifies the recipient of logs.
183    ///
184    /// This parameter is **required**.
185    ///
186    /// ## Examples
187    ///
188    /// ```
189    /// use spdlog_telegram::{Recipient, TelegramSink};
190    ///
191    /// TelegramSink::builder()
192    ///     // chat ID
193    ///     .recipient(-1001234567890)
194    ///     // or username
195    ///     .recipient("@my_channel")
196    ///     // or with thread ID
197    ///     .recipient(
198    ///         Recipient::builder()
199    ///             .username("@my_chat")
200    ///             .thread_id(114)
201    ///             .build()
202    ///     );
203    /// ```
204    #[must_use]
205    pub fn recipient<R>(self, recipient: R) -> TelegramSinkBuilder<ArgT, Recipient>
206    where
207        R: Into<Recipient>,
208    {
209        TelegramSinkBuilder {
210            prop: self.prop,
211            server_url: self.server_url,
212            bot_token: self.bot_token,
213            recipient: recipient.into(),
214            silence: self.silence,
215        }
216    }
217
218    /// Specifies the silence level filter.
219    ///
220    /// Logs with level matching the filter will be sent with
221    /// `disable_notification` set to `true`.
222    ///
223    /// This parameter is **optional**.
224    #[must_use]
225    pub fn silence(mut self, silent_if: LevelFilter) -> Self {
226        self.silence = silent_if;
227        self
228    }
229
230    // Prop
231    //
232
233    /// Specifies a log level filter.
234    ///
235    /// This parameter is **optional**.
236    #[must_use]
237    pub fn level_filter(self, level_filter: LevelFilter) -> Self {
238        self.prop.set_level_filter(level_filter);
239        self
240    }
241
242    /// Specifies a formatter.
243    ///
244    /// This parameter is **optional**.
245    #[must_use]
246    pub fn formatter<F>(self, formatter: F) -> Self
247    where
248        F: Formatter + 'static,
249    {
250        self.prop.set_formatter(formatter);
251        self
252    }
253
254    /// Specifies an error handler.
255    ///
256    /// This parameter is **optional**.
257    #[must_use]
258    pub fn error_handler<F>(self, handler: F) -> Self
259    where
260        F: Into<ErrorHandler>,
261    {
262        self.prop.set_error_handler(handler);
263        self
264    }
265}
266
267impl<ArgR> TelegramSinkBuilder<(), ArgR> {
268    #[doc(hidden)]
269    #[deprecated(note = "\n\n\
270        builder compile-time error:\n\
271        - missing required field `bot_token`\n\n\
272    ")]
273    pub fn build(self, _: Infallible) {}
274}
275
276impl TelegramSinkBuilder<String, ()> {
277    #[doc(hidden)]
278    #[deprecated(note = "\n\n\
279        builder compile-time error:\n\
280        - missing required field `recipient`\n\n\
281    ")]
282    pub fn build(self, _: Infallible) {}
283}
284
285impl TelegramSinkBuilder<String, Recipient> {
286    /// Builds a `TelegramSink`.
287    pub fn build(self) -> Result<TelegramSink> {
288        Ok(TelegramSink {
289            prop: self.prop,
290            silence: Atomic::new(self.silence),
291            requester: Requester::new(
292                self.server_url
293                    .map_or_else(|| Url::parse("https://api.telegram.org"), Ok)
294                    .map_err(Error::ParseUrl)?,
295                &self.bot_token,
296                self.recipient,
297            )?,
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use std::sync::Arc;
305
306    use mockito::Matcher;
307    use serde_json::json;
308
309    use super::*;
310
311    #[test]
312    fn request() {
313        let mut server = mockito::Server::new();
314
315        let error_handler = |err| panic!("error handler triggered: {err}");
316        let sink = Arc::new(
317            TelegramSink::builder()
318                .error_handler(error_handler)
319                .server_url(Url::parse(&server.url()).unwrap())
320                .bot_token("1234567890:AbCdEfGhiJkLmNoPq1R2s3T4u5V6w7X8y9z")
321                .recipient(
322                    Recipient::builder()
323                        .chat_id(-1001234567890)
324                        .thread_id(114)
325                        .reply_to(514)
326                        .build(),
327                )
328                .silence(LevelFilter::MoreVerboseEqual(Level::Info))
329                .build()
330                .unwrap(),
331        );
332        let logger = Logger::builder()
333            .error_handler(error_handler)
334            .sink(sink.clone())
335            .build()
336            .unwrap();
337
338        let mut mocker = |level| {
339            server
340                .mock(
341                    "POST",
342                    "/bot1234567890:AbCdEfGhiJkLmNoPq1R2s3T4u5V6w7X8y9z/sendMessage",
343                )
344                .match_header("content-type", "application/json")
345                .match_body(Matcher::PartialJson(json!({
346                    "chat_id": -1001234567890_i64,
347                    "disable_notification": sink.silence().test(level),
348                    "link_preview_options": {
349                        "is_disabled": true
350                    },
351                    "message_thread_id": 114,
352                    "text": format!("#log #{} Hello Telegram! k=v", level.as_str()),
353                    "reply_parameters": {
354                        "message_id": 514,
355                    }
356                })))
357                .with_header("content-type", "application/json")
358                .with_body(json!({ "ok": true, "result": { /* omitted */ }}).to_string())
359                .create()
360        };
361
362        let mock = mocker(Level::Info);
363        info!(logger: logger, "Hello Telegram!", kv: { k = "v" });
364        mock.assert();
365
366        let mock = mocker(Level::Error);
367        error!(logger: logger, "Hello Telegram!", kv: { k = "v" });
368        mock.assert();
369    }
370}