10 Commits

Author SHA1 Message Date
jude
91310d47d3 Add patreon-sharing option 2025-10-04 18:09:31 +01:00
jude
5ae4baa2a6 Bump version 2025-09-16 21:09:25 +01:00
jude
6884adc5b2 Add some docs 2025-09-16 21:06:51 +01:00
jude
6ade91e11b Add cron parser for start time of a reminder 2025-09-16 21:00:58 +01:00
20f0fb1c20 Merge pull request 'jude/custom-timestamp-formatting' (#4) from jude/custom-timestamp-formatting into current
Reviewed-on: #4
2025-09-16 19:19:07 +00:00
jude
4d14365f2b Add another example 2025-08-22 20:14:28 +01:00
jude
0f4df703eb Fix formatting strings 2025-08-21 22:51:57 +01:00
jude
a9edcec43c Deduplicate dashboard frontend code 2025-06-24 19:56:45 +01:00
jude
cc5f6d9d55 Bump version 2025-06-18 22:13:05 +01:00
jude
761d545496 Improve errors and wording 2025-06-18 22:08:32 +01:00
23 changed files with 463 additions and 76 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 = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@@ -524,6 +524,15 @@ 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"
@@ -2614,11 +2623,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.37" version = "1.7.40"
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.37" version = "1.7.40"
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,6 +35,7 @@ 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"

View File

@@ -0,0 +1,11 @@
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,6 +3,7 @@ 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> {
@@ -42,7 +43,13 @@ 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 {
match natural_parser(partial, &ctx.timezone().await.to_string()).await { let timezone = ctx.timezone().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, or type `/macro finish` to stop at any point. "Run up to 5 commands to record in this macro. Use `/macro finish` to stop recording at any point.
Any commands ran as part of recording will be inconsequential", Any commands performed during recording won't take any actual action- they are only captured for the macro.",
) )
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@@ -12,8 +12,6 @@ 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;
@@ -26,6 +24,8 @@ 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

@@ -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 adding a monthly contribution? .description("Thinking of subscribing?
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
(Also, members of servers you __own__ will be able to set repeating reminders via commands) 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.
Just $2 USD/month! Just $2 USD/month!
@@ -41,8 +41,8 @@ Just $2 USD/month!
} }
} }
/// Details on supporting the bot and Patreon benefits /// Show Patreon information
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")] #[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")]
pub async fn command(ctx: Context<'_>) -> Result<(), Error> { pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await (Options {}).run(ctx).await
} }

View File

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,55 @@
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

