Compare commits
19 Commits
jellywx/fi
...
jellywx/gu
Author | SHA1 | Date | |
---|---|---|---|
b0a04bb289 | |||
eef1f6f3e8 | |||
3d08027325 | |||
94bfd39085 | |||
40cd5f8a36 | |||
133b00a2ce | |||
57336f5c81 | |||
b62d24c024 | |||
8f8235a86e | |||
c8f646a8fa | |||
ecaa382a1e | |||
8991198fd3 | |||
f20b95a482 | |||
8dd7dc6409 | |||
c799d10727 | |||
ceb6fb7b12 | |||
6708abdb0f | |||
a38f6024c1 | |||
bb3386c4e8 |
589
Cargo.lock
generated
589
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@ -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]
|
||||
|
92
migration/05-restructure-guild-table.sql
Normal file
92
migration/05-restructure-guild-table.sql
Normal 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;
|
@ -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"] }
|
||||
|
131
src/commands/autocomplete.rs
Normal file
131
src/commands/autocomplete.rs
Normal 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(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
46
src/commands/command_macro/delete.rs
Normal file
46
src/commands/command_macro/delete.rs
Normal 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(())
|
||||
}
|
89
src/commands/command_macro/list.rs
Normal file
89
src/commands/command_macro/list.rs
Normal 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(¯os, 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
|
||||
}
|
229
src/commands/command_macro/migrate.rs
Normal file
229
src/commands/command_macro/migrate.rs
Normal 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,
|
||||
}
|
||||
}
|
19
src/commands/command_macro/mod.rs
Normal file
19
src/commands/command_macro/mod.rs
Normal 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(())
|
||||
}
|
151
src/commands/command_macro/record.rs
Normal file
151
src/commands/command_macro/record.rs
Normal 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(())
|
||||
}
|
46
src/commands/command_macro/run.rs
Normal file
46
src/commands/command_macro/run.rs
Normal 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(())
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
mod autocomplete;
|
||||
pub mod command_macro;
|
||||
pub mod info_cmds;
|
||||
pub mod moderation_cmds;
|
||||
pub mod reminder_cmds;
|
||||
|
@ -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(¯os, 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
|
||||
}
|
||||
|
@ -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)]
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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::*;
|
||||
|
||||
|
@ -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! {
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
81
src/main.rs
81
src/main.rs
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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
52
src/models/guild_data.rs
Normal 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(())
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,4 +1,4 @@
|
||||
use poise::serenity::model::id::ChannelId;
|
||||
use poise::serenity_prelude::model::id::ChannelId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::*;
|
||||
|
||||
|
@ -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()
|
||||
)
|
||||
|
@ -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
|
||||
)
|
||||
|
12
src/utils.rs
12
src/utils.rs
@ -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?;
|
||||
|
@ -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"
|
||||
|
@ -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));
|
||||
|
Reference in New Issue
Block a user