use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fs::File; use std::io; use std::path::Path; use color_eyre::eyre; use serde::Deserialize; use serde::Serialize; use serenity::all::CreateAttachment; use serenity::all::CreateEmbed; use serenity::all::CreateEmbedAuthor; use serenity::all::CreateMessage; use serenity::model::channel::Message; use serenity::model::prelude::*; use serenity::prelude::*; use serenity::utils::parse_message_url; use crate::commands::Error; use crate::utils; /// A resolved target. #[derive(Debug)] struct Target { /// The link to the target. link: String, /// The content of the target. content: String, /// The author of the target. author: User, /// The attachments to include. attachments: Vec, /// The timestamp of the target. timestamp: Timestamp, } /// A guild's config, containing configured values like the archive channel. #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Config { /// The channel id to which archived messages are sent. #[serde(default)] pub archive_id: Option, /// The whitelisted users which can do archiving additionally to admins. #[serde(default)] pub archivers: BTreeSet, } /// The archive service. #[derive(Debug)] pub struct Archive { /// The guild configs. configs: BTreeMap, } impl TypeMapKey for Archive { type Value = Self; } impl Archive { /// Creates a new empty archive service. pub fn new() -> Self { Self { configs: BTreeMap::new(), } } /// Load the configs from the file at the given path. pub fn load(&mut self, store: impl AsRef) -> eyre::Result<()> { match File::options().read(true).open(store) { Ok(file) => self.configs = serde_json::from_reader(file)?, Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => Err(err)?, } Ok(()) } /// Save the configs to the file at the given path. pub fn save(&self, store: impl AsRef) -> eyre::Result<()> { serde_json::to_writer( File::options() .create(true) .write(true) .truncate(true) .open(store)?, &self.configs, )?; Ok(()) } /// Fails if a user doesn't have the necessary permissions to modify the /// guild settings. async fn check_user_config_permissions( &self, ctx: &Context, cmd: &Message, error_message: &str, ) -> Result<(), Error> { let perms = utils::resolve_member_permissions(ctx, &cmd.member(ctx).await?).await?; if !perms.administrator() { return Err(Error::user(error_message)); } Ok(()) } /// Fails if a user doesn't have the necessary permissions to archive. async fn check_user_archive_permissions( &self, ctx: &Context, cmd: &Message, error_message: &str, ) -> Result<(), Error> { let guild_id = utils::resolve_guild_id(ctx, cmd).await?; if self .configs .get(&guild_id) .map(|c| &c.archivers) .is_some_and(|a| a.contains(&cmd.author.id)) { return Ok(()); } self.check_user_config_permissions(ctx, cmd, error_message) .await } /// Archives a single message, resolving the arguments first. pub async fn command_archive( &self, ctx: &Context, cmd: &Message, message: Option, ) -> Result<(), Error> { let guild_id = utils::resolve_guild_id(ctx, cmd).await?; let archive_id = self .configs .get(&guild_id) .and_then(|c| c.archive_id) .ok_or_else(|| Error::user("guild has no archive channel set"))?; self.check_user_archive_permissions( ctx, cmd, "only admins or archivers can archive messages", ) .await?; let target = resolve_target_message(ctx, cmd, message.as_deref()).await?; let is_self = target.id == cmd.id; let mut target = construct_target(guild_id, &target).await?; if is_self { target.content.clear(); } send_archive_message(ctx, cmd, archive_id, target).await?; cmd.reply(ctx, "Done.").await?; Ok(()) } /// Archives all pinned messages. pub async fn command_archive_all(&self, ctx: &Context, cmd: &Message) -> Result<(), Error> { let guild_channel = utils::resolve_guild_channel(ctx, cmd.channel_id).await?; let mut pins = guild_channel.pins(ctx).await?; pins.sort_by_key(|m| m.id); let archive_id = self .configs .get(&guild_channel.guild_id) .and_then(|c| c.archive_id) .ok_or_else(|| Error::user("guild has no archive channel set"))?; self.check_user_archive_permissions( ctx, cmd, "only admins or archivers can archive messages", ) .await?; // NOTE: we do this in order and therefore not concurrently for message in pins { let target = construct_target(guild_channel.guild_id, &message).await?; send_archive_message(ctx, cmd, archive_id, target).await?; message.unpin(ctx).await?; } cmd.reply(ctx, "Done.").await?; Ok(()) } /// Sets the archive. pub async fn command_set_archive( &mut self, ctx: &Context, cmd: &Message, channel: ChannelId, ) -> Result<(), Error> { self.check_user_config_permissions(ctx, cmd, "only admins can set the archive") .await?; let guild_id = utils::resolve_guild_id(ctx, cmd).await?; let channel = utils::resolve_guild_channel(ctx, channel).await?; let config = self.configs.entry(guild_id).or_default(); if channel.guild_id != guild_id { return Err(Error::user(format!( "<#{}> is not part of this guild", channel.id ))); } let result = if let Some(prev) = config.archive_id { if prev == channel.id { cmd.reply(ctx, "Nothing changed.").await } else { cmd.reply(ctx, format!("Set archive from <#{prev}> to {channel}.")) .await } } else { cmd.reply(ctx, format!("Set archive to {channel}.")).await }; // NOTE: Ensure a message failure doesn't cancel the command's effect. config.archive_id = Some(channel.id); result?; Ok(()) } /// Unsets the current archive. pub async fn command_unset_archive( &mut self, ctx: &Context, cmd: &Message, ) -> Result<(), Error> { self.check_user_config_permissions(ctx, cmd, "only admins can unset the archive") .await?; let guild_id = utils::resolve_guild_id(ctx, cmd).await?; let config = self.configs.entry(guild_id).or_default(); let result = if let Some(prev) = config.archive_id { cmd.reply(ctx, format!("Unset archive from <#{prev}>.")) .await } else { cmd.reply(ctx, "Nothing changed.").await }; // NOTE: as above config.archive_id = None; result?; Ok(()) } /// Replies to the user with the current archive channel. pub async fn command_get_archive(&self, ctx: &Context, cmd: &Message) -> Result<(), Error> { let guild_id = utils::resolve_guild_id(ctx, cmd).await?; let archive_id = self .configs .get(&guild_id) .map(|c| c.archive_id) .unwrap_or_default(); cmd.reply( ctx, if let Some(id) = archive_id { format!("The archive is <#{id}>.") } else { "No archive channel set.".to_owned() }, ) .await?; Ok(()) } /// Replies to the user with the list of archivers. pub async fn command_user(&self, ctx: &Context, cmd: &Message) -> Result<(), Error> { let guild_id = utils::resolve_guild_id(ctx, cmd).await?; if let Some(archivers) = self.configs.get(&guild_id).map(|c| &c.archivers) { let mut content = String::from( "The following users can archive messages in addition to all admins:\n", ); for user in archivers { content.push_str(&format!("- <@!{user}>\n")); } cmd.channel_id .send_message(ctx, CreateMessage::new().content(content)) .await?; } else { cmd.reply( ctx, "Only admins can archive messages (no extra archivers set).", ) .await?; } Ok(()) } /// Adds users to the list of archivers. pub async fn command_user_add( &mut self, ctx: &Context, cmd: &Message, users: &[UserId], ) -> Result<(), Error> { self.check_user_config_permissions(ctx, cmd, "only admins can add new archivers") .await?; let guild_id = utils::resolve_guild_id(ctx, cmd).await?; self.configs .entry(guild_id) .or_default() .archivers .extend(users); cmd.reply(ctx, "Added the given users to the list of archivers.") .await?; Ok(()) } /// Removes users from the list of archivers. pub async fn command_user_remove( &mut self, ctx: &Context, cmd: &Message, users: &[UserId], ) -> Result<(), Error> { self.check_user_config_permissions(ctx, cmd, "only admins can remove archivers") .await?; let guild_id = utils::resolve_guild_id(ctx, cmd).await?; if let Some(archivers) = self.configs.get_mut(&guild_id).map(|c| &mut c.archivers) { for user in users { archivers.remove(&user); } } cmd.reply(ctx, "Removed the given users from the list of archivers.") .await?; Ok(()) } /// Clears the list of archivers. pub async fn command_user_clear(&mut self, ctx: &Context, cmd: &Message) -> Result<(), Error> { self.check_user_config_permissions(ctx, cmd, "only admins can remove all archivers") .await?; let guild_id = utils::resolve_guild_id(ctx, cmd).await?; if let Some(archivers) = self.configs.get_mut(&guild_id).map(|c| &mut c.archivers) { archivers.clear(); } cmd.reply(ctx, "Cleared the list of archivers.").await?; Ok(()) } } /// Constructs a target from the given message. async fn construct_target(guild_id: GuildId, target: &Message) -> Result { let attachments = target .attachments .iter() .map(|a| a.url.clone()) .chain(target.embeds.iter().filter_map(|e| { if e.thumbnail.is_none() && e.video.is_none() { tracing::error!( guild_id = ?target.guild_id, channel_id = ?target.channel_id, message_id = ?target.id, ?e, "neither video nor thumbnail given", ); } Option::or( e.thumbnail.as_ref().map(|t| t.url.clone()), e.video.as_ref().map(|v| v.url.clone()), ) })) .collect(); let content = target.content.clone(); let link = target.id.link(target.channel_id, Some(guild_id)); Ok(Target { link, content, author: target.author.clone(), attachments, timestamp: target.timestamp, }) } /// Resolves the target of the archive command. /// - If there is a reply and no message URL, the reply is chosen. /// - If there is a message URL and no attachments and the URL points to the /// same guild as the command, the message URL is chosen. /// - If there are attachments, the command message is chosen. /// - If none of the above are true, an error is returned. async fn resolve_target_message( ctx: &Context, cmd: &Message, message: Option<&str>, ) -> Result { let target = if let Some(reply) = cmd.referenced_message.as_ref() { if message.is_some() { return Err(Error::user( "provide either a reply or a message link, not both", )); } Message::clone(reply) } else if let Some(url) = message.as_ref() { let (_, url_channel_id, url_message_id) = match parse_message_url(url) { Some(ids) => ids, None => { // NOTE: message may just be a link to an image, collect its attachment if url.starts_with("http") { return Ok(cmd.clone()); } else { return Err(Error::user("invalid message link")); } } }; if !cmd.attachments.is_empty() { return Err(Error::user( "provide either a message link or attachments, not both", )); } utils::get_or_fetch_message(ctx, url_channel_id, url_message_id).await? } else if !cmd.attachments.is_empty() { cmd.clone() } else { return Err(Error::user("must provide either message link or reply")); }; Ok(target) } /// Sends an archive message for the given target. async fn send_archive_message( ctx: &Context, cmd: &Message, archive_id: ChannelId, target: Target, ) -> Result<(), Error> { let mut msg = CreateMessage::new(); msg = msg.content(&target.link); let mut attachments = vec![]; for url in &target.attachments { attachments.push(CreateAttachment::url(&ctx.http, url).await?); } msg = msg.add_files(attachments); msg = msg.add_embed({ let mut embed = CreateEmbed::new().author({ let mut att = CreateEmbedAuthor::new(&target.author.name); att = att.icon_url( target .author .avatar_url() .unwrap_or_else(|| cmd.author.default_avatar_url()), ); att }); // NOTE: we only want to show an embed image if there's exactly one attachment // which is embeddable, see: // https://discord.com/developers/docs/reference#editing-message-attachments-using-attachments-within-embeds if let Some(file_name) = target .attachments .last() .filter(|_| target.attachments.len() == 1) .and_then(|url| url.rsplit_once('/')) .map(|(_, file_name)| file_name) .filter(|file_name| { file_name .rsplit_once('.') .is_some_and(|(_, ext)| matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif")) }) { embed = embed.image(format!("attachment://{file_name}")); } embed = embed.description(target.content); embed = embed.timestamp(target.timestamp); embed }); archive_id.send_message(ctx, msg).await?; Ok(()) }