@@ -1,4 +1,6 @@
use poise::{CommandInteractionType, CreateReply}; use crate::consts::THEME_COLOR;
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};
@@ -18,7 +20,18 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.send( .send(
CreateReply::default() CreateReply::default()
.ephemeral(true) .ephemeral(true)
.content(format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS)) .embed(CreateEmbed::new()
.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 {
@@ -28,9 +41,19 @@ async fn macro_check(ctx: Context<'_>) -> bool {
let _ = ctx let _ = ctx
.send( .send(
CreateReply::default() CreateReply::default().ephemeral(true).embed(
.ephemeral(true) CreateEmbed::new()
.content("Command recorded to macro"), .title("💾 Currently recording 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;
} }
@@ -38,8 +61,18 @@ async fn macro_check(ctx: Context<'_>) -> bool {
None => { None => {
let _ = ctx let _ = ctx
.send( .send(
CreateReply::default().ephemeral(true).content( CreateReply::default().ephemeral(true).embed(
"This command is not supported in macros yet.", CreateEmbed::new()
.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;
@@ -74,6 +107,7 @@ 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 {
@@ -81,12 +115,13 @@ 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 Please check the bot's roles, and any channel overrides. Alternatively, giving the bot \"Administrator\" will bypass permission checks",
\"Administrator\" will bypass permission checks", if permissions.view_channel() { "" } else { "" },
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 { "" },
@@ -100,9 +135,7 @@ Please check the bot's roles, and any channel overrides. Alternatively, giving t
manage_webhooks manage_webhooks
} }
None => { None => true,
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, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer, delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings,
timezone, todo, webhook, timer, timezone, todo, webhook,
}, },
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
@@ -165,7 +165,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
help::command(), help::command(),
info::command(), info::command(),
clock::command(), clock::command(),
donate::command(), poise::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

@@ -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::donate::Options), Donate(crate::commands::patreon::info::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::donate::Options::extract(ctx))), "donate" => Some(Self::Donate(crate::commands::patreon::info::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

@@ -16,8 +16,8 @@ use crate::{
}, },
CtxData, CtxData,
}, },
time_parser::natural_parser, time_parser::{cron_next_timestamp, natural_parser},
utils::{check_guild_subscription, check_subscription}, utils::check_subscription,
Context, Database, Error, Context, Database, Error,
}; };
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
@@ -486,7 +486,10 @@ 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 = natural_parser(&time, &timezone.to_string()).await; let time = match cron_next_timestamp(&time, timezone) {
Some(ts) => Some(ts),
None => natural_parser(&time, &timezone.to_string()).await,
};
match time { match time {
Some(time) => { Some(time) => {
@@ -529,10 +532,7 @@ 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).await if check_subscription(&ctx, ctx.author().id, ctx.guild_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,8 +34,10 @@ 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 = pub static ref TIMENOW_REGEX: Regex = Regex::new(
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap(); r#"<<timenow(?:(?P<sign>[+-])(?P<offset>\d+))?:(?P<timezone>(?:\w|/|_)+?):(?P<format>.+?)?>>"#
)
.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");
} }
@@ -64,7 +66,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(string, |caps: &Captures| { let new = TIMEFROM_REGEX.replace_all(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());
@@ -92,12 +94,26 @@ pub fn substitute(string: &str) -> String {
}); });
TIMENOW_REGEX TIMENOW_REGEX
.replace(&new, |caps: &Captures| { .replace_all(&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 now = Utc::now().with_timezone(&timezone); let mut 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,6 +3,8 @@ 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};
@@ -19,6 +21,12 @@ 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,6 +6,8 @@ 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};
@@ -219,3 +221,7 @@ 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,7 +12,58 @@ use crate::{
ApplicationContext, Context, Error, ApplicationContext, Context, Error,
}; };
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { /// Check if this user/guild combination should be considered subscribed.
/// 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;
@@ -30,17 +81,6 @@ pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<U
} }
} }
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,6 +92,8 @@ 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,7 +305,15 @@ 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);
error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string()); // 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());
}
}
} }
} }
} }

View File

@@ -65,7 +65,16 @@ 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);
return Err(json!({"error": "Failed to configure channel for reminders."})); // Provide more specific error messages based on the error type
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

@@ -10,6 +10,7 @@ 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,
@@ -404,9 +405,19 @@ 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);
return Err( // Provide more specific error messages based on the error type
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}), 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. Please check the bot permissions"
.to_string(),
};
return Err(json!({"error": error_msg}));
} }
let channel = channel.unwrap(); let channel = channel.unwrap();
@@ -716,13 +727,36 @@ async fn create_database_channel(
match row { match row {
Ok(row) => { Ok(row) => {
let is_dm = let is_dm = channel
channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some(); .to_channel(&ctx)
.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| Error::Serenity(e))?; .map_err(|e| match &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();
@@ -747,7 +781,16 @@ 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| Error::Serenity(e))?; .map_err(|e| match &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();
@@ -806,22 +849,15 @@ 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 {
if cookies.get_private("userid").is_some() { render_dashboard(cookies).await
match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
Err(e) => {
warn!("Couldn't render dashboard: {:?}", e);
DashboardPage::NotConfigured(internal_server_error().await)
}
}
} else {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
} }
#[get("/<_..>")] #[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage { pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await
}
async fn render_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,6 +19,24 @@
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>
@@ -37,4 +55,40 @@
</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 %}