Finish refactoring
This commit is contained in:
Generated
+24
@@ -856,6 +856,15 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.13"
|
||||
@@ -871,6 +880,9 @@ name = "log"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
@@ -989,8 +1001,11 @@ dependencies = [
|
||||
"colored",
|
||||
"dotenvy",
|
||||
"include_dir",
|
||||
"linkify",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"scc",
|
||||
"scylla",
|
||||
"thiserror 2.0.12",
|
||||
@@ -1000,6 +1015,8 @@ dependencies = [
|
||||
"twilight-http",
|
||||
"twilight-model",
|
||||
"twilight-util",
|
||||
"twilight-validate",
|
||||
"unidecode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1331,6 +1348,7 @@ dependencies = [
|
||||
"snap",
|
||||
"stable_deref_trait",
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"yoke",
|
||||
@@ -1842,6 +1860,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
+8
-3
@@ -5,14 +5,17 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.88"
|
||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.42", features = ["derive", "env", "string"] }
|
||||
colored = "3.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
include_dir = "0.7.4"
|
||||
log = "0.4.27"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.27", features = ["serde"] }
|
||||
once_cell = "1.21.3"
|
||||
rand = "0.9.2"
|
||||
rand_chacha = { version = "0.9.0", features = ["os_rng"] }
|
||||
scc = "2.3.4"
|
||||
scylla = "1.3.1"
|
||||
scylla = { version = "1.3.1", features = ["time-03"] }
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = ["formatting", "local-offset", "macros"] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||
@@ -20,3 +23,5 @@ twilight-gateway = "0.16.0"
|
||||
twilight-http = { version = "0.16.0", features = ["simd-json"] }
|
||||
twilight-model = "0.16.0"
|
||||
twilight-util = { version = "0.16.0", features = ["builder"] }
|
||||
twilight-validate = "0.16.0"
|
||||
unidecode = "0.3.0"
|
||||
|
||||
+45
@@ -1,5 +1,50 @@
|
||||
#[derive(clap::Parser)]
|
||||
pub struct Args {
|
||||
/// The Discord bot token to run the bot with.
|
||||
#[arg(env = "PAYPHONE_TOKEN")]
|
||||
pub token: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
value_enum,
|
||||
default_value_t = LevelFilter::Info,
|
||||
env = "PAYPHONE_LOGGING",
|
||||
value_name = "LEVEL",
|
||||
long_help = "The minimum level logs have to be to be outputted into the console, or off for no logs."
|
||||
)]
|
||||
pub logging: LevelFilter,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
env = "PAYPHONE_DATABASE_URL",
|
||||
value_name = "URL",
|
||||
default_value_t = String::from("127.0.0.1:9042"))
|
||||
]
|
||||
pub database_url: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
|
||||
pub enum LevelFilter {
|
||||
Off,
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[allow(clippy::from_over_into)]
|
||||
impl Into<log::LevelFilter> for LevelFilter {
|
||||
fn into(self) -> log::LevelFilter {
|
||||
match self {
|
||||
Self::Off => log::LevelFilter::Off,
|
||||
Self::Trace => log::LevelFilter::Trace,
|
||||
Self::Debug => log::LevelFilter::Debug,
|
||||
Self::Info => log::LevelFilter::Info,
|
||||
Self::Warn => log::LevelFilter::Warn,
|
||||
Self::Error => log::LevelFilter::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::channel::message::Embed;
|
||||
use twilight_util::builder::embed::{
|
||||
EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
commands::{COMMANDS, PREFIX, get_command_by_name},
|
||||
constants::{BOT_AVATAR, BOT_NAME, COLOR_RED, COLOR_WHITE},
|
||||
framework::{
|
||||
commands::{
|
||||
Command, CommandError,
|
||||
arguments::{Argument, NeedyString, ParseArguments},
|
||||
context::CommandContext,
|
||||
},
|
||||
interactions::{InteractionError, components::Button, context::InteractionContext},
|
||||
utils::EscapeDiscordMarkdown,
|
||||
},
|
||||
utils::ToTitleCase,
|
||||
};
|
||||
|
||||
pub struct HelpCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command for HelpCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"help"
|
||||
}
|
||||
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[Argument {
|
||||
name: "command",
|
||||
is_optional: true,
|
||||
is_greedy: false,
|
||||
}]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Lists all commands and shows information about them individually."
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: &CommandContext, args: &str) -> Result<(), CommandError> {
|
||||
let command_name: Option<NeedyString> = ParseArguments::parse(args).unwrap();
|
||||
|
||||
if let Some(command_name) = command_name {
|
||||
if let Some(command) = get_command_by_name(&command_name) {
|
||||
ctx.send()
|
||||
.embed(get_command_help_embed(&command))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to send message! {e}"))
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_command_not_found_embed(command_name.0))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_all_command_help_embed())
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_command_help_embed(command: &Arc<dyn Command>) -> Embed {
|
||||
let name = format!("{PREFIX}{}", command.name());
|
||||
let arguments = command
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let usage = if !arguments.is_empty() {
|
||||
format!("{name} {arguments}")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(BOT_NAME)
|
||||
.icon_url(ImageSource::url(BOT_AVATAR).unwrap())
|
||||
.build(),
|
||||
)
|
||||
.thumbnail(ImageSource::url(BOT_AVATAR).unwrap())
|
||||
.color(COLOR_WHITE)
|
||||
.title(format!("{} Command", command.name().to_title_case()))
|
||||
.description(command.description())
|
||||
.field(EmbedFieldBuilder::new("Usage", format!("`{usage}`")).build())
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"Aliases",
|
||||
if !command.aliases().is_empty() {
|
||||
command
|
||||
.aliases()
|
||||
.iter()
|
||||
.map(|alias| format!("`{PREFIX}{alias}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
} else {
|
||||
String::from("*This command doesn't have aliases.*")
|
||||
},
|
||||
))
|
||||
.footer(EmbedFooterBuilder::new(format!(
|
||||
"Use {PREFIX}help <command> to get help about a specific command."
|
||||
)))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn get_all_command_help_embed() -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(EmbedAuthorBuilder::new(BOT_NAME).icon_url(ImageSource::url(BOT_AVATAR).unwrap()))
|
||||
.title("Pick up the phone and start making friends!")
|
||||
.description(format!("Here is the list of all available commands. You can see more details about a specific command with `{PREFIX}help <command>`."))
|
||||
.thumbnail(ImageSource::url(BOT_AVATAR).unwrap())
|
||||
.color(COLOR_WHITE)
|
||||
.field(EmbedFieldBuilder::new("Commands", get_commands_table()).build())
|
||||
.footer(EmbedFooterBuilder::new(format!("v{} - Use {PREFIX}help <command> to get more information about a specific command.", env!("CARGO_PKG_VERSION"))))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn get_commands_table() -> String {
|
||||
let mut table = String::new();
|
||||
|
||||
for command in COMMANDS.iter() {
|
||||
table.push_str(&format!("{:<12}", command.name()));
|
||||
}
|
||||
|
||||
format!("```\n{table}\n```")
|
||||
}
|
||||
|
||||
fn get_command_not_found_embed(name: String) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.color(COLOR_RED)
|
||||
.author(EmbedAuthorBuilder::new(BOT_NAME).icon_url(ImageSource::url(BOT_AVATAR).unwrap()))
|
||||
.title("That's not a command")
|
||||
.description(format!("`{}` is not a valid command name (nor alias). If you put it with the prefix on, take the prefix off before trying again. For example, if you did `p.help p.call`, do `p.help call` instead.\n\nIf you still can't figure it out, press the button below to go back to the full list. Everything's there!", name.escape()))
|
||||
.thumbnail(ImageSource::url(BOT_AVATAR).unwrap())
|
||||
.build()
|
||||
}
|
||||
|
||||
pub struct HelpGoBackButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button for HelpGoBackButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("← Go back to commands list")
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext) -> Result<(), InteractionError> {
|
||||
ctx
|
||||
.original_message()
|
||||
.expect(
|
||||
"Buttons always have the original message in the interaction context, this one didn't (got None)"
|
||||
)
|
||||
.edit()
|
||||
.embed(get_all_command_help_embed())
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("An error occurred while attempting to edit the original message of an interaction. {e:?}"))
|
||||
.map_err(
|
||||
|_| InteractionError::OriginalMessageEditingFailed
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HelpSupportLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button for HelpSupportLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Get help")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://discord.gg/9daSsrfTmE")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HelpWebsiteLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button for HelpWebsiteLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Website")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://payphone.lat/")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HelpDonateLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button for HelpDonateLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Donate")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://ko-fi.com/payphonedev")
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
mod about;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Arc;
|
||||
use twilight_http::Client;
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate, oauth::Application};
|
||||
|
||||
use crate::framework::{
|
||||
commands::{context::CommandContext, get_command_parts, Command}, handlers::Handlers, interactions::{components::Button, registry::Registry}
|
||||
};
|
||||
|
||||
const PREFIX: &str = "pd.";
|
||||
|
||||
static COMMANDS: Lazy<Vec<Arc<dyn Command>>> = Lazy::new(|| vec![Arc::new(about::HelpCommand)]);
|
||||
|
||||
pub fn get_command_by_name(name: &str) -> Option<Arc<dyn Command>> {
|
||||
for command in COMMANDS.iter() {
|
||||
if command
|
||||
.identifiers()
|
||||
.contains(&name.to_ascii_lowercase().as_str())
|
||||
{
|
||||
return Some(command.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn execute_command(
|
||||
event: MessageCreate,
|
||||
handlers: Arc<Handlers>
|
||||
) {
|
||||
let parts = get_command_parts(PREFIX, &event.content);
|
||||
|
||||
if let Some(parts) = parts {
|
||||
if let Some(command) = get_command_by_name(parts.name) {
|
||||
let _ = command
|
||||
.execute(
|
||||
&CommandContext::new(&event, handlers),
|
||||
parts.args,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to execute command! {e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_default_components(registry: &Registry) {
|
||||
let components = vec![Arc::new(about::HelpGoBackButton)];
|
||||
|
||||
for component in components {
|
||||
registry.register(
|
||||
component.id().expect(
|
||||
"All components registered by default must have an ID, there's one which didn't",
|
||||
),
|
||||
component,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
pub const BOT_NAME: &str = "Payphone";
|
||||
pub const BOT_AVATAR: &str = "https://images-ext-1.discordapp.net/external/BymicERFXlYKd52uNU2ihrbUoSm2e5hV3Ae7XY1436o/%3Fsize%3D1024/https/cdn.discordapp.com/avatars/1212940413795827763/f4b7180bb467b035d6e2e6f458e93b7c.png?format=webp&quality=lossless&width=1058&height=1058";
|
||||
pub const DEFAULT_PREFIX: &str = "p.";
|
||||
|
||||
pub const COLOR_WHITE: u32 = 0xE2D9D0;
|
||||
pub const COLOR_RED: u32 = 0xFF8787;
|
||||
|
||||
@@ -2,7 +2,9 @@ CREATE KEYSPACE IF NOT EXISTS payphone
|
||||
WITH replication = {
|
||||
'class': 'NetworkTopologyStrategy',
|
||||
'replication_factor': 3
|
||||
};
|
||||
} AND TABLETS = {
|
||||
'enabled': false
|
||||
};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.users (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
@@ -15,13 +17,15 @@ CREATE TABLE IF NOT EXISTS payphone.users (
|
||||
|
||||
joined_at TIMESTAMP,
|
||||
last_seen_at TIMESTAMP,
|
||||
is_supporter_since TIMESTAMP,
|
||||
is_supporter_until TIMESTAMP
|
||||
supporter_since TIMESTAMP,
|
||||
supporter_until TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.calls (
|
||||
call_id BIGINT PRIMARY KEY,
|
||||
|
||||
channel_ids LIST<BIGINT>,
|
||||
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP,
|
||||
);
|
||||
@@ -30,22 +34,30 @@ CREATE TABLE IF NOT EXISTS payphone.channels (
|
||||
channel_id BIGINT PRIMARY KEY,
|
||||
server_id BIGINT,
|
||||
|
||||
name TEXT,
|
||||
|
||||
call_id BIGINT,
|
||||
is_searching BOOLEAN,
|
||||
|
||||
recently_called_channel_ids LIST<BIGINT>
|
||||
recently_called_server_ids LIST<BIGINT>,
|
||||
|
||||
webhook_id BIGINT,
|
||||
webhook_token TEXT
|
||||
);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.searching_channels AS
|
||||
SELECT channel_id, server_id, is_searching, recently_called_server_ids FROM payphone.channels
|
||||
WHERE channel_id IS NOT NULL AND is_searching = true
|
||||
PRIMARY KEY (is_searching, channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.participants (
|
||||
call_id BIGINT,
|
||||
user_id BIGINT,
|
||||
channel_id BIGINT,
|
||||
|
||||
user_name TEXT,
|
||||
user_avatar_url TEXT,
|
||||
user_mask TEXT,
|
||||
|
||||
PRIMARY KEY (call_id, user_id)
|
||||
PRIMARY KEY (call_id, user_id, channel_id)
|
||||
);
|
||||
|
||||
-- TTL 30 days.
|
||||
@@ -62,6 +74,9 @@ CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
attachments LIST<TEXT>,
|
||||
sticker BIGINT,
|
||||
|
||||
propagation_message_id BIGINT,
|
||||
propagation_channel_id BIGINT,
|
||||
|
||||
replying_to_id BIGINT,
|
||||
|
||||
is_deleted BOOLEAN,
|
||||
|
||||
+54
-7
@@ -1,9 +1,12 @@
|
||||
pub mod models;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use scylla::{
|
||||
client::{session::Session, session_builder::SessionBuilder},
|
||||
errors::ExecutionError,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseError {
|
||||
@@ -44,22 +47,66 @@ pub async fn init(uri: impl ToString) -> Result<Session, DatabaseError> {
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
.inspect(|_| log::info!("The migrations keyspace was successfully set up"))
|
||||
.inspect_err(|e| log::error!("Could not set up migrations keyspace and table! {e:?}"))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
|
||||
for file in MIGRATIONS.files() {
|
||||
let mut sorted_migration_files = MIGRATIONS.files().collect::<Vec<_>>();
|
||||
sorted_migration_files.sort_by(|a, b| {
|
||||
a.path()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.cmp(&b.path().to_string_lossy().to_string())
|
||||
});
|
||||
|
||||
for file in sorted_migration_files {
|
||||
let migration_name = file.path().to_string_lossy().to_string();
|
||||
|
||||
let row = session
|
||||
.query_unpaged(
|
||||
"SELECT name FROM migrations.applied WHERE name = ? LIMIT 1;",
|
||||
(&migration_name,),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Could not check whether the migration \"{migration_name}\" was applied! {e:?}"
|
||||
)
|
||||
})
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?
|
||||
.into_rows_result()
|
||||
.inspect_err(|e| log::error!("The applied migrations' database result were not rows! {e:?}"))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?
|
||||
.maybe_first_row::<(String,)>()
|
||||
.inspect_err(|e| log::error!("The applied migrations' row returned by the database could not be parsed! {e:?}"))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
|
||||
if row.is_none() {
|
||||
execute_file(
|
||||
&session,
|
||||
file.contents_utf8()
|
||||
.ok_or(DatabaseError::MigrationFileInvalid(
|
||||
file.path().to_string_lossy().to_string(),
|
||||
))?
|
||||
.ok_or(DatabaseError::MigrationFileInvalid(migration_name.clone()))?
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not migrate {}! {e:?}", file.path().to_str().unwrap()))
|
||||
.inspect(|_| log::debug!("Migration {migration_name} applied!"))
|
||||
.inspect_err(|e| {
|
||||
log::error!("Could not migrate {}! {e:?}", file.path().to_str().unwrap())
|
||||
})
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
|
||||
session
|
||||
.query_unpaged(
|
||||
"INSERT INTO migrations.applied (name, applied_at) VALUES (?, ?);",
|
||||
(migration_name.clone(), OffsetDateTime::now_utc()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Could not mark migration {migration_name} as applied in DB! {e:?}")
|
||||
})
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
} else {
|
||||
log::trace!("Skipped migration {migration_name} because it was already applied.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
@@ -85,7 +132,7 @@ async fn execute_file(session: &Session, file: String) -> Result<(), FileExecuti
|
||||
.await
|
||||
.map_err(|error| FileExecutionError {
|
||||
error,
|
||||
statement_index,
|
||||
statement_index: statement_index + 1,
|
||||
})?;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
use scylla::{
|
||||
client::session::Session,
|
||||
deserialize::row::DeserializeRow,
|
||||
errors::{ExecutionError, SingleRowError},
|
||||
statement::{
|
||||
Statement,
|
||||
batch::{Batch, BatchType},
|
||||
},
|
||||
};
|
||||
use thiserror::Error;
|
||||
use time::{OffsetDateTime, UtcDateTime};
|
||||
use twilight_http::Client;
|
||||
use twilight_model::{
|
||||
guild::{Guild as TwilightGuild, GuildInfo as TwilightGuildInfo},
|
||||
id::Id,
|
||||
user::User as TwilightUser,
|
||||
};
|
||||
|
||||
use crate::{constants::DEFAULT_PREFIX, utils::get_random_u64};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseOrDiscordError {
|
||||
#[error("An error occurred when querying the database")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("An error occurred when querying the Discord API")]
|
||||
Discord(#[from] twilight_http::Error),
|
||||
|
||||
#[error("The Discord API returned a response that could not be deserialized")]
|
||||
DiscordDeserializationFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("An error occurred when querying the database")]
|
||||
QueryError(ExecutionError),
|
||||
|
||||
#[error("The database responded but the result of the query weren't rows")]
|
||||
ResultNotRows,
|
||||
|
||||
#[error("The resulting row could not be deserialized into an object")]
|
||||
DeserializationFailed,
|
||||
}
|
||||
|
||||
/// Copies the bytes from [`i64`] to [`u64`].
|
||||
///
|
||||
/// WARNING: This does NOT keep the same "represented" value. This only keeps the bytes intact,
|
||||
/// e.g. 0b0101 will still be 0b0101, but the value represented will change since the type changes.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `i` - The [`i64`] to convert.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`u64`] - The resulting [`u64`].
|
||||
fn i64_to_u64(i: i64) -> u64 {
|
||||
u64::from_ne_bytes(i.to_ne_bytes())
|
||||
}
|
||||
|
||||
/// Copies the bytes from [`u64`] to [`i64`].
|
||||
///
|
||||
/// WARNING: This does NOT keep the same "represented" value. This only keeps the bytes intact,
|
||||
/// e.g. 0b0101 will still be 0b0101, but the value represented will change since the type changes.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `u` - The [`u64`] to convert.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`i64`] - The resulting [`i64`].
|
||||
fn u64_to_i64(u: u64) -> i64 {
|
||||
i64::from_ne_bytes(u.to_ne_bytes())
|
||||
}
|
||||
|
||||
trait Object<'a> {
|
||||
type Row: DeserializeRow<'a, 'a>;
|
||||
|
||||
fn from_row(row: Self::Row) -> Self;
|
||||
fn to_row(value: Self) -> Self::Row;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub is_masked: bool,
|
||||
pub is_moderator: bool,
|
||||
pub joined_at: UtcDateTime,
|
||||
pub last_seen_at: UtcDateTime,
|
||||
pub supporter_since: Option<UtcDateTime>,
|
||||
pub supporter_until: Option<UtcDateTime>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for User {
|
||||
type Row = (
|
||||
i64,
|
||||
String,
|
||||
Option<String>,
|
||||
bool,
|
||||
bool,
|
||||
OffsetDateTime,
|
||||
OffsetDateTime,
|
||||
Option<OffsetDateTime>,
|
||||
Option<OffsetDateTime>,
|
||||
);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
name: row.1,
|
||||
email: row.2,
|
||||
is_masked: row.3,
|
||||
is_moderator: row.4,
|
||||
joined_at: row.5.into(),
|
||||
last_seen_at: row.6.into(),
|
||||
supporter_since: row.7.map(|i| i.into()),
|
||||
supporter_until: row.8.map(|i| i.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
value.name,
|
||||
value.email,
|
||||
value.is_masked,
|
||||
value.is_moderator,
|
||||
value.joined_at.into(),
|
||||
value.last_seen_at.into(),
|
||||
value.supporter_since.map(|i| i.into()),
|
||||
value.supporter_until.map(|i| i.into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
user_id,
|
||||
name,
|
||||
email,
|
||||
is_masked,
|
||||
is_moderator,
|
||||
joined_at,
|
||||
last_seen_at,
|
||||
supporter_since,
|
||||
supporter_until
|
||||
FROM
|
||||
payphone.users
|
||||
WHERE
|
||||
user_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<User as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TwilightUser> for User {
|
||||
fn from(value: TwilightUser) -> Self {
|
||||
Self {
|
||||
id: value.id.get(),
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
is_masked: false,
|
||||
is_moderator: false,
|
||||
joined_at: UtcDateTime::now(),
|
||||
last_seen_at: UtcDateTime::now(),
|
||||
supporter_since: None,
|
||||
supporter_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Server {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for Server {
|
||||
type Row = (i64, String, String);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
name: row.1,
|
||||
prefix: row.2,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(u64_to_i64(value.id), value.name, value.prefix)
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
server_id,
|
||||
name,
|
||||
prefix
|
||||
FROM
|
||||
payphone.servers
|
||||
WHERE
|
||||
server_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<Server as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.servers (
|
||||
server_id,
|
||||
name,
|
||||
prefix
|
||||
) VALUES (?, ?, ?) IF NOT EXISTS;
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("{e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.servers (
|
||||
server_id,
|
||||
name,
|
||||
prefix
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?
|
||||
);
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_by_id(id: u64, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
"DELETE FROM payphone.servers WHERE server_id = ?;",
|
||||
(u64_to_i64(id),),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TwilightGuild> for Server {
|
||||
fn from(value: TwilightGuild) -> Self {
|
||||
Self {
|
||||
id: value.id.get(),
|
||||
name: value.name,
|
||||
prefix: DEFAULT_PREFIX.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TwilightGuildInfo> for Server {
|
||||
fn from(value: TwilightGuildInfo) -> Self {
|
||||
Self {
|
||||
id: value.id.get(),
|
||||
name: value.name,
|
||||
prefix: DEFAULT_PREFIX.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Channel {
|
||||
pub id: u64,
|
||||
pub server_id: u64,
|
||||
pub call_id: Option<u64>,
|
||||
pub is_searching: bool,
|
||||
pub recently_called_server_ids: Vec<u64>,
|
||||
pub webhook_id: Option<u64>,
|
||||
pub webhook_token: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for Channel {
|
||||
type Row = (
|
||||
i64,
|
||||
i64,
|
||||
Option<i64>,
|
||||
bool,
|
||||
Vec<i64>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
server_id: i64_to_u64(row.1),
|
||||
call_id: row.2.map(i64_to_u64),
|
||||
is_searching: row.3,
|
||||
recently_called_server_ids: row.4.iter().map(|&i| i64_to_u64(i)).collect(),
|
||||
webhook_id: row.5.map(i64_to_u64),
|
||||
webhook_token: row.6,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
u64_to_i64(value.server_id),
|
||||
value.call_id.map(u64_to_i64),
|
||||
value.is_searching,
|
||||
value
|
||||
.recently_called_server_ids
|
||||
.iter()
|
||||
.map(|&u| u64_to_i64(u))
|
||||
.collect(),
|
||||
value.webhook_id.map(u64_to_i64),
|
||||
value.webhook_token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
channel_id,
|
||||
server_id,
|
||||
call_id,
|
||||
is_searching,
|
||||
recently_called_server_ids,
|
||||
webhook_id,
|
||||
webhook_token
|
||||
FROM
|
||||
payphone.channels
|
||||
WHERE
|
||||
channel_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<Channel as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_by_id(
|
||||
id: u64,
|
||||
session: &Session,
|
||||
http: &Client,
|
||||
) -> Result<Self, DatabaseOrDiscordError> {
|
||||
if let Some(channel) = Channel::get_by_id(id, session)
|
||||
.await
|
||||
.map_err(DatabaseOrDiscordError::Database)?
|
||||
{
|
||||
Ok(channel)
|
||||
} else {
|
||||
let webhook = http
|
||||
.create_webhook(Id::new(id), "Payphone Messages")
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to create webhook for channel {id}: {e:?}"))
|
||||
.map_err(DatabaseOrDiscordError::Discord)?
|
||||
.model()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get model for webhook {id}: {e:?}"))
|
||||
.map_err(|_| DatabaseOrDiscordError::DiscordDeserializationFailed)?;
|
||||
|
||||
let response = http
|
||||
.channel(Id::new(id))
|
||||
.await
|
||||
.map_err(DatabaseOrDiscordError::Discord)?
|
||||
.model()
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("The discord API didn't return a parseable channel! {e:?}")
|
||||
})
|
||||
.map_err(|_| DatabaseOrDiscordError::DiscordDeserializationFailed)?;
|
||||
|
||||
let channel = Channel {
|
||||
id: response.id.get(),
|
||||
server_id: response.guild_id.map(|i| i.get()).unwrap_or(0),
|
||||
call_id: None,
|
||||
is_searching: false,
|
||||
recently_called_server_ids: vec![],
|
||||
webhook_id: Some(webhook.id.get()),
|
||||
webhook_token: webhook.token,
|
||||
};
|
||||
channel
|
||||
.upsert(session)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not upsert channel in database! {e:?}"))
|
||||
.map_err(DatabaseOrDiscordError::Database)?;
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.channels (
|
||||
channel_id,
|
||||
server_id,
|
||||
call_id,
|
||||
is_searching,
|
||||
recently_called_server_ids,
|
||||
webhook_id,
|
||||
webhook_token
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel_search(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
"UPDATE payphone.channels SET is_searching = false WHERE channel_id = ?;",
|
||||
(u64_to_i64(self.id),),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchingChannel {
|
||||
pub id: u64,
|
||||
pub server_id: u64,
|
||||
pub recently_called_server_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for SearchingChannel {
|
||||
type Row = (i64, i64, Vec<i64>);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
server_id: i64_to_u64(row.1),
|
||||
recently_called_server_ids: row.2.iter().map(|&i| i64_to_u64(i)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
u64_to_i64(value.server_id),
|
||||
value
|
||||
.recently_called_server_ids
|
||||
.iter()
|
||||
.map(|&u| u64_to_i64(u))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchingChannel {
|
||||
pub async fn get_all(session: &Session) -> Result<Vec<Self>, DatabaseError> {
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
channel_id,
|
||||
server_id,
|
||||
recently_called_server_ids
|
||||
FROM
|
||||
payphone.searching_channels;
|
||||
"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
Ok(result
|
||||
.rows::<<SearchingChannel as Object>::Row>()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| DatabaseError::DeserializationFailed)?
|
||||
.into_iter()
|
||||
.map(Self::from_row)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Call {
|
||||
pub id: u64,
|
||||
pub channel_ids: Vec<u64>,
|
||||
pub started_at: UtcDateTime,
|
||||
pub finished_at: Option<UtcDateTime>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for Call {
|
||||
type Row = (i64, Vec<i64>, OffsetDateTime, Option<OffsetDateTime>);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
channel_ids: row.1.iter().map(|&i| i64_to_u64(i)).collect(),
|
||||
started_at: row.2.into(),
|
||||
finished_at: row.3.map(|i| i.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
value.channel_ids.iter().map(|&u| u64_to_i64(u)).collect(),
|
||||
value.started_at.into(),
|
||||
value.finished_at.map(|i| i.into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_and_cap<T>(vec: &mut Vec<T>, value: T, max_size: usize) {
|
||||
if vec.len() >= max_size {
|
||||
vec.remove(0);
|
||||
}
|
||||
|
||||
vec.push(value);
|
||||
}
|
||||
|
||||
impl Call {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
call_id,
|
||||
channel_ids,
|
||||
started_at,
|
||||
finished_at
|
||||
FROM
|
||||
payphone.calls
|
||||
WHERE
|
||||
call_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<Call as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the opposite party's channel ID.
|
||||
pub fn other_party_channel_id(&self, channel_id: u64) -> u64 {
|
||||
if self.channel_ids[0] == channel_id {
|
||||
self.channel_ids[1]
|
||||
} else {
|
||||
self.channel_ids[0]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.finished_at.is_none()
|
||||
}
|
||||
|
||||
/// Starts a call, or marks the channel as searching if the call is not started.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `channel` - The channel to start the call in.
|
||||
/// * `session` - The Scylla session to use for database operations.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Result<Option<Self>, DatabaseError>`] - `Ok(Some(Call))` if the call was started
|
||||
/// successfully, or `Ok(None)` if the channel was marked as searching. If an error occurs, it
|
||||
/// returns `Err(DatabaseError)`.
|
||||
pub async fn start(
|
||||
channel: &Channel,
|
||||
session: &Session,
|
||||
) -> Result<Option<Self>, DatabaseError> {
|
||||
let searching_channels = SearchingChannel::get_all(session).await?;
|
||||
|
||||
let call_id = get_random_u64();
|
||||
|
||||
log::trace!("Call ID is {call_id}");
|
||||
|
||||
for searching_channel in searching_channels
|
||||
.iter()
|
||||
.filter(|sc| !sc.recently_called_server_ids.contains(&channel.server_id))
|
||||
{
|
||||
if searching_channel.server_id == channel.server_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if channel
|
||||
.recently_called_server_ids
|
||||
.contains(&searching_channel.server_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
log::trace!("Found a searching channel: {searching_channel:?}");
|
||||
|
||||
let mut searching_channel_recently_called_server_ids = searching_channel
|
||||
.recently_called_server_ids
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(u64_to_i64)
|
||||
.collect::<Vec<_>>();
|
||||
push_and_cap(
|
||||
&mut searching_channel_recently_called_server_ids,
|
||||
u64_to_i64(channel.server_id),
|
||||
5,
|
||||
);
|
||||
|
||||
let (is_applied, ..) = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
UPDATE payphone.channels
|
||||
SET
|
||||
is_searching = false,
|
||||
call_id = ?,
|
||||
recently_called_server_ids = ?
|
||||
WHERE
|
||||
channel_id = ?
|
||||
IF call_id = NULL;
|
||||
"#,
|
||||
(
|
||||
u64_to_i64(call_id),
|
||||
searching_channel_recently_called_server_ids,
|
||||
u64_to_i64(searching_channel.id),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to add channel to the call! {e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.inspect_err(|e| log::error!("Failed to get result of the query! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?
|
||||
.first_row::<(bool, Option<i64>)>()
|
||||
.inspect_err(|e| log::error!("Failed to get first row of the result! {e:?}"))
|
||||
.map_err(|_| DatabaseError::DeserializationFailed)?;
|
||||
|
||||
log::trace!("Is applied: {is_applied:?}");
|
||||
|
||||
if is_applied {
|
||||
let mut channel_recently_called_server_ids = channel
|
||||
.recently_called_server_ids
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(u64_to_i64)
|
||||
.collect::<Vec<_>>();
|
||||
push_and_cap(
|
||||
&mut channel_recently_called_server_ids,
|
||||
u64_to_i64(searching_channel.server_id),
|
||||
5,
|
||||
);
|
||||
|
||||
session
|
||||
.query_unpaged(
|
||||
"UPDATE payphone.channels SET call_id = ?, recently_called_server_ids = ? WHERE channel_id = ?",
|
||||
(u64_to_i64(call_id), channel_recently_called_server_ids, u64_to_i64(channel.id)),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to update channel {e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
let call = Call {
|
||||
id: call_id,
|
||||
channel_ids: vec![searching_channel.id, channel.id].into_iter().collect(),
|
||||
started_at: UtcDateTime::now(),
|
||||
finished_at: None,
|
||||
};
|
||||
call.insert(session)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to insert call {e:?}"))?;
|
||||
|
||||
return Ok(Some(call));
|
||||
} else {
|
||||
// This channel already got a call, so we skip it, preventing race conditions.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
session.query_unpaged(
|
||||
"UPDATE payphone.channels SET is_searching = true WHERE channel_id = ? IF call_id = NULL;",
|
||||
(u64_to_i64(channel.id),),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to mark channel as searching! {e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.calls (
|
||||
call_id,
|
||||
channel_ids,
|
||||
started_at,
|
||||
finished_at
|
||||
) VALUES (?, ?, ?, ?);
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hangup(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
let mut batch = Batch::new(BatchType::Unlogged);
|
||||
|
||||
batch.append_statement(Statement::new(
|
||||
"UPDATE payphone.calls SET finished_at = ? WHERE call_id = ?;",
|
||||
));
|
||||
batch.append_statement(Statement::new(
|
||||
"UPDATE payphone.channels SET call_id = NULL WHERE channel_id = ?;",
|
||||
));
|
||||
batch.append_statement(Statement::new(
|
||||
"UPDATE payphone.channels SET call_id = NULL WHERE channel_id = ?;",
|
||||
));
|
||||
|
||||
let call_id = u64_to_i64(self.id);
|
||||
|
||||
session
|
||||
.batch(
|
||||
&batch,
|
||||
(
|
||||
(OffsetDateTime::now_utc(), call_id),
|
||||
(u64_to_i64(self.channel_ids[0]),),
|
||||
(u64_to_i64(self.channel_ids[1]),),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
log::trace!("Hung up call {call_id}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use twilight_model::gateway::payload::incoming::Ready;
|
||||
|
||||
pub async fn handle_on_ready(event: Ready) {
|
||||
log::info!(
|
||||
"Ready! Logged in as {}#{:>04}",
|
||||
event.user.name,
|
||||
event.user.discriminator
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn handle_on_reconnect() {
|
||||
log::info!("The gateway asked for a reconnection");
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::gateway::payload::incoming::InteractionCreate;
|
||||
|
||||
use crate::framework::handlers::Handlers;
|
||||
|
||||
pub async fn handle_on_interaction_create(event: InteractionCreate, handlers: Arc<Handlers>) {
|
||||
if let Err(e) = handlers.registry.execute(event, handlers.clone()).await {
|
||||
log::error!("An error occurred while processing the interaction! {e:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate};
|
||||
|
||||
use crate::{commands, framework::{handlers::Handlers}};
|
||||
|
||||
pub fn handle_on_message_create(
|
||||
event: MessageCreate,
|
||||
handlers: Arc<Handlers>
|
||||
) -> impl Future<Output = ()> {
|
||||
commands::execute_command(event, handlers)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_gateway::Event;
|
||||
|
||||
use crate::framework::handlers::Handlers;
|
||||
|
||||
mod core;
|
||||
mod interactions;
|
||||
mod messages;
|
||||
|
||||
pub async fn handle_event(event: Event, handlers: Arc<Handlers>) {
|
||||
log::trace!("{event:?}");
|
||||
|
||||
match event {
|
||||
Event::Ready(event) => core::handle_on_ready(*event).await,
|
||||
Event::GatewayReconnect => core::handle_on_reconnect().await,
|
||||
Event::MessageCreate(event) => {
|
||||
messages::handle_on_message_create(*event, handlers).await
|
||||
}
|
||||
Event::InteractionCreate(event) => {
|
||||
interactions::handle_on_interaction_create(*event, handlers).await
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use twilight_model::util::ImageHash;
|
||||
|
||||
pub fn get_avatar_url(hash: Option<ImageHash>, user_id: u64, discriminator: u64) -> String {
|
||||
if let Some(hash) = hash {
|
||||
format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256")
|
||||
} else {
|
||||
format!(
|
||||
"https://cdn.discordapp.com/embed/avatars/{}.png?size=256",
|
||||
match discriminator.to_string().as_str() {
|
||||
"0" => (user_id >> 22) % 6,
|
||||
_ => discriminator % 6,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
pub mod events;
|
||||
pub mod images;
|
||||
pub mod sanitizing;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
pub trait SanitizeDiscordMarkdown {
|
||||
fn sanitize(&self, hard: bool) -> String;
|
||||
}
|
||||
|
||||
impl<T> SanitizeDiscordMarkdown for T
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
fn sanitize(&self, hard: bool) -> String {
|
||||
let input = self.to_string();
|
||||
|
||||
let escaped = if hard {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\\' | '*' | '_' | '~' | '`' | '>' | '<' | '|' | '#' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
escaped
|
||||
} else {
|
||||
input
|
||||
};
|
||||
|
||||
// Now block ping attempts — break mentions
|
||||
escaped
|
||||
.replace("@everyone", "@\u{200B}everyone")
|
||||
.replace("@here", "@\u{200B}here")
|
||||
.replace("<@", "<@\u{200B}") // user mentions
|
||||
.replace("<@&", "<@&\u{200B}") // role mentions
|
||||
.replace("<#", "<#\u{200B}") // channel mentions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt};
|
||||
use twilight_http::Client;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
commands::{Command, runner::CommandRunner},
|
||||
events::traits::{
|
||||
OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
|
||||
OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler,
|
||||
OnServerUpdateHandler,
|
||||
},
|
||||
interactions::{components::Component, registry::ComponentRegistry},
|
||||
runtime::Runtime,
|
||||
},
|
||||
utils::Bye,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RuntimeError {
|
||||
#[error("The application's details could not be fetched")]
|
||||
ApplicationFetchingFailed,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait IntoPrefixes<T> {
|
||||
async fn get(
|
||||
&self,
|
||||
runtime: &Runtime<T>,
|
||||
server_id: Option<u64>,
|
||||
user_id: Option<u64>,
|
||||
) -> Vec<String>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> IntoPrefixes<T> for String {
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> IntoPrefixes<T> for &str {
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Send + Sync + 'static, const N: usize, S: Send + Sync + 'static> IntoPrefixes<S> for [T; N]
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<S>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
self.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<F, Fut, T> IntoPrefixes<T> for F
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Vec<String>> + Send,
|
||||
{
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
(self)().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Bot<T: Send + Sync = ()> {
|
||||
state: Arc<T>,
|
||||
prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync>,
|
||||
intents: Intents,
|
||||
commands: Vec<Arc<dyn Command<T>>>,
|
||||
components: Vec<Arc<dyn Component<T>>>,
|
||||
on_ready_handlers: Vec<Arc<dyn OnReadyHandler<T>>>,
|
||||
on_reconnect_handlers: Vec<Arc<dyn OnReconnectHandler<T>>>,
|
||||
on_interaction_handlers: Vec<Arc<dyn OnInteractionHandler<T>>>,
|
||||
on_message_handlers: Vec<Arc<dyn OnMessageHandler<T>>>,
|
||||
on_message_edit_handlers: Vec<Arc<dyn OnMessageEditHandler<T>>>,
|
||||
on_message_delete_handlers: Vec<Arc<dyn OnMessageDeleteHandler<T>>>,
|
||||
on_server_create_handlers: Vec<Arc<dyn OnServerCreateHandler<T>>>,
|
||||
on_server_update_handlers: Vec<Arc<dyn OnServerUpdateHandler<T>>>,
|
||||
on_server_delete_handlers: Vec<Arc<dyn OnServerDeleteHandler<T>>>,
|
||||
}
|
||||
|
||||
impl<T: 'static + Send + Sync> Bot<T> {
|
||||
pub fn new(state: T, prefixes: impl IntoPrefixes<T> + Send + Sync + 'static) -> Self {
|
||||
Self {
|
||||
state: Arc::new(state),
|
||||
prefixes: Arc::new(prefixes),
|
||||
intents: Intents::empty(),
|
||||
commands: Vec::new(),
|
||||
components: Vec::new(),
|
||||
on_ready_handlers: Vec::new(),
|
||||
on_reconnect_handlers: Vec::new(),
|
||||
on_interaction_handlers: Vec::new(),
|
||||
on_message_handlers: vec![Arc::new(CommandRunner)],
|
||||
on_message_edit_handlers: Vec::new(),
|
||||
on_message_delete_handlers: Vec::new(),
|
||||
on_server_create_handlers: Vec::new(),
|
||||
on_server_update_handlers: Vec::new(),
|
||||
on_server_delete_handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intent(mut self, intent: Intents) -> Self {
|
||||
self.intents.insert(intent);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn command(mut self, command: impl Command<T> + 'static) -> Self {
|
||||
self.commands.push(Arc::new(command));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static) -> Self {
|
||||
self.components.push(Arc::new(component));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ready(mut self, handler: impl OnReadyHandler<T> + 'static) -> Self {
|
||||
self.on_ready_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_reconnect(mut self, handler: impl OnReconnectHandler<T> + 'static) -> Self {
|
||||
self.on_reconnect_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_interaction(mut self, handler: impl OnInteractionHandler<T> + 'static) -> Self {
|
||||
self.on_interaction_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message(mut self, handler: impl OnMessageHandler<T> + 'static) -> Self {
|
||||
self.on_message_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler<T> + 'static) -> Self {
|
||||
self.on_message_edit_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message_delete(mut self, handler: impl OnMessageDeleteHandler<T> + 'static) -> Self {
|
||||
self.on_message_delete_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_create(mut self, handler: impl OnServerCreateHandler<T> + 'static) -> Self {
|
||||
self.on_server_create_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_update(mut self, handler: impl OnServerUpdateHandler<T> + 'static) -> Self {
|
||||
self.on_server_update_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_delete(mut self, handler: impl OnServerDeleteHandler<T> + 'static) -> Self {
|
||||
self.on_server_delete_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(self, token: &str) -> Result<(), RuntimeError> {
|
||||
let http = Client::new(token.to_string());
|
||||
let app = http
|
||||
.current_user_application()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not fetch application data! {e:?}"))
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)?
|
||||
.model()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not parse application data! {e:?}"))
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)?;
|
||||
let registry = ComponentRegistry::new();
|
||||
|
||||
for component in &self.components {
|
||||
let component_id = component.custom_id();
|
||||
|
||||
registry.register(
|
||||
component_id.expect("The component registered by default must have a custom ID"),
|
||||
component.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let bot = Arc::new(self);
|
||||
|
||||
let runtime = Arc::new(Runtime {
|
||||
http,
|
||||
app,
|
||||
registry,
|
||||
state: bot.state.clone(),
|
||||
prefixes: bot.prefixes.clone(),
|
||||
commands: bot.commands.clone(),
|
||||
});
|
||||
|
||||
let mut gateway = Shard::new(ShardId::new(0, 1), token.to_string(), bot.intents);
|
||||
|
||||
loop {
|
||||
if let Some(Ok(event)) = gateway.next_event(EventTypeFlags::all()).await {
|
||||
let task_rt = runtime.clone();
|
||||
let task_bot = bot.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match event {
|
||||
Event::Ready(event) => {
|
||||
for handler in &task_bot.on_ready_handlers {
|
||||
handler
|
||||
.on_ready(&event, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on ready for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GatewayReconnect => {
|
||||
for handler in &task_bot.on_reconnect_handlers {
|
||||
handler
|
||||
.on_reconnect(task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on reconnect for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::InteractionCreate(interaction) => {
|
||||
for handler in &task_bot.on_interaction_handlers {
|
||||
handler
|
||||
.on_interaction(&interaction, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageCreate(message) => {
|
||||
for handler in &task_bot.on_message_handlers {
|
||||
log::trace!("Running {handler:?}");
|
||||
|
||||
handler
|
||||
.on_message(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageUpdate(message) => {
|
||||
for handler in &task_bot.on_message_edit_handlers {
|
||||
handler
|
||||
.on_message_edit(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message edit for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageDelete(message) => {
|
||||
for handler in &task_bot.on_message_delete_handlers {
|
||||
handler
|
||||
.on_message_delete(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildCreate(guild) => {
|
||||
for handler in &task_bot.on_server_create_handlers {
|
||||
handler
|
||||
.on_server_create(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server create for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildDelete(guild) => {
|
||||
for handler in &task_bot.on_server_delete_handlers {
|
||||
handler
|
||||
.on_server_delete(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::trace!("Unhandled event {:?} received", event.kind());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{fmt, ops::{Deref, DerefMut}};
|
||||
use std::{
|
||||
fmt,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::Client;
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate, oauth::Application};
|
||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||
|
||||
use crate::framework::{commands::messages::MessageSender, handlers::Handlers, interactions::registry::Registry};
|
||||
use crate::framework::{commands::messages::MessageSender, runtime::Runtime};
|
||||
|
||||
pub struct CommandContext<'a> {
|
||||
pub struct CommandContext<'a, T> {
|
||||
pub event: &'a MessageCreate,
|
||||
pub handlers: Arc<Handlers>
|
||||
pub rt: Arc<Runtime<T>>,
|
||||
pub prefix: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> CommandContext<'a> {
|
||||
pub fn new(
|
||||
event: &'a MessageCreate,
|
||||
handlers: Arc<Handlers>,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
handlers
|
||||
}
|
||||
impl<'a, T> CommandContext<'a, T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(event: &'a MessageCreate, rt: Arc<Runtime<T>>, prefix: &'a str) -> Self {
|
||||
Self { event, rt, prefix }
|
||||
}
|
||||
|
||||
pub fn send(&self) -> MessageSender {
|
||||
MessageSender::new(
|
||||
self.event.channel_id,
|
||||
self.handlers.clone()
|
||||
)
|
||||
pub fn send(&self) -> MessageSender<T> {
|
||||
MessageSender::new(self.event.channel_id, self.rt.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,27 +19,30 @@ use twilight_model::{
|
||||
use twilight_util::builder::InteractionResponseDataBuilder;
|
||||
|
||||
use crate::framework::{
|
||||
handlers::Handlers,
|
||||
interactions::components::{ActionRow, Component},
|
||||
runtime::Runtime,
|
||||
};
|
||||
|
||||
pub struct MessageSender {
|
||||
pub struct MessageSender<T> {
|
||||
channel_id: Id<ChannelMarker>,
|
||||
|
||||
handlers: Arc<Handlers>,
|
||||
rt: Arc<Runtime<T>>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component>)>,
|
||||
components: Vec<(u8, Arc<dyn Component<T>>)>,
|
||||
}
|
||||
|
||||
impl MessageSender {
|
||||
pub fn new(channel_id: Id<ChannelMarker>, handlers: Arc<Handlers>) -> Self {
|
||||
impl<T> MessageSender<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(channel_id: Id<ChannelMarker>, rt: Arc<Runtime<T>>) -> Self {
|
||||
Self {
|
||||
channel_id,
|
||||
handlers,
|
||||
rt,
|
||||
content: None,
|
||||
components: vec![],
|
||||
embeds: vec![],
|
||||
@@ -58,14 +61,14 @@ impl MessageSender {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self {
|
||||
pub fn component(mut self, component: impl Component<T> + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<Response<Message>, Error> {
|
||||
let mut builder = self.handlers.http.create_message(self.channel_id);
|
||||
let mut builder = self.rt.http.create_message(self.channel_id);
|
||||
|
||||
if let Some(content) = &self.content {
|
||||
builder = builder.content(content);
|
||||
@@ -92,9 +95,7 @@ impl MessageSender {
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.handlers
|
||||
.registry
|
||||
.register(custom_id, component.clone());
|
||||
self.rt.registry.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
@@ -111,7 +112,7 @@ impl MessageSender {
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.handlers.registry.unregister(custom_id);
|
||||
self.rt.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ impl MessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoFuture for MessageSender {
|
||||
impl<T: Send + Sync + 'static> IntoFuture for MessageSender<T> {
|
||||
type Output = Result<Response<Message>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
@@ -128,29 +129,32 @@ impl IntoFuture for MessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessageUpdater {
|
||||
pub struct OriginalMessageUpdater<T> {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
|
||||
handlers: Arc<Handlers>,
|
||||
rt: Arc<Runtime<T>>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component>)>,
|
||||
components: Vec<(u8, Arc<dyn Component<T>>)>,
|
||||
}
|
||||
|
||||
impl OriginalMessageUpdater {
|
||||
impl<T> OriginalMessageUpdater<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
handlers: Arc<Handlers>,
|
||||
rt: Arc<Runtime<T>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interaction_id,
|
||||
interaction_token,
|
||||
handlers,
|
||||
rt,
|
||||
content: None,
|
||||
components: vec![],
|
||||
embeds: vec![],
|
||||
@@ -169,7 +173,7 @@ impl OriginalMessageUpdater {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self {
|
||||
pub fn component(mut self, component: impl Component<T> + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
@@ -203,9 +207,7 @@ impl OriginalMessageUpdater {
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.handlers
|
||||
.registry
|
||||
.register(custom_id, component.clone());
|
||||
self.rt.registry.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
@@ -219,9 +221,9 @@ impl OriginalMessageUpdater {
|
||||
builder = builder.embeds(self.embeds);
|
||||
|
||||
let result = self
|
||||
.handlers
|
||||
.rt
|
||||
.http
|
||||
.interaction(self.handlers.app.id)
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.interaction_id,
|
||||
&self.interaction_token,
|
||||
@@ -234,7 +236,7 @@ impl OriginalMessageUpdater {
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.handlers.registry.unregister(custom_id);
|
||||
self.rt.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +244,7 @@ impl OriginalMessageUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoFuture for OriginalMessageUpdater {
|
||||
impl<T: Send + Sync + 'static> IntoFuture for OriginalMessageUpdater<T> {
|
||||
type Output = Result<Response<EmptyBody>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -6,15 +8,22 @@ use crate::framework::commands::{arguments::Argument, context::CommandContext};
|
||||
pub mod arguments;
|
||||
pub mod context;
|
||||
pub mod messages;
|
||||
pub mod runner;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CommandError {
|
||||
#[error("A message was attempted to be sent but it failed")]
|
||||
MessageSendingFailed,
|
||||
|
||||
#[error("A database query failed")]
|
||||
DatabaseQueryFailed,
|
||||
|
||||
#[error("A database operation failed due to an inconsistent state")]
|
||||
DatabaseInconsistentState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Command: Send + Sync {
|
||||
pub trait Command<T>: Send + Sync + Debug {
|
||||
fn name(&self) -> &'static str;
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
@@ -36,7 +45,7 @@ pub trait Command: Send + Sync {
|
||||
all
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: &CommandContext, args: &str) -> Result<(), CommandError>;
|
||||
async fn execute(&self, ctx: CommandContext<'_, T>, args: &str) -> Result<(), CommandError>;
|
||||
}
|
||||
|
||||
pub struct CommandParts<'a> {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||
|
||||
use crate::framework::{
|
||||
commands::{context::CommandContext, get_command_parts},
|
||||
events::traits::{EventResult, OnMessageHandler},
|
||||
runtime::Runtime,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommandRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Send + Sync + 'static> OnMessageHandler<T> for CommandRunner {
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<T>>) -> EventResult {
|
||||
if event.author.bot || event.author.system.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prefixes = rt
|
||||
.get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get()))
|
||||
.await;
|
||||
|
||||
for prefix in prefixes {
|
||||
if let Some(parts) = get_command_parts(&prefix, &event.content) {
|
||||
let command = rt.commands.iter().find(|cmd| {
|
||||
cmd.identifiers()
|
||||
.contains(&parts.name.to_ascii_lowercase().as_str())
|
||||
});
|
||||
|
||||
if let Some(command) = command {
|
||||
command
|
||||
.execute(CommandContext::new(event, rt.clone(), &prefix), parts.args)
|
||||
.await?;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod traits;
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{error::Error, fmt::Debug, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::{
|
||||
GuildCreate, GuildDelete, GuildUpdate, InteractionCreate, MessageCreate, MessageDelete,
|
||||
MessageUpdate, Ready,
|
||||
};
|
||||
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub type EventResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnReadyHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_ready(&self, event: &Ready, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnReconnectHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_reconnect(&self, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnInteractionHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_interaction(&self, event: &InteractionCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageEditHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageDeleteHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_message_delete(&self, event: &MessageDelete, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerCreateHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_server_create(&self, event: &GuildCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerUpdateHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_server_update(&self, event: &GuildUpdate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerDeleteHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_server_delete(&self, event: &GuildDelete, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use scylla::client::session::Session;
|
||||
use twilight_http::Client;
|
||||
use twilight_model::oauth::Application;
|
||||
|
||||
use crate::framework::interactions::registry::Registry;
|
||||
|
||||
pub struct Handlers {
|
||||
pub http: Client,
|
||||
pub app: Application,
|
||||
pub db: Session,
|
||||
pub registry: Registry,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::channel::message::{
|
||||
@@ -8,18 +8,20 @@ use twilight_model::channel::message::{
|
||||
|
||||
use crate::framework::interactions::{InteractionError, context::InteractionContext};
|
||||
|
||||
pub struct ActionRow {
|
||||
components: Vec<(Option<String>, Arc<dyn Component>)>,
|
||||
type ComponentWithID<T> = (Option<String>, Arc<dyn Component<T>>);
|
||||
|
||||
pub struct ActionRow<T> {
|
||||
components: Vec<ComponentWithID<T>>,
|
||||
}
|
||||
|
||||
impl ActionRow {
|
||||
pub fn new(components: &[(Option<String>, Arc<dyn Component>)]) -> Self {
|
||||
impl<T> ActionRow<T> {
|
||||
pub fn new(components: &[ComponentWithID<T>]) -> Self {
|
||||
Self {
|
||||
components: components.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, id: Option<String>, component: Arc<dyn Component>) {
|
||||
pub fn add(&mut self, id: Option<String>, component: Arc<dyn Component<T>>) {
|
||||
self.components.push((id, component));
|
||||
}
|
||||
|
||||
@@ -27,6 +29,10 @@ impl ActionRow {
|
||||
self.components.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.components.is_empty()
|
||||
}
|
||||
|
||||
pub fn build(self) -> TwilightComponent {
|
||||
TwilightComponent::ActionRow(TwilightActionRow {
|
||||
components: self
|
||||
@@ -39,19 +45,19 @@ impl ActionRow {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Component: Send + Sync {
|
||||
pub trait Component<T>: Send + Sync + Debug {
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Some(String::from("component"))
|
||||
}
|
||||
fn build(&self, id: Option<String>) -> TwilightComponent;
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError>;
|
||||
async fn execute(&self, ctx: InteractionContext<T>) -> Result<(), InteractionError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> Component for T
|
||||
impl<T, S: Send + Sync + 'static> Component<S> for T
|
||||
where
|
||||
T: Button,
|
||||
T: Button<S>,
|
||||
{
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Button::id(self)
|
||||
@@ -73,13 +79,13 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError> {
|
||||
async fn execute(&self, ctx: InteractionContext<S>) -> Result<(), InteractionError> {
|
||||
self.on_press(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Button: Send + Sync {
|
||||
pub trait Button<S: Send + Sync + 'static>: Send + Sync + Debug {
|
||||
fn id(&self) -> Option<String> {
|
||||
if self.url().is_none() {
|
||||
Some(String::from("Button"))
|
||||
@@ -97,7 +103,7 @@ pub trait Button: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext) -> Result<(), InteractionError> {
|
||||
async fn on_press(&self, ctx: InteractionContext<S>) -> Result<(), InteractionError> {
|
||||
ctx.defer().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::Client;
|
||||
use twilight_model::{
|
||||
gateway::payload::incoming::InteractionCreate,
|
||||
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||
id::{Id, marker::InteractionMarker},
|
||||
oauth::Application,
|
||||
};
|
||||
|
||||
use crate::framework::{
|
||||
commands::messages::OriginalMessageUpdater, handlers::Handlers, interactions::{registry::Registry, InteractionError}
|
||||
commands::messages::OriginalMessageUpdater, interactions::InteractionError, runtime::Runtime,
|
||||
};
|
||||
|
||||
pub struct InteractionContext {
|
||||
pub struct InteractionContext<T> {
|
||||
pub event: InteractionCreate,
|
||||
pub handlers: Arc<Handlers>,
|
||||
pub rt: Arc<Runtime<T>>,
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
impl InteractionContext {
|
||||
pub fn new(
|
||||
event: InteractionCreate,
|
||||
handlers: Arc<Handlers>,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
handlers
|
||||
}
|
||||
impl<T> InteractionContext<T> {
|
||||
pub fn new(event: InteractionCreate, rt: Arc<Runtime<T>>, prefix: String) -> Self {
|
||||
Self { event, rt, prefix }
|
||||
}
|
||||
|
||||
pub async fn defer(&self) -> Result<(), InteractionError> {
|
||||
self.handlers.http
|
||||
.interaction(self.handlers.app.id)
|
||||
self.rt
|
||||
.http
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
@@ -46,8 +40,9 @@ impl InteractionContext {
|
||||
}
|
||||
|
||||
pub async fn think(&self) -> Result<(), InteractionError> {
|
||||
self.handlers.http
|
||||
.interaction(self.handlers.app.id)
|
||||
self.rt
|
||||
.http
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
@@ -62,27 +57,30 @@ impl InteractionContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn original_message(&self) -> Option<OriginalMessage> {
|
||||
pub fn original_message(&self) -> Option<OriginalMessage<T>> {
|
||||
self.event.message.as_ref().map(|_| OriginalMessage {
|
||||
interaction_id: self.event.id,
|
||||
interaction_token: self.event.token.clone(),
|
||||
handlers: self.handlers.clone()
|
||||
rt: self.rt.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessage {
|
||||
pub struct OriginalMessage<T> {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
handlers: Arc<Handlers>
|
||||
rt: Arc<Runtime<T>>,
|
||||
}
|
||||
|
||||
impl OriginalMessage {
|
||||
pub fn edit(&self) -> OriginalMessageUpdater {
|
||||
impl<T> OriginalMessage<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn edit(&self) -> OriginalMessageUpdater<T> {
|
||||
OriginalMessageUpdater::new(
|
||||
self.interaction_id,
|
||||
self.interaction_token.clone(),
|
||||
self.handlers.clone(),
|
||||
self.rt.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use thiserror::Error;
|
||||
pub mod components;
|
||||
pub mod context;
|
||||
pub mod registry;
|
||||
pub mod runner;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InteractionError {
|
||||
@@ -19,4 +20,7 @@ pub enum InteractionError {
|
||||
|
||||
#[error("The original message was attempted to be edited, but it failed")]
|
||||
OriginalMessageEditingFailed,
|
||||
|
||||
#[error("No prefixes were returned for the event")]
|
||||
PrefixesVectorEmpty,
|
||||
}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use scc::HashMap;
|
||||
use twilight_http::Client;
|
||||
|
||||
use twilight_model::{
|
||||
application::interaction::InteractionData, gateway::payload::incoming::InteractionCreate,
|
||||
oauth::Application,
|
||||
};
|
||||
|
||||
use crate::framework::{handlers::Handlers, interactions::{context::InteractionContext, InteractionError}};
|
||||
use crate::framework::{
|
||||
interactions::{InteractionError, context::InteractionContext},
|
||||
runtime::Runtime,
|
||||
};
|
||||
|
||||
use super::components::Component;
|
||||
|
||||
pub struct Registry {
|
||||
inner: HashMap<String, Arc<dyn Component>>,
|
||||
#[derive(Default)]
|
||||
pub struct ComponentRegistry<T> {
|
||||
inner: HashMap<String, Arc<dyn Component<T>>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
impl<T> ComponentRegistry<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, id: impl ToString, component: Arc<dyn Component>) {
|
||||
pub fn register(&self, id: impl ToString, component: Arc<dyn Component<T>>) {
|
||||
self.inner.upsert(id.to_string(), component);
|
||||
}
|
||||
|
||||
@@ -32,15 +38,29 @@ impl Registry {
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
event: InteractionCreate,
|
||||
handlers: Arc<Handlers>,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<T>>,
|
||||
) -> Result<(), InteractionError> {
|
||||
if let Some(data) = &event.data {
|
||||
match data {
|
||||
InteractionData::MessageComponent(data) => {
|
||||
if let Some(component) = self.inner.get_async(&data.custom_id).await {
|
||||
let prefixes = rt
|
||||
.get_prefixes(
|
||||
event.guild_id.map(|id| id.get()),
|
||||
event.author_id().map(|id| id.get()),
|
||||
)
|
||||
.await;
|
||||
let prefix = prefixes
|
||||
.first()
|
||||
.ok_or(InteractionError::PrefixesVectorEmpty)?;
|
||||
|
||||
component
|
||||
.execute(InteractionContext::new(event, handlers))
|
||||
.execute(InteractionContext::new(
|
||||
event.clone(),
|
||||
rt.clone(),
|
||||
prefix.to_string(),
|
||||
))
|
||||
.await
|
||||
} else {
|
||||
Err(InteractionError::InteractionNotFound)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::InteractionCreate;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
events::traits::{EventResult, OnInteractionHandler},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InteractionRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl OnInteractionHandler<State> for InteractionRunner {
|
||||
async fn on_interaction(
|
||||
&self,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<State>>,
|
||||
) -> EventResult {
|
||||
Ok(rt.registry.execute(event, rt.clone()).await?)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod bot;
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod interactions;
|
||||
pub mod utils;
|
||||
pub mod handlers;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::Client;
|
||||
use twilight_model::oauth::Application;
|
||||
|
||||
use crate::framework::{
|
||||
bot::IntoPrefixes, commands::Command, interactions::registry::ComponentRegistry,
|
||||
};
|
||||
|
||||
pub struct Runtime<T> {
|
||||
pub http: Client,
|
||||
pub app: Application,
|
||||
pub registry: ComponentRegistry<T>,
|
||||
pub prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync + 'static>,
|
||||
pub commands: Vec<Arc<dyn Command<T>>>,
|
||||
pub state: Arc<T>,
|
||||
}
|
||||
|
||||
impl<T> Runtime<T> {
|
||||
pub async fn get_prefixes(&self, server_id: Option<u64>, user_id: Option<u64>) -> Vec<String> {
|
||||
self.prefixes.clone().get(self, server_id, user_id).await
|
||||
}
|
||||
|
||||
pub fn get_command(&self, name: &str) -> Option<Arc<dyn Command<T>>> {
|
||||
self.commands
|
||||
.iter()
|
||||
.find(|cmd| {
|
||||
cmd.identifiers()
|
||||
.contains(&name.to_ascii_lowercase().as_str())
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
pub trait EscapeDiscordMarkdown {
|
||||
fn escape(self) -> String;
|
||||
}
|
||||
|
||||
impl<T> EscapeDiscordMarkdown for T
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
fn escape(self) -> String {
|
||||
let input = self.to_string();
|
||||
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\\' | '*' | '_' | '~' | '`' | '>' | '|' | '[' | ']' | '(' | ')' | '#' | '+'
|
||||
| '-' | '=' | '.' | '!' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
// Now block ping attempts — break mentions
|
||||
escaped
|
||||
.replace("@everyone", "@\u{200B}everyone")
|
||||
.replace("@here", "@\u{200B}here")
|
||||
.replace("<@", "<@\u{200B}") // user mentions
|
||||
.replace("<@&", "<@&\u{200B}") // role mentions
|
||||
.replace("<#", "<#\u{200B}") // channel mentions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
framework::commands::{
|
||||
Command, CommandError,
|
||||
arguments::{Argument, NeedyString, ParseArguments},
|
||||
context::CommandContext,
|
||||
},
|
||||
groups::about::{
|
||||
components::{
|
||||
HelpDonateLinkButton, HelpGoBackButton, HelpSupportLinkButton, HelpWebsiteLinkButton,
|
||||
},
|
||||
embeds::{get_all_command_help_embed, get_command_help_embed, get_command_not_found_embed},
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for HelpCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"help"
|
||||
}
|
||||
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[Argument {
|
||||
name: "command",
|
||||
is_optional: true,
|
||||
is_greedy: false,
|
||||
}]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Lists all commands and shows information about them individually."
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let command_name: Option<NeedyString> = ParseArguments::parse(args).unwrap();
|
||||
|
||||
if let Some(command_name) = command_name {
|
||||
if let Some(command) = ctx.rt.get_command(&command_name) {
|
||||
ctx.send()
|
||||
.embed(get_command_help_embed(&command, &ctx))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to send message! {e}"))
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_command_not_found_embed(ctx.rt, command_name.0))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_all_command_help_embed(ctx.prefix, ctx.rt))
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
framework::interactions::{InteractionError, components::Button, context::InteractionContext},
|
||||
groups::about::embeds::get_all_command_help_embed,
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpGoBackButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpGoBackButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("← Go back to commands list")
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext<State>) -> Result<(), InteractionError> {
|
||||
ctx
|
||||
.original_message()
|
||||
.expect(
|
||||
"Buttons always have the original message in the interaction context, this one didn't (got None)"
|
||||
)
|
||||
.edit()
|
||||
.embed(get_all_command_help_embed(&ctx.prefix, ctx.rt))
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("An error occurred while attempting to edit the original message of an interaction. {e:?}"))
|
||||
.map_err(
|
||||
|_| InteractionError::OriginalMessageEditingFailed
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpSupportLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpSupportLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Get help")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://discord.gg/9daSsrfTmE")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpWebsiteLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpWebsiteLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Website")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://payphone.lat/")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpDonateLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpDonateLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Donate")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://ko-fi.com/payphonedev")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::channel::message::Embed;
|
||||
use twilight_util::builder::embed::{
|
||||
EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
constants::{COLOR_RED, COLOR_WHITE},
|
||||
discord::{images, sanitizing::SanitizeDiscordMarkdown},
|
||||
framework::{
|
||||
commands::{Command, context::CommandContext},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
utils::ToTitleCase,
|
||||
};
|
||||
|
||||
pub(super) fn get_command_help_embed(
|
||||
command: &Arc<dyn Command<State>>,
|
||||
ctx: &CommandContext<'_, State>,
|
||||
) -> Embed {
|
||||
let name = format!("{}{}", ctx.prefix, command.name());
|
||||
let arguments = command
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let usage = if !arguments.is_empty() {
|
||||
format!("{name} {arguments}")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(ctx.rt.app.name.clone())
|
||||
.icon_url(
|
||||
ImageSource::url(images::get_avatar_url(
|
||||
ctx.rt.app.bot.as_ref().unwrap().avatar,
|
||||
ctx.rt.app.bot.as_ref().unwrap().id.get(),
|
||||
ctx.rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.thumbnail(
|
||||
ImageSource::url(images::get_avatar_url(
|
||||
ctx.rt.app.bot.as_ref().unwrap().avatar,
|
||||
ctx.rt.app.bot.as_ref().unwrap().id.get(),
|
||||
ctx.rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.color(COLOR_WHITE)
|
||||
.title(format!("{} Command", command.name().to_title_case()))
|
||||
.description(command.description())
|
||||
.field(EmbedFieldBuilder::new("Usage", format!("`{usage}`")).build())
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"Aliases",
|
||||
if !command.aliases().is_empty() {
|
||||
command
|
||||
.aliases()
|
||||
.iter()
|
||||
.map(|alias| format!("`{}{alias}`", ctx.prefix))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
} else {
|
||||
String::from("*This command doesn't have aliases.*")
|
||||
},
|
||||
))
|
||||
.footer(EmbedFooterBuilder::new(format!(
|
||||
"Use {}help <command> to get help about a specific command.",
|
||||
ctx.prefix
|
||||
)))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn get_all_command_help_embed<T>(prefix: &str, rt: Arc<Runtime<T>>) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap()))
|
||||
.title("Pick up the phone and start making friends!")
|
||||
.description(format!("Here is the list of all available commands. You can see more details about a specific command with `{prefix}help <command>`."))
|
||||
.thumbnail(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap())
|
||||
.color(COLOR_WHITE)
|
||||
.field(EmbedFieldBuilder::new("Commands", get_commands_table(rt)).build())
|
||||
.footer(EmbedFooterBuilder::new(format!("v{} - Use {}help <command> to get more information about a specific command.", env!("CARGO_PKG_VERSION"), prefix)))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn get_commands_table<T>(rt: Arc<Runtime<T>>) -> String {
|
||||
let mut table = String::new();
|
||||
|
||||
for command in rt.commands.iter() {
|
||||
table.push_str(&format!("{:<12}", command.name()));
|
||||
}
|
||||
|
||||
format!("```\n{table}\n```")
|
||||
}
|
||||
|
||||
pub(super) fn get_command_not_found_embed<T>(rt: Arc<Runtime<T>>, name: String) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.color(COLOR_RED)
|
||||
.author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap()))
|
||||
.title("That's not a command")
|
||||
.description(format!("`{}` is not a valid command name (nor alias). If you put it with the prefix on, take the prefix off before trying again. For example, if you did `p.help p.call`, do `p.help call` instead.\n\nIf you still can't figure it out, press the button below to go back to the full list. Everything's there!", name.sanitize(true)))
|
||||
.thumbnail(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap())
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod components;
|
||||
pub mod embeds;
|
||||
@@ -0,0 +1,204 @@
|
||||
use async_trait::async_trait;
|
||||
use scc::HashSet;
|
||||
use twilight_model::id::Id;
|
||||
|
||||
use crate::{
|
||||
database::models::{Call, Channel},
|
||||
framework::commands::{Command, CommandError, context::CommandContext},
|
||||
state::State,
|
||||
utils::Bye,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct CallCommand {
|
||||
/// A set of channel IDs that are currently searching for a call. This works as a lock for two
|
||||
/// users not to use p.call at the same time in the same channel, as it could create a race
|
||||
/// condition. IDs are removed from this set once the DB is properly updated with the channel's
|
||||
/// state, meaning that not all searching channels will be in this set at all times.
|
||||
channel_lock: HashSet<u64>,
|
||||
}
|
||||
|
||||
impl CallCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channel_lock: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for CallCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"call"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["c"]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let channel = Channel::get_or_create_by_id(
|
||||
ctx.event.channel_id.get(),
|
||||
&ctx.rt.state.db,
|
||||
&ctx.rt.http,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to get or create channel: {e:?}");
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
if channel.is_searching || self.channel_lock.contains(&ctx.event.channel_id.get()) {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"☎️ You are already searching for a call! Use `{}hangup` to hang up the phone.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if channel.call_id.is_some() {
|
||||
ctx.send()
|
||||
.content(format!("☎️ You are already on a call! Use `{}hangup` to hang up the phone, or `{}skip` to skip to the next call.", ctx.prefix, ctx.prefix))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.channel_lock
|
||||
.insert_async(ctx.event.channel_id.get())
|
||||
.await
|
||||
.bye();
|
||||
|
||||
let call = Call::start(&channel, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to start call: {e:?}");
|
||||
self.channel_lock.remove(&ctx.event.channel_id.get());
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
self.channel_lock
|
||||
.remove_async(&ctx.event.channel_id.get())
|
||||
.await;
|
||||
|
||||
if let Some(call) = call {
|
||||
ctx.send().content("☎️ Call connected. Say hi!").await.bye();
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content("☎️ Call connected. Say hi!")
|
||||
.await
|
||||
.bye();
|
||||
} else {
|
||||
ctx.send()
|
||||
.content("☎️ Searching for a call...")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HangupCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for HangupCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"hangup"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["h", "hang"]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let Some(channel) = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get channel: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?
|
||||
else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if channel.is_searching {
|
||||
channel
|
||||
.cancel_search(&ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to cancel search: {e:?}");
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
ctx.send()
|
||||
.content("☎️ The phone was hung up.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(call_id) = channel.call_id {
|
||||
let Some(call) = Call::get_by_id(call_id, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get call: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?
|
||||
else {
|
||||
ctx.send()
|
||||
.content("❌ Call not found.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Err(CommandError::DatabaseInconsistentState);
|
||||
};
|
||||
|
||||
call.hangup(&ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
ctx.send().content("☎️ You hung up the phone.").await.bye();
|
||||
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content("☎️ The other party hung up the phone.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use linkify::LinkFinder;
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id};
|
||||
use twilight_validate::request::webhook_username;
|
||||
|
||||
use crate::{
|
||||
database::models::{Call, Channel},
|
||||
discord::{images, sanitizing::SanitizeDiscordMarkdown},
|
||||
framework::{
|
||||
events::traits::{EventResult, OnMessageHandler},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MessageRedirector;
|
||||
|
||||
#[async_trait]
|
||||
impl OnMessageHandler<State> for MessageRedirector {
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<State>>) -> EventResult {
|
||||
if event.author.bot || event.author.system.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prefixes = rt
|
||||
.get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get()))
|
||||
.await;
|
||||
|
||||
if prefixes.iter().any(|prefix| {
|
||||
event
|
||||
.content
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&prefix.to_ascii_lowercase())
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to get channel from DB when processing message create event! {e:?}"
|
||||
);
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call = if let Some(call_id) = channel.call_id {
|
||||
if let Some(call) = Call::get_by_id(call_id, &rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to get call from DB when processing message create event! {e:?}"
|
||||
);
|
||||
})?
|
||||
{
|
||||
if !call.is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call
|
||||
} else {
|
||||
log::error!(
|
||||
"Call ID {} in channel {} does not correspond to an active call.",
|
||||
call_id,
|
||||
channel.id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(opposite_channel) = Channel::get_by_id(
|
||||
call.other_party_channel_id(event.channel_id.get()),
|
||||
&rt.state.db,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to get opposite channel from DB when processing message create event! {e:?}"
|
||||
);
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some((webhook_id, webhook_token)) = opposite_channel
|
||||
.webhook_id
|
||||
.zip(opposite_channel.webhook_token)
|
||||
else {
|
||||
// Handle DM calls in the future.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let profile = get_display_profile(event);
|
||||
|
||||
rt.http
|
||||
.execute_webhook(Id::new(webhook_id), &webhook_token)
|
||||
.allowed_mentions(None)
|
||||
.username(&profile.name)
|
||||
.avatar_url(&profile.avatar_url)
|
||||
.content(&escape_message(event))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to send message to webhook for call {} in channel {}: {e:?}",
|
||||
call.id,
|
||||
opposite_channel.id
|
||||
);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DisplayProfile {
|
||||
pub name: String,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
fn get_display_profile(event: &MessageCreate) -> DisplayProfile {
|
||||
let member = event.member.clone();
|
||||
|
||||
let name = if let Some(nick) = &member.clone().and_then(|member| member.nick) {
|
||||
if webhook_username(nick).is_err() {
|
||||
"Invalid Nickname".to_string()
|
||||
} else {
|
||||
nick.clone()
|
||||
}
|
||||
} else if let Some(name) = &event.author.global_name {
|
||||
if webhook_username(name).is_err() {
|
||||
"Invalid Global Name".to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
}
|
||||
} else if webhook_username(&event.author.name).is_err() {
|
||||
"Invalid Username".to_string()
|
||||
} else {
|
||||
event.author.name.clone()
|
||||
};
|
||||
|
||||
let avatar_hash = member
|
||||
.and_then(|inner| inner.avatar)
|
||||
.or(event.author.avatar);
|
||||
let avatar_url = images::get_avatar_url(
|
||||
avatar_hash,
|
||||
event.author.id.get(),
|
||||
event.author.discriminator as u64,
|
||||
);
|
||||
|
||||
DisplayProfile { name, avatar_url }
|
||||
}
|
||||
|
||||
fn escape_message(event: &MessageCreate) -> String {
|
||||
let mut message = event.content.to_string();
|
||||
|
||||
for mention in &event.mentions {
|
||||
message = message.replace(
|
||||
&format!("<@{}>", mention.id),
|
||||
&format!("**@{}**", mention.name.sanitize(true)),
|
||||
);
|
||||
}
|
||||
|
||||
for mention in &event.mention_roles {
|
||||
message = message.replace(&format!("<@&{mention}>"), "**@Role**");
|
||||
}
|
||||
|
||||
for mention in &event.mention_channels {
|
||||
message = message.replace(
|
||||
&format!("<#{}>", mention.id),
|
||||
&format!("**#{}**", mention.name.sanitize(true)),
|
||||
);
|
||||
}
|
||||
|
||||
let mut finder = LinkFinder::new();
|
||||
finder.url_must_have_scheme(false);
|
||||
|
||||
for link in finder.links(&message.clone()) {
|
||||
let url = link.as_str();
|
||||
|
||||
message = message.replace(url, &"\\#".repeat(url.len()));
|
||||
}
|
||||
|
||||
let replacements: HashMap<char, Option<char>> = [
|
||||
('@', Some('a')),
|
||||
('0', Some('o')),
|
||||
('1', Some('i')),
|
||||
('2', Some('z')),
|
||||
('3', Some('e')),
|
||||
('4', Some('a')),
|
||||
('5', Some('s')),
|
||||
('6', Some('b')),
|
||||
('7', Some('t')),
|
||||
('8', Some('g')),
|
||||
('9', Some('q')),
|
||||
]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let normalized_unidecoded = unidecode::unidecode(&message)
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
let mut normalized = String::with_capacity(normalized_unidecoded.len());
|
||||
|
||||
for ch in normalized_unidecoded.chars() {
|
||||
if let Some(&replacement) = replacements.get(&ch) {
|
||||
if let Some(replacement) = replacement {
|
||||
normalized.push(replacement);
|
||||
}
|
||||
} else if ch.is_alphanumeric() || ch.is_whitespace() {
|
||||
normalized.push(ch);
|
||||
}
|
||||
|
||||
// skip everything else (punctuation not in map, zero-width, etc.)
|
||||
}
|
||||
|
||||
log::debug!("Escaped message: {normalized}");
|
||||
|
||||
message.sanitize(false)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::Ready;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
events::traits::{EventResult, OnReadyHandler, OnReconnectHandler},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReadyHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnReadyHandler<State> for ReadyHandler {
|
||||
async fn on_ready(&self, event: &Ready, _: Arc<Runtime<State>>) -> EventResult {
|
||||
log::info!(
|
||||
"Ready! Logged in as {}#{:>04}",
|
||||
event.user.name,
|
||||
event.user.discriminator
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReconnectHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnReconnectHandler<State> for ReconnectHandler {
|
||||
async fn on_reconnect(&self, _: Arc<Runtime<State>>) -> EventResult {
|
||||
log::info!("The gateway asked for a reconnection");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod events;
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod about;
|
||||
pub mod calls;
|
||||
pub mod gateway;
|
||||
pub mod servers;
|
||||
@@ -0,0 +1,95 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::{GuildCreate, GuildDelete, GuildUpdate};
|
||||
|
||||
use crate::{
|
||||
database::models::Server,
|
||||
framework::{
|
||||
events::traits::{
|
||||
EventResult, OnServerCreateHandler, OnServerDeleteHandler, OnServerUpdateHandler,
|
||||
},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServerCreateHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnServerCreateHandler<State> for ServerCreateHandler {
|
||||
async fn on_server_create(&self, event: &GuildCreate, rt: Arc<Runtime<State>>) -> EventResult {
|
||||
let server = match event {
|
||||
GuildCreate::Available(guild) => Server::from(guild.clone()),
|
||||
GuildCreate::Unavailable(_) => {
|
||||
// If the guild is unavailable, Discord will later send this event again when the guild
|
||||
// becomes available.
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("The server \"{}\" was created", server.name);
|
||||
|
||||
server.insert(&rt.state.db).await.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Could not insert the recently joined server into the DB!\n\n{server:?}\n\n{e:?}\n"
|
||||
)
|
||||
})?;
|
||||
|
||||
// TODO: Send hello message to the server's default channel on server join.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServerUpdateHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnServerUpdateHandler<State> for ServerUpdateHandler {
|
||||
async fn on_server_update(&self, event: &GuildUpdate, rt: Arc<Runtime<State>>) -> EventResult {
|
||||
let mut server = match Server::get_by_id(event.0.id.get(), &rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Could not get server from DB when it was updated! {e:?}")
|
||||
})? {
|
||||
Some(server) => server,
|
||||
None => {
|
||||
log::debug!("Server was updated but it was not in DB");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if server.name != event.name {
|
||||
server.name = event.name.clone();
|
||||
|
||||
server
|
||||
.upsert(&rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not update server's name. {e:?}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServerDeleteHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnServerDeleteHandler<State> for ServerDeleteHandler {
|
||||
async fn on_server_delete(&self, event: &GuildDelete, rt: Arc<Runtime<State>>) -> EventResult {
|
||||
Server::delete_by_id(event.id.get(), &rt.state.db)
|
||||
.await
|
||||
.inspect(|_| log::debug!("Deleted server with ID {} because Payphone left", event.id))
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Could not delete server with ID {} from DB when Payphone left! {e:?}",
|
||||
event.id
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod events;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod framework;
|
||||
pub mod utils;
|
||||
pub mod state;
|
||||
+5
-9
@@ -2,13 +2,11 @@ use colored::Colorize;
|
||||
use log::{Level, LevelFilter};
|
||||
use time::{OffsetDateTime, macros::format_description};
|
||||
|
||||
pub struct Logger {
|
||||
level: Level,
|
||||
}
|
||||
pub struct Logger;
|
||||
|
||||
impl log::Log for Logger {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= self.level
|
||||
metadata.target() == "payphone" || metadata.target().starts_with("payphone::")
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
@@ -25,11 +23,9 @@ impl log::Log for Logger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static LOGGER: Logger = Logger { level: Level::Info };
|
||||
|
||||
pub fn init() {
|
||||
log::set_logger(&LOGGER)
|
||||
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||
pub fn init(level: LevelFilter) {
|
||||
log::set_logger(&Logger)
|
||||
.map(|()| log::set_max_level(level))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
|
||||
+37
-64
@@ -1,68 +1,38 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use twilight_gateway::{ConfigBuilder, EventTypeFlags, Intents, Shard, ShardId, StreamExt};
|
||||
use twilight_http::Client;
|
||||
use twilight_model::gateway::{
|
||||
payload::outgoing::update_presence::UpdatePresencePayload,
|
||||
presence::{ActivityType, MinimalActivity, Status},
|
||||
};
|
||||
use twilight_gateway::Intents;
|
||||
|
||||
use crate::{
|
||||
cli::Args, commands::register_default_components, discord::events,
|
||||
framework::{handlers::Handlers, interactions::registry::Registry},
|
||||
cli::Args,
|
||||
framework::bot::Bot,
|
||||
groups::{
|
||||
about::{commands::HelpCommand, components::HelpGoBackButton},
|
||||
calls::{
|
||||
commands::{CallCommand, HangupCommand},
|
||||
events::MessageRedirector,
|
||||
},
|
||||
gateway::events::{ReadyHandler, ReconnectHandler},
|
||||
servers::events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler},
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod constants;
|
||||
mod database;
|
||||
mod discord;
|
||||
mod framework;
|
||||
mod groups;
|
||||
mod logging;
|
||||
mod state;
|
||||
mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
pub async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
logging::init();
|
||||
|
||||
let intents = Intents::GUILD_MESSAGES | Intents::MESSAGE_CONTENT;
|
||||
let config = ConfigBuilder::new(args.token.clone(), intents)
|
||||
.presence(
|
||||
UpdatePresencePayload::new(
|
||||
vec![
|
||||
MinimalActivity {
|
||||
kind: ActivityType::Listening,
|
||||
name: "Hello Payphone!".into(),
|
||||
url: None,
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
false,
|
||||
None,
|
||||
Status::Online,
|
||||
)
|
||||
.expect("Could not set presence!"),
|
||||
)
|
||||
.build();
|
||||
let mut shard = Shard::with_config(ShardId::new(0, 1), config);
|
||||
|
||||
let http = Client::new(args.token.clone());
|
||||
let registry = Registry::new();
|
||||
let app =
|
||||
http.current_user_application()
|
||||
.await
|
||||
.expect("Could not get information about the current application")
|
||||
.model()
|
||||
.await
|
||||
.expect("Could not get model of the information of the current application")
|
||||
;
|
||||
|
||||
register_default_components(®istry);
|
||||
logging::init(args.logging.into());
|
||||
|
||||
log::info!("Welcome to Payphone, Nyeki!");
|
||||
log::info!(
|
||||
@@ -70,25 +40,28 @@ async fn main() {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let db = database::init("127.0.0.1:9042")
|
||||
let db = database::init(args.database_url)
|
||||
.await
|
||||
.expect("Could not connect to the database!");
|
||||
|
||||
let handlers = Arc::new(Handlers {
|
||||
app,
|
||||
db,
|
||||
http,
|
||||
registry
|
||||
});
|
||||
log::info!("Database connection established successfully!");
|
||||
|
||||
while let Some(event) = shard.next_event(EventTypeFlags::all()).await {
|
||||
if let Ok(event) = event {
|
||||
tokio::spawn(events::handle_event(
|
||||
event,
|
||||
handlers.clone(),
|
||||
));
|
||||
} else {
|
||||
log::error!("Could not receive the next event.");
|
||||
}
|
||||
}
|
||||
let state = State { db };
|
||||
|
||||
let bot = Bot::new(state, ["p."])
|
||||
.intent(Intents::GUILDS)
|
||||
.intent(Intents::GUILD_MESSAGES)
|
||||
.intent(Intents::MESSAGE_CONTENT)
|
||||
.command(HelpCommand)
|
||||
.command(CallCommand::new())
|
||||
.command(HangupCommand)
|
||||
.component(HelpGoBackButton)
|
||||
.on_ready(ReadyHandler)
|
||||
.on_reconnect(ReconnectHandler)
|
||||
.on_message(MessageRedirector)
|
||||
.on_server_create(ServerCreateHandler)
|
||||
.on_server_update(ServerUpdateHandler)
|
||||
.on_server_delete(ServerDeleteHandler);
|
||||
|
||||
bot.run(&args.token).await.expect("Could not run bot!");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
use scylla::client::session::Session;
|
||||
|
||||
pub struct State {
|
||||
pub db: Session,
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
pub trait ToTitleCase {
|
||||
fn to_title_case(&self) -> String;
|
||||
}
|
||||
@@ -6,6 +9,10 @@ impl<T> ToTitleCase for T
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
/// Removes duplicated whitespaces and capitalizes the first letter of each word.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`String`] - The title-cased version of the string.
|
||||
fn to_title_case(&self) -> String {
|
||||
self.to_string()
|
||||
.split_whitespace()
|
||||
@@ -20,3 +27,21 @@ where
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a random `u64` generated using a cryptographically secure random number generator.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`u64`] - A randomly generated 64-bit unsigned integer.
|
||||
pub fn get_random_u64() -> u64 {
|
||||
let mut rng = ChaCha20Rng::from_os_rng();
|
||||
|
||||
rng.random()
|
||||
}
|
||||
|
||||
pub trait Bye: Sized {
|
||||
/// Whatever the value is, it consumes it and returns a unit value. Used to remove warnings
|
||||
/// about unused results.
|
||||
fn bye(self) {}
|
||||
}
|
||||
|
||||
impl<T> Bye for T {}
|
||||
|
||||
Reference in New Issue
Block a user