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#[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 source_nick: Option<String>,
41 error_message: String,
43 msg: String,
45 #[source_code]
46 src: Option<String>,
47 #[label]
48 span: Option<SourceSpan>,
49 },
50 #[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 pub(crate) 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 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 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}