2 Commits

Author SHA1 Message Date
jude
f35c5082f1 Restructure guilds table 2025-06-18 21:03:41 +01:00
jude
19cfacffe5 Restructure database tables 2025-05-08 20:55:17 +01:00
34 changed files with 222 additions and 13207 deletions

14
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 3
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@@ -524,15 +524,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "cron-parser"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa5650eabdaa360e2c240c2a5f544f10185b439cd76d748e44e3f28128a016b"
dependencies = [
"chrono",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.13" version = "0.5.13"
@@ -2623,12 +2614,11 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.40" version = "1.7.37"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"cron-parser",
"csv", "csv",
"dotenv", "dotenv",
"env_logger", "env_logger",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.40" version = "1.7.37"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
@@ -35,7 +35,6 @@ serenity = { version = "0.12", default-features = false, features = ["builder",
oauth2 = "4" oauth2 = "4"
csv = "1.2" csv = "1.2"
sd-notify = "0.4.1" sd-notify = "0.4.1"
cron-parser = "0.10"
[dependencies.extract_derive] [dependencies.extract_derive]
path = "extract_derive" path = "extract_derive"

12676
gb-ipv4.csv

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
-- Drop all old tables
DROP TABLE IF EXISTS users_old;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS embeds;
DROP TABLE IF EXISTS embed_fields;
DROP TABLE IF EXISTS command_aliases;
DROP TABLE IF EXISTS macro;
DROP TABLE IF EXISTS roles;
SET FOREIGN_KEY_CHECKS=0;
-- Drop columns from channels that are no longer used
ALTER TABLE channels DROP COLUMN `name`;
ALTER TABLE channels DROP COLUMN `blacklisted`;
-- Drop columns from guilds table that are no longer used and rebuild table
CREATE TABLE guilds_new (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
ephemeral_confirmations BOOLEAN NOT NULL DEFAULT 0
);
INSERT INTO guilds_new (id, ephemeral_confirmations) SELECT guild, ephemeral_confirmations FROM guilds;
RENAME TABLE guilds TO guilds_old;
RENAME TABLE guilds_new TO guilds;
-- Update fk on channels to point at new guild table
ALTER TABLE channels
DROP FOREIGN KEY `channels_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE channels SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
-- Rebuild todos table
CREATE TABLE `todos_new` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`guild_id` BIGINT UNSIGNED DEFAULT NULL,
`channel_id` INT UNSIGNED DEFAULT NULL,
`user_id` BIGINT UNSIGNED DEFAULT NULL,
`value` VARCHAR(2000) NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY `fk_channel_id` (`channel_id`)
REFERENCES `channels` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY `fk_user_id` (`user_id`)
REFERENCES `users` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
);
INSERT INTO todos_new (id, guild_id, channel_id, user_id, value) SELECT id, (SELECT guild FROM guilds_old WHERE id = guild_id), channel_id, user_id, value FROM todos;
RENAME TABLE todos TO todos_old;
RENAME TABLE todos_new TO todos;
DROP TABLE todos_old;
-- Update fk on reminder_template to point at new guild table
ALTER TABLE reminder_template
DROP FOREIGN KEY `reminder_template_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED NOT NULL,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE reminder_template SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
-- Update fk on command_macro to point at new guild table
ALTER TABLE command_macro
DROP FOREIGN KEY `command_macro_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED NOT NULL,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE command_macro SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
DROP TABLE guilds_old;
SET FOREIGN_KEY_CHECKS=1;

View File

@@ -1,11 +0,0 @@
CREATE TABLE patreon_link
(
user_id BIGINT UNSIGNED NOT NULL,
guild_id BIGINT UNSIGNED NOT NULL,
linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
INDEX `idx_user_id` (user_id),
INDEX `idx_guild_id` (guild_id),
INDEX `idx_linked_at` (linked_at)
);

View File

@@ -3,7 +3,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS; use chrono_tz::TZ_VARIANTS;
use poise::serenity_prelude::AutocompleteChoice; use poise::serenity_prelude::AutocompleteChoice;
use crate::time_parser::cron_next_timestamp;
use crate::{models::CtxData, time_parser::natural_parser, Context}; use crate::{models::CtxData, time_parser::natural_parser, Context};
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
@@ -43,13 +42,7 @@ pub async fn time_hint_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Auto
if partial.is_empty() { if partial.is_empty() {
vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())] vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())]
} else { } else {
let timezone = ctx.timezone().await; match natural_parser(partial, &ctx.timezone().await.to_string()).await {
let timestamp = match cron_next_timestamp(partial, timezone) {
Some(ts) => Some(ts),
None => natural_parser(partial, &timezone.to_string()).await,
};
match timestamp {
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => { Ok(now) => {
let diff = timestamp - now.as_secs() as i64; let diff = timestamp - now.as_secs() as i64;

View File

@@ -75,8 +75,8 @@ Please select a unique name for your macro.",
CreateEmbed::new() CreateEmbed::new()
.title("Macro Recording Started") .title("Macro Recording Started")
.description( .description(
"Run up to 5 commands to record in this macro. Use `/macro finish` to stop recording at any point. "Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands performed during recording won't take any actual action- they are only captured for the macro.", Any commands ran as part of recording will be inconsequential",
) )
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@@ -15,8 +15,8 @@ impl Recordable for Options {
let footer = footer(ctx); let footer = footer(ctx);
ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate") ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate")
.description("Thinking of subscribing? .description("Thinking of adding a monthly contribution?
Click below for my Patreon and official bot server Click below for my Patreon and official bot server :)
**https://www.patreon.com/jellywx/** **https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/** **https://discord.jellywx.com/**
@@ -26,7 +26,7 @@ With your new rank, you'll be able to:
Set repeating reminders with `/remind` or the dashboard Set repeating reminders with `/remind` or the dashboard
Use unlimited uploads on SoundFX Use unlimited uploads on SoundFX
Members of servers you __own__ will be able to set repeating reminders via commands. You can also choose to share your membership with one other server. (Also, members of servers you __own__ will be able to set repeating reminders via commands)
Just $2 USD/month! Just $2 USD/month!
@@ -41,8 +41,8 @@ Just $2 USD/month!
} }
} }
/// Show Patreon information /// Details on supporting the bot and Patreon benefits
#[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")] #[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> { pub async fn command(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await (Options {}).run(ctx).await
} }

View File

@@ -12,6 +12,8 @@ pub mod dashboard;
#[cfg(not(test))] #[cfg(not(test))]
pub mod delete; pub mod delete;
#[cfg(not(test))] #[cfg(not(test))]
pub mod donate;
#[cfg(not(test))]
pub mod help; pub mod help;
#[cfg(not(test))] #[cfg(not(test))]
pub mod info; pub mod info;
@@ -24,8 +26,6 @@ pub mod nudge;
#[cfg(not(test))] #[cfg(not(test))]
pub mod offset; pub mod offset;
#[cfg(not(test))] #[cfg(not(test))]
pub mod patreon;
#[cfg(not(test))]
pub mod pause; pub mod pause;
#[cfg(not(test))] #[cfg(not(test))]
pub mod remind; pub mod remind;

View File

@@ -1,73 +0,0 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{
utils::{check_user_subscription, Extract, Recordable},
Context, Error,
};
#[derive(Serialize, Deserialize, Extract)]
pub struct Options;
impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().ok_or("This command must be used in a server")?;
let user_id = ctx.author().id;
// Check if user has Patreon subscription
if !check_user_subscription(ctx, user_id).await {
ctx.send(CreateReply::default()
.content("❌ You must be a Patreon subscriber to use this command. Use `/patreon info` for more information.")
.ephemeral(true)
).await?;
return Ok(());
}
let existing_link = sqlx::query!(
"SELECT linked_at FROM patreon_link WHERE user_id = ? AND linked_at > NOW() - INTERVAL 4 WEEK",
user_id.get()
)
.fetch_optional(&ctx.data().database)
.await?;
if existing_link.is_some() {
ctx.send(
CreateReply::default()
.content("❌ You can only link once every 4 weeks. Please try again later.")
.ephemeral(true),
)
.await?;
return Ok(());
}
// Insert or update the patreon_link entry
sqlx::query!(
"INSERT INTO patreon_link (user_id, guild_id, linked_at) VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE user_id = user_id",
user_id.get(),
guild_id.get()
)
.execute(&ctx.data().database)
.await?;
ctx.send(
CreateReply::default()
.content("✅ Successfully linked your Patreon subscription to this server!")
.ephemeral(true),
)
.await?;
Ok(())
}
}
/// Link your Patreon subscription to this server. This command can be run once every four weeks
#[poise::command(
slash_command,
rename = "link",
identifying_name = "patreon_link",
guild_only = true
)]
pub async fn link(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}

View File

@@ -1,11 +0,0 @@
pub mod info;
pub mod link;
pub mod unlink;
use crate::{Context, Error};
/// Manage Patreon subscription features
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn command(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}

View File

@@ -1,55 +0,0 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{
utils::{Extract, Recordable},
Context, Error,
};
#[derive(Serialize, Deserialize, Extract)]
pub struct Options;
impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().ok_or("This command must be used in a server")?;
let user_id = ctx.author().id;
// Remove the patreon_link entry
let result = sqlx::query!(
"DELETE FROM patreon_link WHERE user_id = ? AND guild_id = ?",
user_id.get(),
guild_id.get()
)
.execute(&ctx.data().database)
.await?;
if result.rows_affected() > 0 {
ctx.send(
CreateReply::default()
.content("✅ Successfully unlinked your Patreon subscription from this server!")
.ephemeral(true),
)
.await?;
} else {
ctx.send(
CreateReply::default()
.content("❌ No existing Patreon link found for this server.")
.ephemeral(true),
)
.await?;
}
Ok(())
}
}
/// Unlink your Patreon subscription from this server
#[poise::command(
slash_command,
rename = "unlink",
identifying_name = "patreon_unlink",
guild_only = true
)]
pub async fn unlink(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}

View File

@@ -21,7 +21,7 @@ impl Recordable for Options {
" "
INSERT INTO todos (guild_id, channel_id, value) INSERT INTO todos (guild_id, channel_id, value)
VALUES ( VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?,
(SELECT id FROM channels WHERE channel = ?), (SELECT id FROM channels WHERE channel = ?),
? ?
) )

View File

@@ -23,7 +23,7 @@ pub async fn listener(
if is_new.unwrap_or(false) { if is_new.unwrap_or(false) {
let guild_id = guild.id.get().to_owned(); let guild_id = guild.id.get().to_owned();
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id) sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id)
.execute(&data.database) .execute(&data.database)
.await?; .await?;
@@ -56,7 +56,7 @@ To stay up to date on the latest features and fixes, join our [Discord](https://
} }
FullEvent::GuildDelete { incomplete, .. } => { FullEvent::GuildDelete { incomplete, .. } => {
if !incomplete.unavailable { if !incomplete.unavailable {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.get()) let _ = sqlx::query!("DELETE FROM guilds WHERE id = ?", incomplete.id.get())
.execute(&data.database) .execute(&data.database)
.await; .await;
} }

View File

@@ -1,6 +1,4 @@
use crate::consts::THEME_COLOR; use poise::{CommandInteractionType, CreateReply};
use poise::{serenity_prelude::CreateEmbed, CommandInteractionType, CreateReply};
use serenity::builder::CreateEmbedFooter;
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
@@ -20,18 +18,7 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.send( .send(
CreateReply::default() CreateReply::default()
.ephemeral(true) .ephemeral(true)
.embed(CreateEmbed::new() .content(format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS))
.title("💾 Currently recording macro")
.description(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
),
) )
.await; .await;
} else { } else {
@@ -41,19 +28,9 @@ async fn macro_check(ctx: Context<'_>) -> bool {
let _ = ctx let _ = ctx
.send( .send(
CreateReply::default().ephemeral(true).embed( CreateReply::default()
CreateEmbed::new() .ephemeral(true)
.title("💾 Currently recording macro") .content("Command recorded to macro"),
.description(
"Command recorded. Use `/macro finish` to end recording.",
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
),
) )
.await; .await;
} }
@@ -61,18 +38,8 @@ async fn macro_check(ctx: Context<'_>) -> bool {
None => { None => {
let _ = ctx let _ = ctx
.send( .send(
CreateReply::default().ephemeral(true).embed( CreateReply::default().ephemeral(true).content(
CreateEmbed::new() "This command is not supported in macros yet.",
.title("💾 Currently recording macro")
.description(
"This command is not supported in macros, so it hasn't been recorded. Use `/macro finish` to end recording.",
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
), ),
) )
.await; .await;
@@ -107,7 +74,6 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
return if permissions.send_messages() return if permissions.send_messages()
&& permissions.embed_links() && permissions.embed_links()
&& manage_webhooks && manage_webhooks
&& permissions.view_channel()
{ {
true true
} else { } else {
@@ -115,13 +81,12 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
.send(CreateReply::default().content(format!( .send(CreateReply::default().content(format!(
"The bot appears to be missing some permissions: "The bot appears to be missing some permissions:
{} **View Channels**
{} **Send Message** {} **Send Message**
{} **Embed Links** {} **Embed Links**
{} **Manage Webhooks** {} **Manage Webhooks**
Please check the bot's roles, and any channel overrides. Alternatively, giving the bot \"Administrator\" will bypass permission checks", Please check the bot's roles, and any channel overrides. Alternatively, giving the bot
if permissions.view_channel() { "" } else { "" }, \"Administrator\" will bypass permission checks",
if permissions.send_messages() { "" } else { "" }, if permissions.send_messages() { "" } else { "" },
if permissions.embed_links() { "" } else { "" }, if permissions.embed_links() { "" } else { "" },
if manage_webhooks { "" } else { "" }, if manage_webhooks { "" } else { "" },
@@ -135,7 +100,9 @@ Please check the bot's roles, and any channel overrides. Alternatively, giving t
manage_webhooks manage_webhooks
} }
None => true, None => {
return true;
}
} }
} }

View File

@@ -48,8 +48,8 @@ use crate::test::TestContext;
use crate::{ use crate::{
commands::{ commands::{
allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard, allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard,
delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings, delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer,
timer, timezone, todo, webhook, timezone, todo, webhook,
}, },
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
@@ -165,14 +165,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
help::command(), help::command(),
info::command(), info::command(),
clock::command(), clock::command(),
poise::Command { donate::command(),
subcommands: vec![
patreon::link::link(),
patreon::unlink::unlink(),
patreon::info::info(),
],
..patreon::command()
},
clock_context_menu(), clock_context_menu(),
dashboard::command(), dashboard::command(),
timezone::command(), timezone::command(),

View File

@@ -8,9 +8,7 @@ use crate::{consts::DEFAULT_AVATAR, Error};
pub struct ChannelData { pub struct ChannelData {
pub id: u32, pub id: u32,
pub channel: u64, pub channel: u64,
pub name: Option<String>,
pub nudge: i16, pub nudge: i16,
pub blacklisted: bool,
pub webhook_id: Option<u64>, pub webhook_id: Option<u64>,
pub webhook_token: Option<String>, pub webhook_token: Option<String>,
pub paused: bool, pub paused: bool,
@@ -27,7 +25,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, SELECT id, channel, nudge, webhook_id, webhook_token, paused,
paused_until paused_until
FROM channels FROM channels
WHERE channel = ? WHERE channel = ?
@@ -39,15 +37,11 @@ impl ChannelData {
{ {
Ok(c) Ok(c)
} else { } else {
let props = channel.to_owned().guild().map(|g| (g.guild_id.get().to_owned(), g.name)); let guild_id = channel.to_owned().guild().map(|g| g.guild_id.get().to_owned());
let (guild_id, channel_name) =
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
sqlx::query!( sqlx::query!(
"INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))", "INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, ?)",
channel_id, channel_id,
channel_name,
guild_id guild_id
) )
.execute(&pool.clone()) .execute(&pool.clone())
@@ -56,7 +50,7 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until
FROM channels FROM channels
WHERE channel = ? WHERE channel = ?
", ",
@@ -72,18 +66,14 @@ impl ChannelData {
" "
UPDATE channels UPDATE channels
SET SET
name = ?,
nudge = ?, nudge = ?,
blacklisted = ?,
webhook_id = ?, webhook_id = ?,
webhook_token = ?, webhook_token = ?,
paused = ?, paused = ?,
paused_until = ? paused_until = ?
WHERE id = ? WHERE id = ?
", ",
self.name,
self.nudge, self.nudge,
self.blacklisted,
self.webhook_id, self.webhook_id,
self.webhook_token, self.webhook_token,
self.paused, self.paused,

View File

@@ -43,7 +43,7 @@ pub enum RecordedCommand {
#[serde(rename = "delete")] #[serde(rename = "delete")]
Delete(crate::commands::delete::Options), Delete(crate::commands::delete::Options),
#[serde(rename = "donate")] #[serde(rename = "donate")]
Donate(crate::commands::patreon::info::Options), Donate(crate::commands::donate::Options),
#[serde(rename = "help")] #[serde(rename = "help")]
Help(crate::commands::help::Options), Help(crate::commands::help::Options),
#[serde(rename = "info")] #[serde(rename = "info")]
@@ -111,7 +111,7 @@ impl RecordedCommand {
"clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))), "clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))),
"dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))), "dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))),
"delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))), "delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))),
"donate" => Some(Self::Donate(crate::commands::patreon::info::Options::extract(ctx))), "donate" => Some(Self::Donate(crate::commands::donate::Options::extract(ctx))),
"help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))), "help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))),
"info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))), "info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))),
"look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))), "look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))),

View File

@@ -3,7 +3,7 @@ use sqlx::MySqlPool;
pub struct GuildData { pub struct GuildData {
pub ephemeral_confirmations: bool, pub ephemeral_confirmations: bool,
pub id: u32, pub guild_id: u64,
} }
impl GuildData { impl GuildData {
@@ -13,7 +13,7 @@ impl GuildData {
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> { ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", "SELECT ephemeral_confirmations, id as guild_id FROM guilds WHERE id = ?",
guild_id.get() guild_id.get()
) )
.fetch_one(pool) .fetch_one(pool)
@@ -21,13 +21,13 @@ impl GuildData {
{ {
Ok(c) Ok(c)
} else { } else {
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.get()) sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id.get())
.execute(&pool.clone()) .execute(&pool.clone())
.await?; .await?;
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", "SELECT ephemeral_confirmations, id as guild_id FROM guilds WHERE id = ?",
guild_id.get() guild_id.get()
) )
.fetch_one(pool) .fetch_one(pool)
@@ -39,7 +39,7 @@ impl GuildData {
sqlx::query!( sqlx::query!(
"UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?", "UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?",
self.ephemeral_confirmations, self.ephemeral_confirmations,
self.id self.guild_id
) )
.execute(pool) .execute(pool)
.await .await

View File

@@ -68,16 +68,19 @@ impl Data {
guild_id: GuildId, guild_id: GuildId,
) -> Result<Vec<CommandMacro>, Error> { ) -> Result<Vec<CommandMacro>, Error> {
let rows = sqlx::query!( let rows = sqlx::query!(
"SELECT name, description, commands FROM command_macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT name, description, commands FROM command_macro WHERE guild_id = ?",
guild_id.get() guild_id.get()
) )
.fetch_all(&self.database) .fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro { .await?
.iter()
.map(|row| CommandMacro {
guild_id, guild_id,
name: row.name.clone(), name: row.name.clone(),
description: row.description.clone(), description: row.description.clone(),
commands: serde_json::from_str(&row.commands.to_string()).unwrap(), commands: serde_json::from_str(&row.commands.to_string()).unwrap(),
}).collect(); })
.collect();
Ok(rows) Ok(rows)
} }

View File

@@ -16,8 +16,8 @@ use crate::{
}, },
CtxData, CtxData,
}, },
time_parser::{cron_next_timestamp, natural_parser}, time_parser::natural_parser,
utils::check_subscription, utils::{check_guild_subscription, check_subscription},
Context, Database, Error, Context, Database, Error,
}; };
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
@@ -262,7 +262,7 @@ impl Reminder {
channels.id = reminders.channel_id channels.id = reminders.channel_id
WHERE WHERE
`status` = 'pending' AND `status` = 'pending' AND
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?) channels.guild_id = ?
", ",
guild_id.get() guild_id.get()
) )
@@ -486,10 +486,7 @@ pub async fn create_reminder(
let user_data = ctx.author_data().await.unwrap(); let user_data = ctx.author_data().await.unwrap();
let timezone = timezone.unwrap_or(ctx.timezone().await); let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = match cron_next_timestamp(&time, timezone) { let time = natural_parser(&time, &timezone.to_string()).await;
Some(ts) => Some(ts),
None => natural_parser(&time, &timezone.to_string()).await,
};
match time { match time {
Some(time) => { Some(time) => {
@@ -532,7 +529,10 @@ pub async fn create_reminder(
}; };
let (processed_interval, processed_expires) = if let Some(repeat) = &interval { let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx, ctx.author().id, ctx.guild_id()).await { if check_subscription(&ctx, ctx.author().id).await
|| (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
{
( (
parse_duration(repeat) parse_duration(repeat)
.or_else(|_| parse_duration(&format!("1 {}", repeat))) .or_else(|_| parse_duration(&format!("1 {}", repeat)))

View File

@@ -34,10 +34,8 @@ use crate::{
lazy_static! { lazy_static! {
pub static ref TIMEFROM_REGEX: Regex = pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap(); Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex = Regex::new( pub static ref TIMENOW_REGEX: Regex =
r#"<<timenow(?:(?P<sign>[+-])(?P<offset>\d+))?:(?P<timezone>(?:\w|/|_)+?):(?P<format>.+?)?>>"# Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
)
.unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1"); pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
} }
@@ -66,7 +64,7 @@ fn fmt_displacement(format: &str, seconds: u64) -> String {
} }
pub fn substitute(string: &str) -> String { pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace_all(string, |caps: &Captures| { let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten(); let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str()); let format = caps.name("format").map(|m| m.as_str());
@@ -94,26 +92,12 @@ pub fn substitute(string: &str) -> String {
}); });
TIMENOW_REGEX TIMENOW_REGEX
.replace_all(&new, |caps: &Captures| { .replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten(); let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str()); let format = caps.name("format").map(|m| m.as_str());
let sign = caps.name("sign").map(|m| m.as_str());
let offset = caps.name("offset").map(|m| m.as_str().parse::<i64>().ok()).flatten();
if let (Some(timezone), Some(format)) = (timezone, format) { if let (Some(timezone), Some(format)) = (timezone, format) {
let mut now = Utc::now().with_timezone(&timezone); let now = Utc::now().with_timezone(&timezone);
if let (Some(sign), Some(offset)) = (sign, offset) {
now = now
.checked_add_signed(TimeDelta::seconds(
offset * {
match sign {
"-" => -1,
_ => 1,
}
},
))
.unwrap_or(now)
}
now.format(format).to_string() now.format(format).to_string()
} else { } else {

View File

@@ -3,8 +3,6 @@ use poise::{
CreateReply, CreateReply,
}; };
use serde_json::Value; use serde_json::Value;
use serenity::all::Http;
use serenity::http::CacheHttp;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{Data, Error}; use crate::{Data, Error};
@@ -21,12 +19,6 @@ pub(crate) struct TestContext<'a> {
pub(crate) shard_id: usize, pub(crate) shard_id: usize,
} }
impl CacheHttp for TestContext<'_> {
fn http(&self) -> &Http {
todo!()
}
}
pub(crate) struct MockUser { pub(crate) struct MockUser {
pub(crate) id: UserId, pub(crate) id: UserId,
} }

View File

@@ -6,8 +6,6 @@ use std::{
use chrono::{DateTime, Datelike, Timelike, Utc}; use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use cron_parser::parse;
use std::str::FromStr;
use tokio::process::Command; use tokio::process::Command;
use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION}; use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION};
@@ -221,7 +219,3 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
}) })
.and_then(|inner| if inner < 0 { None } else { Some(inner) }) .and_then(|inner| if inner < 0 { None } else { Some(inner) })
} }
pub fn cron_next_timestamp(expr: &str, timezone: Tz) -> Option<i64> {
parse(expr, &Utc::now().with_timezone(&timezone)).ok().map(|next| next.timestamp() as i64)
}

View File

@@ -12,58 +12,7 @@ use crate::{
ApplicationContext, Context, Error, ApplicationContext, Context, Error,
}; };
/// Check if this user/guild combination should be considered subscribed. pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
/// If the guild has a patreon linked, check the user involved in the link.
/// Otherwise, check the user and the guild's owner
pub async fn check_subscription(
ctx: &Context<'_>,
user_id: UserId,
guild_id: Option<GuildId>,
) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let user_subscribed = check_user_subscription(ctx, user_id).await;
let owner_subscribed = match guild_id {
Some(guild_id) => {
if let Some(owner) = ctx.cache().unwrap().guild(guild_id).map(|g| g.owner_id) {
check_user_subscription(ctx, owner).await
} else {
false
}
}
None => false,
};
let link_subscribed = match guild_id {
Some(guild_id) => {
if let Ok(row) = sqlx::query!(
"SELECT user_id FROM patreon_link WHERE user_id = ?",
guild_id.get()
)
.fetch_one(&ctx.data().database)
.await
{
check_user_subscription(ctx, row.user_id).await
} else {
false
}
}
None => false,
};
user_subscribed || owner_subscribed || link_subscribed
} else {
true
}
}
/// Check a user's subscription status, ignoring Patreon linkage
pub async fn check_user_subscription(
cache_http: impl CacheHttp,
user_id: impl Into<UserId>,
) -> bool {
if let Some(subscription_guild) = *CNC_GUILD { if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await; let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
@@ -81,6 +30,17 @@ pub async fn check_user_subscription(
} }
} }
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|g| g.owner_id) {
check_subscription(&cache_http, owner).await
} else {
false
}
}
pub fn reply_to_interaction_response_message( pub fn reply_to_interaction_response_message(
reply: CreateReply, reply: CreateReply,
) -> CreateInteractionResponseMessage { ) -> CreateInteractionResponseMessage {

View File

@@ -92,8 +92,6 @@ enum Error {
SQLx(sqlx::Error), SQLx(sqlx::Error),
#[allow(unused)] #[allow(unused)]
Serenity(serenity::Error), Serenity(serenity::Error),
#[allow(unused)]
MissingDiscordPermission(&'static str),
} }
pub async fn initialize( pub async fn initialize(

View File

@@ -305,19 +305,11 @@ pub async fn edit_reminder(
Err(e) => { Err(e) => {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type
match e {
crate::web::Error::MissingDiscordPermission(permission) => {
error.push(format!("Please ensure the bot has the \"{}\" permission in the channel", permission));
}
_ => {
error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string()); error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string());
} }
} }
} }
} }
}
}
None => { None => {
warn!( warn!(

View File

@@ -31,7 +31,7 @@ pub async fn get_reminder_templates(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
ReminderTemplate, ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT * FROM reminder_template WHERE guild_id = ?",
id id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())
@@ -87,7 +87,7 @@ pub async fn delete_reminder_template(
check_authorization(cookies, ctx.inner(), id).await?; check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!( match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", "DELETE FROM reminder_template WHERE guild_id = ? AND id = ?",
id, delete_reminder_template.id id, delete_reminder_template.id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())

View File

@@ -66,7 +66,7 @@ pub async fn create_todo(
" "
INSERT INTO todos (guild_id, channel_id, value) INSERT INTO todos (guild_id, channel_id, value)
VALUES ( VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?,
(SELECT id FROM channels WHERE channel = ?), (SELECT id FROM channels WHERE channel = ?),
? ?
) )
@@ -88,7 +88,7 @@ pub async fn create_todo(
" "
INSERT INTO todos (guild_id, channel_id, value) INSERT INTO todos (guild_id, channel_id, value)
VALUES ( VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?,
NULL, NULL,
? ?
) )
@@ -130,11 +130,9 @@ pub async fn get_todo(
channels.channel AS channel_id, channels.channel AS channel_id,
value value
FROM todos FROM todos
INNER JOIN guilds
ON guilds.id = todos.guild_id
LEFT JOIN channels LEFT JOIN channels
ON channels.id = todos.channel_id ON channels.id = todos.channel_id
WHERE guilds.guild = ? WHERE todos.guild_id = ?
", ",
id id
) )
@@ -167,7 +165,7 @@ pub async fn update_todo(
" "
UPDATE todos UPDATE todos
SET value = ? SET value = ?
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) WHERE guild_id = ?
AND id = ? AND id = ?
", ",
todo.value, todo.value,
@@ -202,7 +200,7 @@ pub async fn delete_todo(
sqlx::query!( sqlx::query!(
" "
DELETE FROM todos DELETE FROM todos
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) WHERE guild_id = ?
AND id = ? AND id = ?
", ",
guild_id, guild_id,

View File

@@ -65,16 +65,7 @@ pub async fn create_reminder(
if let Err(e) = channel { if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type return Err(json!({"error": "Failed to configure channel for reminders."}));
let error_msg = match e {
Error::MissingDiscordPermission(permission) => format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
),
_ => "Failed to configure channel for reminders.".to_string(),
};
return Err(json!({"error": error_msg}));
} }
let channel = channel.unwrap(); let channel = channel.unwrap();

View File

@@ -39,7 +39,8 @@ pub async fn export(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
ReminderTemplateCsv, ReminderTemplateCsv,
"SELECT "
SELECT
name, name,
attachment, attachment,
attachment_name, attachment_name,
@@ -60,7 +61,9 @@ pub async fn export(
interval_months, interval_months,
tts, tts,
username username
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", FROM reminder_template
WHERE guild_id = ?
",
id id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())

View File

@@ -38,10 +38,11 @@ pub async fn export(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
TodoCsv, TodoCsv,
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos "
SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
LEFT JOIN channels ON todos.channel_id = channels.id LEFT JOIN channels ON todos.channel_id = channels.id
INNER JOIN guilds ON todos.guild_id = guilds.id INNER JOIN guilds ON todos.guild_id = ?
WHERE guilds.guild = ?", ",
id id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())
@@ -96,7 +97,7 @@ pub async fn import(
Ok(body) => { Ok(body) => {
let mut reader = csv::Reader::from_reader(body.as_slice()); let mut reader = csv::Reader::from_reader(body.as_slice());
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))"; let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), ?)";
let mut query_params = vec![]; let mut query_params = vec![];
for result in reader.deserialize::<TodoCsv>() { for result in reader.deserialize::<TodoCsv>() {

View File

@@ -10,7 +10,6 @@ use rocket::{
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use secrecy::ExposeSecret; use secrecy::ExposeSecret;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serenity::http::HttpError;
use serenity::{ use serenity::{
all::CacheHttp, all::CacheHttp,
builder::CreateWebhook, builder::CreateWebhook,
@@ -374,12 +373,12 @@ pub(crate) async fn create_reminder(
reminder: CreateReminder, reminder: CreateReminder,
) -> JsonResult { ) -> JsonResult {
// check guild in db // check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get()) match sqlx::query!("SELECT 1 as A FROM guilds WHERE id = ?", guild_id.get())
.fetch_one(transaction.executor()) .fetch_one(transaction.executor())
.await .await
{ {
Err(sqlx::Error::RowNotFound) => { Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.get()) if sqlx::query!("INSERT INTO guilds (id) VALUES (?)", guild_id.get())
.execute(transaction.executor()) .execute(transaction.executor())
.await .await
.is_err() .is_err()
@@ -405,19 +404,9 @@ pub(crate) async fn create_reminder(
if let Err(e) = channel { if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type return Err(
let error_msg = match e { json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
Error::MissingDiscordPermission(permission) => { );
format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
)
}
_ => "Failed to configure channel for reminders. Please check the bot permissions"
.to_string(),
};
return Err(json!({"error": error_msg}));
} }
let channel = channel.unwrap(); let channel = channel.unwrap();
@@ -670,7 +659,7 @@ pub(crate) async fn create_reminder_template(
interval_months, interval_months,
tts, tts,
username username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)", ?, ?, ?, ?, ?, ?, ?)",
guild_id.get(), guild_id.get(),
name, name,
@@ -727,36 +716,13 @@ async fn create_database_channel(
match row { match row {
Ok(row) => { Ok(row) => {
let is_dm = channel let is_dm =
.to_channel(&ctx) channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some();
.await
.map_err(|e| {
if let serenity::Error::Http(http_error) = &e {
if let HttpError::UnsuccessfulRequest(response) = http_error {
if response.error.code == 50001 {
return Error::MissingDiscordPermission("View Channel");
}
}
}
Error::Serenity(e)
})?
.private()
.is_some();
if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) { if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) {
let webhook = channel let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR)) .create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await .await
.map_err(|e| match &e { .map_err(|e| Error::Serenity(e))?;
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
let token = webhook.token.unwrap(); let token = webhook.token.unwrap();
@@ -781,16 +747,7 @@ async fn create_database_channel(
let webhook = channel let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR)) .create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await .await
.map_err(|e| match &e { .map_err(|e| Error::Serenity(e))?;
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
let token = webhook.token.unwrap(); let token = webhook.token.unwrap();
@@ -849,15 +806,22 @@ pub async fn todos_redirect(id: &str) -> Redirect {
#[get("/")] #[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage { pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await if cookies.get_private("userid").is_some() {
} match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
#[get("/<_..>")] Err(e) => {
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage { warn!("Couldn't render dashboard: {:?}", e);
render_dashboard(cookies).await
} DashboardPage::NotConfigured(internal_server_error().await)
}
async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage { }
} else {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
}
#[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await { match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f), Ok(f) => DashboardPage::Ok(f),

View File

@@ -19,24 +19,6 @@
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
for the reminder. for the reminder.
</p> </p>
<p class="subtitle">Time</p>
<p class="content">
The bot will take a "best-guess" at what time you entered. It will favour UK date formats
over US date formats (MM/DD/YY) where possible.
<br>
You can also use <code>cron</code>-like syntax to specify the time. For example, using
<code>0 0 1 * *</code> will send the reminder at midnight on the first of the next month.
For more information on cron syntax, see <a href="https://crontab.guru/">crontab.guru</a>.
<br>
<strong>Cron syntax is not repeating</strong>. Please use the optional "interval" field to specify a repetition interval.
</p>
<p class="subtitle">Pings</p>
<p class="content">
Roles and users can be pinged by including their @ mention in the "content" field.
To ping a role, the role must be set as mentionable, and the bot must have permissions to mention the role.
<br>
Please note that when using the dashboard, roles can only be pinged in the "Content..." field and not the embed fields.
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -55,40 +37,4 @@
</div> </div>
</section> </section>
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Custom formatting rules</p>
<p class="content">
Reminder content can be customized using formatting rules.
</p>
<p class="subtitle">timefrom</p>
<p class="content">
The <code>timefrom</code> formatting rule will display a formatted difference
between the time the reminder sends and a specified time.
<br>
For example, if the current time is 1755800000 (UNIX time), the format string
<code>&lt;&lt;timefrom:1755803600&gt;&gt;</code> would display "1 hour"
</p>
<p class="subtitle">timenow</p>
<p class="content">
The <code>timenow</code> formatting rule displays the current time or an offset
from the current time in a given timezone in a custom format.
<br>
For example, if the current time is 1755800000 (UNIX time), the format string
<code>&lt;&lt;timenow:UTC:%H:%M:%S&gt;&gt;</code> would display "18:13:20"
<br>
Optionally, an offset can be provided to display a time from your current time.
For example, if the current time is 1755800000 (UNIX time), the format string
<code>&lt;&lt;timenow+120:UTC:%H:%M:%S&gt;&gt;</code> would display "18:15:20",
or <code>&lt;&lt;timenow-120:UTC:%H:%M:%S&gt;&gt;</code> would display "18:11:20"
<br>
You can use this feature alongside Discord's timestamp formatting. The following
will show the text "in 2 minutes" for all users as a Discord timestamp:
<code>&lt;t:&lt;&lt;timenow+120:UTC:%s&gt;&gt;:R&gt;</code>
</p>
</div>
</div>
</section>
{% endblock %} {% endblock %}