feat: Create/List trivia working

This commit is contained in:
Max batleforc 2025-05-25 21:40:56 +02:00
parent b7d1faebcf
commit eada83fe4a
No known key found for this signature in database
GPG Key ID: 25D243AB4B6AC9E7
17 changed files with 444 additions and 53 deletions

19
Cargo.lock generated
View File

@ -714,6 +714,8 @@ dependencies = [
"clickhouse",
"clickhouse_pool",
"serde",
"serde_json",
"serde_repr",
"tokio",
"tracing",
"uuid",
@ -1910,25 +1912,25 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "poise"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1819d5a45e3590ef33754abce46432570c54a120798bdbf893112b4211fa09a6"
source = "git+https://github.com/serenity-rs/poise?branch=current#518ff0564865bca2abf01ae8995b77340f439ef9"
dependencies = [
"async-trait",
"derivative",
"futures-util",
"indexmap 2.9.0",
"parking_lot",
"poise_macros",
"regex",
"serenity",
"tokio",
"tracing",
"trim-in-place",
]
[[package]]
name = "poise_macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fa2c123c961e78315cd3deac7663177f12be4460f5440dbf62a7ed37b1effea"
source = "git+https://github.com/serenity-rs/poise?branch=current#518ff0564865bca2abf01ae8995b77340f439ef9"
dependencies = [
"darling",
"proc-macro2",
@ -2537,8 +2539,7 @@ dependencies = [
[[package]]
name = "serenity"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76"
source = "git+https://github.com/serenity-rs/serenity?branch=current#9108d28fc7ac14fa67aefe3c5c2deba281bf69a6"
dependencies = [
"arrayvec",
"async-trait",
@ -3286,6 +3287,12 @@ dependencies = [
"tracing-log 0.2.0",
]
[[package]]
name = "trim-in-place"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
[[package]]
name = "triomphe"
version = "0.1.14"

View File

@ -9,7 +9,7 @@ members = [
]
[workspace.dependencies]
poise = '0.6.1'
poise = { git = "https://github.com/serenity-rs/poise", branch = "current" }
tokio = { version = '1.45.0', features = [
'macros',
'rt-multi-thread',
@ -24,3 +24,6 @@ chrono = { version = "0.4.41", features = ["serde"] }
[profile.release]
lto = true
[patch.crates-io]
serenity = { git = "https://github.com/serenity-rs/serenity", branch = "current" }

View File

@ -18,22 +18,22 @@
#### Command
- [ADMIN] trivial init : Creer une activité Trivial Daily
- [ADMIN] trivial list : List les activité
- [ADMIN] trivial config : Modification des paramétre de base d'un event
- [ADMIN] trivial add-question : Ajout de question + réponse ou X réponse
- [ADMIN] trivial del-question : Suppression de question
- [ADMIN] trivial load-question : Charge un fichier/lien de question
- [ADMIN] trivial choose-chan : Choix du channel
- trivial scoreboard : Scoreboard
- trivial score : Score d'une personne par défaut le sien
- trivial top : Top X par défaut 3
- [ADMIN] trivial balance : Modifie le score d'une personne +/-/=
- [x] [ADMIN] trivial create : Creer une activité Trivial Daily
- [x] [ADMIN] trivial list : List les activité
- [ ] [ADMIN] trivial config : Modification des paramétre de base d'un event
- [ ] [ADMIN] trivial add-question : Ajout de question + réponse ou X réponse
- [ ] [ADMIN] trivial del-question : Suppression de question
- [ ] [ADMIN] trivial load-question : Charge un fichier/lien de question
- [ ] [ADMIN] trivial choose-chan : Choix du channel
- [ ] trivial scoreboard : Scoreboard
- [ ] trivial score : Score d'une personne par défaut le sien
- [ ] trivial top : Top X par défaut 3
- [ ] [ADMIN] trivial balance : Modifie le score d'une personne +/-/=
#### TODO
- [x] Créer DB
- [ ] Créer Commande Admin
- [ ] Créer Commande Admin (limiter au admin)
- [ ] Créer Processus Schedule
- [ ] Créer API
- [ ] Créer prise en compte trivial (réponse a une question)

View File

@ -1,27 +1,15 @@
use std::sync::Arc;
use clickhouse_pool::pool_manager::PoolManager;
use help::help;
use poise::serenity_prelude as serenity;
use poise::serenity_prelude::GatewayIntents;
use tracing::{info, instrument};
use tracing::info;
use trivia::trivia;
use utility::{age::age, help::help, server::servers};
use crate::config::Config;
pub mod help;
/// Displays your or another user's account creation date
#[instrument(skip(ctx), level = "info", fields(channel_id = ctx.channel_id().get() , guild_id = ?ctx.guild_id(), user_id = ?ctx.author().id.get(), user_name = ctx.author().name))]
#[poise::command(slash_command, prefix_command)]
async fn age(
ctx: Context<'_>,
#[description = "Selected user"] user: Option<serenity::User>,
) -> Result<(), Error> {
let u = user.as_ref().unwrap_or_else(|| ctx.author());
let response = format!("{}'s account was created at {}", u.name, u.created_at());
ctx.say(response).await?;
Ok(())
}
pub mod trivia;
pub mod utility;
pub struct Data {
pub config: Config,
@ -47,7 +35,7 @@ pub async fn start_bot(config: Config, datalake_config: Arc<PoolManager>) {
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: vec![age(), help()],
commands: vec![age(), help(), servers(), trivia()],
prefix_options: poise::PrefixFrameworkOptions {
prefix: Some(prefix),
..Default::default()

View File

@ -0,0 +1,104 @@
use crate::bot::{Context, Error};
use clickhouse_pool::traits::Model;
use database::trivial::Trivial;
use poise::serenity_prelude;
use tracing::{debug, info, instrument};
/// Create a trivia game
/// Use the current channel by default, or specify a different channel.
#[instrument(name="trivia_create",skip(ctx), level = "info", fields(channel = ctx.channel_id().get(), guild = ?ctx.guild_id().unwrap().get()))]
#[poise::command(prefix_command, slash_command, guild_only, category = "Trivia")]
pub async fn create(
ctx: Context<'_>,
#[description = "Name of the trivia game"] name: String,
#[description = "Description of the trivia game, by default empty"] description: Option<String>,
#[description = "Channel to create the trivia game in, by default current channel"]
channel: Option<serenity_prelude::GuildChannel>,
) -> Result<(), Error> {
let guild_id = match ctx.guild_id() {
Some(id) => id.get(),
None => {
ctx.say("This command can only be used in a server.")
.await?;
return Ok(());
}
};
let channel_id = match channel {
Some(c) => c.id.get(),
None => {
ctx.say("No channel specified, using current channel.")
.await?;
ctx.channel_id().get()
}
};
let creator_id = ctx.author().id.get();
let manager_pool = ctx.data().datalake_config.clone();
// Check if a trivia game already exists in the channel
let get_trivia = Trivial::build_select_query(
Some(&format!(
"channel_id = {} and guild_id = {}",
channel_id, guild_id
)),
None,
None,
);
let trivia_exists: Vec<Trivial> = manager_pool.execute_select_with_retry(&get_trivia).await?;
if !trivia_exists.is_empty() {
ctx.say(format!(
"A trivia game already exists in this channel with the name: {}",
trivia_exists[0].name
))
.await?;
return Ok(());
}
info!(
"No existing trivia game found in channel <#{}>. Proceeding to create a new one.",
channel_id
);
// Create a new trivia game
let new_trivia = Trivial::new(
name,
description.unwrap_or_else(|| "".to_string()),
guild_id,
channel_id,
creator_id,
creator_id,
);
let insert_query = new_trivia.insert_query();
debug!("Insert query: {}", insert_query);
let mut inserter = match manager_pool
.get_insert::<Trivial>(Trivial::table_name())
.await
{
Ok(inserter) => inserter,
Err(e) => {
tracing::error!("Failed to create inserter for Trivial: {}", e);
ctx.say("Failed to create trivia game. Please try again later.")
.await?;
return Ok(());
}
};
inserter.write(&new_trivia).await?;
match inserter.end().await {
Ok(_) => {
info!(
"Trivia game '{}' created successfully in channel <#{}>.",
new_trivia.name, channel_id
);
ctx.say(format!(
"Trivia game '{}' created successfully in channel <#{}>.",
new_trivia.name, channel_id
))
.await?;
}
Err(e) => {
tracing::error!("Failed to create trivia game: {}", e);
ctx.say("Failed to create trivia game. Please try again later.")
.await?;
}
}
Ok(())
}

View File

@ -0,0 +1,153 @@
use crate::bot::{Context, Error};
use clickhouse_pool::traits::Model;
use database::trivial::Trivial;
use poise::{
serenity_prelude::{self, ComponentInteractionCollector},
CreateReply,
};
use tracing::{info, instrument, warn};
/// Turn a trivia into an embed for listing
#[instrument(name = "trivia_list_embed", level = "info")]
pub fn trivia_list_embed(trivia: &Trivial, current_channel: bool) -> serenity_prelude::CreateEmbed {
let mut embed = serenity_prelude::CreateEmbed::default()
.title(&trivia.name)
.description(&trivia.description)
.field("Channel", format!("<#{}>", trivia.channel_id), true)
.field("Status", format!("{:?}", trivia.status), true)
.field("Reward Kind", format!("{:?}", trivia.reward_kind), true)
.field("Reward Amount", trivia.reward_amount.to_string(), true);
if current_channel {
embed = embed.color(serenity_prelude::Color::GOLD);
} else {
embed = embed.color(serenity_prelude::Color::DARK_GREY);
}
embed
}
/// List trivia games in the current server and show if there are any in the current channel.
#[instrument(name="trivia_list",skip(ctx), level = "info", fields(channel = ctx.channel_id().get(), guild = ?ctx.guild_id().unwrap().get()))]
#[poise::command(prefix_command, slash_command, guild_only, category = "Trivia")]
pub async fn list(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = match ctx.guild_id() {
Some(id) => id.get(),
None => {
ctx.say("This command can only be used in a server.")
.await?;
warn!("User tried to list trivia games in a DM channel.");
return Ok(());
}
};
let channel_id = ctx.channel_id().get();
let manager_pool = ctx.data().datalake_config.clone();
// Fetch all trivia games in the guild
let get_trivia =
Trivial::build_select_query(Some(&format!("guild_id = {}", guild_id)), None, None);
let trivia_games: Vec<Trivial> = manager_pool.execute_select_with_retry(&get_trivia).await?;
if trivia_games.is_empty() {
ctx.say("No trivia games found in this server.").await?;
info!(
"User requested trivia list, but no games found in guild {}",
guild_id
);
return Ok(());
}
// Put the current channel's trivia game in a vec
let mut current_channel_trivia = Vec::new();
for trivia in &trivia_games {
if trivia.channel_id == channel_id {
// push the trivia game to be the first one in the list
current_channel_trivia.insert(0, trivia.clone());
} else {
current_channel_trivia.push(trivia.clone());
}
}
if current_channel_trivia.len() == 1 {
// If there is only one trivia game, just send it
let reply = CreateReply::default().embed(trivia_list_embed(
&current_channel_trivia[0],
current_channel_trivia[0].channel_id == channel_id,
));
ctx.send(reply).await?;
return Ok(());
}
let ctx_id = ctx.id();
let prev_button_id = format!("{}prev", ctx_id);
let next_button_id = format!("{}next", ctx_id);
let close_button_id = format!("{}close", ctx_id);
let reply = {
let components = serenity_prelude::CreateActionRow::Buttons(vec![
serenity_prelude::CreateButton::new(&prev_button_id).emoji('◀'),
serenity_prelude::CreateButton::new(&close_button_id)
.emoji('❌')
.style(serenity_prelude::ButtonStyle::Danger),
serenity_prelude::CreateButton::new(&next_button_id).emoji('▶'),
]);
CreateReply::default()
.embed(trivia_list_embed(
&current_channel_trivia[0],
current_channel_trivia[0].channel_id == channel_id,
))
.components(vec![components])
};
ctx.send(reply).await?;
// Loop through incoming interactions with the navigation buttons
let mut current_page = 0;
while let Some(press) = ComponentInteractionCollector::new(ctx)
.filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
.timeout(std::time::Duration::from_secs(3600 * 2))
.await
{
if press.data.custom_id == next_button_id {
current_page += 1;
if current_page >= current_channel_trivia.len() {
current_page = 0;
}
} else if press.data.custom_id == prev_button_id {
current_page = current_page
.checked_sub(1)
.unwrap_or(current_channel_trivia.len() - 1);
} else if press.data.custom_id == close_button_id {
// Close the interaction
press
.create_response(
ctx.serenity_context(),
serenity_prelude::CreateInteractionResponse::UpdateMessage(
serenity_prelude::CreateInteractionResponseMessage::new()
.content("Trivia list closed.")
.components(vec![]),
),
)
.await?;
return Ok(());
} else {
// This is an unrelated button interaction
continue;
}
// Update the message with the new page contents
press
.create_response(
ctx.serenity_context(),
serenity_prelude::CreateInteractionResponse::UpdateMessage(
serenity_prelude::CreateInteractionResponseMessage::new().embed(
trivia_list_embed(
&current_channel_trivia[current_page],
current_channel_trivia[current_page].channel_id == channel_id,
),
),
),
)
.await?;
}
Ok(())
}

View File

@ -0,0 +1,20 @@
pub mod create;
pub mod list;
use crate::bot::{Context, Error};
use create::create;
use list::list;
use tracing::instrument;
/// Handle trivia command
#[instrument(skip(ctx), level = "info", fields(channel = ctx.channel_id().get(), guild = ?ctx.guild_id().unwrap().get()))]
#[poise::command(
slash_command,
prefix_command,
category = "Trivia",
subcommands("create", "list"),
guild_only = true
)]
pub async fn trivia(ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}

View File

@ -0,0 +1,16 @@
use crate::bot::{Context, Error};
use poise::serenity_prelude as serenity;
use tracing::instrument;
/// Displays your or another user's account creation date
#[instrument(skip(ctx), level = "info", fields(channel_id = ctx.channel_id().get() , guild_id = ?ctx.guild_id(), user_id = ?ctx.author().id.get(), user_name = ctx.author().name))]
#[poise::command(slash_command, prefix_command, category = "Utility")]
pub async fn age(
ctx: Context<'_>,
#[description = "Selected user"] user: Option<serenity::User>,
) -> Result<(), Error> {
let u = user.as_ref().unwrap_or_else(|| ctx.author());
let response = format!("{}'s account was created at {}", u.name, u.created_at());
ctx.say(response).await?;
Ok(())
}

View File

@ -1,5 +1,5 @@
use crate::bot::{Context, Error};
use poise::samples::HelpConfiguration;
use poise::builtins::PrettyHelpConfiguration;
use tracing::instrument;
/// Show help message
@ -23,7 +23,7 @@ pub async fn help(
let extra_text_at_bottom = "\
Provided by Mak with and too much ";
let config = HelpConfiguration {
let config = PrettyHelpConfiguration {
show_subcommands: true,
show_context_menu_commands: false,
ephemeral: true,
@ -31,6 +31,6 @@ Provided by Mak with ❤️ and too much ☕";
..Default::default()
};
poise::builtins::help(ctx, command.as_deref(), config).await?;
poise::builtins::pretty_help(ctx, command.as_deref(), config).await?;
Ok(())
}

View File

@ -0,0 +1,3 @@
pub mod age;
pub mod help;
pub mod server;

View File

@ -0,0 +1,10 @@
use crate::bot::{Context, Error};
use tracing::instrument;
/// Lists all servers the bot is in
#[instrument(skip(ctx), level = "info",fields(channel = ctx.channel_id().get(), guild = ?ctx.guild_id().unwrap().get()))]
#[poise::command(slash_command, prefix_command, category = "Utility")]
pub async fn servers(ctx: Context<'_>) -> Result<(), Error> {
poise::builtins::servers(ctx).await?;
Ok(())
}

View File

@ -14,6 +14,8 @@ services:
CLICKHOUSE_PASSWORD: password
CLICKHOUSE_DB: default
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
volumes:
- ./resources/config_clickhouse.xml:/etc/clickhouse-server/config.d/config.xml
jaeger:
image: jaegertracing/jaeger:${JAEGER_VERSION:-2.6.0}
ports:
@ -32,10 +34,9 @@ services:
environment:
CONNECTIONS: click
LABEL_click: ClickHouse
SERVER_click: database
USER_click: default
PASSWORD_click: password
PORT_click: 8123
URL_click: http://database:8123
ENGINE_click: clickhouse@dbgate-plugin-clickhouse
DATABASE_click: default
depends_on:

View File

@ -7,6 +7,8 @@ use crate::metrics::SharedRegistrar;
use crate::pool::{get_query_type, ClickhouseConnectionPool, ClickhouseError};
use crate::traits::Model;
use anyhow::Result;
use clickhouse::insert::Insert;
use clickhouse::Row;
use serde::de::DeserializeOwned;
use tokio::sync::mpsc;
use tokio::time::interval;
@ -47,6 +49,21 @@ impl PoolManager {
self.pool.clone()
}
pub async fn get_insert<T: Row>(
&self,
table_name: &str,
) -> Result<Insert<T>, clickhouse::error::Error>
where
T: Model + Send + Sync + 'static,
{
self.pool
.clone()
.get_connection()
.await
.unwrap()
.insert(table_name)
}
pub fn seconds_since_last_recycle(&self) -> u64 {
let last = self.last_recycle_time;

View File

@ -11,5 +11,7 @@ clickhouse_pool = { path = "../clickhouse_pool" }
clickhouse = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
serde_repr = "0.1.20"
serde_json = { workspace = true }
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -2,15 +2,26 @@ use chrono::{DateTime, Utc};
use clickhouse::Row;
use clickhouse_pool::traits::Model;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum TrivialRewardKind {
OnlyTheFirstOne = 0,
TopThree = 1,
TopFive = 2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum TrivialStatus {
Init = 0,
Started = 1,
Finished = 2,
Paused = 3,
}
#[derive(Debug, Clone, PartialEq, PartialOrd, Row, Serialize, Deserialize)]
pub struct Trivial {
#[serde(with = "clickhouse::serde::uuid")]
@ -34,7 +45,53 @@ pub struct Trivial {
pub role_ping_enabled: bool,
pub reward_kind: TrivialRewardKind,
pub reward_amount: u64,
/// Whether or not the bot should send an ephemeral message to the user when their answer is taken into account.
pub taken_into_account: bool,
pub status: TrivialStatus,
}
impl Default for Trivial {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
name: String::new(),
description: String::new(),
guild_id: 0,
channel_id: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
creator_id: 0,
updater_id: 0,
random_question: true,
role_ping: 0,
role_ping_enabled: false,
reward_kind: TrivialRewardKind::TopThree,
reward_amount: 3,
taken_into_account: true,
status: TrivialStatus::Init,
}
}
}
impl Trivial {
pub fn new(
name: String,
description: String,
guild_id: u64,
channel_id: u64,
creator_id: u64,
updater_id: u64,
) -> Self {
Self {
name,
description,
guild_id,
channel_id,
creator_id,
updater_id,
..Default::default()
}
}
}
impl Model for Trivial {
@ -52,8 +109,8 @@ impl Model for Trivial {
description String,
guild_id UInt64,
channel_id UInt64,
created_at DateTime64(3),
updated_at DateTime64(3),
created_at DateTime64(3, 'UTC'),
updated_at DateTime64(3, 'UTC'),
creator_id UInt64,
updater_id UInt64,
random_question Bool,
@ -61,7 +118,8 @@ impl Model for Trivial {
role_ping_enabled Bool,
reward_kind Enum8('OnlyTheFirstOne' = 0, 'TopThree' = 1, 'TopFive' = 2),
reward_amount UInt64,
taken_into_account Bool
taken_into_account Bool,
status Enum8('Init' = 0, 'Started' = 1, 'Finished' = 2, 'Paused' = 3)
) ENGINE = MergeTree()
ORDER BY id
"#
@ -84,6 +142,7 @@ impl Model for Trivial {
"reward_kind",
"reward_amount",
"taken_into_account",
"status",
]
}
@ -91,21 +150,22 @@ impl Model for Trivial {
(
Self::column_names(),
vec![
self.id.to_string(),
self.name.clone(),
self.description.clone(),
format!("'{}'", self.id),
format!("'{}'", self.name),
format!("'{}'", self.description),
self.guild_id.to_string(),
self.channel_id.to_string(),
self.created_at.to_string(),
self.updated_at.to_string(),
format!("'{}'", self.created_at.to_rfc3339()),
format!("'{}'", self.updated_at.to_rfc3339()),
self.creator_id.to_string(),
self.updater_id.to_string(),
self.random_question.to_string(),
self.role_ping.to_string(),
self.role_ping_enabled.to_string(),
format!("{:?}", self.reward_kind),
format!("'{:?}'", serde_json::to_string(&self.reward_kind)),
self.reward_amount.to_string(),
self.taken_into_account.to_string(),
format!("'{:?}'", serde_json::to_string(&self.status)),
],
)
}

View File

@ -22,7 +22,7 @@ level = 2
[[tracing]]
kind = "Otel"
name = "otel"
level = 2
level = 1
[tracing.additional]
endpoint = "http://localhost:4317"

View File

@ -0,0 +1,7 @@
<yandex>
<logger>
<console>true</console>
<log remove="remove"/>
<errorlog remove="remove"/>
</logger>
</yandex>