Skip to main content

tg_cli/
lib.rs

1use std::io::{self};
2
3use teloxide::{
4    Bot, RequestError,
5    payloads::{EditMessageTextSetters, SendDocumentSetters, SendMessageSetters},
6    prelude::Requester,
7    types::{ChatId, InputFile, MessageId, ParseMode as TeloxideParseMode},
8};
9
10use crate::config::Config;
11
12pub(crate) mod config;
13
14#[derive(Debug)]
15pub enum SendMessageError {
16    MissingToken,
17    MissingChatId,
18    RuntimeInit(io::Error),
19    Request(RequestError),
20}
21
22impl std::fmt::Display for SendMessageError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            SendMessageError::MissingToken => {
26                write!(f, "No token configured. Run `tg setup` first.")
27            }
28            SendMessageError::MissingChatId => {
29                write!(f, "No chat ID configured. Run `tg setup` first.")
30            }
31            SendMessageError::RuntimeInit(err) => {
32                write!(f, "Failed to initialize async runtime: {err}")
33            }
34            SendMessageError::Request(err) => write!(f, "Failed to send message: {err}"),
35        }
36    }
37}
38
39impl std::error::Error for SendMessageError {
40    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
41        match self {
42            SendMessageError::RuntimeInit(err) => Some(err),
43            SendMessageError::Request(err) => Some(err),
44            SendMessageError::MissingToken | SendMessageError::MissingChatId => None,
45        }
46    }
47}
48
49pub type TgResult<T> = Result<T, SendMessageError>;
50
51pub struct TgSession {
52    bot: Bot,
53    chat_id: ChatId,
54}
55
56#[derive(Debug, Clone, Copy)]
57pub enum ParseMode {
58    Markdown,
59    Html,
60}
61
62impl From<ParseMode> for TeloxideParseMode {
63    fn from(mode: ParseMode) -> Self {
64        match mode {
65            ParseMode::Markdown => TeloxideParseMode::MarkdownV2,
66            ParseMode::Html => TeloxideParseMode::Html,
67        }
68    }
69}
70
71impl TgSession {
72    pub fn from_config() -> TgResult<Self> {
73        let config = Config::load();
74        let token = config.token.ok_or(SendMessageError::MissingToken)?;
75        let chat_id = config.chat_id.ok_or(SendMessageError::MissingChatId)?;
76
77        Ok(Self {
78            bot: Bot::new(token),
79            chat_id: ChatId(chat_id),
80        })
81    }
82
83    fn sanitize_text(text: String) -> String {
84        text.replace("\r\n", "\n")
85            .replace('_', "\\_")
86            .replace('*', "\\*")
87            .replace('[', "\\[")
88            .replace(']', "\\]")
89            .replace('(', "\\(")
90            .replace(')', "\\)")
91            .replace('~', "\\~")
92            .replace('`', "\\`")
93            .replace('>', "\\>")
94            .replace('#', "\\#")
95            .replace('+', "\\+")
96            .replace('-', "\\-")
97            .replace('=', "\\=")
98            .replace('|', "\\|")
99            .replace('{', "\\{")
100            .replace('}', "\\}")
101            .replace('.', "\\.")
102            .replace('!', "\\!")
103    }
104
105    pub async fn send_message(
106        &self,
107        text: String,
108        parse_mode: ParseMode,
109        silent: bool,
110    ) -> TgResult<i32> {
111        let mut req = self
112            .bot
113            .send_message(self.chat_id, Self::sanitize_text(text));
114        req = req.parse_mode(parse_mode.into());
115
116        if silent {
117            req = req.disable_notification(true);
118        }
119
120        let message = req.await.map_err(SendMessageError::Request)?;
121        Ok(message.id.0)
122    }
123
124    pub async fn send_document(&self, path: &std::path::Path, silent: bool) -> TgResult<()> {
125        let input_file = InputFile::file(path);
126        let mut req = self.bot.send_document(self.chat_id, input_file);
127
128        if silent {
129            req = req.disable_notification(true);
130        }
131
132        req.await.map_err(SendMessageError::Request)?;
133        Ok(())
134    }
135
136    pub async fn edit_message(
137        &self,
138        message_id: i32,
139        text: String,
140        parse_mode: ParseMode,
141    ) -> TgResult<()> {
142        let mut req = self.bot.edit_message_text(
143            self.chat_id,
144            MessageId(message_id),
145            Self::sanitize_text(text),
146        );
147        req = req.parse_mode(parse_mode.into());
148        req.await.map_err(SendMessageError::Request)?;
149        Ok(())
150    }
151}
152
153pub async fn send_tg_message(text: String, parse_mode: ParseMode, silent: bool) -> TgResult<()> {
154    let session = TgSession::from_config()?;
155    session.send_message(text, parse_mode, silent).await?;
156    Ok(())
157}
158
159pub fn send_tg_message_blocking(text: String, parse_mode: ParseMode, silent: bool) -> TgResult<()> {
160    if tokio::runtime::Handle::try_current().is_ok() {
161        let worker = std::thread::spawn(move || {
162            let rt = tokio::runtime::Builder::new_current_thread()
163                .enable_all()
164                .build()
165                .map_err(SendMessageError::RuntimeInit)?;
166            rt.block_on(send_tg_message(text, parse_mode, silent))
167        });
168
169        return match worker.join() {
170            Ok(result) => result,
171            Err(_) => Err(SendMessageError::RuntimeInit(io::Error::other(
172                "failed to join Telegram sender thread",
173            ))),
174        };
175    }
176
177    let rt = tokio::runtime::Builder::new_current_thread()
178        .enable_all()
179        .build()
180        .map_err(SendMessageError::RuntimeInit)?;
181    rt.block_on(send_tg_message(text, parse_mode, silent))
182}
183
184#[cfg(feature = "non-blocking")]
185#[macro_export]
186macro_rules! telegram {
187    () => {{
188        $crate::telegram!("")
189    }};
190    ($($arg:tt)*) => {{
191        let msg = format!($($arg)*);
192        tokio::spawn(async move {
193            if let Err(err) = $crate::send_tg_message(
194                msg,
195                $crate::ParseMode::Markdown,
196                false,
197            ).await {
198                eprintln!("{err}");
199            }
200        });
201    }};
202}
203
204#[cfg(not(feature = "non-blocking"))]
205#[macro_export]
206macro_rules! telegram {
207    () => {{
208        $crate::telegram!("")
209    }};
210    ($($arg:tt)*) => {{
211        if let Err(err) = $crate::send_tg_message_blocking(
212            format!($($arg)*),
213            $crate::ParseMode::Markdown,
214            false,
215        ) {
216            eprintln!("{err}");
217        }
218    }};
219}