Compare commits
12 Commits
jude/restr
...
current
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e98cb67f5f | ||
|
|
1d8fd39d13 | ||
|
|
91310d47d3 | ||
|
|
5ae4baa2a6 | ||
|
|
6884adc5b2 | ||
|
|
6ade91e11b | ||
| 20f0fb1c20 | |||
|
|
4d14365f2b | ||
|
|
0f4df703eb | ||
|
|
a9edcec43c | ||
|
|
cc5f6d9d55 | ||
|
|
761d545496 |
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
@@ -524,6 +524,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.13"
|
||||
@@ -2614,11 +2623,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reminder-rs"
|
||||
version = "1.7.37"
|
||||
version = "1.7.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron-parser",
|
||||
"csv",
|
||||
"dotenv",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reminder-rs"
|
||||
version = "1.7.37"
|
||||
version = "1.7.41"
|
||||
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0 only"
|
||||
@@ -35,6 +35,7 @@ serenity = { version = "0.12", default-features = false, features = ["builder",
|
||||
oauth2 = "4"
|
||||
csv = "1.2"
|
||||
sd-notify = "0.4.1"
|
||||
cron-parser = "0.10"
|
||||
|
||||
[dependencies.extract_derive]
|
||||
path = "extract_derive"
|
||||
|
||||
11
migrations/20250924203400_patreon_link.sql
Normal file
11
migrations/20250924203400_patreon_link.sql
Normal 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)
|
||||
);
|
||||
@@ -3,6 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use chrono_tz::TZ_VARIANTS;
|
||||
use poise::serenity_prelude::AutocompleteChoice;
|
||||
|
||||
use crate::time_parser::cron_next_timestamp;
|
||||
use crate::{models::CtxData, time_parser::natural_parser, Context};
|
||||
|
||||
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() {
|
||||
vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())]
|
||||
} 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) {
|
||||
Ok(now) => {
|
||||
let diff = timestamp - now.as_secs() as i64;
|
||||
|
||||
@@ -75,8 +75,8 @@ Please select a unique name for your macro.",
|
||||
CreateEmbed::new()
|
||||
.title("Macro Recording Started")
|
||||
.description(
|
||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
||||
Any commands ran as part of recording will be inconsequential",
|
||||
"Run up to 5 commands to record in this macro. Use `/macro finish` to stop recording at any point.
|
||||
Any commands performed during recording won't take any actual action- they are only captured for the macro.",
|
||||
)
|
||||
.color(*THEME_COLOR),
|
||||
),
|
||||
|
||||
@@ -12,8 +12,6 @@ pub mod dashboard;
|
||||
#[cfg(not(test))]
|
||||
pub mod delete;
|
||||
#[cfg(not(test))]
|
||||
pub mod donate;
|
||||
#[cfg(not(test))]
|
||||
pub mod help;
|
||||
#[cfg(not(test))]
|
||||
pub mod info;
|
||||
@@ -26,6 +24,8 @@ pub mod nudge;
|
||||
#[cfg(not(test))]
|
||||
pub mod offset;
|
||||
#[cfg(not(test))]
|
||||
pub mod patreon;
|
||||
#[cfg(not(test))]
|
||||
pub mod pause;
|
||||
#[cfg(not(test))]
|
||||
pub mod remind;
|
||||
|
||||
@@ -15,8 +15,8 @@ impl Recordable for Options {
|
||||
let footer = footer(ctx);
|
||||
|
||||
ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate")
|
||||
.description("Thinking of adding a monthly contribution?
|
||||
Click below for my Patreon and official bot server :)
|
||||
.description("Thinking of subscribing?
|
||||
Click below for my Patreon and official bot server
|
||||
|
||||
**https://www.patreon.com/jellywx/**
|
||||
**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
|
||||
• 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!
|
||||
|
||||
@@ -41,8 +41,8 @@ Just $2 USD/month!
|
||||
}
|
||||
}
|
||||
|
||||
/// Details on supporting the bot and Patreon benefits
|
||||
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
|
||||
pub async fn command(ctx: Context<'_>) -> Result<(), Error> {
|
||||
/// Show Patreon information
|
||||
#[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")]
|
||||
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
|
||||
(Options {}).run(ctx).await
|
||||
}
|
||||
57
src/commands/patreon/link.rs
Normal file
57
src/commands/patreon/link.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
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(());
|
||||
}
|
||||
|
||||
// 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 guild_id = ?",
|
||||
user_id.get(),
|
||||
guild_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 to allow other users Patreon access.
|
||||
#[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
|
||||
}
|
||||
11
src/commands/patreon/mod.rs
Normal file
11
src/commands/patreon/mod.rs
Normal 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(())
|
||||
}
|
||||
50
src/commands/patreon/unlink.rs
Normal file
50
src/commands/patreon/unlink.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
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 user_id = ctx.author().id;
|
||||
|
||||
// Remove the patreon_link entry
|
||||
let result = sqlx::query!("DELETE FROM patreon_link WHERE user_id = ?", user_id.get())
|
||||
.execute(&ctx.data().database)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.content("✅ Successfully unlinked your Patreon subscription!")
|
||||
.ephemeral(true),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.content("❌ No existing Patreon link found.")
|
||||
.ephemeral(true),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlink your Patreon subscription
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "unlink",
|
||||
identifying_name = "patreon_unlink",
|
||||
guild_only = false
|
||||
)]
|
||||
pub async fn unlink(ctx: Context<'_>) -> Result<(), Error> {
|
||||
(Options {}).run(ctx).await
|
||||
}
|
||||
57
src/hooks.rs
57
src/hooks.rs
@@ -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};
|
||||
|
||||
@@ -18,7 +20,18 @@ async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
.send(
|
||||
CreateReply::default()
|
||||
.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;
|
||||
} else {
|
||||
@@ -28,9 +41,19 @@ async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
|
||||
let _ = ctx
|
||||
.send(
|
||||
CreateReply::default()
|
||||
.ephemeral(true)
|
||||
.content("Command recorded to macro"),
|
||||
CreateReply::default().ephemeral(true).embed(
|
||||
CreateEmbed::new()
|
||||
.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;
|
||||
}
|
||||
@@ -38,8 +61,18 @@ async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
None => {
|
||||
let _ = ctx
|
||||
.send(
|
||||
CreateReply::default().ephemeral(true).content(
|
||||
"This command is not supported in macros yet.",
|
||||
CreateReply::default().ephemeral(true).embed(
|
||||
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;
|
||||
@@ -74,6 +107,7 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
return if permissions.send_messages()
|
||||
&& permissions.embed_links()
|
||||
&& manage_webhooks
|
||||
&& permissions.view_channel()
|
||||
{
|
||||
true
|
||||
} else {
|
||||
@@ -81,12 +115,13 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
.send(CreateReply::default().content(format!(
|
||||
"The bot appears to be missing some permissions:
|
||||
|
||||
{} **View Channels**
|
||||
{} **Send Message**
|
||||
{} **Embed Links**
|
||||
{} **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 \"Administrator\" will bypass permission checks",
|
||||
if permissions.view_channel() { "✅" } else { "❌" },
|
||||
if permissions.send_messages() { "✅" } else { "❌" },
|
||||
if permissions.embed_links() { "✅" } else { "❌" },
|
||||
if manage_webhooks { "✅" } else { "❌" },
|
||||
@@ -100,9 +135,7 @@ Please check the bot's roles, and any channel overrides. Alternatively, giving t
|
||||
manage_webhooks
|
||||
}
|
||||
|
||||
None => {
|
||||
return true;
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@@ -48,8 +48,8 @@ use crate::test::TestContext;
|
||||
use crate::{
|
||||
commands::{
|
||||
allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard,
|
||||
delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer,
|
||||
timezone, todo, webhook,
|
||||
delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings,
|
||||
timer, timezone, todo, webhook,
|
||||
},
|
||||
consts::THEME_COLOR,
|
||||
event_handlers::listener,
|
||||
@@ -165,7 +165,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
help::command(),
|
||||
info::command(),
|
||||
clock::command(),
|
||||
donate::command(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
patreon::link::link(),
|
||||
patreon::unlink::unlink(),
|
||||
patreon::info::info(),
|
||||
],
|
||||
..patreon::command()
|
||||
},
|
||||
clock_context_menu(),
|
||||
dashboard::command(),
|
||||
timezone::command(),
|
||||
|
||||
@@ -43,7 +43,7 @@ pub enum RecordedCommand {
|
||||
#[serde(rename = "delete")]
|
||||
Delete(crate::commands::delete::Options),
|
||||
#[serde(rename = "donate")]
|
||||
Donate(crate::commands::donate::Options),
|
||||
Donate(crate::commands::patreon::info::Options),
|
||||
#[serde(rename = "help")]
|
||||
Help(crate::commands::help::Options),
|
||||
#[serde(rename = "info")]
|
||||
@@ -111,7 +111,7 @@ impl RecordedCommand {
|
||||
"clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))),
|
||||
"dashboard" => Some(Self::Dashboard(crate::commands::dashboard::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))),
|
||||
"info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))),
|
||||
"look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))),
|
||||
|
||||
@@ -16,8 +16,8 @@ use crate::{
|
||||
},
|
||||
CtxData,
|
||||
},
|
||||
time_parser::natural_parser,
|
||||
utils::{check_guild_subscription, check_subscription},
|
||||
time_parser::{cron_next_timestamp, natural_parser},
|
||||
utils::check_subscription,
|
||||
Context, Database, Error,
|
||||
};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
@@ -486,7 +486,10 @@ pub async fn create_reminder(
|
||||
let user_data = ctx.author_data().await.unwrap();
|
||||
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 {
|
||||
Some(time) => {
|
||||
@@ -529,9 +532,8 @@ pub async fn create_reminder(
|
||||
};
|
||||
|
||||
let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
|
||||
if check_subscription(&ctx, ctx.author().id).await
|
||||
|| (ctx.guild_id().is_some()
|
||||
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
|
||||
if check_subscription(&ctx, &ctx.data().database, ctx.author().id, ctx.guild_id())
|
||||
.await
|
||||
{
|
||||
(
|
||||
parse_duration(repeat)
|
||||
@@ -547,7 +549,7 @@ pub async fn create_reminder(
|
||||
)
|
||||
} else {
|
||||
ctx.send(CreateReply::default().content(
|
||||
"`repeat` is only available to Patreon subscribers or self-hosted users",
|
||||
"`interval` is only available to Patreon subscribers or self-hosted users",
|
||||
))
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ use crate::{
|
||||
lazy_static! {
|
||||
pub static ref TIMEFROM_REGEX: Regex =
|
||||
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
|
||||
pub static ref TIMENOW_REGEX: Regex =
|
||||
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
|
||||
pub static ref TIMENOW_REGEX: Regex = Regex::new(
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ fn fmt_displacement(format: &str, seconds: u64) -> 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 format = caps.name("format").map(|m| m.as_str());
|
||||
|
||||
@@ -92,12 +94,26 @@ pub fn substitute(string: &str) -> String {
|
||||
});
|
||||
|
||||
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 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) {
|
||||
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()
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,8 @@ use poise::{
|
||||
CreateReply,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serenity::all::Http;
|
||||
use serenity::http::CacheHttp;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{Data, Error};
|
||||
@@ -19,6 +21,12 @@ pub(crate) struct TestContext<'a> {
|
||||
pub(crate) shard_id: usize,
|
||||
}
|
||||
|
||||
impl CacheHttp for TestContext<'_> {
|
||||
fn http(&self) -> &Http {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MockUser {
|
||||
pub(crate) id: UserId,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
|
||||
use chrono::{DateTime, Datelike, Timelike, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use cron_parser::parse;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION};
|
||||
@@ -219,3 +220,7 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
|
||||
})
|
||||
.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)
|
||||
}
|
||||
|
||||
68
src/utils.rs
68
src/utils.rs
@@ -9,10 +9,62 @@ use poise::{
|
||||
|
||||
use crate::{
|
||||
consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
|
||||
ApplicationContext, Context, Error,
|
||||
ApplicationContext, Context, Database, 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: impl CacheHttp,
|
||||
database: impl Executor<'_, Database = Database>,
|
||||
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 guild_id = ?",
|
||||
guild_id.get()
|
||||
)
|
||||
.fetch_one(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 {
|
||||
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
|
||||
|
||||
@@ -30,17 +82,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(
|
||||
reply: CreateReply,
|
||||
) -> CreateInteractionResponseMessage {
|
||||
@@ -76,6 +117,7 @@ pub trait Extract {
|
||||
}
|
||||
|
||||
pub use extract_derive::Extract;
|
||||
use sqlx::Executor;
|
||||
|
||||
macro_rules! extract_arg {
|
||||
($ctx:ident, $name:ident, String) => {
|
||||
|
||||
@@ -92,6 +92,8 @@ enum Error {
|
||||
SQLx(sqlx::Error),
|
||||
#[allow(unused)]
|
||||
Serenity(serenity::Error),
|
||||
#[allow(unused)]
|
||||
MissingDiscordPermission(&'static str),
|
||||
}
|
||||
|
||||
pub async fn initialize(
|
||||
@@ -231,39 +233,6 @@ pub async fn initialize(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
||||
offline!(true);
|
||||
|
||||
if let Some(subscription_guild) = *CNC_GUILD {
|
||||
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
|
||||
|
||||
if let Ok(member) = guild_member {
|
||||
for role in member.roles {
|
||||
if SUBSCRIPTION_ROLES.contains(&role.get()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_guild_subscription(
|
||||
cache_http: impl CacheHttp,
|
||||
guild_id: impl Into<GuildId>,
|
||||
) -> bool {
|
||||
offline!(true);
|
||||
|
||||
if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|guild| guild.owner_id) {
|
||||
check_subscription(&cache_http, owner).await
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_authorization(
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &Context,
|
||||
|
||||
@@ -7,38 +7,43 @@ pub mod todos;
|
||||
|
||||
use std::env;
|
||||
|
||||
use crate::utils::check_subscription;
|
||||
use crate::web::guards::transaction::Transaction;
|
||||
use crate::web::{check_authorization, routes::JsonResult};
|
||||
pub use channels::get_guild_channels;
|
||||
pub use emojis::get_guild_emojis;
|
||||
pub use reminders::*;
|
||||
use rocket::{get, http::CookieJar, serde::json::json, State};
|
||||
pub use roles::get_guild_roles;
|
||||
use serenity::all::UserId;
|
||||
use serenity::{
|
||||
client::Context,
|
||||
model::id::{GuildId, RoleId},
|
||||
};
|
||||
pub use templates::*;
|
||||
|
||||
use crate::web::{check_authorization, routes::JsonResult};
|
||||
|
||||
#[get("/api/guild/<id>")]
|
||||
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
|
||||
pub async fn get_guild_info(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
mut transaction: Transaction<'_>,
|
||||
) -> JsonResult {
|
||||
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
|
||||
check_authorization(cookies, ctx.inner(), id).await?;
|
||||
|
||||
match GuildId::new(id)
|
||||
.to_guild_cached(ctx.inner())
|
||||
.map(|guild| (guild.owner_id, guild.name.clone()))
|
||||
{
|
||||
Some((owner_id, name)) => {
|
||||
let member_res = GuildId::new(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
|
||||
.member(&ctx.inner(), owner_id)
|
||||
.await;
|
||||
let user_id =
|
||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||
|
||||
let patreon = member_res.map_or(false, |member| {
|
||||
member
|
||||
.roles
|
||||
.contains(&RoleId::new(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||
});
|
||||
match GuildId::new(id).to_guild_cached(ctx.inner()).map(|guild| guild.name.clone()) {
|
||||
Some(name) => {
|
||||
let patreon = check_subscription(
|
||||
ctx.inner(),
|
||||
transaction.executor(),
|
||||
UserId::from(user_id),
|
||||
Some(GuildId::from(id)),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(json!({ "patreon": patreon, "name": name }))
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ use serenity::{
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::utils::check_subscription;
|
||||
use crate::web::{
|
||||
check_authorization, check_guild_subscription, check_subscription,
|
||||
check_authorization,
|
||||
consts::MIN_INTERVAL,
|
||||
guards::transaction::Transaction,
|
||||
routes::{
|
||||
@@ -186,8 +187,13 @@ pub async fn edit_reminder(
|
||||
|| reminder.interval_months.flatten().is_some()
|
||||
|| reminder.interval_seconds.flatten().is_some()
|
||||
{
|
||||
if check_guild_subscription(&ctx.inner(), id).await
|
||||
|| check_subscription(&ctx.inner(), user_id).await
|
||||
if check_subscription(
|
||||
ctx.inner(),
|
||||
transaction.executor(),
|
||||
UserId::from(user_id),
|
||||
Some(GuildId::from(id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let new_interval_length = match reminder.interval_days {
|
||||
Some(interval) => interval.unwrap_or(0),
|
||||
@@ -305,7 +311,15 @@ pub async fn edit_reminder(
|
||||
Err(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serenity::{client::Context, model::id::UserId};
|
||||
use sqlx::types::Json;
|
||||
|
||||
use crate::utils::check_subscription;
|
||||
use crate::web::{
|
||||
check_subscription,
|
||||
consts::{
|
||||
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
|
||||
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
|
||||
@@ -65,7 +65,16 @@ pub async fn create_reminder(
|
||||
if let Err(e) = channel {
|
||||
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();
|
||||
@@ -122,7 +131,7 @@ pub async fn create_reminder(
|
||||
|| reminder.interval_days.is_some()
|
||||
|| reminder.interval_months.is_some()
|
||||
{
|
||||
if !check_subscription(&ctx, user_id).await {
|
||||
if !check_subscription(&ctx, transaction.executor(), user_id, None).await {
|
||||
return Err(json!({"error": "Patreon is required to set intervals"}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use rocket::{
|
||||
use serenity::{client::Context, model::id::UserId};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::utils::check_subscription;
|
||||
use crate::web::{
|
||||
check_subscription,
|
||||
guards::transaction::Transaction,
|
||||
routes::{
|
||||
dashboard::{
|
||||
@@ -162,7 +162,9 @@ pub async fn edit_reminder(
|
||||
|| reminder.interval_months.flatten().is_some()
|
||||
|| reminder.interval_seconds.flatten().is_some()
|
||||
{
|
||||
if check_subscription(&ctx.inner(), user_id).await {
|
||||
if check_subscription(&ctx.inner(), transaction.executor(), UserId::from(user_id), None)
|
||||
.await
|
||||
{
|
||||
let new_interval_length = match reminder.interval_days {
|
||||
Some(interval) => interval.unwrap_or(0),
|
||||
None => sqlx::query!(
|
||||
|
||||
@@ -10,6 +10,7 @@ use rocket::{
|
||||
use rocket_dyn_templates::Template;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serenity::http::HttpError;
|
||||
use serenity::{
|
||||
all::CacheHttp,
|
||||
builder::CreateWebhook,
|
||||
@@ -19,9 +20,9 @@ use serenity::{
|
||||
use sqlx::types::Json;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::utils::check_subscription;
|
||||
use crate::web::{
|
||||
catchers::internal_server_error,
|
||||
check_guild_subscription, check_subscription,
|
||||
consts::{
|
||||
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
|
||||
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
|
||||
@@ -404,9 +405,19 @@ pub(crate) async fn create_reminder(
|
||||
if let Err(e) = channel {
|
||||
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||
|
||||
return Err(
|
||||
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||
);
|
||||
// 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. Please check the bot permissions"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
return Err(json!({"error": error_msg}));
|
||||
}
|
||||
|
||||
let channel = channel.unwrap();
|
||||
@@ -466,9 +477,7 @@ pub(crate) async fn create_reminder(
|
||||
|| reminder.interval_days.is_some()
|
||||
|| reminder.interval_months.is_some()
|
||||
{
|
||||
if !check_guild_subscription(&ctx, guild_id).await
|
||||
&& !check_subscription(&ctx, user_id).await
|
||||
{
|
||||
if !check_subscription(&ctx, transaction.executor(), user_id, Some(guild_id)).await {
|
||||
return Err(json!({"error": "Patreon is required to set intervals"}));
|
||||
}
|
||||
}
|
||||
@@ -716,13 +725,36 @@ async fn create_database_channel(
|
||||
|
||||
match row {
|
||||
Ok(row) => {
|
||||
let is_dm =
|
||||
channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some();
|
||||
let is_dm = channel
|
||||
.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()) {
|
||||
let webhook = channel
|
||||
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
|
||||
.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();
|
||||
|
||||
@@ -747,7 +779,16 @@ async fn create_database_channel(
|
||||
let webhook = channel
|
||||
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
|
||||
.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();
|
||||
|
||||
@@ -806,22 +847,15 @@ pub async fn todos_redirect(id: &str) -> Redirect {
|
||||
|
||||
#[get("/")]
|
||||
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
|
||||
if cookies.get_private("userid").is_some() {
|
||||
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"))
|
||||
}
|
||||
render_dashboard(cookies).await
|
||||
}
|
||||
|
||||
#[get("/<_..>")]
|
||||
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() {
|
||||
match NamedFile::open(Path::new(path!("static/index.html"))).await {
|
||||
Ok(f) => DashboardPage::Ok(f),
|
||||
|
||||
@@ -19,6 +19,24 @@
|
||||
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
|
||||
for the reminder.
|
||||
</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>
|
||||
</section>
|
||||
@@ -37,4 +55,40 @@
|
||||
</div>
|
||||
</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><<timefrom:1755803600>></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><<timenow:UTC:%H:%M:%S>></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><<timenow+120:UTC:%H:%M:%S>></code> would display "18:15:20",
|
||||
or <code><<timenow-120:UTC:%H:%M:%S>></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><t:<<timenow+120:UTC:%s>>:R></code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user