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
+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
}
}