Skip to main content

tg_cli/
lib.rs

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}