Extract trait

This commit is contained in:
jude 2024-02-17 20:24:30 +00:00
parent 4823754955
commit c1305cfb36
14 changed files with 336 additions and 176 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
/target target
.env .env
/venv /venv
.cargo .cargo

View File

@ -1,17 +1,13 @@
use crate::{consts::MINUTE, models::CtxData, Context, Error}; use crate::{consts::MINUTE, models::CtxData, Context, Error};
/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) pub struct Options {
#[poise::command( minutes: Option<isize>,
slash_command, seconds: Option<isize>,
identifying_name = "nudge", }
default_member_permissions = "MANAGE_GUILD"
)] pub async fn nudge(ctx: Context<'_>, options: Options) -> Result<(), Error> {
pub async fn nudge( let combined_time =
ctx: Context<'_>, options.minutes.map_or(0, |m| m * MINUTE as isize) + options.seconds.map_or(0, |s| s);
#[description = "Number of minutes to nudge new reminders by"] minutes: Option<isize>,
#[description = "Number of seconds to nudge new reminders by"] seconds: Option<isize>,
) -> Result<(), Error> {
let combined_time = minutes.map_or(0, |m| m * MINUTE as isize) + seconds.map_or(0, |s| s);
if combined_time < i16::MIN as isize || combined_time > i16::MAX as isize { if combined_time < i16::MIN as isize || combined_time > i16::MAX as isize {
ctx.say("Nudge times must be less than 500 minutes").await?; ctx.say("Nudge times must be less than 500 minutes").await?;
@ -26,3 +22,17 @@ pub async fn nudge(
Ok(()) Ok(())
} }
/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`)
#[poise::command(
slash_command,
identifying_name = "nudge",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn command(
ctx: Context<'_>,
#[description = "Number of minutes to nudge new reminders by"] minutes: Option<isize>,
#[description = "Number of seconds to nudge new reminders by"] seconds: Option<isize>,
) -> Result<(), Error> {
nudge(ctx, Options { minutes, seconds }).await
}

View File

@ -1,25 +1,23 @@
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
consts::{HOUR, MINUTE}, consts::{HOUR, MINUTE},
Context, Error, Context, Error,
}; };
/// Move all reminders in the current server by a certain amount of time. Times get added together #[derive(Serialize, Deserialize, Default)]
#[poise::command( pub struct Options {
slash_command, hours: Option<isize>,
identifying_name = "offset", minutes: Option<isize>,
default_member_permissions = "MANAGE_GUILD" seconds: Option<isize>,
)] }
pub async fn offset(
ctx: Context<'_>, async fn offset(ctx: Context<'_>, options: Options) -> Result<(), Error> {
#[description = "Number of hours to offset by"] hours: Option<isize>,
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
) -> Result<(), Error> {
ctx.defer().await?; ctx.defer().await?;
let combined_time = hours.map_or(0, |h| h * HOUR as isize) let combined_time = options.hours.map_or(0, |h| h * HOUR as isize)
+ minutes.map_or(0, |m| m * MINUTE as isize) + options.minutes.map_or(0, |m| m * MINUTE as isize)
+ seconds.map_or(0, |s| s); + options.seconds.map_or(0, |s| s);
if combined_time == 0 { if combined_time == 0 {
ctx.say("Please specify one of `hours`, `minutes` or `seconds`").await?; ctx.say("Please specify one of `hours`, `minutes` or `seconds`").await?;
@ -69,3 +67,18 @@ pub async fn offset(
Ok(()) Ok(())
} }
/// Move all reminders in the current server by a certain amount of time. Times get added together
#[poise::command(
slash_command,
identifying_name = "offset",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn command(
ctx: Context<'_>,
#[description = "Number of hours to offset by"] hours: Option<isize>,
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
) -> Result<(), Error> {
offset(ctx, Options { hours, minutes, seconds }).await
}

View File

@ -1,22 +1,28 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::{models::CtxData, time_parser::natural_parser, Context, Error}; use crate::{
models::CtxData, time_parser::natural_parser, utils::Extract, ApplicationContext, Context,
Error,
};
/// Pause all reminders on the current channel until a certain time or indefinitely #[derive(Serialize, Deserialize, Extract)]
#[poise::command( pub struct Options {
slash_command, until: Option<String>,
identifying_name = "pause", }
default_member_permissions = "MANAGE_GUILD"
)] impl Extract for Options {
pub async fn pause( fn extract(ctx: ApplicationContext) -> Self {
ctx: Context<'_>, Self { until: extract_arg!(ctx, "until", Option<String>) }
#[description = "When to pause until"] until: Option<String>, }
) -> Result<(), Error> { }
pub async fn pause(ctx: Context<'_>, options: Options) -> Result<(), Error> {
let timezone = ctx.timezone().await; let timezone = ctx.timezone().await;
let mut channel = ctx.channel_data().await.unwrap(); let mut channel = ctx.channel_data().await.unwrap();
match until { match options.until {
Some(until) => { Some(until) => {
let parsed = natural_parser(&until, &timezone.to_string()).await; let parsed = natural_parser(&until, &timezone.to_string()).await;
@ -65,3 +71,16 @@ pub async fn pause(
Ok(()) Ok(())
} }
/// Pause all reminders on the current channel until a certain time or indefinitely
#[poise::command(
slash_command,
identifying_name = "pause",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn command(
ctx: Context<'_>,
#[description = "When to pause until"] until: Option<String>,
) -> Result<(), Error> {
pause(ctx, Options { until }).await
}

View File

@ -4,18 +4,49 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
models::reminder::create_reminder, models::reminder::create_reminder,
utils::{extract_arg, Extract},
ApplicationContext, Context, Error, ApplicationContext, Context, Error,
}; };
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize)]
pub struct RemindOptions { pub struct Options {
pub time: String, time: String,
pub content: String, content: String,
pub channels: Option<String>, channels: Option<String>,
pub interval: Option<String>, interval: Option<String>,
pub expires: Option<String>, expires: Option<String>,
pub tts: Option<bool>, tts: Option<bool>,
pub timezone: Option<Tz>, timezone: Option<String>,
}
impl Extract for Options {
fn extract(ctx: ApplicationContext) -> Self {
Self {
time: extract_arg!(ctx, "time", String),
content: extract_arg!(ctx, "content", String),
channels: extract_arg!(ctx, "channels", Option<String>),
interval: extract_arg!(ctx, "interval", Option<String>),
expires: extract_arg!(ctx, "expires", Option<String>),
tts: extract_arg!(ctx, "tts", Option<bool>),
timezone: extract_arg!(ctx, "timezone", Option<String>),
}
}
}
pub async fn remind(ctx: Context<'_>, options: Options) -> Result<(), Error> {
let tz = options.timezone.map(|t| t.parse::<Tz>().ok()).flatten();
create_reminder(
ctx,
options.time,
options.content,
options.channels,
options.interval,
options.expires,
options.tts,
tz,
)
.await
} }
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content. /// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
@ -24,8 +55,8 @@ pub struct RemindOptions {
identifying_name = "remind", identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD" default_member_permissions = "MANAGE_GUILD"
)] )]
pub async fn remind( pub async fn command(
ctx: ApplicationContext<'_>, ctx: Context<'_>,
#[description = "The time (and optionally date) to set the reminder for"] #[description = "The time (and optionally date) to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"] #[autocomplete = "time_hint_autocomplete"]
time: String, time: String,
@ -41,8 +72,5 @@ pub async fn remind(
#[autocomplete = "timezone_autocomplete"] #[autocomplete = "timezone_autocomplete"]
timezone: Option<String>, timezone: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); remind(ctx, Options { time, content, channels, interval, expires, tts, timezone }).await
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
.await
} }

View File

@ -5,25 +5,24 @@ use poise::{
serenity_prelude::{CreateEmbed, CreateEmbedFooter}, serenity_prelude::{CreateEmbed, CreateEmbedFooter},
CreateReply, CreateReply,
}; };
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
commands::autocomplete::timezone_autocomplete, consts::THEME_COLOR, models::CtxData, Context, commands::autocomplete::timezone_autocomplete, consts::THEME_COLOR, models::CtxData, Context,
Error, Error,
}; };
/// Select your timezone #[derive(Serialize, Deserialize)]
#[poise::command(slash_command, identifying_name = "timezone")] pub struct Options {
pub async fn timezone( pub timezone: Option<String>,
ctx: Context<'_>, }
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
#[autocomplete = "timezone_autocomplete"] pub async fn timezone_fn(ctx: Context<'_>, options: Options) -> Result<(), Error> {
timezone: Option<String>,
) -> Result<(), Error> {
let mut user_data = ctx.author_data().await.unwrap(); let mut user_data = ctx.author_data().await.unwrap();
let footer_text = format!("Current timezone: {}", user_data.timezone); let footer_text = format!("Current timezone: {}", user_data.timezone);
if let Some(timezone) = timezone { if let Some(timezone) = options.timezone {
match timezone.parse::<Tz>() { match timezone.parse::<Tz>() {
Ok(tz) => { Ok(tz) => {
user_data.timezone = timezone.clone(); user_data.timezone = timezone.clone();
@ -115,3 +114,14 @@ You may want to use one of the popular timezones below, otherwise click [here](h
Ok(()) Ok(())
} }
/// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")]
pub async fn command(
ctx: Context<'_>,
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
timezone_fn(ctx, Options { timezone }).await
}

View File

@ -1,15 +1,19 @@
use log::warn; use log::warn;
use poise::CreateReply; use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{models::CtxData, Context, Error}; use crate::{models::CtxData, utils::Extract, ApplicationContext, Context, Error};
/// View the webhook being used to send reminders to this channel #[derive(Serialize, Deserialize)]
#[poise::command( pub struct Options;
slash_command,
identifying_name = "webhook_url", impl Extract for Options {
required_permissions = "ADMINISTRATOR" fn extract(_ctx: ApplicationContext) -> Self {
)] Self {}
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> { }
}
pub async fn webhook(ctx: Context<'_>, _options: Options) -> Result<(), Error> {
match ctx.channel_data().await { match ctx.channel_data().await {
Ok(data) => { Ok(data) => {
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
@ -34,3 +38,13 @@ Do not share it!
Ok(()) Ok(())
} }
/// View the webhook being used to send reminders to this channel
#[poise::command(
slash_command,
identifying_name = "webhook_url",
required_permissions = "ADMINISTRATOR"
)]
pub async fn command(ctx: Context<'_>) -> Result<(), Error> {
webhook(ctx, Options {}).await
}

46
src/extract_macro/Cargo.lock generated Normal file
View File

@ -0,0 +1,46 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "extract_macro"
version = "0.1.0"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"

View File

@ -0,0 +1,11 @@
[package]
name = "extract_macro"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

View File

@ -0,0 +1,70 @@
macro_rules! extract_arg {
($ctx:ident, $name:literal, String) => {
$ctx.args.iter().find(|opt| opt.name == $name).map(|opt| &opt.value).map_or_else(
|| String::new(),
|v| match v {
poise::serenity_prelude::ResolvedValue::String(s) => s.to_string(),
_ => String::new(),
},
)
};
($ctx:ident, $name:literal, Option<String>) => {
$ctx.args
.iter()
.find(|opt| opt.name == $name)
.map(|opt| &opt.value)
.map(|v| match v {
poise::serenity_prelude::ResolvedValue::String(s) => Some(s.to_string()),
_ => None,
})
.flatten()
};
($ctx:ident, $name:literal, bool) => {
$ctx.args.iter().find(|opt| opt.name == $name).map(|opt| &opt.value).map_or(false, |v| {
match v {
poise::serenity_prelude::ResolvedValue::Boolean(b) => b.to_owned(),
_ => false,
}
})
};
($ctx:ident, $name:literal, Option<bool>) => {
$ctx.args
.iter()
.find(|opt| opt.name == $name)
.map(|opt| &opt.value)
.map(|v| match v {
poise::serenity_prelude::ResolvedValue::Boolean(b) => Some(b.to_owned()),
_ => None,
})
.flatten()
};
}
use proc_macro::TokenStream;
use syn::parse::Parser;
#[proc_macro_derive(Extract)]
pub fn extract(input: TokenStream) -> TokenStream {
// Construct a string representation of the type definition
let s = input.to_string();
// Parse the string representation
let ast = syn::parse_derive_input(&s).unwrap();
// Build the impl
let gen = impl_extract(&ast);
// Return the generated impl
gen.parse().unwrap()
}
fn impl_extract(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: ) -> Self {
println!("Hello, World! My name is {}", stringify!(#name));
}
}
})
}

View File

@ -37,7 +37,7 @@ use crate::{
commands::{ commands::{
allowed_dm, clock::clock, clock_context_menu::clock_context_menu, command_macro, allowed_dm, clock::clock, clock_context_menu::clock_context_menu, command_macro,
dashboard::dashboard, delete, donate::donate, help::help, info::info, look, multiline, dashboard::dashboard, delete, donate::donate, help::help, info::info, look, multiline,
nudge, offset, pause, remind, settings, timer, timezone::timezone, todo, webhook::webhook, nudge, offset, pause, remind, settings, timer, timezone, todo, webhook,
}, },
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
@ -109,7 +109,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
clock(), clock(),
clock_context_menu(), clock_context_menu(),
dashboard(), dashboard(),
timezone(), timezone::command(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
allowed_dm::set_allowed_dm::set_allowed_dm(), allowed_dm::set_allowed_dm::set_allowed_dm(),
@ -127,7 +127,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
}], }],
..settings::settings() ..settings::settings()
}, },
webhook(), webhook::command(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
command_macro::delete_macro::delete_macro(), command_macro::delete_macro::delete_macro(),
@ -138,9 +138,9 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
..command_macro::command_macro() ..command_macro::command_macro()
}, },
pause::pause(), pause::command(),
offset::offset(), offset::command(),
nudge::nudge(), nudge::command(),
look::look(), look::look(),
delete::delete(), delete::delete(),
poise::Command { poise::Command {
@ -152,7 +152,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
..timer::timer() ..timer::timer()
}, },
multiline::multiline(), multiline::multiline(),
remind::remind(), remind::command(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
poise::Command { poise::Command {
@ -197,12 +197,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
sqlx::migrate!().run(&database).await?; sqlx::migrate!().run(&database).await?;
let popular_timezones = sqlx::query!( let popular_timezones = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone "
SELECT IFNULL(timezone, 'UTC') AS timezone
FROM users FROM users
WHERE timezone IS NOT NULL WHERE timezone IS NOT NULL
GROUP BY timezone GROUP BY timezone
ORDER BY COUNT(timezone) DESC ORDER BY COUNT(timezone) DESC
LIMIT 21" LIMIT 21
"
) )
.fetch_all(&database) .fetch_all(&database)
.await .await

View File

@ -1,84 +1,27 @@
use chrono_tz::Tz; use poise::serenity_prelude::model::id::GuildId;
use poise::serenity_prelude::{model::id::GuildId, ResolvedValue};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{commands::remind, utils::Extract, ApplicationContext, Context, Error};
commands::remind::RemindOptions, models::reminder::create_reminder, ApplicationContext,
Context, Error,
};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(tag = "command_name")] #[serde(tag = "command_name")]
pub enum RecordedCommand { pub enum RecordedCommand {
Remind(RemindOptions), Remind(remind::Options),
}
macro_rules! extract_arg {
($ctx:ident, $name:literal, String) => {
$ctx.args.iter().find(|opt| opt.name == $name).map(|opt| &opt.value).map_or_else(
|| String::new(),
|v| match v {
ResolvedValue::String(s) => s.to_string(),
_ => String::new(),
},
)
};
($ctx:ident, $name:literal, Option<String>) => {
$ctx.args.iter().find(|opt| opt.name == $name).map(|opt| &opt.value).map(|v| match v {
ResolvedValue::String(s) => s.to_string(),
_ => String::new(),
})
};
($ctx:ident, $name:literal, bool) => {
$ctx.args.iter().find(|opt| opt.name == $name).map(|opt| &opt.value).map(|v| match v {
ResolvedValue::Boolean(b) => b.to_owned(),
_ => false,
})
};
($ctx:ident, $name:literal, Option<Tz>) => {
$ctx.args
.iter()
.find(|opt| opt.name == $name)
.map(|opt| &opt.value)
.map(|v| match v {
ResolvedValue::String(s) => s.parse::<Tz>().ok(),
_ => None,
})
.flatten()
};
} }
impl RecordedCommand { impl RecordedCommand {
pub fn from_context(ctx: ApplicationContext) -> Option<Self> { pub fn from_context(ctx: ApplicationContext) -> Option<Self> {
match ctx.command().identifying_name.as_str() { match ctx.command().identifying_name.as_str() {
"remind" => Some(Self::Remind(RemindOptions { "remind" => Some(Self::Remind(remind::Options::extract(ctx))),
time: extract_arg!(ctx, "time", String),
content: extract_arg!(ctx, "content", String),
channels: extract_arg!(ctx, "channels", Option<String>),
interval: extract_arg!(ctx, "interval", Option<String>),
expires: extract_arg!(ctx, "expires", Option<String>),
tts: extract_arg!(ctx, "tts", bool),
timezone: extract_arg!(ctx, "timezone", Option<Tz>),
})),
_ => None, _ => None,
} }
} }
pub async fn execute(self, ctx: ApplicationContext<'_>) -> Result<(), Error> { pub async fn execute(self, ctx: ApplicationContext<'_>) -> Result<(), Error> {
match self { match self {
RecordedCommand::Remind(command_options) => { RecordedCommand::Remind(options) => {
create_reminder( remind::remind(Context::Application(ctx), options).await
Context::Application(ctx),
command_options.time,
command_options.content,
command_options.channels,
command_options.interval,
command_options.expires,
command_options.tts,
command_options.timezone,
)
.await
} }
} }
} }

View File

@ -69,7 +69,9 @@ pub struct ReminderBuilder {
impl ReminderBuilder { impl ReminderBuilder {
pub async fn build(self) -> Result<Reminder, ReminderError> { pub async fn build(self) -> Result<Reminder, ReminderError> {
let queried_time = sqlx::query!( let queried_time = sqlx::query!(
"SELECT DATE_ADD(?, INTERVAL (SELECT nudge FROM channels WHERE id = ?) SECOND) AS `utc_time`", "
SELECT DATE_ADD(?, INTERVAL (SELECT nudge FROM channels WHERE id = ?) SECOND) AS `utc_time`
",
self.utc_time, self.utc_time,
self.channel, self.channel,
) )
@ -84,7 +86,7 @@ impl ReminderBuilder {
} else { } else {
sqlx::query!( sqlx::query!(
" "
INSERT INTO reminders ( INSERT INTO reminders (
`uid`, `uid`,
`channel_id`, `channel_id`,
`utc_time`, `utc_time`,
@ -98,21 +100,9 @@ INSERT INTO reminders (
`attachment_name`, `attachment_name`,
`attachment`, `attachment`,
`set_by` `set_by`
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, )
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?
)
", ",
self.uid, self.uid,
self.channel, self.channel,

View File

@ -9,7 +9,7 @@ use poise::{
use crate::{ use crate::{
consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
Context, ApplicationContext, Context,
}; };
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
@ -64,3 +64,7 @@ pub fn footer(ctx: Context<'_>) -> CreateEmbedFooter {
shard_count, shard_count,
)) ))
} }
pub trait Extract {
fn extract(ctx: ApplicationContext) -> Self;
}