Initialize Twilight project, set up basic framework, initialize DB module
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
|
||||
.env
|
||||
Generated
+2370
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "payphone"
|
||||
version = "4.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.88"
|
||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||
colored = "3.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
include_dir = "0.7.4"
|
||||
log = "0.4.27"
|
||||
once_cell = "1.21.3"
|
||||
scc = "2.3.4"
|
||||
scylla = "1.3.1"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = ["formatting", "local-offset", "macros"] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||
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"] }
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
scylla-node-1:
|
||||
image: scylladb/scylla:latest
|
||||
container_name: scylla-node-1
|
||||
command: --smp 1 --memory 750M --overprovisioned 1 --seeds=scylla-node-1
|
||||
ports:
|
||||
- "9042:9042" # CQL
|
||||
volumes:
|
||||
- scylla-data-1:/var/lib/scylla
|
||||
networks:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.2
|
||||
|
||||
scylla-node-2:
|
||||
image: scylladb/scylla:latest
|
||||
container_name: scylla-node-2
|
||||
command: --smp 1 --memory 750M --overprovisioned 1 --seeds=scylla-node-1
|
||||
volumes:
|
||||
- scylla-data-2:/var/lib/scylla
|
||||
networks:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.3
|
||||
|
||||
scylla-node-3:
|
||||
image: scylladb/scylla:latest
|
||||
container_name: scylla-node-3
|
||||
command: --smp 1 --memory 750M --overprovisioned 1 --seeds=scylla-node-1
|
||||
volumes:
|
||||
- scylla-data-3:/var/lib/scylla
|
||||
networks:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.4
|
||||
|
||||
volumes:
|
||||
scylla-data-1:
|
||||
scylla-data-2:
|
||||
scylla-data-3:
|
||||
|
||||
networks:
|
||||
scylla-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/24
|
||||
@@ -0,0 +1,5 @@
|
||||
#[derive(clap::Parser)]
|
||||
pub struct Args {
|
||||
#[arg(env = "PAYPHONE_TOKEN")]
|
||||
pub token: String,
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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 = format!("{name} {arguments}");
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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 COLOR_WHITE: u32 = 0xE2D9D0;
|
||||
pub const COLOR_RED: u32 = 0xFF8787;
|
||||
@@ -0,0 +1,152 @@
|
||||
CREATE KEYSPACE IF NOT EXISTS payphone
|
||||
WITH replication = {
|
||||
'class': 'NetworkTopologyStrategy',
|
||||
'replication_factor': 3
|
||||
};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.users (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
|
||||
is_masked BOOLEAN,
|
||||
is_moderator BOOLEAN,
|
||||
|
||||
joined_at TIMESTAMP,
|
||||
last_seen_at TIMESTAMP,
|
||||
is_supporter_since TIMESTAMP,
|
||||
is_supporter_until TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.calls (
|
||||
call_id BIGINT PRIMARY KEY,
|
||||
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.channels (
|
||||
channel_id BIGINT PRIMARY KEY,
|
||||
server_id BIGINT,
|
||||
|
||||
name TEXT,
|
||||
|
||||
is_searching BOOLEAN,
|
||||
|
||||
recently_called_channel_ids LIST<BIGINT>
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.participants (
|
||||
call_id BIGINT,
|
||||
user_id BIGINT,
|
||||
|
||||
user_name TEXT,
|
||||
user_avatar_url TEXT,
|
||||
user_mask TEXT,
|
||||
|
||||
PRIMARY KEY (call_id, user_id)
|
||||
);
|
||||
|
||||
-- TTL 30 days.
|
||||
CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
call_id BIGINT,
|
||||
message_id BIGINT,
|
||||
author_id BIGINT,
|
||||
|
||||
author_name TEXT,
|
||||
author_avatar_url TEXT,
|
||||
author_mask ASCII,
|
||||
|
||||
content TEXT,
|
||||
attachments LIST<TEXT>,
|
||||
sticker BIGINT,
|
||||
|
||||
replying_to_id BIGINT,
|
||||
|
||||
is_deleted BOOLEAN,
|
||||
is_edited BOOLEAN,
|
||||
is_censored BOOLEAN,
|
||||
|
||||
PRIMARY KEY (call_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.servers (
|
||||
server_id BIGINT PRIMARY KEY,
|
||||
|
||||
name TEXT,
|
||||
prefix TEXT,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.reports (
|
||||
report_id BIGINT,
|
||||
|
||||
message_author_id BIGINT,
|
||||
message_author_name TEXT,
|
||||
message_author_avatar_url TEXT,
|
||||
message_author_mask ASCII,
|
||||
|
||||
message_id BIGINT,
|
||||
message_content TEXT,
|
||||
message_attachments LIST<TEXT>,
|
||||
message_sticker BIGINT,
|
||||
|
||||
message_replying_to_id BIGINT,
|
||||
message_replying_to_content TEXT,
|
||||
message_replying_to_attachments LIST<TEXT>,
|
||||
message_replying_to_sticker BIGINT,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
|
||||
created_by_ids SET<BIGINT>,
|
||||
|
||||
reviewed_by_id BIGINT,
|
||||
resolution ASCII,
|
||||
|
||||
PRIMARY KEY (message_author_id, report_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.user_incidents (
|
||||
user_id BIGINT,
|
||||
incident_id BIGINT,
|
||||
|
||||
-- ban, warning
|
||||
type ASCII,
|
||||
|
||||
call_id BIGINT,
|
||||
report_id BIGINT,
|
||||
|
||||
message_id BIGINT,
|
||||
message_content TEXT,
|
||||
message_attachments LIST<TEXT>,
|
||||
message_sticker BIGINT,
|
||||
|
||||
message_replying_to_id BIGINT,
|
||||
message_replying_to_content TEXT,
|
||||
message_replying_to_attachments LIST<TEXT>,
|
||||
message_replying_to_sticker BIGINT,
|
||||
|
||||
reason TEXT,
|
||||
|
||||
created_by_id BIGINT,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
duration ASCII,
|
||||
|
||||
PRIMARY KEY (user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.server_incidents (
|
||||
server_id BIGINT PRIMARY KEY,
|
||||
incident_id BIGINT,
|
||||
|
||||
reason TEXT,
|
||||
|
||||
created_by_id BIGINT,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
duration ASCII
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
use include_dir::{Dir, include_dir};
|
||||
use scylla::{
|
||||
client::{session::Session, session_builder::SessionBuilder},
|
||||
errors::ExecutionError,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("A connection was attempted to be established to the database, but it failed")]
|
||||
ConnectionFailed,
|
||||
|
||||
#[error("The database connection was established but the migrations could not be applied")]
|
||||
MigrationFailed,
|
||||
|
||||
#[error("The migration file was invalidly formatted (not UTF-8)")]
|
||||
MigrationFileInvalid(String),
|
||||
}
|
||||
|
||||
static MIGRATIONS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/database/migrations");
|
||||
|
||||
pub async fn init(uri: impl ToString) -> Result<Session, DatabaseError> {
|
||||
let session = SessionBuilder::new()
|
||||
.known_node(uri.to_string())
|
||||
.build()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not connect to the database! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ConnectionFailed)?;
|
||||
|
||||
execute_file(
|
||||
&session,
|
||||
r#"
|
||||
CREATE KEYSPACE IF NOT EXISTS migrations
|
||||
WITH replication = {
|
||||
'class': 'NetworkTopologyStrategy',
|
||||
'replication_factor': 3
|
||||
};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS migrations.applied (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP
|
||||
);
|
||||
"#
|
||||
.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() {
|
||||
execute_file(
|
||||
&session,
|
||||
file.contents_utf8()
|
||||
.ok_or(DatabaseError::MigrationFileInvalid(
|
||||
file.path().to_string_lossy().to_string(),
|
||||
))?
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not migrate {}! {e:?}", file.path().to_str().unwrap()))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("An error occurred while executing a statement in the file")]
|
||||
struct FileExecutionError {
|
||||
pub statement_index: usize,
|
||||
pub error: ExecutionError,
|
||||
}
|
||||
|
||||
async fn execute_file(session: &Session, file: String) -> Result<(), FileExecutionError> {
|
||||
let statements: Vec<&str> = file.split(";").collect();
|
||||
|
||||
for (statement_index, statement) in statements.iter().enumerate() {
|
||||
if statement.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
session
|
||||
.query_unpaged(format!("{};", statement.trim()), ())
|
||||
.await
|
||||
.map_err(|error| FileExecutionError {
|
||||
error,
|
||||
statement_index,
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 @@
|
||||
pub mod events;
|
||||
@@ -0,0 +1,222 @@
|
||||
use std::{fmt, ops::{Deref, DerefMut}};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArgumentParsingError {
|
||||
#[error("This argument was missing in the arguments array")]
|
||||
MissingArgument,
|
||||
|
||||
#[error("An argument was there but it could not be parsed into the specified value")]
|
||||
InvalidArgument,
|
||||
}
|
||||
|
||||
pub trait FromArguments: Sized {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError>;
|
||||
}
|
||||
|
||||
impl FromArguments for String {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
match args.split_once(" ") {
|
||||
Some((arg, rest)) => Ok((arg.to_string(), rest)),
|
||||
None => Ok((args.to_string(), "")),
|
||||
}
|
||||
} else {
|
||||
Ok((args.to_string(), ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GreedyString(pub String);
|
||||
|
||||
impl FromArguments for GreedyString {
|
||||
fn from_args<'a>(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
let value = GreedyString(args.to_string());
|
||||
|
||||
Ok((value, ""))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for GreedyString {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for GreedyString {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GreedyString {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NeedyString(pub String);
|
||||
|
||||
impl Deref for NeedyString {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for NeedyString {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NeedyString {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for NeedyString {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
match args.split_once(" ") {
|
||||
Some((arg, rest)) => Ok((Self(arg.to_string()), rest)),
|
||||
None => Ok((Self(args.to_string()), "")),
|
||||
}
|
||||
} else {
|
||||
Err(ArgumentParsingError::MissingArgument)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for bool {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
let arg: &str;
|
||||
let rest: &str;
|
||||
|
||||
match args.split_once(" ") {
|
||||
Some((a, b)) => {
|
||||
arg = a;
|
||||
rest = b;
|
||||
}
|
||||
None => {
|
||||
arg = args;
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
|
||||
match arg {
|
||||
"y" | "yes" | "on" | "true" | "t" | "1" => Ok((true, rest)),
|
||||
"n" | "no" | "off" | "false" | "f" | "0" => Ok((false, rest)),
|
||||
_ => Err(ArgumentParsingError::InvalidArgument),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromArguments for Option<T>
|
||||
where
|
||||
T: FromArguments,
|
||||
{
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
match T::from_args(args) {
|
||||
Ok((result, rest)) => Ok((Some(result), rest)),
|
||||
Err(_) => Ok((None, args)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ParseArguments: Sized {
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError>;
|
||||
}
|
||||
|
||||
impl ParseArguments for () {
|
||||
fn parse(_: &str) -> Result<Self, ArgumentParsingError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ParseArguments for A
|
||||
where
|
||||
A: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args)?;
|
||||
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ParseArguments for (A,)
|
||||
where
|
||||
A: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args)?;
|
||||
|
||||
Ok((a,))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B> ParseArguments for (A, B)
|
||||
where
|
||||
A: FromArguments,
|
||||
B: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args)?;
|
||||
let (b, _) = B::from_args(args)?;
|
||||
|
||||
Ok((a, b))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B, C> ParseArguments for (A, B, C)
|
||||
where
|
||||
A: FromArguments,
|
||||
B: FromArguments,
|
||||
C: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args)?;
|
||||
let (b, args) = B::from_args(args)?;
|
||||
let (c, _) = C::from_args(args)?;
|
||||
|
||||
Ok((a, b, c))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Argument {
|
||||
pub name: &'static str,
|
||||
pub is_optional: bool,
|
||||
pub is_greedy: bool,
|
||||
}
|
||||
|
||||
impl fmt::Display for Argument {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut argument = String::new();
|
||||
|
||||
if self.is_optional {
|
||||
argument.push('[');
|
||||
} else {
|
||||
argument.push('<');
|
||||
}
|
||||
|
||||
argument.push_str(self.name);
|
||||
|
||||
if self.is_greedy {
|
||||
argument.push_str("...");
|
||||
}
|
||||
|
||||
if self.is_optional {
|
||||
argument.push(']');
|
||||
} else {
|
||||
argument.push('>');
|
||||
}
|
||||
|
||||
write!(f, "{argument}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::Client;
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate, oauth::Application};
|
||||
|
||||
use crate::framework::{commands::messages::MessageSender, handlers::Handlers, interactions::registry::Registry};
|
||||
|
||||
pub struct CommandContext<'a> {
|
||||
pub event: &'a MessageCreate,
|
||||
pub handlers: Arc<Handlers>
|
||||
}
|
||||
|
||||
impl<'a> CommandContext<'a> {
|
||||
pub fn new(
|
||||
event: &'a MessageCreate,
|
||||
handlers: Arc<Handlers>,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
handlers
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self) -> MessageSender {
|
||||
MessageSender::new(
|
||||
self.event.channel_id,
|
||||
self.handlers.clone()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use std::{
|
||||
future::{Future, IntoFuture},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use twilight_http::{Error, Response, response::marker::EmptyBody};
|
||||
use twilight_model::{
|
||||
channel::{
|
||||
Message,
|
||||
message::{Component as TwilightComponent, Embed},
|
||||
},
|
||||
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||
id::{
|
||||
Id,
|
||||
marker::{ChannelMarker, InteractionMarker},
|
||||
},
|
||||
};
|
||||
use twilight_util::builder::InteractionResponseDataBuilder;
|
||||
|
||||
use crate::framework::{
|
||||
handlers::Handlers,
|
||||
interactions::components::{ActionRow, Component},
|
||||
};
|
||||
|
||||
pub struct MessageSender {
|
||||
channel_id: Id<ChannelMarker>,
|
||||
|
||||
handlers: Arc<Handlers>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component>)>,
|
||||
}
|
||||
|
||||
impl MessageSender {
|
||||
pub fn new(channel_id: Id<ChannelMarker>, handlers: Arc<Handlers>) -> Self {
|
||||
Self {
|
||||
channel_id,
|
||||
handlers,
|
||||
content: None,
|
||||
components: vec![],
|
||||
embeds: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl ToString) -> Self {
|
||||
self.content = Some(content.to_string());
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed(mut self, embed: Embed) -> Self {
|
||||
self.embeds.push(embed);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component + '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);
|
||||
|
||||
if let Some(content) = &self.content {
|
||||
builder = builder.content(content);
|
||||
}
|
||||
|
||||
let mut current_row = ActionRow::new(&[]);
|
||||
let mut current_index = 0;
|
||||
|
||||
let mut rows: Vec<TwilightComponent> = vec![];
|
||||
let mut custom_ids: Vec<String> = vec![];
|
||||
|
||||
for (index, component) in self.components {
|
||||
if index != current_index {
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
|
||||
current_index += 1;
|
||||
}
|
||||
|
||||
let custom_id = component.custom_id();
|
||||
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.handlers
|
||||
.registry
|
||||
.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
builder = builder.components(&rows);
|
||||
builder = builder.embeds(&self.embeds);
|
||||
|
||||
let result = builder.await;
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.handlers.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoFuture for MessageSender {
|
||||
type Output = Result<Response<Message>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(self.send())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessageUpdater {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
|
||||
handlers: Arc<Handlers>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component>)>,
|
||||
}
|
||||
|
||||
impl OriginalMessageUpdater {
|
||||
pub fn new(
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
handlers: Arc<Handlers>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interaction_id,
|
||||
interaction_token,
|
||||
handlers,
|
||||
content: None,
|
||||
components: vec![],
|
||||
embeds: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl ToString) -> Self {
|
||||
self.content = Some(content.to_string());
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed(mut self, embed: Embed) -> Self {
|
||||
self.embeds.push(embed);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<Response<EmptyBody>, Error> {
|
||||
let mut builder = InteractionResponseDataBuilder::new();
|
||||
|
||||
if let Some(content) = &self.content {
|
||||
builder = builder.content(content);
|
||||
}
|
||||
|
||||
let mut current_row = ActionRow::new(&[]);
|
||||
let mut current_index = 0;
|
||||
|
||||
let mut rows: Vec<TwilightComponent> = vec![];
|
||||
let mut custom_ids: Vec<String> = vec![];
|
||||
|
||||
for (index, component) in self.components {
|
||||
if index != current_index {
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
|
||||
current_index += 1;
|
||||
}
|
||||
|
||||
let custom_id = component.custom_id();
|
||||
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.handlers
|
||||
.registry
|
||||
.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
builder = builder.components(rows);
|
||||
builder = builder.embeds(self.embeds);
|
||||
|
||||
let result = self
|
||||
.handlers
|
||||
.http
|
||||
.interaction(self.handlers.app.id)
|
||||
.create_response(
|
||||
self.interaction_id,
|
||||
&self.interaction_token,
|
||||
&InteractionResponse {
|
||||
data: Some(builder.build()),
|
||||
kind: InteractionResponseType::UpdateMessage,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.handlers.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoFuture for OriginalMessageUpdater {
|
||||
type Output = Result<Response<EmptyBody>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(self.send())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::framework::commands::{arguments::Argument, context::CommandContext};
|
||||
|
||||
pub mod arguments;
|
||||
pub mod context;
|
||||
pub mod messages;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CommandError {
|
||||
#[error("A message was attempted to be sent but it failed")]
|
||||
MessageSendingFailed,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Command: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"A Payphone command."
|
||||
}
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Returns the command name + all its aliases.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Vec<&'static str>`] - A vector with the command's name and its aliases.
|
||||
fn identifiers(&self) -> Vec<&'static str> {
|
||||
let mut all = vec![self.name()];
|
||||
all.extend_from_slice(self.aliases());
|
||||
all
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: &CommandContext, args: &str) -> Result<(), CommandError>;
|
||||
}
|
||||
|
||||
pub struct CommandParts<'a> {
|
||||
pub name: &'a str,
|
||||
pub args: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> CommandParts<'a> {
|
||||
fn new(name: &'a str, args: &'a str) -> Self {
|
||||
Self { name, args }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_command_parts<'a>(prefix: &str, message: &'a str) -> Option<CommandParts<'a>> {
|
||||
let message_trimmed_start = message.trim_ascii_start();
|
||||
|
||||
if message_trimmed_start
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&prefix.to_ascii_lowercase())
|
||||
{
|
||||
let message = message_trimmed_start.split_at(prefix.len()).1;
|
||||
|
||||
match message.split_once(" ") {
|
||||
Some((name, args)) => Some(CommandParts::new(name, args)),
|
||||
None => Some(CommandParts::new(message, "")),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::channel::message::{
|
||||
Component as TwilightComponent,
|
||||
component::{ActionRow as TwilightActionRow, Button as TwilightButton, ButtonStyle},
|
||||
};
|
||||
|
||||
use crate::framework::interactions::{InteractionError, context::InteractionContext};
|
||||
|
||||
pub struct ActionRow {
|
||||
components: Vec<(Option<String>, Arc<dyn Component>)>,
|
||||
}
|
||||
|
||||
impl ActionRow {
|
||||
pub fn new(components: &[(Option<String>, Arc<dyn Component>)]) -> Self {
|
||||
Self {
|
||||
components: components.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, id: Option<String>, component: Arc<dyn Component>) {
|
||||
self.components.push((id, component));
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.components.len()
|
||||
}
|
||||
|
||||
pub fn build(self) -> TwilightComponent {
|
||||
TwilightComponent::ActionRow(TwilightActionRow {
|
||||
components: self
|
||||
.components
|
||||
.into_iter()
|
||||
.map(|(id, c)| c.build(id))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Component: Send + Sync {
|
||||
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_trait]
|
||||
impl<T> Component for T
|
||||
where
|
||||
T: Button,
|
||||
{
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Button::id(self)
|
||||
}
|
||||
|
||||
fn build(&self, id: Option<String>) -> TwilightComponent {
|
||||
TwilightComponent::Button(TwilightButton {
|
||||
custom_id: id,
|
||||
disabled: false,
|
||||
emoji: None,
|
||||
label: self.label().map(|i| i.to_string()),
|
||||
style: if self.url().is_none() {
|
||||
self.style()
|
||||
} else {
|
||||
ButtonStyle::Link
|
||||
},
|
||||
url: self.url().map(|i| i.to_string()),
|
||||
sku_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError> {
|
||||
self.on_press(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Button: Send + Sync {
|
||||
fn id(&self) -> Option<String> {
|
||||
if self.url().is_none() {
|
||||
Some(String::from("Button"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn style(&self) -> ButtonStyle {
|
||||
ButtonStyle::Secondary
|
||||
}
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext) -> Result<(), InteractionError> {
|
||||
ctx.defer().await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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}
|
||||
};
|
||||
|
||||
pub struct InteractionContext {
|
||||
pub event: InteractionCreate,
|
||||
pub handlers: Arc<Handlers>,
|
||||
}
|
||||
|
||||
impl InteractionContext {
|
||||
pub fn new(
|
||||
event: InteractionCreate,
|
||||
handlers: Arc<Handlers>,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
handlers
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn defer(&self) -> Result<(), InteractionError> {
|
||||
self.handlers.http
|
||||
.interaction(self.handlers.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
&InteractionResponse {
|
||||
data: None,
|
||||
kind: InteractionResponseType::DeferredUpdateMessage,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| InteractionError::DeferringFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn think(&self) -> Result<(), InteractionError> {
|
||||
self.handlers.http
|
||||
.interaction(self.handlers.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
&InteractionResponse {
|
||||
data: None,
|
||||
kind: InteractionResponseType::DeferredChannelMessageWithSource,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| InteractionError::DeferringFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn original_message(&self) -> Option<OriginalMessage> {
|
||||
self.event.message.as_ref().map(|_| OriginalMessage {
|
||||
interaction_id: self.event.id,
|
||||
interaction_token: self.event.token.clone(),
|
||||
handlers: self.handlers.clone()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessage {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
handlers: Arc<Handlers>
|
||||
}
|
||||
|
||||
impl OriginalMessage {
|
||||
pub fn edit(&self) -> OriginalMessageUpdater {
|
||||
OriginalMessageUpdater::new(
|
||||
self.interaction_id,
|
||||
self.interaction_token.clone(),
|
||||
self.handlers.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod components;
|
||||
pub mod context;
|
||||
pub mod registry;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InteractionError {
|
||||
#[error(
|
||||
"The ID for this interaction did not point to any existing interaction in the registry"
|
||||
)]
|
||||
InteractionNotFound,
|
||||
|
||||
#[error("The interaction was attempted to be deferred, but it failed to be done")]
|
||||
DeferringFailed,
|
||||
|
||||
#[error("Discord responded with an interaction type Payphone does not use")]
|
||||
InteractionTypeUnsent,
|
||||
|
||||
#[error("The original message was attempted to be edited, but it failed")]
|
||||
OriginalMessageEditingFailed,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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 super::components::Component;
|
||||
|
||||
pub struct Registry {
|
||||
inner: HashMap<String, Arc<dyn Component>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, id: impl ToString, component: Arc<dyn Component>) {
|
||||
self.inner.upsert(id.to_string(), component);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, id: impl ToString) {
|
||||
self.inner.remove(&id.to_string());
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
event: InteractionCreate,
|
||||
handlers: Arc<Handlers>,
|
||||
) -> 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 {
|
||||
component
|
||||
.execute(InteractionContext::new(event, handlers))
|
||||
.await
|
||||
} else {
|
||||
Err(InteractionError::InteractionNotFound)
|
||||
}
|
||||
}
|
||||
_ => Err(InteractionError::InteractionTypeUnsent),
|
||||
}
|
||||
} else {
|
||||
Err(InteractionError::InteractionTypeUnsent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod interactions;
|
||||
pub mod utils;
|
||||
pub mod handlers;
|
||||
@@ -0,0 +1,33 @@
|
||||
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,57 @@
|
||||
use colored::Colorize;
|
||||
use log::{Level, LevelFilter};
|
||||
use time::{OffsetDateTime, macros::format_description};
|
||||
|
||||
pub struct Logger {
|
||||
level: Level,
|
||||
}
|
||||
|
||||
impl log::Log for Logger {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= self.level
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
println!(
|
||||
"{} {} {}",
|
||||
get_formatted_timestamp(),
|
||||
get_formatted_level(record.level()),
|
||||
record.args(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static LOGGER: Logger = Logger { level: Level::Info };
|
||||
|
||||
pub fn init() {
|
||||
log::set_logger(&LOGGER)
|
||||
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn get_formatted_timestamp() -> String {
|
||||
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
|
||||
|
||||
let format = format_description!(
|
||||
"[day]/[month]/[year] at [hour]:[minute]:[second].[subsecond digits:2]"
|
||||
);
|
||||
|
||||
now.format(&format).unwrap().dimmed().to_string()
|
||||
}
|
||||
|
||||
fn get_formatted_level(level: Level) -> String {
|
||||
let string = format!("[{level}]");
|
||||
let string = format!("{string:<7}");
|
||||
|
||||
match level {
|
||||
Level::Trace => string.dimmed().to_string(),
|
||||
Level::Debug => string.white().to_string(),
|
||||
Level::Info => string.blue().to_string(),
|
||||
Level::Warn => string.yellow().to_string(),
|
||||
Level::Error => string.red().bold().to_string(),
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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 crate::{
|
||||
cli::Args, commands::register_default_components, discord::events,
|
||||
framework::{handlers::Handlers, interactions::registry::Registry},
|
||||
};
|
||||
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod constants;
|
||||
mod database;
|
||||
mod discord;
|
||||
mod framework;
|
||||
mod logging;
|
||||
mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
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);
|
||||
|
||||
log::info!("Welcome to Payphone, Nyeki!");
|
||||
log::info!(
|
||||
"Payphone is running on version v{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let db = database::init("127.0.0.1:9042")
|
||||
.await
|
||||
.expect("Could not connect to the database!");
|
||||
|
||||
let handlers = Arc::new(Handlers {
|
||||
app,
|
||||
db,
|
||||
http,
|
||||
registry
|
||||
});
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
pub trait ToTitleCase {
|
||||
fn to_title_case(&self) -> String;
|
||||
}
|
||||
|
||||
impl<T> ToTitleCase for T
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
fn to_title_case(&self) -> String {
|
||||
self.to_string()
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user