diff --git a/.env.example b/.env.example index 2235b01..5062bbb 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,11 @@ # The Discord authorization token for the bot, requires the MESSAGE_CONTENT intent DISCORD_AUTHORIZATION_TOKEN= # The connection string to the MongoDB database -MONGODB_URI='' \ No newline at end of file +MONGODB_URI='' + +# The api server for the poll command +API_SERVER='' +# The client id for the api +API_CLIENT_ID='' +# The client secret for the api +API_CLIENT_SECRET='' diff --git a/configuration.schema.json b/configuration.schema.json index 4df0cfa..c57bc67 100644 --- a/configuration.schema.json +++ b/configuration.schema.json @@ -63,7 +63,6 @@ } }, "description": "Introduce new threads with a message.", - "minItems": 1, "uniqueItems": true }, "message_responses": { @@ -138,8 +137,7 @@ "items": { "type": "integer" }, - "uniqueItems": true, - "minItems": 1 + "uniqueItems": true }, "match": { "$ref": "#/$defs/regex", diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..c3434f5 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,62 @@ +use reqwest::header::HeaderMap; +use reqwest::Client; +use serde::de::DeserializeOwned; + +use super::model::auth::Authentication; + +use super::routing::Endpoint; + +pub struct Api { + pub client: Client, + pub server: reqwest::Url, + pub client_id: String, + pub client_secret: String, +} + +struct RequestInfo<'a> { + headers: Option, + route: Endpoint<'a>, +} + +impl Api { + pub fn new(server: reqwest::Url, client_id: String, client_secret: String) -> Self { + let client = Client::builder() + .build() + .expect("Cannot build reqwest::Client"); + + Api { + client, + server, + client_id, + client_secret, + } + } + + async fn fire(&self, request_info: &RequestInfo<'_>) -> Result { + let client = &self.client; + let mut req = request_info.route.to_request(&self.server); + + if let Some(headers) = &request_info.headers { + *req.headers_mut() = headers.clone(); + } + + client.execute(req).await?.json::().await + } + + pub async fn authenticate( + &self, + discord_id_hash: &str, + ) -> Result { + let route = Endpoint::Authenticate { + id: &self.client_id, + secret: &self.client_secret, + discord_id_hash, + }; + self + .fire(&RequestInfo { + headers: None, + route, + }) + .await + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..503ee09 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod model; +mod routing; diff --git a/src/api/model/auth.rs b/src/api/model/auth.rs new file mode 100644 index 0000000..776b0a7 --- /dev/null +++ b/src/api/model/auth.rs @@ -0,0 +1,6 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Authentication { + pub access_token: String, +} diff --git a/src/api/model/mod.rs b/src/api/model/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/api/model/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/api/routing.rs b/src/api/routing.rs new file mode 100644 index 0000000..27f1803 --- /dev/null +++ b/src/api/routing.rs @@ -0,0 +1,28 @@ +use reqwest::{Body, Method, Request}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum Endpoint<'a> { + Authenticate { + id: &'a str, + secret: &'a str, + discord_id_hash: &'a str, + }, +} + +macro_rules! route { + ($self:ident, $server:ident, $endpoint:literal, $method:ident) => {{ + let mut req = Request::new(Method::$method, $server.join($endpoint).unwrap()); + *req.body_mut() = Some(Body::from(serde_json::to_vec($self).unwrap())); + req + }}; +} + +impl Endpoint<'_> { + pub fn to_request(&self, server: &reqwest::Url) -> Request { + match self { + Self::Authenticate { .. } => route!(self, server, "/auth/", POST), + } + } +} diff --git a/src/commands/misc.rs b/src/commands/misc.rs index 037450d..8d75534 100644 --- a/src/commands/misc.rs +++ b/src/commands/misc.rs @@ -1,4 +1,5 @@ -use poise::serenity_prelude::{self as serenity, MessageId}; +use chrono::Utc; +use poise::serenity_prelude::{self as serenity, MessageId, ReactionType}; use poise::ReplyHandle; use crate::{Context, Error}; @@ -10,6 +11,13 @@ pub async fn reply( #[description = "The message id to reply to"] reply_message: Option, #[description = "The message to send"] message: String, ) -> Result<(), Error> { + async fn send_ephermal<'a>( + ctx: &Context<'a>, + content: &str, + ) -> Result, serenity::Error> { + ctx.send(|f| f.ephemeral(true).content(content)).await + } + let http = &ctx.discord().http; let channel = &ctx.channel_id(); @@ -38,9 +46,47 @@ pub async fn reply( Ok(()) } -async fn send_ephermal<'a>( - ctx: &Context<'a>, - content: &str, -) -> Result, serenity::Error> { - ctx.send(|f| f.ephemeral(true).content(content)).await +/// Start a poll. +#[poise::command(slash_command)] +pub async fn poll( + ctx: Context<'_>, + #[description = "The id of the poll"] id: u64, + #[description = "The poll message"] message: String, + #[description = "The poll title"] title: String, + #[description = "The minumum server age in days to allow members to poll"] age: u16, +) -> Result<(), Error> { + let data = ctx.data().read().await; + let configuration = &data.configuration; + let embed_color = configuration.general.embed_color; + + ctx.send(|m| { + m.embed(|e| { + let guild = &ctx.guild().unwrap(); + if let Some(url) = guild.icon_url() { + e.thumbnail(url.clone()).footer(|f| { + f.icon_url(url).text(format!( + "{} • {}", + guild.name, + Utc::today().format("%Y/%m/%d") + )) + }) + } else { + e + } + .title(title) + .description(message) + .color(embed_color) + }) + .components(|c| { + c.create_action_row(|r| { + r.create_button(|b| { + b.label("Vote") + .emoji(ReactionType::Unicode("🗳️".to_string())) + .custom_id(format!("poll:{}:{}", id, age)) + }) + }) + }) + }) + .await?; + Ok(()) } diff --git a/src/db/model.rs b/src/db/model.rs index 4e5e4d3..3438e0c 100644 --- a/src/db/model.rs +++ b/src/db/model.rs @@ -1,7 +1,7 @@ use std::fmt::Display; use bson::Document; -use poise::serenity_prelude::{PermissionOverwrite}; +use poise::serenity_prelude::PermissionOverwrite; use serde::{Deserialize, Serialize}; use serde_with_macros::skip_serializing_none; @@ -23,6 +23,21 @@ pub struct LockedChannel { pub overwrites: Option>, } +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct Poll { + pub author: Option, + pub image_url: Option, + pub votes: Option, +} + +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct PollAuthor { + pub name: Option, + pub id: Option, +} + impl From for Document { fn from(muted: Muted) -> Self { to_document(&muted) diff --git a/src/events/interaction.rs b/src/events/interaction.rs new file mode 100644 index 0000000..7e1b588 --- /dev/null +++ b/src/events/interaction.rs @@ -0,0 +1,53 @@ +use chrono::{Duration, Utc}; +use poise::serenity_prelude::{ + ComponentType, + MessageComponentInteraction, + MessageComponentInteractionData, +}; + +use super::*; +use crate::utils; +pub async fn interaction_create( + ctx: &serenity::Context, + interaction: &serenity::Interaction, +) -> Result<(), crate::serenity::SerenityError> { + if let serenity::Interaction::MessageComponent(MessageComponentInteraction { + data: + MessageComponentInteractionData { + component_type: ComponentType::Button, + custom_id, + .. + }, + .. + }) = interaction + { + if custom_id.starts_with("poll") { + handle_poll(ctx, interaction, custom_id).await? + } + } + + Ok(()) +} + +pub async fn handle_poll( + ctx: &serenity::Context, + interaction: &serenity::Interaction, + custom_id: &str, +) -> Result<(), crate::serenity::SerenityError> { + fn parse(str: &str) -> T + where + ::Err: std::fmt::Debug, + T: std::str::FromStr, + { + str.parse::().unwrap() + } + + let poll: Vec<_> = custom_id.split(':').collect::>(); + + let poll_id = parse::(poll[1]); + let min_age = parse::(poll[2]); + + let min_join_date = serenity::Timestamp::from(Utc::now() - Duration::days(min_age)); + + utils::poll::handle_poll(ctx, interaction, poll_id, min_join_date).await +} diff --git a/src/events/mod.rs b/src/events/mod.rs index 461661e..4884546 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -1,11 +1,13 @@ use std::sync::Arc; use poise::serenity_prelude::{self as serenity, Mutex, RwLock, ShardManager, UserId}; +use tracing::log::error; use crate::{Data, Error}; mod guild_member_addition; mod guild_member_update; +mod interaction; mod message_create; mod ready; mod thread_create; @@ -46,10 +48,21 @@ impl Handler { // Manually dispatch events from serenity to poise #[serenity::async_trait] impl serenity::EventHandler for Handler>> { - async fn ready(&self, ctx: serenity::Context, ready: serenity::Ready) { - *self.bot_id.write().await = Some(ready.user.id); + async fn guild_member_addition( + &self, + ctx: serenity::Context, + mut new_member: serenity::Member, + ) { + guild_member_addition::guild_member_addition(&ctx, &mut new_member).await; + } - ready::load_muted_members(&ctx, &ready).await; + async fn guild_member_update( + &self, + ctx: serenity::Context, + old_if_available: Option, + new: serenity::Member, + ) { + guild_member_update::guild_member_update(&ctx, &old_if_available, &new).await; } async fn message(&self, ctx: serenity::Context, new_message: serenity::Message) { @@ -61,13 +74,6 @@ impl serenity::EventHandler for Handler>> { .await; } - async fn interaction_create(&self, ctx: serenity::Context, interaction: serenity::Interaction) { - self.dispatch_poise_event(&ctx, &poise::Event::InteractionCreate { - interaction, - }) - .await; - } - async fn message_update( &self, ctx: serenity::Context, @@ -83,20 +89,24 @@ impl serenity::EventHandler for Handler>> { .await; } - async fn thread_create(&self, ctx: serenity::Context, thread: serenity::GuildChannel) { - thread_create::thread_create(&ctx, &thread).await; + async fn ready(&self, ctx: serenity::Context, ready: serenity::Ready) { + *self.bot_id.write().await = Some(ready.user.id); + + ready::load_muted_members(&ctx, &ready).await; } - async fn guild_member_addition(&self, ctx: serenity::Context, mut new_member: serenity::Member) { - guild_member_addition::guild_member_addition(&ctx, &mut new_member).await; + async fn interaction_create(&self, ctx: serenity::Context, interaction: serenity::Interaction) { + if let Err(e) = interaction::interaction_create(&ctx, &interaction).await { + error!("Failed to handle interaction: {:?}.", e); + } + + self.dispatch_poise_event(&ctx, &poise::Event::InteractionCreate { + interaction, + }) + .await; } - async fn guild_member_update( - &self, - ctx: serenity::Context, - old_if_available: Option, - new: serenity::Member, - ) { - guild_member_update::guild_member_update(&ctx, &old_if_available, &new).await; + async fn thread_create(&self, ctx: serenity::Context, thread: serenity::GuildChannel) { + thread_create::thread_create(&ctx, &thread).await; } } diff --git a/src/main.rs b/src/main.rs index 54c52b2..5a50e91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::env; use std::sync::Arc; +use api::client::Api; use commands::{configuration, misc, moderation}; use db::database::Database; use events::Handler; @@ -12,6 +13,7 @@ use utils::bot::load_configuration; use crate::model::application::Configuration; +mod api; mod commands; mod db; mod events; @@ -30,6 +32,7 @@ pub struct Data { configuration: Configuration, database: Arc, pending_unmutes: HashMap>>, + api: Api, } #[tokio::main] @@ -53,6 +56,7 @@ async fn main() { moderation::lock(), moderation::unlock(), misc::reply(), + misc::poll(), ]; poise::set_qualified_names(&mut commands); @@ -79,6 +83,14 @@ async fn main() { .unwrap(), ), pending_unmutes: HashMap::new(), + api: Api::new( + reqwest::Url::parse( + &env::var("API_SERVER").expect("API_SERVER environment variable not set"), + ) + .expect("Invalid API_SERVER"), + env::var("API_CLIENT_ID").expect("API_CLIENT_ID environment variable not set"), + env::var("API_CLIENT_SECRET").expect("API_CLIENT_SECRET environment variable not set"), + ), })); let handler = Arc::new(Handler::new( diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 69f45fa..ea197f7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,8 +1,9 @@ use poise::serenity_prelude::{self as serenity, Member, RoleId}; +pub mod autorespond; pub mod bot; pub mod decancer; pub mod embed; +pub mod media_channel; pub mod moderation; -pub mod autorespond; -pub mod media_channel; \ No newline at end of file +pub mod poll; diff --git a/src/utils/moderation.rs b/src/utils/moderation.rs index dc37fef..14fb35c 100644 --- a/src/utils/moderation.rs +++ b/src/utils/moderation.rs @@ -43,7 +43,6 @@ pub async fn mute_on_join(ctx: &serenity::Context, new_member: &mut serenity::Me ) .await { - if let Ok(found) = cursor.advance().await { if found { debug!("Muted member {} rejoined the server", new_member.user.tag()); diff --git a/src/utils/poll.rs b/src/utils/poll.rs new file mode 100644 index 0000000..fe1ab99 --- /dev/null +++ b/src/utils/poll.rs @@ -0,0 +1,78 @@ +use poise::serenity_prelude::{ButtonStyle, ReactionType, Timestamp}; + +use tracing::log::{error, info, trace}; + +use super::bot::get_data_lock; +use super::*; + +pub async fn handle_poll( + ctx: &serenity::Context, + interaction: &serenity::Interaction, + poll_id: u64, + min_join_date: Timestamp, +) -> Result<(), crate::serenity::SerenityError> { + trace!("Handling poll: {}.", poll_id); + + let data = get_data_lock(ctx).await; + let data = data.read().await; + + let component = &interaction.clone().message_component().unwrap(); + + let member = component.member.as_ref().unwrap(); + + let eligible = member.joined_at.unwrap() <= min_join_date; + let auth_token = if eligible { + let result = data + .api + .authenticate(&member.user.id.to_string()) + .await + .map(|auth| auth.access_token); + + if let Err(ref e) = result { + error!("API Request error: {}", e) + } + result.ok() + } else { + None + }; + + component + .create_interaction_response(&ctx.http, |r| { + r.interaction_response_data(|m| { + if let Some(token) = auth_token.as_deref() { + let url = format!("https://revanced.app/polling#{}", token); + m.components(|c| { + c.create_action_row(|r| { + r.create_button(|b| { + b.label("Vote") + .emoji(ReactionType::Unicode("🗳️".to_string())) + .style(ButtonStyle::Link) + .url(&url) + }) + }) + }) + } else { + m + } + .ephemeral(true) + .embed(|e| { + if auth_token.is_some() { + e.title("Cast your vote") + .description("You can now vote on the poll.") + } else if !eligible { + info!("Member {} failed to vote.", member.display_name()); + e.title("You can not vote") + .description("You are not eligible to vote on this poll.") + } else { + e.title("Error") + .description("An error has occured. Please try again later.") + } + .color(data.configuration.general.embed_color) + .thumbnail(member.user.face()) + }) + }) + }) + .await?; + + Ok(()) +}