feat(utils): poll command (#40)

Co-authored-by: Ax333l <main@axelen.xyz>
This commit is contained in:
oSumAtrIX 2022-12-15 20:37:21 +01:00 committed by GitHub
parent 90643206b1
commit 3be6b4693c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 356 additions and 36 deletions

View File

@ -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=''
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=''

View File

@ -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",

62
src/api/client.rs Normal file
View File

@ -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<HeaderMap>,
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<T: DeserializeOwned>(&self, request_info: &RequestInfo<'_>) -> Result<T, reqwest::Error> {
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::<T>().await
}
pub async fn authenticate(
&self,
discord_id_hash: &str,
) -> Result<Authentication, reqwest::Error> {
let route = Endpoint::Authenticate {
id: &self.client_id,
secret: &self.client_secret,
discord_id_hash,
};
self
.fire(&RequestInfo {
headers: None,
route,
})
.await
}
}

3
src/api/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod client;
pub mod model;
mod routing;

6
src/api/model/auth.rs Normal file
View File

@ -0,0 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Authentication {
pub access_token: String,
}

1
src/api/model/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod auth;

28
src/api/routing.rs Normal file
View File

@ -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),
}
}
}

View File

@ -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<String>,
#[description = "The message to send"] message: String,
) -> Result<(), Error> {
async fn send_ephermal<'a>(
ctx: &Context<'a>,
content: &str,
) -> Result<ReplyHandle<'a>, 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<ReplyHandle<'a>, 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(())
}

View File

@ -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<Vec<PermissionOverwrite>>,
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Poll {
pub author: Option<PollAuthor>,
pub image_url: Option<String>,
pub votes: Option<u16>,
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct PollAuthor {
pub name: Option<String>,
pub id: Option<u64>,
}
impl From<Muted> for Document {
fn from(muted: Muted) -> Self {
to_document(&muted)

53
src/events/interaction.rs Normal file
View File

@ -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<T>(str: &str) -> T
where
<T as std::str::FromStr>::Err: std::fmt::Debug,
T: std::str::FromStr,
{
str.parse::<T>().unwrap()
}
let poll: Vec<_> = custom_id.split(':').collect::<Vec<_>>();
let poll_id = parse::<u64>(poll[1]);
let min_age = parse::<i64>(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
}

View File

@ -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<T: Send + Sync> Handler<T> {
// Manually dispatch events from serenity to poise
#[serenity::async_trait]
impl serenity::EventHandler for Handler<Arc<RwLock<Data>>> {
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<serenity::Member>,
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<Arc<RwLock<Data>>> {
.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<Arc<RwLock<Data>>> {
.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 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 thread_create(&self, ctx: serenity::Context, thread: serenity::GuildChannel) {
thread_create::thread_create(&ctx, &thread).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 guild_member_update(
&self,
ctx: serenity::Context,
old_if_available: Option<serenity::Member>,
new: serenity::Member,
) {
guild_member_update::guild_member_update(&ctx, &old_if_available, &new).await;
}
}

View File

@ -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<Database>,
pending_unmutes: HashMap<u64, JoinHandle<Option<Error>>>,
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(

View File

@ -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;
pub mod poll;

78
src/utils/poll.rs Normal file
View File

@ -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(())
}