Compare commits

..

10 Commits

Author SHA1 Message Date
a1cd0e6a25 use sqlx's query macro in some places
Some checks failed
Docker Image CI / build_amd64 (push) Has been cancelled
Docker Image CI / build_arm64 (push) Has been cancelled
Docker Image CI / create_manifest (push) Has been cancelled
2025-06-27 13:30:38 -04:00
c054ef1c1c use sqlx migrations 2025-06-26 12:58:44 -04:00
4509359c0e fix timeout cheating 2025-05-27 11:44:44 -04:00
3b741835bd fix formatting and remove instances of printing IDs 2025-05-18 16:24:15 -04:00
d35426ec07 add forget admin command 2025-05-18 16:18:01 -04:00
e798e82395 improved admin controls over self roles 2025-05-18 16:10:07 -04:00
e5ca4473bc remove unecessary use 2025-05-17 23:35:02 -04:00
1874058c45 more concise guild getting 2025-05-17 23:31:48 -04:00
6f1adcee5e use consistent guild getter syntax 2025-05-17 23:16:59 -04:00
f75c0aa057 update dependencies 2025-05-17 15:04:48 -04:00
28 changed files with 607 additions and 544 deletions

3
.envrc Normal file
View File

@@ -0,0 +1,3 @@
set -a
source .env
set +a

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target
.env
.env
.vscode

428
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS bank (
id BIGINT PRIMARY KEY,
balance INT
)

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS selfroles (
userid BIGINT NOT NULL,
guildid BIGINT NOT NULL,
roleid BIGINT,
UNIQUE (userid, guildid)
)

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS games (
id BIGSERIAL PRIMARY KEY,
name CHAR[255]
)

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
owner BIGINT NOT NULL,
game BIGINT NOT NULL,
item BIGINT NOT NULL,
data JSON NOT NULL,
name TEXT
)

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS dailies (
userid BIGINT NOT NULL PRIMARY KEY,
last TIMESTAMPTZ,
streak INT
)

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS settings (
guildid BIGINT NOT NULL PRIMARY KEY,
positional_role BIGINT,
banrole BIGINT,
hoist_selfroles BOOLEAN,
prefix TEXT
)

View File

