From f70019f0a227a3897312c637b1d04bdf69ff665e Mon Sep 17 00:00:00 2001 From: Jacherr Date: Mon, 29 Jul 2024 00:21:04 +0100 Subject: [PATCH] add prelim tag support --- assyst-common/src/util/discord.rs | 11 + assyst-core/src/command/arguments.rs | 58 ++++- assyst-core/src/command/misc/mod.rs | 2 +- assyst-core/src/command/misc/prefix.rs | 6 +- assyst-core/src/command/misc/tag.rs | 306 +++++++++++++++++++++-- assyst-core/src/gateway_handler/reply.rs | 11 +- assyst-database/src/lib.rs | 33 +-- assyst-database/src/model/mod.rs | 1 + assyst-database/src/model/tag.rs | 150 +++++++++++ 9 files changed, 526 insertions(+), 52 deletions(-) create mode 100644 assyst-database/src/model/tag.rs diff --git a/assyst-common/src/util/discord.rs b/assyst-common/src/util/discord.rs index 861f957..238bd28 100644 --- a/assyst-common/src/util/discord.rs +++ b/assyst-common/src/util/discord.rs @@ -1,3 +1,4 @@ +use regex::Regex; use twilight_http::Client; use twilight_model::id::marker::GuildMarker; use twilight_model::id::Id; @@ -80,3 +81,13 @@ pub fn format_discord_timestamp(input: u64) -> String { format_time(input) } } + +pub fn mention_to_id(s: &str) -> Option { + let mention: Regex = Regex::new(r"(?:<@!?)?(\d{16,20})>?").unwrap(); + + mention + .captures(s) + .and_then(|capture| capture.get(1)) + .map(|id| id.as_str()) + .and_then(|id| id.parse::().ok()) +} diff --git a/assyst-core/src/command/arguments.rs b/assyst-core/src/command/arguments.rs index 3bca213..23cbae1 100644 --- a/assyst-core/src/command/arguments.rs +++ b/assyst-core/src/command/arguments.rs @@ -1,7 +1,7 @@ use std::fmt::Display; use assyst_common::markdown::parse_codeblock; -use assyst_common::util::discord::{get_avatar_url, id_from_mention}; +use assyst_common::util::discord::{get_avatar_url, id_from_mention, mention_to_id}; use assyst_common::util::{parse_to_millis, regex}; use serde::Deserialize; use twilight_model::application::command::CommandOption; @@ -9,9 +9,10 @@ use twilight_model::application::interaction::application_command::CommandOption use twilight_model::channel::message::sticker::{MessageSticker, StickerFormatType}; use twilight_model::channel::message::Embed; use twilight_model::channel::Attachment; -use twilight_model::id::marker::ChannelMarker; +use twilight_model::id::marker::{ChannelMarker, UserMarker}; use twilight_model::id::Id; -use twilight_util::builder::command::{AttachmentBuilder, IntegerBuilder, NumberBuilder, StringBuilder}; +use twilight_model::user::User as TwlUser; +use twilight_util::builder::command::{AttachmentBuilder, IntegerBuilder, NumberBuilder, StringBuilder, UserBuilder}; use super::errors::TagParseError; use super::{CommandCtxt, InteractionCommandParseCtxt, Label, RawMessageParseCtxt}; @@ -252,6 +253,57 @@ impl ParseArgument for Codeblock { } } +/// A user argument (mention or ID) +#[derive(Debug)] +pub struct User(pub TwlUser); +impl ParseArgument for User { + async fn parse_raw_message(ctxt: &mut RawMessageParseCtxt<'_>, label: Label) -> Result { + let next = ctxt.next_word(label)?; + let id = mention_to_id(next); + + let user = ctxt + .cx + .assyst() + .http_client + .user(Id::::new(id.unwrap_or(next.parse::().unwrap_or(1)))) + .await + .map_err(|e| TagParseError::TwilightHttp(Box::new(e)))? + .model() + .await + .map_err(|e| TagParseError::TwilightDeserialize(Box::new(e)))?; + + Ok(User(user)) + } + + async fn parse_command_option(ctxt: &mut InteractionCommandParseCtxt<'_>) -> Result { + let word = ctxt.next_option()?; + + if let CommandOptionValue::User(id) = word.value { + let user = ctxt + .cx + .assyst() + .http_client + .user(id) + .await + .map_err(|e| TagParseError::TwilightHttp(Box::new(e)))? + .model() + .await + .map_err(|e| TagParseError::TwilightDeserialize(Box::new(e)))?; + + Ok(User(user)) + } else { + Err(TagParseError::MismatchedCommandOptionType(( + "User".to_owned(), + word.value.clone(), + ))) + } + } + + fn as_command_option(name: &str) -> CommandOption { + UserBuilder::new(name, "user argument").required(true).build() + } +} + /// The rest of a message as an argument. This should be the last argument if used. #[derive(Debug)] pub struct Rest(pub String); diff --git a/assyst-core/src/command/misc/mod.rs b/assyst-core/src/command/misc/mod.rs index d3126b3..4b0afbb 100644 --- a/assyst-core/src/command/misc/mod.rs +++ b/assyst-core/src/command/misc/mod.rs @@ -13,7 +13,7 @@ use assyst_database::model::free_tier_2_requests::FreeTier2Requests; use assyst_database::model::guild_disabled_command::GuildDisabledCommand; use assyst_proc_macro::command; -use super::arguments::{Image, ImageUrl, Rest, RestNoFlags, Word}; +use super::arguments::{Image, ImageUrl, RestNoFlags, Word}; use super::registry::get_or_init_commands; use super::{Category, CommandCtxt}; use crate::command::Availability; diff --git a/assyst-core/src/command/misc/prefix.rs b/assyst-core/src/command/misc/prefix.rs index d21986d..2629c14 100644 --- a/assyst-core/src/command/misc/prefix.rs +++ b/assyst-core/src/command/misc/prefix.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use anyhow::{bail, Context}; +use anyhow::{bail, ensure, Context}; use assyst_common::markdown::Markdown; use assyst_database::model::prefix::Prefix; use assyst_proc_macro::command; @@ -44,9 +44,7 @@ pub async fn set(ctxt: CommandCtxt<'_>, new: Word) -> anyhow::Result<()> { bail!("Prefix getting and setting can only be used in guilds.") }; - if new.0.len() > 14 { - bail!("Prefixes cannot be longer than 14 characters.") - } + ensure!(new.0.len() < 14, "Prefixes cannot be longer than 14 characters."); let new = Prefix { prefix: new.0 }; new.set(&ctxt.assyst().database_handler, guild_id.get()) diff --git a/assyst-core/src/command/misc/tag.rs b/assyst-core/src/command/misc/tag.rs index 5e148ae..ad78499 100644 --- a/assyst-core/src/command/misc/tag.rs +++ b/assyst-core/src/command/misc/tag.rs @@ -1,10 +1,11 @@ -use std::time::Duration; +use std::fmt::Write; +use std::time::{Duration, SystemTime}; use anyhow::{anyhow, bail, ensure, Context}; use assyst_common::markdown::Markdown; -use assyst_common::util::discord::{format_tag, get_avatar_url}; +use assyst_common::util::discord::{format_discord_timestamp, format_tag, get_avatar_url}; use assyst_common::util::string_from_likely_utf8; -use assyst_database::Tag; +use assyst_database::model::tag::Tag; use assyst_proc_macro::command; use assyst_tag::parser::ParseMode; use assyst_tag::ParseResult; @@ -12,33 +13,293 @@ use tokio::runtime::Handle; use twilight_model::channel::Message; use twilight_model::id::Id; -use super::{CommandCtxt, Rest, Word}; +use super::CommandCtxt; use crate::assyst::ThreadSafeAssyst; -use crate::command::arguments::{Image, ImageUrl}; +use crate::command::arguments::{Image, ImageUrl, RestNoFlags, User, Word}; use crate::command::{Availability, Category}; use crate::define_commandgroup; use crate::downloader::{download_content, ABSOLUTE_INPUT_FILE_SIZE_LIMIT_BYTES}; use crate::rest::eval::fake_eval; -#[command(description = "creates a tag", cooldown = Duration::from_secs(2), access = Availability::Public, category = Category::Misc, usage = " ")] -pub async fn create(ctxt: CommandCtxt<'_>, name: Word, contents: Rest) -> anyhow::Result<()> { - ctxt.reply(format!("create tag, name={}, contents={}", name.0, contents.0)) +#[command( + description = "create a tag", + aliases = ["add"], + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = "[name] [contents]", + examples = ["test hello", "script 1+2 is: {js:1+2}"] +)] +pub async fn create(ctxt: CommandCtxt<'_>, name: Word, contents: RestNoFlags) -> anyhow::Result<()> { + const RESERVED_NAMES: &[&str] = &["create", "add", "edit", "raw", "remove", "delete", "list", "info"]; + + let author = ctxt.data.author.id.get(); + let Some(guild_id) = ctxt.data.guild_id else { + bail!("Tags can only be created in guilds.") + }; + ensure!(name.0.len() < 20, "Tag names cannot exceed 20 characters."); + ensure!( + !RESERVED_NAMES.contains(&&name.0[..]), + "Tag name cannot be a reserved word." + ); + + let tag = Tag { + name: name.0, + guild_id: guild_id.get() as i64, + data: contents.0, + author: author as i64, + created_at: SystemTime::now().elapsed().unwrap().as_millis() as i64, + }; + + let success = tag + .set(&ctxt.assyst().database_handler) + .await + .context("Failed to create tag")?; + + ensure!(success, "That tag name is already used in this server."); + + ctxt.reply(format!("Successfully created tag {}", tag.name.codestring())) .await?; + Ok(()) } -#[command(description = "runs a tag", cooldown = Duration::from_secs(2), access = Availability::Public, category = Category::Misc, usage = "")] -pub async fn default(ctxt: CommandCtxt<'_>, tag_name: Word, arguments: Vec) -> anyhow::Result<()> { +#[command( + description = "edit a tag that you own", + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = "[name] [contents]", + examples = ["test hello there", "script 2+2 is: {js:2+2}"] +)] +pub async fn edit(ctxt: CommandCtxt<'_>, name: Word, contents: RestNoFlags) -> anyhow::Result<()> { + let author = ctxt.data.author.id.get(); let Some(guild_id) = ctxt.data.guild_id else { - bail!("Tags can only be used in guilds.") + bail!("Tags can only be edited in guilds.") }; - let tag = ctxt + let success = Tag::edit( + &ctxt.assyst().database_handler, + author as i64, + guild_id.get() as i64, + &name.0, + &contents.0, + ) + .await + .context("Failed to edit tag")?; + + ensure!(success, "Failed to edit that tag. Does it exist, and do you own it?"); + + ctxt.reply(format!("Successfully edited tag {}", contents.0.codestring())) + .await?; + + Ok(()) +} + +#[command( + description = "delete a tag that you own (server managers can delete any tag in the server)", + aliases = ["remove"], + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = "[name]", + examples = ["test", "script"] +)] +pub async fn delete(ctxt: CommandCtxt<'_>, name: Word) -> anyhow::Result<()> { + let author = ctxt.data.author.id.get(); + let Some(guild_id) = ctxt.data.guild_id else { + bail!("Tags can only be deleted in guilds.") + }; + + let success = if ctxt .assyst() - .database_handler - .get_tag(guild_id.get() as i64, &tag_name.0) + .rest_cache_handler + .user_is_guild_manager(guild_id.get(), author) + .await + .context("Failed to fetch user permissions")? + { + Tag::delete_force(&ctxt.assyst().database_handler, &name.0, guild_id.get() as i64) + .await + .context("Failed to delete tag")? + } else { + Tag::delete( + &ctxt.assyst().database_handler, + &name.0, + guild_id.get() as i64, + author as i64, + ) + .await + .context("Failed to delete tag")? + }; + + ensure!(success, "Failed to delete that tag. Does it exist, and do you own it?"); + + ctxt.reply(format!("Successfully edited tag {}", name.0.codestring())) + .await?; + + Ok(()) +} + +#[command( + description = "list tags in the server (or owned by a certain user in the server)", + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = " ", + examples = ["1 @jacher", "1", ""] +)] +pub async fn list(ctxt: CommandCtxt<'_>, page: u64, user: Option) -> anyhow::Result<()> { + const DEFAULT_LIST_COUNT: i64 = 15; + + let Some(guild_id) = ctxt.data.guild_id else { + bail!("Tags can only be listed in guilds.") + }; + + // user-specific search if arg is a mention + let user_id: Option = user.map(|x| x.0.id.get() as i64); + + ensure!(page >= 1, "Page must be greater or equal to 1"); + + let offset = (page as i64 - 1) * DEFAULT_LIST_COUNT; + let count = match user_id { + Some(u) => Tag::get_count_for_user(&ctxt.assyst().database_handler, guild_id.get() as i64, u) + .await + .context("Failed to get tag count for user in guild")?, + None => Tag::get_count_in_guild(&ctxt.assyst().database_handler, guild_id.get() as i64) + .await + .context("Failed to get tag count in guild")?, + }; + + ensure!(count > 0, "No tags found for the requested filter"); + let pages = (count as f64 / DEFAULT_LIST_COUNT as f64).ceil() as i64; + ensure!(pages >= page as i64, "Cannot go beyond final page"); + + let tags = match user_id { + Some(u) => { + Tag::get_paged_for_user( + &ctxt.assyst().database_handler, + guild_id.get() as i64, + u, + offset, + DEFAULT_LIST_COUNT, + ) + .await? + }, + None => { + Tag::get_paged( + &ctxt.assyst().database_handler, + guild_id.get() as i64, + offset, + DEFAULT_LIST_COUNT, + ) + .await? + }, + }; + + let mut message = format!( + "🗒️ **Tags in this server{0}**\nView a tag by running `{1}t `, or go to the next page by running `{1}t list {2}`\n\n", + { + match user_id { + Some(u) => format!(" for user <@{u}>"), + None => "".to_owned(), + } + }, + ctxt.data.calling_prefix, + page + 1 + ); + + for (index, tag) in tags.iter().enumerate() { + let offset = (index as i64) + offset + 1; + writeln!( + message, + "{}. {} {}", + offset, + tag.name, + match user_id { + Some(_) => "".to_owned(), + None => format!("(<@{}>)", tag.author), + } + )?; + } + + write!( + message, + "\nShowing {} tags (page {page}/{pages}) ({count} total tags)", + tags.len() + )?; + + ctxt.reply(message).await?; + + Ok(()) +} + +#[command( + description = "get information about a tag in the server", + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = "[name]", + examples = ["test", "script"] +)] +pub async fn info(ctxt: CommandCtxt<'_>, name: Word) -> anyhow::Result<()> { + let Some(guild_id) = ctxt.data.guild_id else { + bail!("Tag information can only be fetched in guilds.") + }; + + let tag = Tag::get(&ctxt.assyst().database_handler, guild_id.get() as i64, &name.0) + .await? + .context("Tag not found in this server.")?; + + let fmt = format_discord_timestamp(tag.created_at as u64); + let message = format!( + "🗒️ **Tag information: **{}\n\nAuthor: <@{}>\nCreated: {}", + tag.name, tag.author, fmt + ); + + ctxt.reply(message).await?; + + Ok(()) +} + +#[command( + description = "get the raw content of a tag without parsing it", + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = "[name]", + examples = ["test", "script"] +)] +pub async fn raw(ctxt: CommandCtxt<'_>, name: Word) -> anyhow::Result<()> { + let Some(guild_id) = ctxt.data.guild_id else { + bail!("Tag raw content can only be fetched in guilds.") + }; + + let tag = Tag::get(&ctxt.assyst().database_handler, guild_id.get() as i64, &name.0) .await? - .context("Tag not found in this server")?; + .context("Tag not found in this server.")?; + + ctxt.reply(tag.data.codeblock("")).await?; + + Ok(()) +} + +#[command( + description = "run a tag in the current server", + cooldown = Duration::from_secs(2), + access = Availability::Public, + category = Category::Misc, + usage = "[tag name] ", + examples = ["test", "whatever"] +)] +pub async fn default(ctxt: CommandCtxt<'_>, tag_name: Word, arguments: Vec) -> anyhow::Result<()> { + let Some(guild_id) = ctxt.data.guild_id else { + bail!("Tags can only be used in guilds.") + }; + + let tag = Tag::get(&ctxt.assyst().database_handler, guild_id.get() as i64, &tag_name.0) + .await + .context("Failed to fetch tag")? + .context("Tag not found in this server.")?; let assyst = ctxt.assyst().clone(); let message = ctxt.data.message.unwrap().clone(); @@ -165,12 +426,12 @@ impl assyst_tag::Context for TagContext { fn get_tag_contents(&self, tag: &str) -> anyhow::Result { let tag = self .tokio - .block_on(async { self.assyst.database_handler.get_tag(self.guild_id() as i64, tag).await }); + .block_on(async { Tag::get(&self.assyst.database_handler, self.guild_id() as i64, tag).await }); match tag { Ok(Some(Tag { data, .. })) => Ok(data), Ok(None) => Err(anyhow!("Tag not found")), - Err(e) => Err(e.into()), + Err(e) => Err(e), } } } @@ -182,10 +443,15 @@ define_commandgroup! { aliases: ["t"], cooldown: Duration::from_secs(2), description: "assyst's tag system (documentation: https://jacher.io/tags)", - usage: "", + usage: "[subcommand|tag name] ", commands: [ - "create" => create + "create" => create, + "edit" => edit, + "delete" => delete, + "list" => list, + "info" => info, + "raw" => raw ], - default_interaction_subcommand: "view", + default_interaction_subcommand: "run", default: default } diff --git a/assyst-core/src/gateway_handler/reply.rs b/assyst-core/src/gateway_handler/reply.rs index 5dbae5e..f7bec68 100644 --- a/assyst-core/src/gateway_handler/reply.rs +++ b/assyst-core/src/gateway_handler/reply.rs @@ -15,10 +15,19 @@ use crate::rest::NORMAL_DISCORD_UPLOAD_LIMIT_BYTES; /// Trims a `String` in-place such that it fits in Discord's 2000 character message limit. fn trim_content_fits(content: &mut String) { - if let Some((truncated_byte_index, _)) = content.char_indices().nth(2000) { + const CODEBLOCK: &str = "```"; + let codeblocked = content.ends_with(CODEBLOCK); + if let Some((truncated_byte_index, _)) = + content + .char_indices() + .nth(if codeblocked { 2000 - CODEBLOCK.len() } else { 2000 }) + { // If the content length exceeds 2000 characters, truncate it at the 2000th characters' byte // index content.truncate(truncated_byte_index); + if codeblocked { + *content += CODEBLOCK; + } } } diff --git a/assyst-database/src/lib.rs b/assyst-database/src/lib.rs index 9bba32b..5ee00f7 100644 --- a/assyst-database/src/lib.rs +++ b/assyst-database/src/lib.rs @@ -1,5 +1,7 @@ #![feature(trait_alias)] +use std::borrow::Cow; + use cache::DatabaseCache; use sqlx::postgres::{PgPool, PgPoolOptions}; use tracing::info; @@ -10,17 +12,13 @@ pub mod model; static MAX_CONNECTIONS: u32 = 1; #[derive(sqlx::FromRow, Debug)] -pub struct DatabaseSize { - pub size: String, +pub struct Count { + pub count: i64, } #[derive(sqlx::FromRow, Debug)] -pub struct Tag { - pub name: String, - pub data: String, - pub author: i64, - pub guild_id: i64, - pub created_at: i64, +pub struct DatabaseSize { + pub size: String, } /// Database hendler providing a connection to the database and helper methods for inserting, @@ -51,20 +49,9 @@ impl DatabaseHandler { Ok(sqlx::query_as::<_, DatabaseSize>(query).fetch_one(&self.pool).await?) } +} - pub async fn get_tag(&self, guild_id: i64, name: &str) -> Result, sqlx::Error> { - let query = r#"SELECT * FROM tags WHERE name = $1 AND guild_id = $2"#; - - let result = sqlx::query_as(query) - .bind(name) - .bind(guild_id) - .fetch_one(&self.pool) - .await; - - match result { - Ok(v) => Ok(Some(v)), - Err(sqlx::Error::RowNotFound) => Ok(None), - Err(e) => Err(e), - } - } +pub(crate) fn is_unique_violation(error: &sqlx::Error) -> bool { + const UNIQUE_CONSTRAINT_VIOLATION_CODE: Cow<'_, str> = Cow::Borrowed("23505"); + error.as_database_error().and_then(|e| e.code()) == Some(UNIQUE_CONSTRAINT_VIOLATION_CODE) } diff --git a/assyst-database/src/model/mod.rs b/assyst-database/src/model/mod.rs index aefcb23..6534bbf 100644 --- a/assyst-database/src/model/mod.rs +++ b/assyst-database/src/model/mod.rs @@ -5,4 +5,5 @@ pub mod global_blacklist; pub mod guild_disabled_command; pub mod prefix; pub mod reminder; +pub mod tag; pub mod user_votes; diff --git a/assyst-database/src/model/tag.rs b/assyst-database/src/model/tag.rs new file mode 100644 index 0000000..c239535 --- /dev/null +++ b/assyst-database/src/model/tag.rs @@ -0,0 +1,150 @@ +use crate::{is_unique_violation, Count, DatabaseHandler}; + +#[derive(sqlx::FromRow, Debug)] +pub struct Tag { + pub name: String, + pub data: String, + pub author: i64, + pub guild_id: i64, + pub created_at: i64, +} +impl Tag { + pub async fn get(handler: &DatabaseHandler, guild_id: i64, name: &str) -> anyhow::Result> { + let query = r#"SELECT * FROM tags WHERE name = $1 AND guild_id = $2"#; + + let result = sqlx::query_as(query) + .bind(name) + .bind(guild_id) + .fetch_one(&handler.pool) + .await; + + match result { + Ok(v) => Ok(Some(v)), + Err(sqlx::Error::RowNotFound) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub async fn set(&self, handler: &DatabaseHandler) -> Result { + let query = r#"INSERT INTO tags VALUES ($1, $2, $3, $4, $5)"#; + + sqlx::query(query) + .bind(&self.name) + .bind(&self.data) + .bind(self.author) + .bind(self.guild_id) + .bind(self.created_at) + .execute(&handler.pool) + .await + .map(|_| true) + .or_else(|e| if is_unique_violation(&e) { Ok(false) } else { Err(e) }) + } + + pub async fn delete_force(handler: &DatabaseHandler, name: &str, guild_id: i64) -> Result { + let query = r#"DELETE FROM tags WHERE name = $1 AND guild_id = $2"#; + + sqlx::query(query) + .bind(name) + .bind(guild_id) + .execute(&handler.pool) + .await + .map(|rows| rows.rows_affected() > 0) + .or_else(|e| if is_unique_violation(&e) { Ok(false) } else { Err(e) }) + } + + pub async fn delete( + handler: &DatabaseHandler, + name: &str, + guild_id: i64, + author: i64, + ) -> Result { + let query = r#"DELETE FROM tags WHERE name = $1 AND author = $2 AND guild_id = $3"#; + + sqlx::query(query) + .bind(name) + .bind(author) + .bind(guild_id) + .execute(&handler.pool) + .await + .map(|rows| rows.rows_affected() > 0) + .or_else(|e| if is_unique_violation(&e) { Ok(false) } else { Err(e) }) + } + + pub async fn edit( + handler: &DatabaseHandler, + author: i64, + guild_id: i64, + name: &str, + new_content: &str, + ) -> Result { + let query = r#"UPDATE tags SET data = $1 WHERE name = $2 AND author = $3 AND guild_id = $4"#; + + sqlx::query(query) + .bind(new_content) + .bind(name) + .bind(author) + .bind(guild_id) + .execute(&handler.pool) + .await + .map(|r| r.rows_affected() > 0) + } + + pub async fn get_paged( + handler: &DatabaseHandler, + guild_id: i64, + offset: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let query = r#"SELECT * FROM tags WHERE guild_id = $1 ORDER BY created_at DESC OFFSET $2 LIMIT $3"#; + + sqlx::query_as(query) + .bind(guild_id) + .bind(offset) + .bind(limit) + .fetch_all(&handler.pool) + .await + } + + pub async fn get_paged_for_user( + handler: &DatabaseHandler, + guild_id: i64, + user_id: i64, + offset: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let query = + r#"SELECT * FROM tags WHERE guild_id = $1 AND author = $2 ORDER BY created_at DESC OFFSET $3 LIMIT $4"#; + + sqlx::query_as(query) + .bind(guild_id) + .bind(user_id) + .bind(offset) + .bind(limit) + .fetch_all(&handler.pool) + .await + } + + pub async fn get_count_in_guild(handler: &DatabaseHandler, guild_id: i64) -> Result { + let query = r#"SELECT count(*) FROM tags WHERE guild_id = $1"#; + + let result: Result = sqlx::query_as(query).bind(guild_id).fetch_one(&handler.pool).await; + + result.map(|c| c.count) + } + + pub async fn get_count_for_user( + handler: &DatabaseHandler, + guild_id: i64, + user_id: i64, + ) -> Result { + let query = r#"SELECT count(*) FROM tags WHERE guild_id = $1 AND author = $2"#; + + let result: Result = sqlx::query_as(query) + .bind(guild_id) + .bind(user_id) + .fetch_one(&handler.pool) + .await; + + result.map(|c| c.count) + } +}