19 Commits

Author SHA1 Message Date
b0a04bb289 Default channel command 2022-09-17 18:09:40 +01:00
eef1f6f3e8 Correct migration. Add guilds on interaction. Correct queries 2022-09-17 18:08:45 +01:00
3d08027325 Add migration for guild IDs to use discord ID 2022-09-17 18:08:45 +01:00
94bfd39085 Patch compilation against live schema 2022-09-17 13:05:50 +01:00
40cd5f8a36 Patch compilation against live schema 2022-09-17 13:03:52 +01:00
133b00a2ce Patch compilation against live schema 2022-09-17 12:52:03 +01:00
57336f5c81 Change macro list to use fields to prevent going over limit
Add length checks for name and description
2022-09-17 12:37:58 +01:00
b62d24c024 Add an autocomplete for time hints
Shows the approximate time until a reminder will send in the autocomplete area.
2022-09-12 17:49:10 +01:00
8f8235a86e Move macro commands to own module
Lots of code here
2022-09-12 16:45:00 +01:00
c8f646a8fa Override timezone per command
Timezone option that will override the timezone on a per-command basis
2022-09-11 18:59:46 +01:00
ecaa382a1e Add join message 2022-09-11 17:38:53 +01:00
8991198fd3 Use autocomplete to ensure content box is shown 2022-09-11 15:24:02 +01:00
f20b95a482 Upgrade poise. Combine remind/multiline into one command 2022-09-08 17:58:05 +01:00
8dd7dc6409 Added command for multiline reminders 2022-09-07 18:27:13 +01:00
c799d10727 Move extra processes to user data setup 2022-09-03 16:19:59 +01:00
ceb6fb7b12 bump version 2022-09-03 15:49:05 +01:00
6708abdb0f Merge pull request #10 from reminder-bot/jellywx/fix-dm-reminders
group by channel instead of guild
2022-09-03 15:44:00 +01:00
a38f6024c1 Migrate natural commands 2022-09-03 15:40:29 +01:00
bb3386c4e8 migration for $r commands 2022-08-14 16:22:00 +01:00
32 changed files with 1558 additions and 886 deletions

589
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +1,29 @@
[package]
name = "reminder_rs"
version = "1.6.3"
version = "1.6.6"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
[dependencies]
poise = "0.2"
poise = "0.3"
dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
regex = "1.4"
lazy-regex = "2.3.0"
regex = "1.6"
log = "0.4"
env_logger = "0.8"
env_logger = "0.9"
chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
chrono-tz = { version = "0.6", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
serde_json = "1.0"
serde_repr = "0.1"
rmp-serde = "0.15"
rand = "0.7"
rmp-serde = "1.1"
rand = "0.8"
levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.13"
[dependencies.postman]

View File

@ -0,0 +1,92 @@
SET foreign_key_checks = 0;
START TRANSACTION;
-- drop existing constraints
ALTER TABLE channels DROP FOREIGN KEY `channels_ibfk_1`;
ALTER TABLE command_aliases DROP FOREIGN KEY `command_aliases_ibfk_1`;
ALTER TABLE events DROP FOREIGN KEY `events_ibfk_1`;
ALTER TABLE guild_users DROP FOREIGN KEY `guild_users_ibfk_1`;
ALTER TABLE macro DROP FOREIGN KEY `macro_ibfk_1`;
ALTER TABLE roles DROP FOREIGN KEY `roles_ibfk_1`;
ALTER TABLE todos DROP FOREIGN KEY `todos_ibfk_2`;
ALTER TABLE reminder_template DROP FOREIGN KEY `reminder_template_ibfk_1`;
-- update foreign key types
ALTER TABLE channels MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE command_aliases MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE events MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE guild_users MODIFY `guild` BIGINT UNSIGNED;
ALTER TABLE macro MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE roles MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE todos MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE reminder_template MODIFY `guild_id` BIGINT UNSIGNED;
-- update foreign key values
UPDATE channels SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE command_aliases SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE events SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE guild_users SET `guild` = (SELECT `guild` FROM guilds WHERE guilds.`id` = guild_users.`guild`);
UPDATE macro SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE roles SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE todos SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE reminder_template SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
-- update guilds table
ALTER TABLE guilds MODIFY `id` BIGINT UNSIGNED NOT NULL;
UPDATE guilds SET `id` = `guild`;
ALTER TABLE guilds DROP COLUMN `guild`;
ALTER TABLE guilds ADD COLUMN `default_channel` BIGINT UNSIGNED;
ALTER TABLE guilds ADD CONSTRAINT `default_channel_fk`
FOREIGN KEY (`default_channel`)
REFERENCES channels(`channel`)
ON DELETE SET NULL
ON UPDATE CASCADE;
-- re-add constraints
ALTER TABLE channels ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE command_aliases ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE events ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE guild_users ADD CONSTRAINT
FOREIGN KEY (`guild`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE macro ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE roles ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE todos ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
COMMIT;
SET foreign_key_checks = 1;

View File

@ -12,5 +12,5 @@ chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -0,0 +1,131 @@
use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS;
use poise::AutocompleteChoice;
use crate::{models::CtxData, time_parser::natural_parser, Context};
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = ?
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
}
pub async fn multiline_autocomplete(
_ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() }]
} else {
vec![
AutocompleteChoice { name: partial.to_string(), value: partial.to_string() },
AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() },
]
}
}
pub async fn time_hint_autocomplete(
ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice {
name: "Start typing a time...".to_string(),
value: "now".to_string(),
}]
} else {
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => {
let diff = timestamp - now.as_secs() as i64;
if diff < 0 {
vec![AutocompleteChoice {
name: "Time is in the past".to_string(),
value: "now".to_string(),
}]
} else {
if diff > 86400 {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!(
"In approximately {} days, {} hours",
diff / 86400,
(diff % 86400) / 3600
),
value: partial.to_string(),
},
]
} else if diff > 3600 {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!("In approximately {} hours", diff / 3600),
value: partial.to_string(),
},
]
} else {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!("In approximately {} minutes", diff / 60),
value: partial.to_string(),
},
]
}
}
}
Err(_) => {
vec![AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
}]
}
},
None => {
vec![AutocompleteChoice {
name: "Time not recognised".to_string(),
value: "now".to_string(),
}]
}
}
}
}

