1use std::io::{self};
2
3use teloxide::{
4 Bot, RequestError,
5 payloads::{EditMessageTextSetters, SendMessageSetters},
6 prelude::Requester,
7 types::{ChatId, 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 edit_message(
125 &self,
126 message_id: i32,
127 text: String,
128 parse_mode: ParseMode,
129 ) -> TgResult<()> {
130 let mut req = self.bot.edit_message_text(
131 self.chat_id,
132 MessageId(message_id),
133 Self::sanitize_text(text),
134 );
135 req = req.parse_mode(parse_mode.into());
136 req.await.map_err(SendMessageError::Request)?;
137 Ok(())
138 }
139}
140
141pub async fn send_tg_message(text: String, parse_mode: ParseMode, silent: bool) -> TgResult<()> {
142 let session = TgSession::from_config()?;
143 session.send_message(text, parse_mode, silent).await?;
144 Ok(())
145}
146
147pub fn send_tg_message_blocking(text: String, parse_mode: ParseMode, silent: bool) -> TgResult<()> {
148 if tokio::runtime::Handle::try_current().is_ok() {
149 let worker = std::thread::spawn(move || {
150 let rt = tokio::runtime::Builder::new_current_thread()
151 .enable_all()
152 .build()
153 .map_err(SendMessageError::RuntimeInit)?;
154 rt.block_on(send_tg_message(text, parse_mode, silent))
155 });
156
157 return match worker.join() {
158 Ok(result) => result,
159 Err(_) => Err(SendMessageError::RuntimeInit(io::Error::other(
160 "failed to join Telegram sender thread",
161 ))),
162 };
163 }
164
165 let rt = tokio::runtime::Builder::new_current_thread()
166 .enable_all()
167 .build()
168 .map_err(SendMessageError::RuntimeInit)?;
169 rt.block_on(send_tg_message(text, parse_mode, silent))
170}
171
172#[cfg(feature = "non-blocking")]
173#[macro_export]
174macro_rules! telegram {
175 () => {{
176 $crate::telegram!("")
177 }};
178 ($($arg:tt)*) => {{
179 let msg = format!($($arg)*);
180 tokio::spawn(async move {
181 if let Err(err) = $crate::send_tg_message(
182 msg,
183 $crate::ParseMode::Markdown,
184 false,
185 ).await {
186 eprintln!("{err}");
187 }
188 });
189 }};
190}
191
192#[cfg(not(feature = "non-blocking"))]
193#[macro_export]
194macro_rules! telegram {
195 () => {{
196 $crate::telegram!("")
197 }};
198 ($($arg:tt)*) => {{
199 if let Err(err) = $crate::send_tg_message_blocking(
200 format!($($arg)*),
201 $crate::ParseMode::Markdown,
202 false,
203 ) {
204 eprintln!("{err}");
205 }
206 }};
207}