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}