Initialize Twilight project, set up basic framework, initialize DB module

This commit is contained in:
2025-08-07 04:32:34 -03:00
parent 00d61545eb
commit 24d2344f37
29 changed files with 4095 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
.env
Generated
+2370
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#[derive(clap::Parser)]
pub struct Args {
#[arg(env = "PAYPHONE_TOKEN")]
pub token: String,
}
+217
View File
@@ -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")
}
}
+59
View File
@@ -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,
);
}
}
+5
View File
@@ -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;
+152
View File
@@ -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
);
+93
View File
@@ -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(())
}
+13
View File
@@ -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");
}
+11
View File
@@ -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:?}");
}
}
+12
View File
@@ -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)
}
+25
View File
@@ -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
}
_ => {}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod events;
+222
View File
@@ -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}")
}
}
+30
View File
@@ -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()
)
}
}
+252
View File
@@ -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())
}
}
+69
View File
@@ -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
}
}
+12
View File
@@ -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,
}
+103
View File
@@ -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
}
}
+88
View File
@@ -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(),
)
}
}
+22
View File
@@ -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,
}
+55
View File
@@ -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)
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod commands;
pub mod interactions;
pub mod utils;
pub mod handlers;
+33
View File
@@ -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
}
}
+57
View File
@@ -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
View File
@@ -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(&registry);
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.");
}
}
}
+22
View File
@@ -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(" ")
}
}