View File

@ -0,0 +1,46 @@
use super::super::autocomplete::macro_name_autocomplete;
use crate::{Context, Error};
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = ? AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}

View File

@ -0,0 +1,89 @@
use poise::CreateReply;
use crate::{
component_models::pager::{MacroPager, Pager},
consts::THEME_COLOR,
models::{command_macro::CommandMacro, CtxData},
Context, Error,
};
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
((macros.len() as f64) / 25.0).ceil() as usize
}
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
let mut page = page;
if page >= pages {
page = pages - 1;
}
let lower = (page * 25).min(macros.len());
let upper = ((page + 1) * 25).min(macros.len());
let fields = macros[lower..upper].iter().map(|m| {
if let Some(description) = &m.description {
(
m.name.clone(),
format!("*{}*\n- Has {} commands", description, m.commands.len()),
true,
)
} else {
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
}
});
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.fields(fields)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
});
reply
}

View File

@ -0,0 +1,229 @@
use lazy_regex::regex;
use poise::serenity_prelude::command::CommandOptionType;
use regex::Captures;
use serde_json::{json, Value};
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
struct Alias {
name: String,
command: String,
}
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
#[poise::command(
slash_command,
rename = "migrate",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "migrate_macro"
)]
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let mut transaction = ctx.data().database.begin().await?;
let aliases = sqlx::query_as!(
Alias,
"SELECT name, command FROM command_aliases WHERE guild_id = ?",
guild_id.0
)
.fetch_all(&mut transaction)
.await?;
let mut added_aliases = 0;
for alias in aliases {
match parse_text_command(guild_id, alias.name, &alias.command) {
Some(cmd_macro) => {
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
cmd_macro.guild_id.0,
cmd_macro.name,
cmd_macro.description,
cmd_macro.commands
)
.execute(&mut transaction)
.await?;
added_aliases += 1;
}
None => {}
}
}
transaction.commit().await?;
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
Ok(())
}
fn parse_text_command(
guild_id: GuildId,
alias_name: String,
command: &str,
) -> Option<RawCommandMacro> {
match command.split_once(" ") {
Some((command_word, args)) => {
let command_word = command_word.to_lowercase();
if command_word == "r"
|| command_word == "i"
|| command_word == "remind"
|| command_word == "interval"
{
let matcher = regex!(
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
);
match matcher.captures(&args) {
Some(captures) => {
let mut args: Vec<Value> = vec![];
if let Some(group) = captures.name("time") {
let content = group.as_str();
args.push(json!({
"name": "time",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("content") {
let content = group.as_str();
args.push(json!({
"name": "content",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("interval") {
let content = group.as_str();
args.push(json!({
"name": "interval",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("expires") {
let content = group.as_str();
args.push(json!({
"name": "expires",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("mentions") {
let content = group.as_str();
args.push(json!({
"name": "channels",
"value": content,
"type": CommandOptionType::String,
}));
}
Some(RawCommandMacro {
guild_id,
name: alias_name,
description: None,
commands: json!([
{
"command_name": "remind",
"options": args,
}
]),
})
}
None => None,
}
} else if command_word == "n" || command_word == "natural" {
let matcher_primary = regex!(
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
);
let matcher_secondary = regex!(
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
);
match matcher_primary.captures(&args) {
Some(captures) => {
let captures_secondary = matcher_secondary.captures(&args);
let mut args: Vec<Value> = vec![];
if let Some(group) = captures.name("time") {
let content = group.as_str();
args.push(json!({
"name": "time",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("content") {
let content = group.as_str();
args.push(json!({
"name": "content",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) =
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
{
let content = group.as_str();
args.push(json!({
"name": "interval",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) =
captures_secondary.and_then(|c: Captures| c.name("expires"))
{
let content = group.as_str();
args.push(json!({
"name": "expires",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("mentions") {
let content = group.as_str();
args.push(json!({
"name": "channels",
"value": content,
"type": CommandOptionType::String,
}));
}
Some(RawCommandMacro {
guild_id,
name: alias_name,
description: None,
commands: json!([
{
"command_name": "remind",
"options": args,
}
]),
})
}
None => None,
}
} else {
None
}
}
None => None,
}
}

View File

@ -0,0 +1,19 @@
use crate::{Context, Error};
pub mod delete;
pub mod list;
pub mod migrate;
pub mod record;
pub mod run;
/// Record and replay command sequences
#[poise::command(
slash_command,
rename = "macro",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "macro_base"
)]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}

View File

@ -0,0 +1,151 @@
use std::collections::hash_map::Entry;
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
if name.len() > 100 {
ctx.say("Name must be less than 100 characters").await?;
return Ok(());
}
if description.as_ref().map_or(0, |d| d.len()) > 100 {
ctx.say("Description must be less than 100 characters").await?;
return Ok(());
}
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!(
"
SELECT 1 as _e FROM macro WHERE guild_id = ? AND name = ?",
guild_id.0,
name
)
.fetch_one(&ctx.data().database)
.await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.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",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
})
})
.await?;
}
}
Ok(())
}
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}

View File

@ -0,0 +1,46 @@
use super::super::autocomplete::macro_name_autocomplete;
use crate::{models::command_macro::guild_command_macro, Context, Data, Error};
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(())
}

View File

@ -1,3 +1,5 @@
mod autocomplete;
pub mod command_macro;
pub mod info_cmds;
pub mod moderation_cmds;
pub mod reminder_cmds;

View File

@ -1,32 +1,11 @@
use std::collections::hash_map::Entry;
use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein;
use poise::CreateReply;
use log::warn;
use poise::serenity_prelude::{ChannelId, Mentionable};
use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::{
command_macro::{guild_command_macro, CommandMacro},
CtxData,
},
Context, Data, Error,
};
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
use super::autocomplete::timezone_autocomplete;
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
/// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")]
@ -170,18 +149,57 @@ pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set defaults for commands
#[poise::command(
slash_command,
identifying_name = "default",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn default(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set a default channel for reminders to be sent to
#[poise::command(
slash_command,
guild_only = true,
identifying_name = "default_channel",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn default_channel(
ctx: Context<'_>,
#[description = "Channel to send reminders to by default"] channel: Option<ChannelId>,
) -> Result<(), Error> {
if let Some(mut guild_data) = ctx.guild_data().await {
guild_data.default_channel = channel.map(|c| c.0);
guild_data.commit_changes(&ctx.data().database).await?;
if let Some(channel) = channel {
ctx.send(|r| {
r.ephemeral(true).content(format!("Default channel set to {}", channel.mention()))
})
.await?;
} else {
ctx.send(|r| r.ephemeral(true).content("Default channel unset.")).await?;
}
}
Ok(())
}
/// View the webhook being used to send reminders to this channel
#[poise::command(
slash_command,
identifying_name = "webhook_url",
required_permissions = "ADMINISTRATOR"
required_permissions = "ADMINISTRATOR",
default_member_permissions = "ADMINISTRATOR"
)]
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
match ctx.channel_data().await {
Ok(data) => {
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
let _ = ctx
.send(|b| {
ctx.send(|b| {
b.ephemeral(true).content(format!(
"**Warning!**
This link can be used by users to anonymously send messages, with or without permissions.
@ -190,389 +208,17 @@ Do not share it!
id, token,
))
})
.await;
} else {
let _ = ctx.say("No webhook configured on this channel.").await;
}
}
Err(_) => {
let _ = ctx.say("No webhook configured on this channel.").await;
}
}
Ok(())
}
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
}
/// Record and replay command sequences
#[poise::command(
slash_command,
rename = "macro",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "macro_base"
)]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!(
"
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&ctx.data().database)
.await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.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",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
})
})
.await?;
ctx.say("No webhook configured on this channel.").await?;
}
}
Ok(())
}
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
warn!("Error fetching channel data: {:?}", e);
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
ctx.say("No webhook configured on this channel.").await?;
}
}
Ok(())
}
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.fold(1, |mut pages, p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
pages += 1;
}
pages
})
}
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
let mut page = page;
if page >= pages {
page = pages - 1;
}
let mut char_count = 0;
let mut skipped_char_count = 0;
let mut skipped_pages = 0;
let display_vec: Vec<String> = macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.skip_while(|p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
skipped_pages += 1;
}
skipped_pages < page
})
.take_while(|p| {
char_count += p.len();
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>();
let display = display_vec.join("\n");
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.description(display)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
});
reply
}

