Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Hall of fame #142

Merged
merged 4 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dsl.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

diesel --database-url "db.sqlite" migration --migration-dir migrations/sqlite "$@"
4 changes: 4 additions & 0 deletions migrations/sqlite/2023-11-07-004602_add_hall_of_fame/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`

DROP TABLE IF EXISTS "hall_of_fame_entries";
DROP TABLE IF EXISTS "hall_of_fame_tables";
24 changes: 24 additions & 0 deletions migrations/sqlite/2023-11-07-004602_add_hall_of_fame/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Your SQL goes here

CREATE TABLE IF NOT EXISTS "hall_of_fame_tables"
(
"id" INTEGER PRIMARY KEY NOT NULL,
"guild_id" BIGINT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"creation_date" TEXT NOT NULL,

UNIQUE ("guild_id", "title")
);

CREATE TABLE IF NOT EXISTS "hall_of_fame_entries"
(
"id" INTEGER PRIMARY KEY NOT NULL,
"hof_id" INTEGER NOT NULL,
"user_id" BIGINT NOT NULL,
"description" TEXT,
"creation_date" TEXT NOT NULL,

FOREIGN KEY ("hof_id") REFERENCES "hall_of_fame_tables" ("id") ON DELETE CASCADE
);

287 changes: 287 additions & 0 deletions src/commands/hall_of_fame.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};

use diesel::{prelude::*, ExpressionMethods};
use itertools::Itertools;
use poise::serenity_prelude::{GuildId, MessageBuilder, User, UserId};
use time::OffsetDateTime;
use tokio::sync::RwLock;

use crate::{
commands::{DISCORD_EMBED_FIELDS_LIMIT, TIME_FORMAT},
ctx_data::CtxData,
models::hall_of_fame::{Entry, NewEntry, NewTable, Table},
Conn, Context, Error, Result,
};

#[derive(Debug)]
pub struct HofData {
hofs: RwLock<HashMap<GuildId, HashSet<String>>>,
}

impl HofData {
pub async fn add_table(&self, guild_id: GuildId, table: String) {
let mut hofs = self.hofs.write().await;
hofs.entry(guild_id).or_default().insert(table);
}

pub async fn get_hof_tables(&self, guild_id: &GuildId) -> HashSet<String> {
// TODO: allow dirty read
self.hofs
.read()
.await
.get(guild_id)
.cloned()
.unwrap_or_default()
}
}

impl HofData {
pub fn new(db: &Conn) -> Self {
use crate::schema::hall_of_fame_tables::dsl::*;

let hofs = hall_of_fame_tables
.load::<Table>(&mut db.get().unwrap())
.unwrap();

let hofs = hofs
.into_iter()
.group_by(|h| h.guild_id)
.into_iter()
.map(|(grp, hfs)| (GuildId(grp as u64), hfs.map(|h| h.title).collect()))
.collect();

Self {
hofs: RwLock::new(hofs),
}
}
}

#[allow(clippy::unused_async)]
#[poise::command(slash_command, subcommands("show", "create", "add"))]
pub async fn hof(_ctx: Context<'_>) -> Result<()> {
Ok(())
}

async fn autocomplete<'a>(ctx: Context<'_>, partial: &'a str) -> HashSet<String> {
let guild = ctx.guild_id().unwrap();

ctx.data()
.hof_data
.get_hof_tables(&guild)
.await
.into_iter()
.filter(|h| h.starts_with(partial))
.sorted()
.collect()
}

/// List Hall of Fame tables
#[poise::command(slash_command)]
pub async fn show(
ctx: Context<'_>,
#[autocomplete = "autocomplete"] hof: String,
user: Option<User>,
) -> Result<()> {
let guild = ctx.guild_id().unwrap();

match user {
None => show_hof(ctx, guild, hof).await?,
Some(user_id) => show_user(ctx, guild, hof, user_id).await?,
};

Ok(())
}

async fn show_hof(ctx: Context<'_>, guild: GuildId, hof: String) -> Result<()> {
use crate::schema::{hall_of_fame_entries::dsl::*, hall_of_fame_tables::dsl::*};

let hof = hall_of_fame_tables
.filter(guild_id.eq::<i64>(guild.into()))
.filter(title.eq(&hof))
.first::<Table>(&mut ctx.data().db.get().unwrap())?;

let entries = hall_of_fame_entries
.filter(hof_id.eq(hof.id))
.load::<Entry>(&mut ctx.data().db.get().unwrap())?;

let entries: Vec<_> = entries
.into_iter()
.counts_by(|e| e.user_id)
.into_iter()
.sorted_by_cached_key(|(_, v)| -(*v as i32))
.take(DISCORD_EMBED_FIELDS_LIMIT as usize)
.collect();

let mut entries2 = vec![];
for (k, v) in entries.into_iter() {
entries2.push((get_nickname(ctx, &guild, k).await?, v, true));
}

let response = ctx
.send(|reply| {
reply.embed(|embed| {
let desc = hof.description.unwrap_or_default();
let desc = if entries2.is_empty() {
let mix = if desc.is_empty() { "" } else { "\n\n" };
format!("{}{}{}", desc, mix, "There are no entries.")
} else {
desc
};
embed.description(desc);

embed.title(&hof.title).fields(entries2);

embed
})
})
.await?;
Ok(())
}
async fn show_user(ctx: Context<'_>, guild: GuildId, hof: String, user: User) -> Result<()> {
use crate::schema::{hall_of_fame_entries::dsl::*, hall_of_fame_tables::dsl::*};

let hof = hall_of_fame_tables
.filter(guild_id.eq::<i64>(guild.into()))
.filter(title.eq(&hof))
.first::<Table>(&mut ctx.data().db.get().unwrap())?;

let entries = hall_of_fame_entries
.filter(hof_id.eq(hof.id))
.filter(user_id.eq(user.id.0 as i64))
.load::<Entry>(&mut ctx.data().db.get().unwrap())?;

let entries: Vec<_> = entries
.into_iter()
.map(|e| {
format!(
"*{}*: {}",
e.creation_date,
e.description.unwrap_or(String::from("Missing reason"))
)
})
.collect();

let mut msg = MessageBuilder::new();
msg.push(format!("### {} entries for ", hof.title))
.mention(&user)
.push_line("");

for entry in entries.iter().rev() {
msg.push_line(format!("- {}", entry));
}

ctx.reply(msg.build()).await?;

Ok(())
}

async fn get_nickname(ctx: Context<'_>, guild_id: &GuildId, id: i64) -> Result<String> {
let userid = UserId(id as u64);
let user = userid.to_user(ctx).await?;
let guild_nick = user.nick_in(ctx, guild_id).await;
Ok(guild_nick.unwrap_or(user.name))
}

#[derive(Debug, poise::Modal)]
#[name = "Create Hall of Fame table"]
struct HofCreationModal {
#[min_length = 4]
#[max_length = 64]
title: String,
#[paragraph]
#[max_length = 128]
description: Option<String>,
}

#[poise::command(slash_command)]
pub async fn create(ctx: poise::ApplicationContext<'_, Arc<CtxData>, Error>) -> Result<()> {
use poise::Modal as _;

use crate::schema::hall_of_fame_tables::dsl::*;

let data = HofCreationModal::execute(ctx).await?;

if let Some(data) = data {
let guild = ctx.guild_id().unwrap();
let time = OffsetDateTime::now_utc().format(&TIME_FORMAT).unwrap();

let desc = match data.description {
Some(s) => {
let desc = s.trim();
if desc.is_empty() {
None
} else {
Some(desc.to_string())
}
}
None => None,
};

let new_hof = NewTable {
guild_id: &(guild.0 as i64),
title: &data.title,
description: desc,
creation_date: &time,
};

let result = diesel::insert_into(hall_of_fame_tables)
.values(&new_hof)
.execute(&mut ctx.data().db.get().unwrap());

let response = match result {
Ok(_) => {
ctx.data.hof_data.add_table(guild, data.title).await;
"Success"
}
_ => "Failure",
};

ctx.send(|reply| reply.content(response).reply(true).ephemeral(true))
.await?;
}

Ok(())
}

#[poise::command(slash_command)]
pub async fn add(
ctx: Context<'_>,
#[autocomplete = "autocomplete"] hof: String,
user: User,
#[max_length = 128] reason: String,
) -> Result<()> {
use crate::schema::{hall_of_fame_entries::dsl::*, hall_of_fame_tables::dsl::*};
let guild = ctx.guild_id().unwrap();
let time = OffsetDateTime::now_utc().format(&TIME_FORMAT).unwrap();

let hof = hall_of_fame_tables
.filter(guild_id.eq::<i64>(guild.into()))
.filter(title.eq(&hof))
.first::<Table>(&mut ctx.data().db.get().unwrap())?;

let new_entry = NewEntry {
hof_id: &hof.id,
user_id: &(user.id.0 as i64),
description: Some(&reason),
creation_date: &time,
};

let result = diesel::insert_into(hall_of_fame_entries)
.values(&new_entry)
.execute(&mut ctx.data().db.get().unwrap());

let msg = MessageBuilder::new()
.mention(&user)
.push(" was added to ")
.push_bold(&hof.title)
.push(": ")
.push_italic_safe(&reason)
.build();

ctx.reply(msg).await?;

Ok(())
}
7 changes: 6 additions & 1 deletion src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use std::sync::LazyLock;
use std::sync::{LazyLock, OnceLock};

use regex::Regex;
use time::{format_description, format_description::FormatItem};

use crate::{Context, Result};

pub mod changelog;
pub mod hall_of_fame;
pub mod ping;

#[allow(clippy::cast_precision_loss)]
Expand All @@ -14,6 +16,9 @@ pub mod todo;
pub const DISCORD_EMBED_FIELDS_LIMIT: u32 = 24;

static USER_PING_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<@(\d+)>").unwrap());
static TIME_FORMAT: LazyLock<Vec<FormatItem<'static>>> = LazyLock::new(|| {
format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap()
});

#[poise::command(track_edits, slash_command)]
pub async fn help(
Expand Down
16 changes: 3 additions & 13 deletions src/commands/todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,11 @@ use tokio_stream::{self as stream, StreamExt};
use tracing::debug;

use crate::{
commands::DISCORD_EMBED_FIELDS_LIMIT,
commands::{DISCORD_EMBED_FIELDS_LIMIT, TIME_FORMAT},
models::todo::{NewTodo, Todo},
Conn, Context, Result,
};

static TIME_FORMAT: OnceLock<Vec<FormatItem<'static>>> = OnceLock::new();

#[derive(Debug, Clone, Copy, Default)]
pub enum Priority {
#[default]
Expand Down Expand Up @@ -161,10 +159,6 @@ impl TodoData {
})
.collect::<HashMap<_, _>>();

let _ = TIME_FORMAT.set(
format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap(),
);

Self {
iterators: Mutex::new(iterators),
}
Expand Down Expand Up @@ -303,9 +297,7 @@ pub async fn add(
let data = if content.len() > 1024 {
"Content can't have more than 1024 characters.".to_string()
} else {
let time = OffsetDateTime::now_utc()
.format(&TIME_FORMAT.get().unwrap())
.unwrap();
let time = OffsetDateTime::now_utc().format(&TIME_FORMAT).unwrap();
let new_id = ctx.data().todo_data.get_id(ctx.channel_id());
let nickname = match &assignee {
Some(m) => get_member_nickname(m),
Expand Down Expand Up @@ -375,9 +367,7 @@ pub async fn delete(ctx: Context<'_>, #[description = "TODO id"] todo_id: i64) -
pub async fn complete(ctx: Context<'_>, #[description = "TODO id"] todo_id: i64) -> Result<()> {
use crate::schema::todos::dsl::{channel_id, completion_date, id, todo, todos};

let time = OffsetDateTime::now_utc()
.format(&TIME_FORMAT.get().unwrap())
.unwrap();
let time = OffsetDateTime::now_utc().format(&TIME_FORMAT).unwrap();

let completed: QueryResult<String> = diesel::update(todos)
.filter(channel_id.eq(i64::from(ctx.channel_id())))
Expand Down
Loading