8 Commits

Author SHA1 Message Date
jude
3d0436eb8b Apply patreon sharing across web/bot 2025-10-20 18:36:28 +01:00
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
22 changed files with 360 additions and 84 deletions

12
Cargo.lock generated
View File

@@ -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.38"
version = "1.7.40"
dependencies = [
"base64 0.22.1",
"chrono",
"chrono-tz",
"cron-parser",
"csv",
"dotenv",
"env_logger",

View File

@@ -1,6 +1,6 @@
[package]
name = "reminder-rs"
version = "1.7.38"
version = "1.7.40"
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"

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 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;

View File

@@ -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;

View File

@@ -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
}

View File

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

@@ -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(),

View File

@@ -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))),

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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,
}

View File

@@ -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)
}

View File

@@ -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 user_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) => {

View File

@@ -233,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,

View File

@@ -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),

View File

@@ -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,
@@ -131,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"}));
}
}

View File

@@ -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!(

View File

@@ -20,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,
@@ -477,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"}));
}
}

View File

@@ -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>&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 %}