View File

@ -8,12 +8,16 @@ use chrono::NaiveDateTime;
use chrono_tz::Tz;
use num_integer::Integer;
use poise::{
serenity::{builder::CreateEmbed, model::channel::Channel},
serenity_prelude::{component::ButtonStyle, ReactionType},
CreateReply,
serenity_prelude::{
builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
},
CreateReply, Modal,
};
use crate::{
commands::autocomplete::{
multiline_autocomplete, time_hint_autocomplete, timezone_autocomplete,
},
component_models::{
pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector, UndoReminder,
@ -36,7 +40,7 @@ use crate::{
},
time_parser::natural_parser,
utils::{check_guild_subscription, check_subscription},
Context, Error,
ApplicationContext, Context, Error,
};
/// Pause all reminders on the current channel until a certain time or indefinitely
@ -548,23 +552,81 @@ pub async fn delete_timer(
Ok(())
}
/// Create a new reminder
#[derive(poise::Modal)]
#[name = "Reminder"]
struct ContentModal {
#[name = "Content"]
#[placeholder = "Message..."]
#[paragraph]
#[max_length = 2000]
content: String,
}
/// Create a reminder. Press "+4 more" for other options.
#[poise::command(
slash_command,
identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn remind(
ctx: Context<'_>,
#[description = "A description of the time to set the reminder for"] time: String,
#[description = "The message content to send"] content: String,
ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"]
time: String,
#[description = "The message content to send"]
#[autocomplete = "multiline_autocomplete"]
content: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"]
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>,
#[description = "Set a timezone override for this reminder only"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
if content.is_empty() {
let data = ContentModal::execute(ctx).await?;
create_reminder(
Context::Application(ctx),
time,
data.content,
channels,
interval,
expires,
tts,
tz,
)
.await
} else {
create_reminder(
Context::Application(ctx),
time,
content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
}
async fn create_reminder(
ctx: Context<'_>,
time: String,
content: String,
channels: Option<String>,
interval: Option<String>,
expires: Option<String>,
tts: Option<bool>,
timezone: Option<Tz>,
) -> Result<(), Error> {
if interval.is_none() && expires.is_some() {
ctx.say("`expires` can only be used with `interval`").await?;
@ -575,7 +637,7 @@ pub async fn remind(
ctx.defer().await?;
let user_data = ctx.author_data().await.unwrap();
let timezone = ctx.timezone().await;
let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = natural_parser(&time, &timezone.to_string()).await;
@ -591,7 +653,9 @@ pub async fn remind(
let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
if list.is_empty() {
if ctx.guild_id().is_some() {
if let Some(channel_id) = ctx.default_channel().await {
vec![ReminderScope::Channel(channel_id.0)]
} else if ctx.guild_id().is_some() {
vec![ReminderScope::Channel(ctx.channel_id().0)]
} else {
vec![ReminderScope::User(ctx.author().id.0)]

View File

@ -47,7 +47,7 @@ pub async fn todo_guild_add(
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO todos (guild_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
VALUES (?, ?)",
ctx.guild_id().unwrap().0,
task
)
@ -70,9 +70,7 @@ VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
)]
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
"SELECT todos.id, value FROM todos WHERE guild_id = ?",
ctx.guild_id().unwrap().0,
)
.fetch_all(&ctx.data().database)
@ -122,7 +120,7 @@ pub async fn todo_channel_add(
sqlx::query!(
"INSERT INTO todos (guild_id, channel_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)",
ctx.guild_id().unwrap().0,
ctx.channel_id().0,
task

View File

@ -5,9 +5,9 @@ use std::io::Cursor;
use chrono_tz::Tz;
use log::warn;
use poise::{
serenity::{
serenity_prelude as serenity,
serenity_prelude::{
builder::CreateEmbed,
client::Context,
model::{
application::interaction::{
message_component::MessageComponentInteraction, InteractionResponseType,
@ -15,15 +15,15 @@ use poise::{
},
channel::Channel,
},
Context,
},
serenity_prelude as serenity,
};
use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
use crate::{
commands::{
moderation_cmds::{max_macro_page, show_macro_page},
command_macro::list::{max_macro_page, show_macro_page},
reminder_cmds::{max_delete_page, show_delete_page},
todo_cmds::{max_todo_page, show_todo_page},
},
@ -222,9 +222,7 @@ WHERE channels.channel = ?",
.collect::<Vec<(usize, String)>>()
} else {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
"SELECT todos.id, value FROM todos WHERE guild_id = ?",
pager.guild_id,
)
.fetch_all(&data.database)

View File

@ -1,6 +1,8 @@
// todo split pager out into a single struct
use chrono_tz::Tz;
use poise::serenity::{builder::CreateComponents, model::application::component::ButtonStyle};
use poise::serenity_prelude::{
builder::CreateComponents, model::application::component::ButtonStyle,
};
use serde::{Deserialize, Serialize};
use serde_repr::*;

View File

@ -2,7 +2,7 @@ pub const DAY: u64 = 86_400;
pub const HOUR: u64 = 3_600;
pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096;
pub const SELECT_MAX_ENTRIES: usize = 25;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
@ -12,7 +12,7 @@ pub const MACRO_MAX_COMMANDS: usize = 5;
use std::{collections::HashSet, env, iter::FromIterator};
use poise::serenity::model::prelude::AttachmentType;
use poise::serenity_prelude::model::prelude::AttachmentType;
use regex::Regex;
lazy_static! {

View File

@ -1,12 +1,14 @@
use std::{collections::HashMap, env, sync::atomic::Ordering};
use std::{collections::HashMap, env};
use log::{error, info, warn};
use log::error;
use poise::{
serenity::{model::application::interaction::Interaction, utils::shard_id},
serenity_prelude as serenity,
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
};
use crate::{component_models::ComponentDataModel, Data, Error};
use crate::{
component_models::ComponentDataModel, models::guild_data::GuildData, Data, Error, THEME_COLOR,
};
pub async fn listener(
ctx: &serenity::Context,
@ -17,45 +19,6 @@ pub async fn listener(
poise::Event::Ready { .. } => {
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
}
poise::Event::CacheReady { .. } => {
info!("Cache Ready! Preparing extra processes");
if !data.is_loop_running.load(Ordering::Relaxed) {
let kill_tx = data.broadcast.clone();
let kill_recv = data.broadcast.subscribe();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let pool1 = data.database.clone();
let pool2 = data.database.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman");
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web");
}
data.is_loop_running.swap(true, Ordering::Relaxed);
}
}
poise::Event::ChannelDelete { channel } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
.execute(&data.database)
@ -66,11 +29,74 @@ pub async fn listener(
if *is_new {
let guild_id = guild.id.as_u64().to_owned();
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id)
.execute(&data.database)
.await
.unwrap();
.await?;
if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
error!("DiscordBotList: {:?}", e);
}
let default_channel = guild.default_channel_guaranteed();
if let Some(default_channel) = default_channel {
default_channel
.send_message(&ctx, |m| {
m.embed(|e| {
e.title("Thank you for adding Reminder Bot!").description(
"To get started:
• Set your timezone with `/timezone`
• Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only)
• Create your first reminder with `/remind`
__Support__
If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com).
__Updates__
To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com).
",
).color(*THEME_COLOR)
})
})
.await?;
}
}
}
poise::Event::GuildDelete { incomplete, .. } => {
let _ = sqlx::query!("DELETE FROM guilds WHERE id = ?", incomplete.id.0)
.execute(&data.database)
.await;
}
poise::Event::InteractionCreate { interaction } => {
match interaction {
Interaction::ApplicationCommand(app_command) => {
if let Some(guild_id) = app_command.guild_id {
// check database guild exists
GuildData::from_guild(guild_id, &data.database).await?;
}
}
Interaction::MessageComponent(component) => {
let component_model =
ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await;
}
_ => {}
}
}
_ => {}
}
Ok(())
}
async fn post_guild_count(
ctx: &serenity::Context,
http: &reqwest::Client,
guild_id: u64,
) -> Result<(), reqwest::Error> {
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild_id, shard_count);
@ -79,9 +105,7 @@ pub async fn listener(
.cache
.guilds()
.iter()
.filter(|g| {
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
})
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
.count() as u64;
let mut hm = HashMap::new();
@ -89,40 +113,16 @@ pub async fn listener(
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let response = data
.http
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
http.post(
format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64())
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
poise::Event::GuildDelete { incomplete, .. } => {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
.execute(&data.database)
.await;
}
poise::Event::InteractionCreate { interaction } => {
if let Interaction::MessageComponent(component) = interaction {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await;
}
}
_ => {}
}
.await
.map(|_| ())
} else {
Ok(())
}
}

View File

@ -1,9 +1,14 @@
use poise::serenity::model::channel::Channel;
use poise::{
serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
};
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
async fn macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx {
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
app_ctx.interaction
{
if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "finish_macro" {
let mut lock = ctx.data().recording_macros.write().await;
@ -35,6 +40,7 @@ async fn macro_check(ctx: Context<'_>) -> bool {
}
}
}
}
true
}

View File

@ -18,12 +18,12 @@ use std::{
env,
error::Error as StdError,
fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool,
};
use chrono_tz::Tz;
use dotenv::dotenv;
use poise::serenity::model::{
use log::{error, warn};
use poise::serenity_prelude::model::{
gateway::GatewayIntents,
id::{GuildId, UserId},
};
@ -31,7 +31,7 @@ use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
consts::THEME_COLOR,
event_handlers::listener,
hooks::all_checks,
@ -43,14 +43,14 @@ type Database = MySql;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
pub struct Data {
database: Pool<Database>,
http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
popular_timezones: Vec<Tz>,
is_loop_running: AtomicBool,
broadcast: Sender<()>,
_broadcast: Sender<()>,
}
impl Debug for Data {
@ -111,13 +111,18 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
moderation_cmds::webhook(),
poise::Command {
subcommands: vec![
moderation_cmds::delete_macro(),
moderation_cmds::finish_macro(),
moderation_cmds::list_macro(),
moderation_cmds::record_macro(),
moderation_cmds::run_macro(),
command_macro::delete::delete_macro(),
command_macro::record::finish_macro(),
command_macro::list::list_macro(),
command_macro::record::record_macro(),
command_macro::run::run_macro(),
command_macro::migrate::migrate_macro(),
],
..moderation_cmds::macro_base()
..command_macro::macro_base()
},
poise::Command {
subcommands: vec![moderation_cmds::default_channel()],
..moderation_cmds::default()
},
reminder_cmds::pause(),
reminder_cmds::offset(),
@ -167,7 +172,12 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
let popular_timezones = sqlx::query!(
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
"SELECT IFNULL(timezone, 'UTC') AS timezone
FROM users
WHERE timezone IS NOT NULL
GROUP BY timezone
ORDER BY COUNT(timezone) DESC
LIMIT 21"
)
.fetch_all(&database)
.await
@ -176,27 +186,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
.map(|t| t.timezone.parse::<Tz>().unwrap())
.collect::<Vec<Tz>>();
poise::Framework::build()
poise::Framework::builder()
.token(discord_token)
.user_data_setup(move |ctx, _bot, framework| {
Box::pin(async move {
register_application_commands(
ctx,
framework,
env::var("DEBUG_GUILD")
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
.ok(),
)
.await
.unwrap();
register_application_commands(ctx, framework, None).await.unwrap();
let kill_tx = tx.clone();
let kill_recv = tx.subscribe();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let pool1 = database.clone();
let pool2 = database.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman");
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web");
}
Ok(Data {
http: reqwest::Client::new(),
database,
popular_timezones,
recording_macros: Default::default(),
is_loop_running: AtomicBool::new(false),
broadcast: tx,
_broadcast: tx,
})
})
})

View File

@ -1,5 +1,5 @@
use chrono::NaiveDateTime;
use poise::serenity::model::channel::Channel;
use poise::serenity_prelude::model::channel::Channel;
use sqlx::MySqlPool;
pub struct ChannelData {
@ -38,7 +38,7 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u
sqlx::query!(
"
INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))
INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, ?)
",
channel_id,
channel_name,

View File

@ -1,7 +1,8 @@
use poise::serenity::model::{
use poise::serenity_prelude::model::{
application::interaction::application_command::CommandDataOption, id::GuildId,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Context, Data, Error};
@ -29,13 +30,20 @@ pub struct CommandMacro<U, E> {
pub commands: Vec<RecordedCommand<U, E>>,
}
pub struct RawCommandMacro {
pub guild_id: GuildId,
pub name: String,
pub description: Option<String>,
pub commands: Value,
}
pub async fn guild_command_macro(
ctx: &Context<'_>,
name: &str,
) -> Option<CommandMacro<Data, Error>> {
let row = sqlx::query!(
"
SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?
SELECT * FROM macro WHERE guild_id = ? AND name = ?
",
ctx.guild_id().unwrap().0,
name

52
src/models/guild_data.rs Normal file
View File

@ -0,0 +1,52 @@
use sqlx::MySqlPool;
use crate::GuildId;
pub struct GuildData {
pub id: u64,
pub default_channel: Option<u64>,
}
impl GuildData {
pub async fn from_guild(guild: GuildId, pool: &MySqlPool) -> Result<Self, sqlx::Error> {
let guild_id = guild.0;
if let Ok(row) = sqlx::query_as_unchecked!(
Self,
"
SELECT id, default_channel FROM guilds WHERE id = ?
",
guild_id
)
.fetch_one(pool)
.await
{
Ok(row)
} else {
sqlx::query!(
"
INSERT IGNORE INTO guilds (id) VALUES (?)
",
guild_id
)
.execute(&pool.clone())
.await?;
Ok(Self { id: guild_id, default_channel: None })
}
}
pub async fn commit_changes(&self, pool: &MySqlPool) -> Result<(), sqlx::Error> {
sqlx::query!(
"
UPDATE guilds SET default_channel = ? WHERE id = ?
",
self.default_channel,
self.id
)
.execute(pool)
.await?;
Ok(())
}
}

View File

@ -1,28 +1,28 @@
pub mod channel_data;
pub mod command_macro;
pub mod guild_data;
pub mod reminder;
pub mod timer;
pub mod user_data;
use chrono_tz::Tz;
use poise::serenity::{async_trait, model::id::UserId};
use log::warn;
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelId};
use crate::{
models::{channel_data::ChannelData, user_data::UserData},
models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
CommandMacro, Context, Data, Error, GuildId,
};
#[async_trait]
pub trait CtxData {
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>;
async fn author_data(&self) -> Result<UserData, Error>;
async fn timezone(&self) -> Tz;
async fn channel_data(&self) -> Result<ChannelData, Error>;
async fn guild_data(&self) -> Option<GuildData>;
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
async fn default_channel(&self) -> Option<ChannelId>;
}
#[async_trait]
@ -51,24 +51,55 @@ impl CtxData for Context<'_> {
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
self.data().command_macros(self.guild_id().unwrap()).await
}
async fn default_channel(&self) -> Option<ChannelId> {
match self.guild_id() {
Some(guild_id) => {
let guild_data = GuildData::from_guild(guild_id, &self.data().database).await;
match guild_data {
Ok(data) => data.default_channel.map(|c| ChannelId(c)),
Err(e) => {
warn!("SQL error: {:?}", e);
None
}
}
}
None => None,
}
}
async fn guild_data(&self) -> Option<GuildData> {
match self.guild_id() {
Some(guild_id) => GuildData::from_guild(guild_id, &self.data().database).await.ok(),
None => None,
}
}
}
impl Data {
pub(crate) async fn command_macros(
pub async fn command_macros(
&self,
guild_id: GuildId,
) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
let rows = sqlx::query!(
"SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"SELECT name, description, commands FROM macro WHERE guild_id = ?",
guild_id.0
)
.fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro {
.await?
.iter()
.map(|row| CommandMacro {
guild_id,
name: row.name.clone(),
description: row.description.clone(),
commands: serde_json::from_str(&row.commands).unwrap(),
}).collect();
})
.collect();
Ok(rows)
}

View File

@ -2,7 +2,7 @@ use std::{collections::HashSet, fmt::Display};
use chrono::{Duration, NaiveDateTime, Utc};
use chrono_tz::Tz;
use poise::serenity::{
use poise::serenity_prelude::{
http::CacheHttp,
model::{
channel::GuildChannel,

View File

@ -1,4 +1,4 @@
use poise::serenity::model::id::ChannelId;
use poise::serenity_prelude::model::id::ChannelId;
use serde::{Deserialize, Serialize};
use serde_repr::*;

View File

@ -8,9 +8,9 @@ use std::hash::{Hash, Hasher};
use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use poise::{
serenity::model::id::{ChannelId, GuildId, UserId},
serenity_prelude::Cache,
use poise::serenity_prelude::{
model::id::{ChannelId, GuildId, UserId},
Cache,
};
use sqlx::Executor;
@ -245,7 +245,7 @@ LEFT JOIN
ON
reminders.set_by = users.id
WHERE
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
channels.guild_id = ?
",
guild_id.as_u64()
)

View File

@ -1,6 +1,6 @@
use chrono_tz::Tz;
use log::error;
use poise::serenity::{http::CacheHttp, model::id::UserId};
use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
use sqlx::MySqlPool;
use crate::consts::LOCAL_TIMEZONE;
@ -22,7 +22,7 @@ impl UserData {
match sqlx::query!(
"
SELECT timezone FROM users WHERE user = ?
SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
",
user_id
)

View File

@ -1,11 +1,11 @@
use poise::{
serenity::{
serenity_prelude as serenity,
serenity_prelude::{
builder::CreateApplicationCommands,
http::CacheHttp,
interaction::MessageFlags,
model::id::{GuildId, UserId},
},
serenity_prelude as serenity,
serenity_prelude::interaction::MessageFlags,
};
use crate::{
@ -14,10 +14,10 @@ use crate::{
};
pub async fn register_application_commands(
ctx: &poise::serenity::client::Context,
ctx: &serenity::Context,
framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>,
) -> Result<(), poise::serenity::Error> {
) -> Result<(), serenity::Error> {
let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands;
for command in commands {
@ -28,7 +28,7 @@ pub async fn register_application_commands(
commands_builder.add_application_command(context_menu_command);
}
}
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0);
let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id {
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;

View File

@ -12,7 +12,7 @@ oauth2 = "4"
log = "0.4"
reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
chrono = "0.4"
chrono-tz = "0.5"
lazy_static = "1.4.0"

View File

@ -61,7 +61,10 @@ pub async fn get_user_info(
.member(&ctx.inner(), user_id)
.await;
let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id)
let timezone = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
user_id
)
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));