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}