Skip to main content

irssc/
bot.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs::File,
4    io::{Read, Write},
5    path::PathBuf,
6    sync::Arc,
7};
8
9use chrono::{DateTime, Utc};
10use irc::{
11    client::Client,
12    proto::{Command, Message, Prefix},
13};
14use log::{error, trace, warn};
15use miette::{IntoDiagnostic, SourceSpan, WrapErr};
16use serde::{Deserialize, Serialize};
17use tokio::sync::RwLock;
18
19use crate::{HELP_TXT, INFO_TXT, rss::get_feed};
20
21/// List of all possible bot commands and their parameters
22#[derive(Debug)]
23pub(crate) enum BotCommand {
24    Help { user: String },
25    Info { user: String },
26    Subscribe { url: String, user: String },
27    Unsubscribe { url: String, user: String },
28    List { user: String },
29}
30
31#[derive(thiserror::Error, miette::Diagnostic, Debug)]
32pub(crate) enum BotCommandError {
33    #[error("No matching command found")]
34    NoCommandFound,
35    #[error(
36        "A command has been recognized but an error occurred while attempting to parse it: {error_message}. See help command."
37    )]
38    CommandError {
39        /// the user having input the command
40        source_nick: Option<String>,
41        /// The error message
42        error_message: String,
43        /// The message being parsed
44        msg: String,
45        #[source_code]
46        src: Option<String>,
47        #[label]
48        span: Option<SourceSpan>,
49    },
50    /// This bot has no such command.
51    #[error("Command \"{1}\" unrecognized. See help command.")]
52    NoSuchCommand(User, String),
53}
54
55impl TryFrom<&Message> for BotCommand {
56    type Error = BotCommandError;
57
58    fn try_from(value: &Message) -> Result<Self, Self::Error> {
59        if let Command::PRIVMSG(_dest, msg) = &value.command {
60            let Some(prefix) = &value.prefix else {
61                return Err(BotCommandError::NoCommandFound);
62            };
63            let Prefix::Nickname(nick, _, _) = prefix.clone() else {
64                return Err(BotCommandError::NoCommandFound);
65            };
66            let split: Vec<&str> = msg.split_whitespace().collect();
67            if split.is_empty() {
68                return Err(BotCommandError::NoCommandFound);
69            }
70            match split
71                .first()
72                .expect("We checked before if split.len()>0 but we got None on split.get(1)…")
73            {
74                &"help" | &"h" => return Ok(BotCommand::Help { user: nick }),
75                &"info" | &"i" => return Ok(BotCommand::Info { user: nick }),
76                &"subscribe" | &"sub" | &"s" => {
77                    if let Some(url) = split.get(1) {
78                        return Ok(BotCommand::Subscribe {
79                            url: url.to_string(),
80                            user: nick,
81                        });
82                    } else {
83                        return Err(BotCommandError::CommandError {
84                            error_message: "Found subscribe command but without its <url> argument"
85                                .to_string(),
86                            msg: msg.clone(),
87                            source_nick: Some(nick),
88                            src: None,
89                            span: None,
90                        });
91                    }
92                }
93                &"unsubscribe" | &"unsub" | &"u" => {
94                    if let Some(url) = split.get(1) {
95                        return Ok(BotCommand::Unsubscribe {
96                            url: url.to_string(),
97                            user: nick,
98                        });
99                    } else {
100                        return Err(BotCommandError::CommandError {
101                            error_message:
102                                "Found unsubscribe command but without its <url> argument"
103                                    .to_string(),
104                            msg: msg.clone(),
105                            source_nick: Some(nick),
106                            src: None,
107                            span: None,
108                        });
109                    }
110                }
111                &"list" | &"ls" | &"l" => return Ok(BotCommand::List { user: nick }),
112                cmd => return Err(BotCommandError::NoSuchCommand(nick, cmd.to_string())),
113            }
114        }
115        Err(BotCommandError::NoCommandFound)
116    }
117}
118
119type SubUrl = String;
120type User = String;
121
122#[derive(Debug, Deserialize, Serialize, Clone)]
123pub(crate) struct Subscriptions {
124    #[serde(skip)]
125    server_name: String,
126    pub(crate) subs: HashMap<SubUrl, HashSet<User>>,
127    pub(crate) last_check: DateTime<Utc>,
128}
129
130impl Subscriptions {
131    fn get_fname(server_name: &str) -> PathBuf {
132        if let Ok(base_path) = std::env::var("IRSSC_DATA_DIR") {
133            let p = PathBuf::from(base_path);
134            if !p.is_dir() {
135                warn!("IRSSC_DATA_DIR was set to an non-existing directory!");
136            }
137            p
138        } else {
139            PathBuf::from(".")
140        }
141        .join(format!("{server_name}.data.toml"))
142    }
143
144    pub(crate) async fn load(server_name: &str) -> miette::Result<Self> {
145        let server_config_path = Self::get_fname(server_name);
146        if !server_config_path.exists() {
147            return Ok(Self {
148                server_name: server_name.to_string(),
149                subs: HashMap::new(),
150                last_check: Utc::now(),
151            });
152        }
153        let mut f = File::open(&server_config_path)
154            .into_diagnostic()
155            .wrap_err(format!(
156                "Could not open server storage at path {}",
157                server_config_path.display()
158            ))?;
159        let fsize = f.metadata().map(|m| m.len()).unwrap_or(1024);
160        let mut readbuffer = String::with_capacity(fsize as usize);
161        f.read_to_string(&mut readbuffer).into_diagnostic()?;
162        let mut subs: Subscriptions = toml::from_str(&readbuffer)
163            .into_diagnostic()
164            .wrap_err("Could not read storage file")?;
165        subs.server_name = server_name.to_string();
166        Ok(subs)
167    }
168
169    pub(crate) async fn save(&self) -> miette::Result<()> {
170        let fname = Self::get_fname(&self.server_name);
171        let mut f = File::create(&fname)
172            .into_diagnostic()
173            .wrap_err(format!("could not create file {}", fname.display()))?;
174        let s = toml::to_string_pretty(self).into_diagnostic()?;
175        f.write_all(s.as_bytes()).into_diagnostic()?;
176        Ok(())
177    }
178}
179
180pub(crate) async fn dispatch_message(
181    msg: &Message,
182    config: &irc::client::data::Config,
183    storage: Arc<RwLock<Subscriptions>>,
184    client: &Client,
185) -> miette::Result<()> {
186    // filter anything that's not a privmsg, we are not interested in those.
187    let Command::PRIVMSG(_s1, _s2) = &msg.command else {
188        return Ok(());
189    };
190    trace!("dispatching message {msg:#?}");
191    match BotCommand::try_from(msg) {
192        Ok(bot_cmd) => match bot_cmd {
193            BotCommand::Subscribe { url, user } => {
194                match get_feed(&url).await {
195                    Ok(_feed) => {
196                        storage
197                            .write()
198                            .await
199                            .subs
200                            .entry(url.clone())
201                            .or_insert_with(HashSet::new)
202                            .insert(user.clone());
203                        storage.write().await.save().await?;
204                        client
205                            .send_privmsg(
206                                user,
207                                format!("You have successfully subscribed to {url}."),
208                            )
209                            .into_diagnostic()?;
210                    }
211                    Err(e) => {
212                        // send back to client that we could not load the feed
213                        client
214                            .send_privmsg(
215                                user,
216                                format!("I could not contact the given feed URL: {e}"),
217                            )
218                            .into_diagnostic()?;
219                    }
220                }
221            }
222            BotCommand::Unsubscribe { url, user } => {
223                let mut storage = storage.write().await;
224                storage.subs.get_mut(&url).map(|set| set.remove(&user));
225                if storage.subs.get(&url).is_some_and(HashSet::is_empty) {
226                    storage.subs.remove(&url);
227                }
228                storage.save().await?;
229            }
230            BotCommand::List { user } => {
231                let storage = storage.read().await;
232                let user_subs = storage
233                    .subs
234                    .iter()
235                    .filter_map(|(k, v)| if v.contains(&user) { Some(k) } else { None });
236                for sub in user_subs {
237                    client.send_privmsg(&user, sub).into_diagnostic()?;
238                }
239            }
240            BotCommand::Help { user } => client
241                .send_privmsg(
242                    user,
243                    HELP_TXT
244                        .replace("\n", "\r\n")
245                        .replace("{bot_name}", config.username()),
246                )
247                .into_diagnostic()?,
248            BotCommand::Info { user } => client
249                .send_privmsg(user, INFO_TXT.replace("\n", "\r\n"))
250                .into_diagnostic()?,
251        },
252        Err(e) => match &e {
253            BotCommandError::NoCommandFound => {
254                trace!("dispatch_message: No command founds")
255            }
256            BotCommandError::CommandError { source_nick, .. } => {
257                if let Some(user) = source_nick {
258                    error!("Sending error back to client: {e:#?}");
259                    client.send_privmsg(user, e.to_string()).into_diagnostic()?;
260                } else {
261                    error!(
262                        "can't send error back to client as we don't know the source user: {e:#?}"
263                    );
264                }
265            }
266            BotCommandError::NoSuchCommand(user, _cmd) => {
267                error!("Sending error back to client: {e:#?}");
268                client.send_privmsg(user, e.to_string()).into_diagnostic()?;
269            }
270        },
271    }
272    Ok(())
273}