1use std::{
2 fs,
3 io::{self},
4 path::PathBuf,
5};
6
7use teloxide::{
8 Bot, RequestError,
9 payloads::{EditMessageTextSetters, SendDocumentSetters, SendMessageSetters},
10 prelude::Requester,
11 types::{ChatId, InputFile, MessageId, ParseMode as TeloxideParseMode},
12};
13
14use crate::config::{ConfigFile, config_path};
15
16mod config;
17mod secret_store;
18
19#[derive(Debug)]
20pub enum SendMessageError {
21 MissingToken(Option<String>),
22 MissingChatId(Option<String>),
23 RuntimeInit(io::Error),
24 Request(RequestError),
25}
26
27impl std::fmt::Display for SendMessageError {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 SendMessageError::MissingToken(None) => {
31 write!(f, "No token configured. Run `tg setup` first.")
32 }
33 SendMessageError::MissingToken(Some(profile)) => {
34 write!(
35 f,
36 "No token configured for profile '{profile}'. Run `tg --profile {profile} setup` first."
37 )
38 }
39 SendMessageError::MissingChatId(None) => {
40 write!(f, "No chat ID configured. Run `tg setup` first.")
41 }
42 SendMessageError::MissingChatId(Some(profile)) => {
43 write!(
44 f,
45 "No chat ID configured for profile '{profile}'. Run `tg --profile {profile} setup` first."
46 )
47 }
48 SendMessageError::RuntimeInit(err) => {
49 write!(f, "Failed to initialize async runtime: {err}")
50 }
51 SendMessageError::Request(err) => write!(f, "Failed to send message: {err}"),
52 }
53 }
54}
55
56impl std::error::Error for SendMessageError {
57 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
58 match self {
59 SendMessageError::RuntimeInit(err) => Some(err),
60 SendMessageError::Request(err) => Some(err),
61 SendMessageError::MissingToken(_) | SendMessageError::MissingChatId(_) => None,
62 }
63 }
64}
65
66pub type TgResult<T> = Result<T, SendMessageError>;
67
68pub struct SetupStatus {
69 pub has_token: bool,
70 pub chat_id: Option<i64>,
71}
72
73pub struct BotConfigStatus {
74 pub path: PathBuf,
75 pub config_file_present: bool,
76 pub chat_id: Option<i64>,
77 pub token: TokenStatus,
78 pub secret_service: SecretServiceStatus,
79}
80
81pub enum TokenStatus {
82 SecretService,
83 PlaintextFallback,
84 NotConfigured,
85}
86
87pub enum SecretServiceStatus {
88 Available,
89 Unavailable,
90 Error(String),
91}
92
93pub struct TgSession {
94 bot: Bot,
95 chat_id: ChatId,
96}
97
98#[derive(Debug, Clone, Copy)]
99pub enum ParseMode {
100 Markdown,
101 Html,
102}
103
104impl From<ParseMode> for TeloxideParseMode {
105 fn from(mode: ParseMode) -> Self {
106 match mode {
107 ParseMode::Markdown => TeloxideParseMode::MarkdownV2,
108 ParseMode::Html => TeloxideParseMode::Html,
109 }
110 }
111}
112
113impl TgSession {
114 pub async fn from_config(profile: Option<&str>) -> TgResult<Self> {
115 let file = ConfigFile::load();
116 let profile_data = file.get_profile(profile);
117 let token = profile_data
118 .resolved_token_for(profile)
119 .await
120 .ok_or_else(|| SendMessageError::MissingToken(profile.map(|s| s.to_string())))?;
121 let chat_id = profile_data
122 .chat_id
123 .ok_or_else(|| SendMessageError::MissingChatId(profile.map(|s| s.to_string())))?;
124
125 Ok(Self {
126 bot: Bot::new(token),
127 chat_id: ChatId(chat_id),
128 })
129 }
130
131 fn sanitize_text(text: String) -> String {
132 text.replace("\r\n", "\n")
133 .replace('_', "\\_")
134 .replace('*', "\\*")
135 .replace('[', "\\[")
136 .replace(']', "\\]")
137 .replace('(', "\\(")
138 .replace(')', "\\)")
139 .replace('~', "\\~")
140 .replace('`', "\\`")
141 .replace('>', "\\>")
142 .replace('#', "\\#")
143 .replace('+', "\\+")
144 .replace('-', "\\-")
145 .replace('=', "\\=")
146 .replace('|', "\\|")
147 .replace('{', "\\{")
148 .replace('}', "\\}")
149 .replace('.', "\\.")
150 .replace('!', "\\!")
151 }
152
153 pub async fn send_message(
154 &self,
155 text: String,
156 parse_mode: ParseMode,
157 silent: bool,
158 ) -> TgResult<i32> {
159 let mut req = self
160 .bot
161 .send_message(self.chat_id, Self::sanitize_text(text));
162 req = req.parse_mode(parse_mode.into());
163
164 if silent {
165 req = req.disable_notification(true);
166 }
167
168 let message = req.await.map_err(SendMessageError::Request)?;
169 Ok(message.id.0)
170 }
171
172 pub async fn send_document(&self, path: &std::path::Path, silent: bool) -> TgResult<()> {
173 let input_file = InputFile::file(path);
174 let mut req = self.bot.send_document(self.chat_id, input_file);
175
176 if silent {
177 req = req.disable_notification(true);
178 }
179
180 req.await.map_err(SendMessageError::Request)?;
181 Ok(())
182 }
183
184 pub async fn edit_message(
185 &self,
186 message_id: i32,
187 text: String,
188 parse_mode: ParseMode,
189 ) -> TgResult<()> {
190 let mut req = self.bot.edit_message_text(
191 self.chat_id,
192 MessageId(message_id),
193 Self::sanitize_text(text),
194 );
195 req = req.parse_mode(parse_mode.into());
196 req.await.map_err(SendMessageError::Request)?;
197 Ok(())
198 }
199}
200
201pub async fn load_setup_status(profile: Option<&str>) -> SetupStatus {
202 let file = ConfigFile::load();
203 let profile_data = file.get_profile(profile);
204 SetupStatus {
205 has_token: profile_data.resolved_token_for(profile).await.is_some(),
206 chat_id: profile_data.chat_id,
207 }
208}
209
210pub async fn bot_from_config_token(profile: Option<&str>) -> TgResult<Bot> {
211 let file = ConfigFile::load();
212 let profile_data = file.get_profile(profile);
213 let token = profile_data
214 .resolved_token_for(profile)
215 .await
216 .ok_or_else(|| SendMessageError::MissingToken(profile.map(|s| s.to_string())))?;
217 Ok(Bot::new(token))
218}
219
220pub async fn listen_config(profile: Option<&str>) -> TgResult<(Bot, ChatId)> {
221 let bot = bot_from_config_token(profile).await?;
222 let file = ConfigFile::load();
223 let chat_id = file
224 .get_profile(profile)
225 .chat_id
226 .ok_or_else(|| SendMessageError::MissingChatId(profile.map(|s| s.to_string())))?;
227 Ok((bot, ChatId(chat_id)))
228}
229
230pub async fn inspect_bot_config(profile: Option<&str>) -> BotConfigStatus {
231 let path = config_path();
232 let file = ConfigFile::load();
233 let profile_data = file.get_profile(profile);
234
235 let (secret_service, token) =
236 match secret_store::load_token_for(profile.map(|s| s.to_string())).await {
237 Ok(Some(_)) => (SecretServiceStatus::Available, TokenStatus::SecretService),
238 Ok(None) => (
239 SecretServiceStatus::Available,
240 if profile_data.token.is_some() {
241 TokenStatus::PlaintextFallback
242 } else {
243 TokenStatus::NotConfigured
244 },
245 ),
246 Err(err) if secret_store::is_unavailable(&err) => (
247 SecretServiceStatus::Unavailable,
248 if profile_data.token.is_some() {
249 TokenStatus::PlaintextFallback
250 } else {
251 TokenStatus::NotConfigured
252 },
253 ),
254 Err(err) => (
255 SecretServiceStatus::Error(err.to_string()),
256 if profile_data.token.is_some() {
257 TokenStatus::PlaintextFallback
258 } else {
259 TokenStatus::NotConfigured
260 },
261 ),
262 };
263
264 BotConfigStatus {
265 config_file_present: path.exists(),
266 path,
267 chat_id: profile_data.chat_id,
268 token,
269 secret_service,
270 }
271}
272
273pub async fn save_bot_config(token: &str, chat_id: i64, profile: Option<&str>) {
274 let mut file = ConfigFile::load();
275 let mut profile_data = file.get_profile(profile);
276 let _ = profile_data.persist_token_for(token, profile).await;
277 profile_data.chat_id = Some(chat_id);
278 file.set_profile(profile, profile_data);
279 file.save();
280}
281
282pub fn save_chat_id(chat_id: i64, profile: Option<&str>) {
283 let mut file = ConfigFile::load();
284 let mut profile_data = file.get_profile(profile);
285 profile_data.chat_id = Some(chat_id);
286 file.set_profile(profile, profile_data);
287 file.save();
288}
289
290pub async fn delete_bot_config(profile: Option<&str>) -> bool {
291 let path = config_path();
292 let mut file = ConfigFile::load();
293 let had_data = file.get_profile(profile).chat_id.is_some();
294 file.delete_profile(profile);
295
296 if file.is_empty() {
297 if path.exists() {
298 fs::remove_file(&path).expect("failed to delete config");
299 }
300 } else {
301 file.save();
302 }
303
304 let mut removed_any = had_data;
305
306 match secret_store::delete_token_for(profile.map(|s| s.to_string())).await {
307 Ok(()) => {
308 removed_any = true;
309 }
310 Err(err) if secret_store::is_unavailable(&err) => {
311 eprintln!(
312 "Warning: Secret Service API unavailable; could not delete keyring token ({err})."
313 );
314 }
315 Err(err) => {
316 eprintln!("Warning: failed to delete keyring token ({err}).");
317 }
318 }
319
320 removed_any
321}
322
323pub fn list_profile_names() -> Vec<String> {
324 let file = ConfigFile::load();
325 let mut names: Vec<String> = file.profiles.keys().cloned().collect();
326 names.sort();
327 names
328}
329
330pub async fn send_tg_message(
331 text: String,
332 parse_mode: ParseMode,
333 silent: bool,
334 profile: Option<&str>,
335) -> TgResult<()> {
336 let session = TgSession::from_config(profile).await?;
337 session.send_message(text, parse_mode, silent).await?;
338 Ok(())
339}
340
341pub fn send_tg_message_blocking(
342 text: String,
343 parse_mode: ParseMode,
344 silent: bool,
345 profile: Option<&str>,
346) -> TgResult<()> {
347 let profile_owned = profile.map(|s| s.to_string());
348 if tokio::runtime::Handle::try_current().is_ok() {
349 let worker = std::thread::spawn(move || {
350 let rt = tokio::runtime::Builder::new_current_thread()
351 .enable_all()
352 .build()
353 .map_err(SendMessageError::RuntimeInit)?;
354 rt.block_on(send_tg_message(
355 text,
356 parse_mode,
357 silent,
358 profile_owned.as_deref(),
359 ))
360 });
361
362 return match worker.join() {
363 Ok(result) => result,
364 Err(_) => Err(SendMessageError::RuntimeInit(io::Error::other(
365 "failed to join Telegram sender thread",
366 ))),
367 };
368 }
369
370 let rt = tokio::runtime::Builder::new_current_thread()
371 .enable_all()
372 .build()
373 .map_err(SendMessageError::RuntimeInit)?;
374 rt.block_on(send_tg_message(
375 text,
376 parse_mode,
377 silent,
378 profile_owned.as_deref(),
379 ))
380}
381
382#[cfg(feature = "non-blocking")]
383#[macro_export]
384macro_rules! telegram {
385 () => {{
386 $crate::telegram!("")
387 }};
388 ($($arg:tt)*) => {{
389 let msg = format!($($arg)*);
390 let profile = std::env::var("TG_PROFILE").ok();
391 tokio::spawn(async move {
392 if let Err(err) = $crate::send_tg_message(
393 msg,
394 $crate::ParseMode::Markdown,
395 false,
396 profile.as_deref(),
397 )
398 .await
399 {
400 eprintln!("{err}");
401 }
402 });
403 }};
404}
405
406#[cfg(not(feature = "non-blocking"))]
407#[macro_export]
408macro_rules! telegram {
409 () => {{
410 $crate::telegram!("")
411 }};
412 ($($arg:tt)*) => {{
413 let profile = std::env::var("TG_PROFILE").ok();
414 if let Err(err) = $crate::send_tg_message_blocking(
415 format!($($arg)*),
416 $crate::ParseMode::Markdown,
417 false,
418 profile.as_deref(),
419 ) {
420 eprintln!("{err}");
421 }
422 }};
423}