Finish refactoring

This commit is contained in:
2025-08-10 21:15:53 -03:00
parent f123282699
commit a004fe2308
50 changed files with 2639 additions and 587 deletions
Generated
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
}
}
-221
View File
@@ -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")
}
}
-59
View File
@@ -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
View File
@@ -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;
+22 -7
View File
@@ -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
View File
@@ -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,
})?;
}
+828
View File
@@ -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(())
}
}
-13
View File
@@ -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");
}
-11
View File
@@ -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:?}");
}
}
-12
View File
@@ -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)
}
-25
View File
@@ -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
}
_ => {}
}
}
+15
View File
@@ -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
View File
@@ -1 +1,2 @@
pub mod events;
pub mod images;
pub mod sanitizing;
+38
View File
@@ -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
}
}
+330
View File
@@ -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());
}
}
});
}
}
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
use std::{fmt, ops::{Deref, DerefMut}};
use std::{
fmt,
ops::{Deref, DerefMut},
};
use thiserror::Error;
+13 -19
View File
@@ -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())
}
}
+30 -28
View File
@@ -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>>;
+11 -2
View File
@@ -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> {
+45
View File
@@ -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(())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod traits;
+83
View File
@@ -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;
}
-12
View File
@@ -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,
}
+19 -13
View File
@@ -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
}
}
+23 -25
View File
@@ -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(),
)
}
}
+4
View File
@@ -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,
}
+30 -10
View File
@@ -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)
+26
View File
@@ -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?)
}
}
+3 -2
View File
@@ -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;
+33
View File
@@ -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()
}
}
-33
View File
@@ -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
}
}
+75
View File
@@ -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(())
}
}
+79
View File
@@ -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")
}
}
+132
View File
@@ -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()
}
+3
View File
@@ -0,0 +1,3 @@
pub mod commands;
pub mod components;
pub mod embeds;
+204
View File
@@ -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(())
}
}
+228
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod commands;
pub mod events;
+40
View File
@@ -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(())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod events;
+4
View File
@@ -0,0 +1,4 @@
pub mod about;
pub mod calls;
pub mod gateway;
pub mod servers;
+95
View File
@@ -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(())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod events;
+3
View File
@@ -0,0 +1,3 @@
pub mod framework;
pub mod utils;
pub mod state;
+5 -9
View File
@@ -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
View File
@@ -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(&registry);
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!");
}
+5
View File
@@ -0,0 +1,5 @@
use scylla::client::session::Session;
pub struct State {
pub db: Session,
}
+25
View File
@@ -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 {}