@@ -1,4 +1,4 @@
use crate::common::{Context, Error};
use crate::common::{Context, Error, BigBirbError};
use crate::commands::settings;
use poise::serenity_prelude as serenity;
@@ -10,13 +10,7 @@ pub async fn ban(ctx: Context<'_>,
#[rest]
reason: Option<String>) -> Result<(), Error>
{
let guild = match ctx.guild_id() {
Some(g) => g,
None => {
ctx.reply("This command must be ran within a guild.").await?;
return Ok(());
}
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
if let Some(role) = settings::get_banrole(ctx, guild).await? {
let member = guild.member(&ctx, user.id).await?;
@@ -35,13 +29,7 @@ pub async fn ban(ctx: Context<'_>,
#[poise::command(slash_command, prefix_command)]
pub async fn unban(ctx: Context<'_>, user: serenity::User) -> Result<(), Error>
{
let guild = match ctx.guild_id() {
Some(g) => g,
None => {
ctx.reply("This command must be ran within a guild.").await?;
return Ok(());
}
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
if let Some(role) = settings::get_banrole(ctx, guild).await? {
let member = guild.member(&ctx, user.id).await?;

View File

@@ -6,9 +6,9 @@ use poise::serenity_prelude as serenity;
#[poise::command(slash_command, prefix_command, aliases("bal", "b"))]
pub async fn balance(ctx: Context<'_>, user: Option<serenity::User>) -> Result<(), Error> {
let user = user.as_ref().unwrap_or(ctx.author());
let db = &ctx.data().database;
let mut tx = ctx.data().database.begin().await?;
let wealth = super::get_balance(user.id, db).await?;
let wealth = super::get_balance(user.id, &mut tx).await?;
common::no_ping_reply(&ctx, format!("{} **{}** token(s).",
if user.id == ctx.author().id {

View File

@@ -106,6 +106,9 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
let mut tx = ctx.data().database.begin().await?;
let mut balance = super::get_balance(ctx.author().id, &mut *tx).await?;
// whether the player is going to time out in the next 60 seconds
let mut timeout = false;
let amount = match amount.to_lowercase().as_str() {
"all" => balance,
"half" => balance / 2,
@@ -165,7 +168,7 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
"**Dealer's hand**: {} ({})\n",
"**Your hand**: {} ({})\n\n",
"**Bet**: {}\n",
"Bust! You've lost **{}** tokens."
"Bust! You've lost **{}** token(s)."
),
dealers_hand.iter().map(|card| format!("`{card}`")).collect::<Vec<String>>().join(", "),
dealers_count,
@@ -194,13 +197,15 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
concat!(
"**Dealer's hand**: {} ({})\n",
"**Your hand**: {} ({})\n\n",
"**Bet**: {}"
"**Bet**: {}",
"{}"
),
format!("`{}`, `XX`", dealers_hand[0]),
dealers_hand[0].value(matches!(dealers_hand[0], Card { rank: Rank::Ace, .. })),
players_hand.iter().map(|card| format!("`{card}`")).collect::<Vec<String>>().join(", "),
players_count,
amount,
if timeout { "\n*You have 60 seconds to make a decision.*" } else { "" }
)
)
.components(components)
@@ -209,10 +214,36 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
msg.edit(ctx, reply).await?;
let Some(mci) = serenity::ComponentInteractionCollector::new(ctx.serenity_context())
.timeout(Duration::from_secs(120))
.timeout(Duration::from_secs(60))
.filter(move |mci| mci.data.custom_id.starts_with("blackjack")).await else {
ctx.reply("failed interaction!").await?;
return Ok(());
if timeout {
msg.edit(ctx, poise::CreateReply::default()
.components(vec![])
.content(
format!(
concat!(
"**Dealer's hand**: {} ({})\n",
"**Your hand**: {} ({})\n\n",
"**Bet**: {}\n",
"{}"
),
dealers_hand.iter().map(|card| format!("`{card}`")).collect::<Vec<String>>().join(", "),
dealers_count,
players_hand.iter().map(|card| format!("`{card}`")).collect::<Vec<String>>().join(", "),
players_count,
amount, format!("No bets go without a game! You've lost **{amount}** token(s).")
)
)).await?;
balance -= amount;
super::change_balance(ctx.author().id, balance, &mut *tx).await?;
tx.commit().await?;
return Ok(());
} else {
timeout = true;
continue;
}
};
if mci.member.clone().unwrap().user.id != ctx.author().id {
@@ -225,6 +256,9 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
continue;
}
// Reset timeout after player interacts
timeout = false;
mci.create_response(ctx, serenity::CreateInteractionResponse::Acknowledge).await?;
match &mci.data.custom_id[..] {
@@ -251,20 +285,20 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
if players_count == 21 && players_hand.len() == 2 {
let amount = amount * 3 / 2;
balance += amount;
format!("You've won with a Blackjack! You've gained **{amount}** tokens.")
format!("You've won with a Blackjack! You've gained **{amount}** token(s).")
} else {
balance += amount;
format!("You've won! **{amount}** tokens have been added to your account.")
format!("You've won! **{amount}** token(s) have been added to your account.")
}
}
Ordering::Greater if dealers_count > 21 => {
if players_count == 21 && players_hand.len() == 2 {
let amount = amount * 3 / 2;
balance += amount;
format!("You've won with a Blackjack! You've gained **{amount}** tokens.")
format!("You've won with a Blackjack! You've gained **{amount}** token(s).")
} else {
balance += amount;
format!("You've won! **{amount}** tokens have been added to your account.")
format!("You've won! **{amount}** token(s) have been added to your account.")
}
}
Ordering::Equal => {
@@ -272,7 +306,7 @@ pub async fn blackjack(ctx: Context<'_>, amount: String) -> Result<(), Error>
}
Ordering::Greater => {
balance -= amount;
format!("You've lost. **{amount}** tokens to the dealer.")
format!("You've lost. **{amount}** token(s) to the dealer.")
}
};

View File

@@ -1,58 +1,54 @@
use crate::{Context, Error};
use poise::serenity_prelude::{UserId, User};
use sqlx::{types::chrono::{DateTime, Utc, TimeZone}, PgExecutor, Row};
use sqlx::{types::chrono::{DateTime, TimeZone, Utc}, PgConnection};
use std::time::Duration;
async fn get_streak<'a, E>(db: E, user: UserId) -> Result<Option<i32>, Error>
where
E: PgExecutor<'a>,
{
match sqlx::query(
"SELECT streak FROM dailies WHERE userid = $1"
).bind(user.get() as i64).fetch_one(db).await
{
Ok(row) => Ok(Some(row.get(0))),
Err(sqlx::Error::RowNotFound) => Ok(None),
Err(e) => Err(Box::new(e)),
}
pub async fn get_streak(conn: &mut PgConnection, user: UserId) -> Result<Option<i32>, Error> {
let result = sqlx::query!(
"SELECT streak FROM dailies WHERE userid = $1",
user.get() as i64
)
.fetch_optional(conn)
.await?;
Ok(result.map(|r| r.streak).unwrap_or(None))
}
async fn set_streak<'a, E>(db: E, user: UserId, streak: i32) -> Result<(), Error>
where
E: PgExecutor<'a>,
{
sqlx::query("INSERT INTO dailies (userid, streak) VALUES ($1, $2) ON CONFLICT (userid) DO UPDATE SET streak = EXCLUDED.streak")
.bind(user.get() as i64)
.bind(streak)
.execute(db).await?;
pub async fn set_streak(conn: &mut PgConnection, user: UserId, streak: i32) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO dailies (userid, streak) VALUES ($1, $2)
ON CONFLICT (userid) DO UPDATE SET streak = EXCLUDED.streak",
user.get() as i64,
streak
)
.execute(conn)
.await?;
Ok(())
}
async fn get_last<'a, E>(db: E, user: UserId) -> Result<Option<DateTime<Utc>>, Error>
where
E: PgExecutor<'a>,
{
match sqlx::query(
"SELECT last FROM dailies WHERE userid = $1"
).bind(user.get() as i64).fetch_one(db).await
{
Ok(row) => Ok(Some(row.get(0))),
Err(sqlx::Error::RowNotFound) => Ok(None),
Err(e) => Err(Box::new(e)),
}
pub async fn get_last(conn: &mut PgConnection, user: UserId) -> Result<Option<DateTime<Utc>>, Error> {
let result = sqlx::query!(
"SELECT last FROM dailies WHERE userid = $1",
user.get() as i64
)
.fetch_optional(conn)
.await?;
Ok(result.map(|r| r.last).unwrap_or(None))
}
async fn set_last<'a, E>(db: E, user: UserId, last: DateTime<Utc>) -> Result<(), Error>
where
E: PgExecutor<'a>,
{
sqlx::query("INSERT INTO dailies (userid, last) VALUES ($1, $2) ON CONFLICT (userid) DO UPDATE SET last = EXCLUDED.last")
.bind(user.get() as i64)
.bind(last)
.execute(db).await?;
pub async fn set_last(conn: &mut PgConnection, user: UserId, last: DateTime<Utc>) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO dailies (userid, last) VALUES ($1, $2)
ON CONFLICT (userid) DO UPDATE SET last = EXCLUDED.last",
user.get() as i64,
last
)
.execute(conn)
.await?;
Ok(())
}
@@ -60,13 +56,14 @@ where
/// Tells you what your current daily streak is
#[poise::command(slash_command, prefix_command)]
pub async fn streak(ctx: Context<'_>, user: Option<User>) -> Result<(), Error> {
let db = &ctx.data().database;
let mut tx = ctx.data().database.begin().await?;
let (user, who) = match user {
Some(user) => (user.id, format!("{} has", user.display_name())),
None => (ctx.author().id, "You have".to_string()),
};
ctx.reply(format!("{who} a daily streak of **{}**", get_streak(db, user).await?.unwrap_or(0))).await?;
ctx.reply(format!("{who} a daily streak of **{}**", get_streak(&mut tx, user).await?.unwrap_or(0))).await?;
Ok(())
}

View File

@@ -1,7 +1,6 @@
use crate::common::{Context, Error};
use poise::serenity_prelude::UserId;
use sqlx::Row;
enum LeaderboardType {
Tokens(usize),
@@ -13,15 +12,16 @@ async fn display_leaderboard(ctx: Context<'_>, t: LeaderboardType) -> Result<(),
match t {
LeaderboardType::Tokens(count) => {
let rows = sqlx::query(
let rows = sqlx::query!(
r#"
SELECT id, balance FROM bank
ORDER BY balance DESC
LIMIT $1
"#
).bind(count as i32).fetch_all(db).await?;
"#,
count as i32
).fetch_all(db).await?;
let users: Vec<(_, i32)> = rows.iter().map(|row| (UserId::new(row.get::<i64, _>(0) as u64), row.get(1))).collect();
let users: Vec<(_, i32)> = rows.iter().map(|row| (UserId::new(row.id as u64), row.balance.unwrap_or(100))).collect();
let mut output = String::new();
for (id, balance) in users {
@@ -32,15 +32,16 @@ async fn display_leaderboard(ctx: Context<'_>, t: LeaderboardType) -> Result<(),
ctx.reply(format!("```\n{output}```")).await?;
}
LeaderboardType::Dailies(count) => {
let rows = sqlx::query(
let rows = sqlx::query!(
r#"
SELECT userid, streak FROM dailies
ORDER BY streak DESC
LIMIT $1
"#
).bind(count as i32).fetch_all(db).await?;
"#,
count as i32
).fetch_all(db).await?;
let users: Vec<(_, i32)> = rows.iter().map(|row| (UserId::new(row.get::<i64, _>(0) as u64), row.get(1))).collect();
let users: Vec<(_, i32)> = rows.iter().map(|row| (UserId::new(row.userid as u64), row.streak.unwrap_or(0))).collect();
let mut output = String::new();
for (id, streak) in users {

View File

@@ -9,7 +9,7 @@ pub mod blackjack;
use crate::{inventory::{self, Inventory}, common::{Context, Error}};
use poise::serenity_prelude::{self as serenity, futures::StreamExt, UserId};
use sqlx::{Row, PgExecutor};
use sqlx::PgConnection;
use std::collections::HashMap;
#[derive(Clone)]
@@ -79,16 +79,15 @@ mod items {
}
}
pub async fn get_balance<'a, E>(id: UserId, db: E) -> Result<i32, Error>
where
E: PgExecutor<'a>,
pub async fn get_balance(id: UserId, db: &mut PgConnection) -> Result<i32, Error>
{
let row = sqlx::query("SELECT balance FROM bank WHERE id = $1")
.bind(id.get() as i64)
.fetch_one(db).await.ok();
let row = sqlx::query!(
"SELECT balance FROM bank WHERE id = $1",
id.get() as i64
).fetch_one(db).await.ok();
let balance = if let Some(row) = row {
row.try_get("balance")?
row.balance.unwrap_or(100)
} else {
100
};
@@ -96,14 +95,13 @@ where
Ok(balance)
}
pub async fn change_balance<'a, E>(id: UserId, balance: i32, db: E) -> Result<(), Error>
where
E: PgExecutor<'a>,
pub async fn change_balance(id: UserId, balance: i32, db: &mut PgConnection) -> Result<(), Error>
{
sqlx::query("INSERT INTO bank (id, balance) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance")
.bind(id.get() as i64)
.bind(balance)
.execute(db).await?;
sqlx::query!(
r#"INSERT INTO bank (id, balance) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance"#,
id.get() as i64, balance
).execute(db).await?;
Ok(())
}

View File

@@ -16,8 +16,8 @@ async fn autocomplete_shop<'a>(
ctx: Context<'_>,
partial: &'a str,
) -> impl Iterator<Item = serenity::AutocompleteChoice> + use<'a> {
let db = &ctx.data().database;
let balance = super::get_balance(ctx.author().id, db).await;
let mut tx = ctx.data().database.begin().await.unwrap();
let balance = super::get_balance(ctx.author().id, &mut *tx).await;
ITEMS.values()
.filter(move |(_, item)| item.name.contains(partial))

View File

@@ -38,6 +38,7 @@ pub fn commands() -> Vec<Command<Data, Error>> {
gambling::blackjack::blackjack(),
eval::eval(),
self_roles::role(),
self_roles::editrole(),
settings::setting(),
administration::ban::ban(),
administration::ban::unban(),

View File

@@ -0,0 +1,125 @@
use crate::common::{self, Context, Error, BigBirbError};
use poise::serenity_prelude::{User, Role};
/// Change the name of a user's personal role
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn name(ctx: Context<'_>, user: User, #[rest] name: String) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let role = guild.role(ctx, super::name::change_user_role_name(ctx, &user, guild, name).await?).await?;
common::no_ping_reply(&ctx, format!("{role} has been updated.")).await?;
Ok(())
}
/// Change the name of a user's personal role
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn color(ctx: Context<'_>, user: User, color: String) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let color = super::color::parse_color(&color)?;
let role = guild.role(ctx, super::color::change_user_role_color(ctx, &user, guild, color).await?).await?;
common::no_ping_reply(&ctx, format!("{role}'s color has been updated.")).await?;
Ok(())
}
/// Change a user's role name and color at once
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn set(ctx: Context<'_>, user: User, color: String, #[rest] name: String) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let color = super::color::parse_color(&color)?;
super::color::change_user_role_color(ctx, &user, guild, color).await?;
let role = guild.role(ctx, super::name::change_user_role_name(ctx, &user, guild, name).await?).await?;
common::no_ping_reply(&ctx, format!("{role} has been updated.")).await?;
Ok(())
}
/// Remove and delete user's self role
#[poise::command(slash_command, prefix_command, aliases("disown"), required_permissions = "MANAGE_ROLES")]
pub async fn remove(ctx: Context<'_>, user: User) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let mut tx = ctx.data().database.begin().await?;
if let Some(role) = super::get_user_role(user.id, guild, &mut *tx).await? {
guild.delete_role(ctx, role).await?;
super::remove_role(role, guild, &mut *tx).await?;
tx.commit().await?;
common::no_ping_reply(&ctx, format!("{user}'s self role has been deleted.")).await?;
} else {
common::no_ping_reply(&ctx, format!("{user} has no self role.")).await?;
}
Ok(())
}
/// Remove a selfrole from the database without deleting it or attempting to take it from the user
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn forget(ctx: Context<'_>, user: User) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let mut tx = ctx.data().database.begin().await?;
if let Some(role) = super::get_user_role(user.id, guild, &mut *tx).await? {
super::remove_role(role, guild, &mut *tx).await?;
common::no_ping_reply(&ctx, format!("{user}'s selfrole has been forgotten.")).await?;
} else {
common::no_ping_reply(&ctx, format!("{user} has no selfrole set.")).await?;
}
tx.commit().await?;
Ok(())
}
/// Give a user an existing role as their self role
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn give(ctx: Context<'_>, user: User, role: Role, force: Option<bool>) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let force = force.unwrap_or(false);
let member = guild.member(ctx, user).await?;
let mut tx = ctx.data().database.begin().await?;
if force {
// delete existing self role for user
if let Some(original) = super::get_user_role(member.user.id, guild, &mut *tx).await? {
guild.delete_role(ctx, original).await?;
super::remove_role(role.id, guild, &mut *tx).await?;
}
// remove role from another user if it is already registered as their self role
if let Some(user) = super::get_user_by_role(role.id, guild, &mut *tx).await? {
let m = guild.member(ctx, user).await?;
m.remove_role(ctx, role.id).await?;
super::remove_role(role.id, guild, &mut *tx).await?;
}
super::update_user_role(member.user.id, guild, role.id, &mut *tx).await?;
member.add_role(ctx, role.id).await?;
} else {
if let Some(original) = super::get_user_role(member.user.id, guild, &mut *tx).await? {
let original = guild.role(ctx, original).await?;
common::no_ping_reply(&ctx, format!("{original} is already set as this user's self role, enable force to overwrite.")).await?;
return Ok(());
}
if let Some(owner) = super::get_user_by_role(role.id, guild, &mut *tx).await? {
let owner = owner.to_user(ctx).await?;
common::no_ping_reply(&ctx, format!("{role} is already owned by {owner}, enable force to overwrite.")).await?;
return Ok(());
}
super::update_user_role(member.user.id, guild, role.id, &mut *tx).await?;
}
tx.commit().await?;
common::no_ping_reply(&ctx, format!("{member} has been given the self role {role}.")).await?;
Ok(())
}

View File

@@ -1,11 +1,11 @@
use crate::common::{self, Context, Error};
use crate::common::{self, Context, Error, BigBirbError};
use once_cell::sync::Lazy;
use std::collections::HashMap;
use hex_color::HexColor;
use poise::serenity_prelude::{colours, Color, EditRole};
use poise::serenity_prelude::{colours, Color, User, GuildId, RoleId, EditRole};
static COLORS: Lazy<HashMap<&'static str, Color>> = Lazy::new(|| {
HashMap::from([
@@ -59,6 +59,25 @@ async fn autocomplete_colors<'a>(
COLORS.clone().into_keys().filter(move |x| x.split_whitespace().any(|x| x.starts_with(partial)))
}
pub fn parse_color(s: &str) -> Result<Color, Error> {
let color = if let Some(named) = COLORS.get(s) {
named.clone()
} else {
let rgb = HexColor::parse_rgb(&s)?;
Color::from_rgb(rgb.r, rgb.g, rgb.b)
};
Ok(color)
}
pub async fn change_user_role_color(ctx: Context<'_>, user: &User, guild: GuildId, color: Color) -> Result<RoleId, Error> {
let mut tx = ctx.data().database.begin().await?;
let role = super::edit_role(ctx, user.id, guild, EditRole::new().colour(color), &mut *tx).await?;
tx.commit().await?;
Ok(role)
}
/// Change the color of your personal role
#[poise::command(slash_command, prefix_command)]
pub async fn color(ctx: Context<'_>,
@@ -66,27 +85,12 @@ pub async fn color(ctx: Context<'_>,
#[rest]
color: String) -> Result<(), Error>
{
let color = if let Some(named) = COLORS.get(color.as_str()) {
named.clone()
} else {
let rgb = HexColor::parse_rgb(&color)?;
Color::from_rgb(rgb.r, rgb.g, rgb.b)
};
let guild = if let Some(guild) = ctx.guild_id() {
guild
} else {
ctx.reply("This command can only be run inside of a guild.").await?;
return Ok(());
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let user = ctx.author();
let color = parse_color(&color)?;
let mut tx = ctx.data().database.begin().await?;
let role = super::edit_role(ctx, user.id, guild, EditRole::new().colour(color), &mut *tx).await?;
tx.commit().await?;
common::no_ping_reply(&ctx, format!("{}'s color has been updated.", guild.role(ctx, role).await?)).await?;
let role = guild.role(ctx, change_user_role_color(ctx, &user, guild, color).await?).await?;
common::no_ping_reply(&ctx, format!("{role}'s color has been updated.")).await?;
Ok(())
}

View File

@@ -1,16 +1,10 @@
use crate::common::{Context, Error};
use crate::common::{Context, Error, BigBirbError};
/// Remove and delete your personal role
#[poise::command(slash_command, prefix_command)]
pub async fn disown(ctx: Context<'_>) -> Result<(), Error> {
let guild = if let Some(guild) = ctx.guild_id() {
guild
} else {
ctx.reply("This command can only be run inside of a guild.").await?;
return Ok(());
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let user = ctx.author();
let mut tx = ctx.data().database.begin().await?;

View File

@@ -1,31 +1,47 @@
use crate::common::{Context, Error};
use sqlx::{PgConnection, Row};
use sqlx::PgConnection;
use poise::serenity_prelude::{EditRole, GuildId, Permissions, RoleId, UserId};
mod register;
mod whois;
mod color;
mod name;
pub mod color;
pub mod name;
mod disown;
mod remove;
mod admin;
#[poise::command(
prefix_command,
slash_command,
subcommands(
"register::register",
"whois::whois",
"color::color",
"name::name",
"color::color",
"disown::disown",
"remove::remove",
"whois::whois",
)
)]
pub async fn role(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
#[poise::command(
prefix_command,
slash_command,
subcommands(
"admin::name",
"admin::color",
"admin::remove",
"admin::forget",
"admin::set",
"admin::give",
"whois::whois",
),
required_permissions = "MANAGE_ROLES",
)]
pub async fn editrole(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Edit a user's personal role, creates it with some default values if it doesn't exist.
pub async fn edit_role(ctx: Context<'_>, user: UserId, guild: GuildId, edit: EditRole<'_>, db: &mut PgConnection) -> Result<RoleId, Error>
{
@@ -67,18 +83,14 @@ async fn create_role(
/// Remove a row concerning a user's self role from the database
pub async fn remove_user_role(user: UserId, guild: GuildId, db: &mut PgConnection) -> Result<(), Error> {
sqlx::query("DELETE FROM selfroles WHERE userid = $1 AND guildid = $2")
.bind(user.get() as i64)
.bind(guild.get() as i64)
sqlx::query!("DELETE FROM selfroles WHERE userid = $1 AND guildid = $2", user.get() as i64, guild.get() as i64)
.execute(db).await?;
Ok(())
}
pub async fn remove_role(role: RoleId, guild: GuildId, db: &mut PgConnection) -> Result<(), Error> {
sqlx::query("DELETE FROM selfroles WHERE roleid = $1 AND guildid = $2")
.bind(role.get() as i64)
.bind(guild.get() as i64)
sqlx::query!("DELETE FROM selfroles WHERE roleid = $1 AND guildid = $2", role.get() as i64, guild.get() as i64)
.execute(db).await?;
Ok(())
@@ -86,10 +98,10 @@ pub async fn remove_role(role: RoleId, guild: GuildId, db: &mut PgConnection) ->
/// Replace a user's custom role with a new one
pub async fn update_user_role(user: UserId, guild: GuildId, role: RoleId, db: &mut PgConnection) -> Result<(), Error> {
sqlx::query("INSERT INTO selfroles (userid, guildid, roleid) VALUES($1, $2, $3) ON CONFLICT (userid, guildid) DO UPDATE SET roleid = EXCLUDED.roleid")
.bind(user.get() as i64)
.bind(guild.get() as i64)
.bind(role.get() as i64)
sqlx::query!(
r#"INSERT INTO selfroles (userid, guildid, roleid) VALUES($1, $2, $3)
ON CONFLICT (userid, guildid) DO UPDATE SET roleid = EXCLUDED.roleid"#,
user.get() as i64, guild.get() as i64, role.get() as i64)
.execute(db).await?;
Ok(())
@@ -97,12 +109,21 @@ pub async fn update_user_role(user: UserId, guild: GuildId, role: RoleId, db: &m
/// Get a user's personal role id from the database
pub async fn get_user_role(user: UserId, guild: GuildId, db: &mut PgConnection) -> Result<Option<RoleId>, Error> {
match sqlx::query("SELECT roleid FROM selfroles WHERE userid = $1 AND guildid = $2")
.bind(user.get() as i64)
.bind(guild.get() as i64)
match sqlx::query!("SELECT roleid FROM selfroles WHERE userid = $1 AND guildid = $2", user.get() as i64, guild.get() as i64)
.fetch_one(db).await
{
Ok(row) => Ok(Some(RoleId::new(row.try_get::<i64, usize>(0)? as u64))),
Ok(row) => Ok(Some(RoleId::new(row.roleid.unwrap() as u64))),
Err(sqlx::Error::RowNotFound) => Ok(None),
Err(e) => return Err(Box::new(e)),
}
}
/// Get a user from the role id
pub async fn get_user_by_role(role: RoleId, guild: GuildId, db: &mut PgConnection) -> Result<Option<UserId>, Error> {
match sqlx::query!("SELECT userid FROM selfroles WHERE roleid = $1 AND guildid = $2", role.get() as i64, guild.get() as i64)
.fetch_one(db).await
{
Ok(row) => Ok(Some(UserId::new(row.userid as u64))),
Err(sqlx::Error::RowNotFound) => Ok(None),
Err(e) => return Err(Box::new(e)),
}

View File

@@ -1,26 +1,23 @@
use crate::common::{self, Context, Error};
use poise::serenity_prelude::EditRole;
/// Change the name of your personal role
#[poise::command(slash_command, prefix_command)]
pub async fn name(ctx: Context<'_>, #[rest] name: String) -> Result<(), Error> {
let guild = if let Some(guild) = ctx.guild_id() {
guild
} else {
ctx.reply("This command can only be run inside of a guild.").await?;
return Ok(());
};
let user = ctx.author();
use crate::common::{self, Context, Error, BigBirbError};
use poise::serenity_prelude::{EditRole, User, GuildId, RoleId};
pub async fn change_user_role_name(ctx: Context<'_>, user: &User, guild: GuildId, name: String) -> Result<RoleId, Error> {
let mut tx = ctx.data().database.begin().await?;
let role = super::edit_role(ctx, user.id, guild, EditRole::new().name(name), &mut *tx).await?;
tx.commit().await?;
common::no_ping_reply(&ctx, format!("{} has been updated.", guild.role(ctx, role).await?)).await?;
Ok(role)
}
/// Change the name of your personal role
#[poise::command(slash_command, prefix_command, )]
pub async fn name(ctx: Context<'_>, #[rest] name: String) -> Result<(), Error> {
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let user = ctx.author();
let role = guild.role(ctx, change_user_role_name(ctx, user, guild, name).await?).await?;
common::no_ping_reply(&ctx, format!("{role} has been updated.")).await?;
Ok(())
}

View File

@@ -1,31 +0,0 @@
use crate::common::{self, Context, Error};
use poise::serenity_prelude as serenity;
/// Register an existing role as a user's custom role. This deletes their current self role.
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn register(ctx: Context<'_>, user: serenity::User, role: serenity::Role) -> Result<(), Error> {
let guild = if let Some(guild) = ctx.guild_id() {
guild
} else {
ctx.reply("This command can only be run inside of a guild.").await?;
return Ok(());
};
let mut tx = ctx.data().database.begin().await?;
if let Some(role) = super::get_user_role(user.id, guild, &mut *tx).await? {
guild.delete_role(ctx, role).await?;
}
let member = guild.member(ctx, user).await?;
member.add_role(ctx, role.id).await?;
super::update_user_role(member.user.id, guild, role.id, &mut *tx).await?;
tx.commit().await?;
common::no_ping_reply(&ctx, format!("{} has been set as {}'s self role.", role, member.user)).await?;
Ok(())
}

View File

@@ -1,27 +0,0 @@
use crate::common::{self, Context, Error};
use poise::serenity_prelude as serenity;
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_ROLES")]
pub async fn remove(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> {
let guild = if let Some(guild) = ctx.guild_id() {
guild
} else {
ctx.reply("This command can only be run inside of a guild.").await?;
return Ok(());
};
let mut tx = ctx.data().database.begin().await?;
if let Some(role) = super::get_user_role(user.id, guild, &mut *tx).await? {
guild.delete_role(ctx, role).await?;
super::remove_role(role, guild, &mut *tx).await?;
tx.commit().await?;
common::no_ping_reply(&ctx, format!("{}'s self role has been deleted.", user)).await?;
} else {
common::no_ping_reply(&ctx, format!("{} has no self role.", user)).await?;
}
Ok(())
}

View File

@@ -3,7 +3,6 @@ use crate::common::{self, Context, Error};
use poise::serenity_prelude as serenity;
use serenity::UserId;
use sqlx::Row;
/// Let you know who is the owner of a role.
#[poise::command(slash_command, prefix_command)]
@@ -11,11 +10,10 @@ pub async fn whois(ctx: Context<'_>, role: serenity::Role) -> Result<(), Error>
let db = &ctx.data().database;
if let Some(guild) = ctx.guild_id() {
let user = match sqlx::query("SELECT userid FROM selfroles WHERE roleid = $1")
.bind(role.id.get() as i64)
let user = match sqlx::query!("SELECT userid FROM selfroles WHERE roleid = $1", role.id.get() as i64)
.fetch_one(db).await
{
Ok(row) => UserId::new(row.try_get::<i64, usize>(0)? as u64),
Ok(row) => UserId::new(row.userid as u64),
Err(sqlx::Error::RowNotFound) => {
ctx.reply("This role is not owned by anyone.").await?;
return Ok(());
@@ -25,7 +23,7 @@ pub async fn whois(ctx: Context<'_>, role: serenity::Role) -> Result<(), Error>
let member = guild.member(ctx, user).await?;
common::no_ping_reply(&ctx, format!("{} owns this role.", member)).await?;
common::no_ping_reply(&ctx, format!("{member} owns this role.")).await?;
} else {
ctx.reply("This command must be used within a server!").await?;
}

View File

@@ -1,4 +1,4 @@
use crate::common::{self, Context, Error};
use crate::common::{self, Context, Error, BigBirbError};
use poise::serenity_prelude::{Role, RoleId, GuildId};
use sqlx::Row;
@@ -20,13 +20,7 @@ async fn get_prefix(ctx: Context<'_>, guild: GuildId) -> Result<Option<String>,
#[poise::command(prefix_command, slash_command)]
async fn prefix(ctx: Context<'_>, prefix: Option<String>) -> Result<(), Error> {
let guild = match ctx.guild_id() {
Some(g) => g,
None => {
ctx.reply("This command must be ran within a guild.").await?;
return Ok(());
}
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
match prefix {
Some(prefix) => {
@@ -74,14 +68,7 @@ pub async fn get_positional_role(ctx: Context<'_>, guild: GuildId) -> Result<Opt
#[poise::command(prefix_command, slash_command)]
pub async fn position(ctx: Context<'_>, role: Option<Role>) -> Result<(), Error> {
let guild = match ctx.guild_id() {
Some(g) => g,
None => {
ctx.reply("This command must be ran within a guild.").await?;
return Ok(());
}
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let member = ctx.author_member().await.unwrap();
if !member.permissions(ctx).iter().any(|p| p.manage_guild()) {
@@ -132,13 +119,7 @@ pub async fn get_hoist_selfroles(ctx: Context<'_>, guild: GuildId) -> Result<boo
#[poise::command(prefix_command, slash_command)]
pub async fn hoist(ctx: Context<'_>, hoist: Option<bool>) -> Result<(), Error> {
let guild = match ctx.guild_id() {
Some(g) => g,
None => {
ctx.reply("This command must be ran within a guild.").await?;
return Ok(());
}
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
match hoist {
Some(hoist) => {
@@ -195,14 +176,7 @@ pub async fn get_banrole(ctx: Context<'_>, guild: GuildId) -> Result<Option<Role
#[poise::command(prefix_command, slash_command)]
pub async fn banrole(ctx: Context<'_>, role: Option<Role>) -> Result<(), Error> {
let guild = match ctx.guild_id() {
Some(g) => g,
None => {
ctx.reply("This command must be ran within a guild.").await?;
return Ok(());
}
};
let guild = ctx.guild_id().ok_or(BigBirbError::GuildOnly)?;
let member = ctx.author_member().await.unwrap();
if !member.permissions(ctx).iter().any(|p| p.manage_guild()) {

View File

@@ -1,3 +1,4 @@
use std::{error, fmt};
use poise::ReplyHandle;
use sqlx::{Pool, Postgres};
@@ -20,3 +21,20 @@ pub async fn no_ping_reply<'a>(ctx: &'a Context<'_>, text: impl Into<String>) ->
.allowed_mentions(CreateAllowedMentions::new())
).await?)
}
#[derive(Debug, Clone, Copy)]
pub enum BigBirbError {
GuildOnly,
}
impl fmt::Display for BigBirbError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let s = match self {
Self::GuildOnly => "This command must be run inside of a guild.",
};
write!(f, "{s}")
}
}
impl error::Error for BigBirbError {}

View File

@@ -55,9 +55,8 @@ async fn event_handler(
}
async fn get_prefix(ctx: PartialContext<'_, Data, Error>) -> Result<Option<String>, Error> {
let guild = match ctx.guild_id {
Some(guild) => guild,
None => return Ok(ctx.data.prefix.clone()),
let Some(guild) = ctx.guild_id else {
return Ok(ctx.data.prefix.clone());
};
let db = &ctx.data.database;
@@ -112,69 +111,9 @@ async fn main() -> Result<(), Error> {
.max_connections(5)
.connect(&database_url).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS bank (
id BIGINT PRIMARY KEY,
balance INT
)
"#,
).execute(&database).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS selfroles (
userid BIGINT NOT NULL,
guildid BIGINT NOT NULL,
roleid BIGINT,
UNIQUE (userid, guildid)
)
"#,
).execute(&database).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS games (
id BIGSERIAL PRIMARY KEY,
name CHAR[255]
)
"#
).execute(&database).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
owner BIGINT NOT NULL,
game BIGINT NOT NULL,
item BIGINT NOT NULL,
data JSON NOT NULL,
name TEXT
)
"#
).execute(&database).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS dailies (
userid BIGINT NOT NULL PRIMARY KEY,
last TIMESTAMPTZ,
streak INT
)
"#
).execute(&database).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS settings (
guildid BIGINT NOT NULL PRIMARY KEY,
positional_role BIGINT,
banrole BIGINT,
hoist_selfroles BOOLEAN,
prefix TEXT
)
"#
).execute(&database).await?;
sqlx::migrate!()
.run(&database)
.await?;
println!("Bot is ready!");