Author SHA1 Message Date
nyeki 458f89a3a3 Remove trace logs for the moderation module 2025-08-12 16:53:23 -03:00
nyeki 4df7c8ce7d Add message auto-censorship 2025-08-12 16:52:29 -03:00
nyeki 28f8c8e702 Push branch 2025-08-12 11:42:09 -03:00
18 changed files with 197 additions and 630 deletions
+4 -4
View File
@@ -45,16 +45,16 @@
//! ```shell
//! $ cargo run -- start [TOKEN]
//! ```
//!
//!
//! # Running the Documentation Server
//!
//!
//! Payphone has a built-in documentation server that serves the project's documentation and
//! refreshes it on changes. To start the documentation server, run the following command:
//!
//!
//! ```shell
//! $ cargo run -- docs --open
//! ```
//!
//!
//! The `--open` flag is optional and will open the documentation in the default browser after
//! starting the server. You can also use the `--bind` argument to specify the address to bind the
//! server to, which defaults to `0.0.0.0:3000`.
+41 -27
View File
@@ -6,28 +6,6 @@ CREATE KEYSPACE IF NOT EXISTS payphone
'enabled': false
};
CREATE TYPE IF NOT EXISTS payphone.message (
id BIGINT,
author_id BIGINT,
author_name TEXT,
author_avatar_url TEXT,
author_mask ASCII,
content TEXT,
attachments LIST<TEXT>,
sticker BIGINT,
propagation_message_id BIGINT,
propagation_channel_id BIGINT,
replying_to_id,
is_deleted BOOLEAN,
is_edited BOOLEAN,
is_censored BOOLEAN,
);
CREATE TABLE IF NOT EXISTS payphone.users (
user_id BIGINT PRIMARY KEY,
@@ -86,8 +64,24 @@ CREATE TABLE IF NOT EXISTS payphone.participants (
CREATE TABLE IF NOT EXISTS payphone.messages (
call_id BIGINT,
message_id BIGINT,
author_id BIGINT,
data FROZEN<payphone.message>,
author_name TEXT,
author_avatar_url TEXT,
author_mask ASCII,
content TEXT,
attachments LIST<TEXT>,
sticker BIGINT,
propagation_message_id BIGINT,
propagation_channel_id BIGINT,
replying_to_id BIGINT,
is_deleted BOOLEAN,
is_edited BOOLEAN,
is_censored BOOLEAN,
PRIMARY KEY (call_id, message_id)
);
@@ -98,11 +92,24 @@ CREATE TABLE IF NOT EXISTS payphone.servers (
name TEXT,
prefix TEXT,
);
CREATE TABLE IF NOT EXISTS payphone.reports (
report_id BIGINT,
message FROZEN<payphone.message>,
replying_to FROZEN<payphone.message>,
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,
@@ -125,8 +132,15 @@ CREATE TABLE IF NOT EXISTS payphone.user_incidents (
call_id BIGINT,
report_id BIGINT,
message FROZEN<payphone.message>,
replying_to FROZEN<payphone.message>,
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,
-213
View File
@@ -1,5 +1,4 @@
use scylla::{
DeserializeValue, SerializeValue,
client::session::Session,
deserialize::row::DeserializeRow,
errors::{ExecutionError, SingleRowError},
@@ -191,218 +190,6 @@ impl From<TwilightUser> for User {
}
}
#[derive(Debug, Clone, SerializeValue, DeserializeValue)]
pub struct Message {
pub id: i64,
pub author_id: i64,
pub author_name: String,
pub author_avatar_url: String,
pub author_mask: Option<String>,
pub content: String,
pub attachments: Option<Vec<String>>,
pub sticker: Option<i64>,
pub propagation_message_id: Option<i64>,
pub propagation_channel_id: Option<i64>,
pub replying_to_id: Option<i64>,
pub is_deleted: bool,
pub is_edited: bool,
pub is_censored: bool,
}
impl<'a> Object<'a> for Message {
type Row = (
i64,
i64,
String,
String,
Option<String>,
String,
Option<Vec<String>>,
Option<i64>,
Option<i64>,
Option<i64>,
Option<i64>,
bool,
bool,
bool,
);
fn from_row(row: Self::Row) -> Self {
Self {
id: row.0,
author_id: row.1,
author_name: row.2,
author_avatar_url: row.3,
author_mask: row.4,
content: row.5,
attachments: row.6,
sticker: row.7,
propagation_message_id: row.8,
propagation_channel_id: row.9,
replying_to_id: row.10,
is_deleted: row.11,
is_edited: row.12,
is_censored: row.13,
}
}
fn to_row(value: Self) -> Self::Row {
(
value.id,
value.author_id,
value.author_name,
value.author_avatar_url,
value.author_mask,
value.content,
value.attachments,
value.sticker,
value.propagation_message_id,
value.propagation_channel_id,
value.replying_to_id,
value.is_deleted,
value.is_edited,
value.is_censored,
)
}
}
pub struct MessageRow {
pub call_id: u64,
pub message_id: u64,
pub data: Message,
}
impl MessageRow {
pub async fn get_by_call_and_message_id(
call_id: u64,
message_id: u64,
session: &Session,
) -> Result<Option<Self>, DatabaseError> {
let call_id = u64_to_i64(call_id);
let message_id = u64_to_i64(message_id);
let result = session
.query_unpaged(
r#"
SELECT call_id, message_id, data
FROM payphone.messages
WHERE call_id = ? AND message_id = ?
LIMIT 1;
"#,
(call_id, message_id),
)
.await
.map_err(DatabaseError::QueryError)?
.into_rows_result()
.map_err(|_| DatabaseError::ResultNotRows)?;
match result.single_row::<<MessageRow as Object>::Row>() {
Ok(row) => Ok(Some(<MessageRow as Object>::from_row(row))),
Err(error) => match error {
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
Err(DatabaseError::DeserializationFailed)
}
SingleRowError::UnexpectedRowCount(_) => Ok(None),
},
}
}
}
impl<'a> Object<'a> for Report {
type Row = (
i64, // report_id
Message,
Option<Message>,
OffsetDateTime,
Option<OffsetDateTime>,
Vec<i64>,
Option<i64>,
Option<String>,
);
fn from_row(row: Self::Row) -> Self {
Self {
report_id: i64_to_u64(row.0),
message: row.1,
replying_to: row.2,
created_at: row.3,
reviewed_at: row.4,
created_by_ids: row.5.iter().map(|&id| i64_to_u64(id)).collect(),
reviewed_by_id: row.6.map(i64_to_u64),
resolution: row.7,
}
}
fn to_row(value: Self) -> Self::Row {
(
u64_to_i64(value.report_id),
value.message,
value.replying_to,
value.created_at,
value.reviewed_at,
value
.created_by_ids
.iter()
.map(|&id| u64_to_i64(id))
.collect(),
value.reviewed_by_id.map(u64_to_i64),
value.resolution,
)
}
}
// --- BEGIN: Payphone Message and Report Models ---
#[derive(Debug, Clone)]
pub struct Report {
pub report_id: u64,
pub message: Message,
pub replying_to: Option<Message>,
pub created_at: OffsetDateTime,
pub reviewed_at: Option<OffsetDateTime>,
pub created_by_ids: Vec<u64>,
pub reviewed_by_id: Option<u64>,
pub resolution: Option<String>,
}
impl<'a> Object<'a> for MessageRow {
type Row = (i64, i64, <Message as Object<'a>>::Row);
fn from_row(row: Self::Row) -> Self {
Self {
call_id: i64_to_u64(row.0),
message_id: i64_to_u64(row.1),
data: <Message as Object>::from_row(row.2),
}
}
fn to_row(value: Self) -> Self::Row {
(
u64_to_i64(value.call_id),
u64_to_i64(value.message_id),
<Message as Object>::to_row(value.data),
)
}
}
impl Report {
pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> {
let row = Self::to_row(self.clone());
session
.query_unpaged(
r#"
INSERT INTO payphone.reports (
report_id,
message,
replying_to,
created_at,
reviewed_at,
created_by_ids,
reviewed_by_id,
resolution
) VALUES (?, ?, ?, ?, ?, ?, ?, ?);
"#,
row,
)
.await
.map_err(DatabaseError::QueryError)?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Server {
pub id: u64,
+46 -38
View File
@@ -1,24 +1,24 @@
use crate::framework::{
commands::{Command, runner::CommandRunner},
events::traits::{
OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler,
OnServerUpdateHandler,
},
interactions::{
components::Component, message_command::MessageCommand,
message_command_registry::MessageCommandRegistry,
message_command_runner::MessageCommandRunner, registry::ComponentRegistry,
},
runtime::Runtime,
};
use crate::utils::Bye;
use async_trait::async_trait;
use std::{fmt::Debug, sync::Arc};
use async_trait::async_trait;
use thiserror::Error;
use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt};
use twilight_http::Client;
use crate::{
framework::{
commands::{Command, runner::CommandRunner},
events::traits::{
OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler,
OnServerUpdateHandler,
},
interactions::{components::Component, registry::ComponentRegistry},
runtime::Runtime,
},
utils::Bye,
};
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error("The application's details could not be fetched")]
@@ -37,14 +37,24 @@ pub trait IntoPrefixes<T> {
#[async_trait]
impl<T> IntoPrefixes<T> for String {
async fn get(&self, _: &Runtime<T>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
async fn get(
&self,
_: &Runtime<T>,
_: Option<u64>,
_: Option<u64>,
) -> Vec<String> {
vec![self.to_string()]
}
}
#[async_trait]
impl<T> IntoPrefixes<T> for &str {
async fn get(&self, _: &Runtime<T>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
async fn get(
&self,
_: &Runtime<T>,
_: Option<u64>,
_: Option<u64>,
) -> Vec<String> {
vec![self.to_string()]
}
}
@@ -54,7 +64,12 @@ impl<T: Send + Sync + 'static, const N: usize, S: Send + Sync + 'static> IntoPre
where
T: ToString,
{
async fn get(&self, _: &Runtime<S>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
async fn get(
&self,
_: &Runtime<S>,
_: Option<u64>,
_: Option<u64>,
) -> Vec<String> {
self.iter().map(|s| s.to_string()).collect()
}
}
@@ -65,20 +80,23 @@ where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Vec<String>> + Send,
{
async fn get(&self, _: &Runtime<T>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
async fn get(
&self,
_: &Runtime<T>,
_: Option<u64>,
_: Option<u64>,
) -> Vec<String> {
(self)().await
}
}
pub struct Bot<T: Send + Sync + Debug = ()> {
pub struct Bot<T: Send + Sync = ()> {
state: Arc<T>,
prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync>,
intents: Intents,
commands: Vec<Arc<dyn Command<T>>>,
components: Vec<Arc<dyn Component<T>>>,
on_ready_handlers: Vec<Arc<dyn OnReadyHandler<T>>>,
message_commands: Vec<Arc<dyn MessageCommand<T>>>,
message_command_registry: Arc<MessageCommandRegistry<T>>,
on_reconnect_handlers: Vec<Arc<dyn OnReconnectHandler<T>>>,
on_interaction_handlers: Vec<Arc<dyn OnInteractionHandler<T>>>,
on_message_handlers: Vec<Arc<dyn OnMessageHandler<T>>>,
@@ -89,22 +107,17 @@ pub struct Bot<T: Send + Sync + Debug = ()> {
on_server_delete_handlers: Vec<Arc<dyn OnServerDeleteHandler<T>>>,
}
impl<T: 'static + Send + Sync + Debug> Bot<T> {
impl<T: 'static + Send + Sync> Bot<T> {
pub fn new(state: T, prefixes: impl IntoPrefixes<T> + Send + Sync + 'static) -> Self {
let message_command_registry = Arc::new(MessageCommandRegistry::new());
Self {
state: Arc::new(state),
prefixes: Arc::new(prefixes),
intents: Intents::empty(),
commands: Vec::new(),
components: Vec::new(),
message_commands: Vec::new(),
message_command_registry: message_command_registry.clone(),
on_ready_handlers: Vec::new(),
on_reconnect_handlers: Vec::new(),
on_interaction_handlers: vec![Arc::new(MessageCommandRunner {
registry: message_command_registry,
})],
on_interaction_handlers: Vec::new(),
on_message_handlers: vec![Arc::new(CommandRunner)],
on_message_edit_handlers: Vec::new(),
on_message_delete_handlers: Vec::new(),
@@ -113,13 +126,6 @@ impl<T: 'static + Send + Sync + Debug> Bot<T> {
on_server_delete_handlers: Vec::new(),
}
}
pub fn message_command(mut self, command: impl MessageCommand<T> + 'static) -> Self {
let arc_command = Arc::new(command);
self.message_command_registry.register(arc_command.name(), arc_command.clone());
self.message_commands.push(arc_command);
self
}
pub fn intent(mut self, intent: Intents) -> Self {
self.intents.insert(intent);
@@ -192,10 +198,11 @@ impl<T: 'static + Send + Sync + Debug> Bot<T> {
.await
.inspect_err(|e| log::error!("Could not parse application data! {e:?}"))
.map_err(|_| RuntimeError::ApplicationFetchingFailed)?;
let registry = ComponentRegistry::new();
for component in &self.components {
let component_id = component.custom_id();
registry.register(
component_id.expect("The component registered by default must have a custom ID"),
component.clone(),
@@ -219,6 +226,7 @@ impl<T: 'static + Send + Sync + Debug> Bot<T> {
if let Some(Ok(event)) = gateway.next_event(EventTypeFlags::all()).await {
let task_rt = runtime.clone();
let task_bot = bot.clone();
tokio::spawn(async move {
match event {
Event::Ready(event) => {
+1 -6
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use twilight_model::{
gateway::payload::incoming::InteractionCreate,
http::interaction::{InteractionResponse, InteractionResponseType},
@@ -6,7 +7,6 @@ use twilight_model::{
};
use crate::framework::{
interactions::messages::InteractionResponder,
commands::messages::OriginalMessageUpdater, interactions::InteractionError, runtime::Runtime,
};
@@ -17,11 +17,6 @@ pub struct InteractionContext<T> {
}
impl<T> InteractionContext<T> {
pub fn send(
self: &Arc<Self>,
) -> InteractionResponder<T> {
InteractionResponder::new(self.clone())
}
pub fn new(event: InteractionCreate, rt: Arc<Runtime<T>>, prefix: String) -> Self {
Self { event, rt, prefix }
}
@@ -1,19 +0,0 @@
use crate::framework::interactions::{InteractionError, context::InteractionContext};
use async_trait::async_trait;
use std::fmt::Debug;
use twilight_model::gateway::payload::incoming::InteractionCreate;
#[async_trait]
pub trait MessageCommand<T>: Send + Sync + Debug {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str {
"A Payphone context menu command."
}
async fn execute(
&self,
ctx: InteractionContext<T>,
target_message_id: u64,
) -> Result<(), InteractionError>;
}
@@ -1,84 +0,0 @@
// to be later integrated into interaction registry...currently component registry
use crate::framework::{
interactions::{
InteractionError, context::InteractionContext, message_command::MessageCommand,
},
runtime::Runtime,
};
use scc::HashMap;
use std::sync::Arc;
use twilight_model::{
application::command::CommandType, application::interaction::InteractionData,
gateway::payload::incoming::InteractionCreate,
};
#[derive(Default, Debug)]
pub struct MessageCommandRegistry<T> {
inner: HashMap<String, Arc<dyn MessageCommand<T>>>,
}
impl<T> MessageCommandRegistry<T>
where
T: Send + Sync + 'static,
{
pub fn new() -> Self {
Self {
inner: Default::default(),
}
}
pub fn register(&self, name: impl ToString, command: Arc<dyn MessageCommand<T>>) {
self.inner.upsert(name.to_string(), command);
}
pub fn unregister(&self, name: impl ToString) {
self.inner.remove(&name.to_string());
}
pub async fn execute(
&self,
event: &InteractionCreate,
rt: Arc<Runtime<T>>,
) -> Result<(), InteractionError> {
if let Some(data) = &event.data {
if let InteractionData::ApplicationCommand(data) = data {
if data.kind == CommandType::Message {
if let Some(command) = self.inner.get_async(&data.name).await {
let prefixes = rt
.get_prefixes(
event.guild_id.map(|id| id.get()),
event.author_id().map(|id| id.get()),
)
.await;
let prefix = prefixes
.first()
.ok_or(InteractionError::PrefixesVectorEmpty)?;
// Message commands have a resolved target_id
if let Some(target_id) = data.target_id {
command
.execute(
InteractionContext::new(
event.clone(),
rt.clone(),
prefix.to_string(),
),
target_id.get(),
)
.await
} else {
Err(InteractionError::InteractionNotFound)
}
} else {
Err(InteractionError::InteractionNotFound)
}
} else {
Err(InteractionError::InteractionTypeUnsent)
}
} else {
Err(InteractionError::InteractionTypeUnsent)
}
} else {
Err(InteractionError::InteractionTypeUnsent)
}
}
}
@@ -1,30 +0,0 @@
use crate::framework::interactions::InteractionError::InteractionTypeUnsent;
use async_trait::async_trait;
use std::sync::Arc;
use twilight_model::gateway::payload::incoming::InteractionCreate;
use crate::framework::{
events::traits::{EventResult, OnInteractionHandler},
interactions::message_command_registry::MessageCommandRegistry,
runtime::Runtime,
};
#[derive(Debug)]
pub struct MessageCommandRunner<T> {
pub registry: Arc<MessageCommandRegistry<T>>,
}
#[async_trait]
impl<T: Send + Sync + std::fmt::Debug + 'static> OnInteractionHandler<T>
for MessageCommandRunner<T>
{
async fn on_interaction(&self, event: &InteractionCreate, rt: Arc<Runtime<T>>) -> EventResult {
if let Err(e) = self.registry.execute(event, rt).await {
// Only log errors that aren't "not a message command"
if !matches!(e, InteractionTypeUnsent) {
log::error!("Message command dispatch error: {e:?}");
}
}
Ok(())
}
}
-40
View File
@@ -1,40 +0,0 @@
use std::sync::Arc;
use twilight_model::http::interaction::{
InteractionResponse, InteractionResponseData, InteractionResponseType,
};
use crate::framework::{
interactions::{InteractionError, context::InteractionContext},
runtime::Runtime,
};
pub struct InteractionResponder<T> {
ctx: Arc<InteractionContext<T>>,
}
impl<T> InteractionResponder<T> {
pub fn new(ctx: Arc<InteractionContext<T>>) -> Self {
Self { ctx }
}
pub async fn content(&self, content: impl Into<String>) -> Result<(), InteractionError> {
self.ctx
.rt
.http
.interaction(self.ctx.rt.app.id)
.create_response(
self.ctx.event.id,
&self.ctx.event.token,
&InteractionResponse {
kind: InteractionResponseType::ChannelMessageWithSource,
data: Some(InteractionResponseData {
content: Some(content.into()),
..Default::default()
}),
},
)
.await
.map_err(|_| InteractionError::RespondingFailed)?;
Ok(())
}
}
+1 -10
View File
@@ -1,10 +1,7 @@
pub mod message_command_runner;
pub mod messages;
use thiserror::Error;
pub mod components;
pub mod context;
pub mod message_command;
pub mod message_command_registry;
pub mod registry;
pub mod runner;
@@ -24,12 +21,6 @@ pub enum InteractionError {
#[error("The original message was attempted to be edited, but it failed")]
OriginalMessageEditingFailed,
#[error("Responding to the interaction failed")]
RespondingFailed,
#[error("No prefixes were returned for the event")]
PrefixesVectorEmpty,
#[error(transparent)]
DatabaseError(#[from] crate::database::models::DatabaseError),
}
+37 -37
View File
@@ -1,35 +1,35 @@
//! A framework for building Discord bots, built with Twilight.
//!
//!
//! Since Twilight is a low-level library, it can be hard to build a bot with it. Payphone's
//! framework provides a high-level interface for building Discord bots, with a focus on modularity
//! and ease of use. The framework is designed to be extensible, allowing you to add new features
//! and commands without modifying the core codebase. It also provides a set of utilities for
//! common tasks, such as sending messages, handling events, and managing state.
//!
//!
//! # Creating a Bot
//!
//!
//! Creating a bot with Payphone is as simple as creating a new instance of the `Bot` struct and
//! running it. The `Bot` struct is the main entry point for the framework and provides methods for
//! running the bot, handling events, and managing state.
//!
//!
//! ```
//! use crate::framework::bot::Bot;
//! use crate::state::State;
//!
//!
//! // Your app's state.
//! let state = State::new();
//!
//!
//! // Create a new bot instance with the state and a list of prefixes.
//! let bot: Bot<State> = Bot::new(state, ["p."]);
//!
//!
//! bot.run(&token).await.unwrap();
//! ```
//!
//!
//! ### Prefixes
//!
//!
//! The framework supports multiple prefixes per command and allows you to set them dynamically
//! based on server and user IDs.
//!
//!
//! ```
//! async fn get_prefixes(
//! rt: &Runtime<State>,
@@ -37,15 +37,15 @@
//! user_id: Option<u64>
//! ) -> Vec<String> {
//! // The runtime contains your state at [`Runtime::state`].
//!
//!
//! vec!["p.".to_string()]
//! }
//!
//!
//! let bot: Bot<State> = Bot::new(state, get_prefixes);
//! ```
//!
//!
//! Otherwise, you can just pass a static list of prefixes as shown in the previous example.
//!
//!
//! ```
//! // All of these are valid ways to create a bot with a prefix.
//! let bot: Bot<State> = Bot::new(state, ["p."]);
@@ -53,77 +53,77 @@
//! let bot: Bot<State> = Bot::new(state, "p.".to_string());
//! let bot: Bot<State> = Bot::new(state, ["p.".to_string()]);
//! ```
//!
//!
//! Setting no prefix will make the bot raise errors when receiving messages.
//!
//!
//! ## Adding Commands
//!
//!
//! Creating a command is quite simple. You just need to implement the `Command` trait and register
//! it with the bot.
//!
//!
//! ```
//! use crate::framework::commands::{Command, CommandError, context::CommandContext};
//!
//!
//! pub struct MyCommand;
//!
//!
//! #[async_trait::async_trait]
//! impl Command<State> for MyCommand {
//! fn name(&self) -> &'static str {
//! "my_command"
//! }
//!
//!
//! async fn execute(&self, ctx: CommandContext<'_, T>, _args: &str) -> Result<(), CommandError> {
//! // Your command logic here.
//! ctx.send()
//! .content("Hello, world!")
//! .await
//! .map_err(|_| CommandError::MessageSendingFailed)?;
//!
//!
//! Ok(())
//! }
//! }
//! ```
//!
//!
//! Then you just need to register the command with the bot:
//!
//!
//! ```
//! let bot = Bot::new(state, ["p."]).command(MyCommand);
//!
//!
//! bot.run(&token).await.unwrap();
//! ```
//!
//!
//! Log into your Discord account and invite the bot to your server. Then you can test the command
//! by sending `p.my_command` in a channel the bot has access to.
//!
//!
//! ### Configuring Commands Further
//!
//!
//! Commands can be configured with additional metadata, such as aliases, descriptions, and
//! arguments. The following is a full example of a command with all these features:
//!
//!
//! ```
//! use crate::framework::commands::{
//! Command, CommandError, context::CommandContext, arguments::NeedyGreedyString
//! };
//! use crate::state::State;
//!
//!
//! pub struct HelloCommand;
//!
//!
//! #[async_trait::async_trait]
//! impl Command<State> for HelloCommand {
//! fn name(&self) -> &'static str {
//! "hello"
//! }
//!
//!
//! /// A list of alternative names for the command.
//! fn aliases(&self) -> &'static [&'static str] {
//! &["hola", "hey", "hi", "konnichiwa"]
//! }
//!
//!
//! /// A description of the command and its functionality.
//! fn description(&self) -> &'static str {
//! "This command responds with a greeting."
//! }
//!
//!
//! /// A list of arguments the command accepts. These are solely for documentation purposes,
//! /// they won't affect the parsing you do in the command's `execute` method.
//! fn arguments(&self) -> &'static [Argument] {
@@ -135,7 +135,7 @@
//! },
//! ]
//! }
//!
//!
//! async fn execute(
//! &self,
//! ctx: CommandContext<'_, T>,
@@ -146,12 +146,12 @@
//! args
//! )
//! .unwrap_or(NeedyGreedyString("???".to_string()));
//!
//!
//! ctx.send()
//! .content(format!("Hello, {}!", name.0))
//! .await
//! .map_err(|_| CommandError::MessageSendingFailed)?;
//!
//!
//! Ok(())
//! }
//! }
+2 -86
View File
@@ -1,98 +1,14 @@
use async_trait::async_trait;
use scc::HashSet;
use std::sync::Arc;
use twilight_model::id::Id;
use crate::{
database::models::{Call, Channel, Message, MessageRow, Report},
database::models::{Call, Channel},
framework::commands::{Command, CommandError, context::CommandContext},
framework::interactions::{
InteractionError, context::InteractionContext, message_command::MessageCommand,
},
state::State,
utils::{Bye, get_random_u64},
utils::Bye,
};
#[derive(Debug, Default)]
pub struct ReportMessageCommand;
#[async_trait]
impl MessageCommand<State> for ReportMessageCommand {
fn name(&self) -> &'static str {
"Report Message"
}
fn description(&self) -> &'static str {
"Report a message to the moderators."
}
async fn execute(
&self,
ctx: InteractionContext<State>,
target_message_id: u64,
) -> Result<(), InteractionError> {
// Extract channel_id from resolved data or fallback to event.channel (Discord API v10+)
let channel_id = ctx
.event
.channel
.as_ref()
.map(|c| c.id.get())
.or_else(|| ctx.event.channel.as_ref().map(|c| c.id.get()))
.ok_or(InteractionError::InteractionNotFound)?;
let db = &ctx.rt.state.db;
let channel = Channel::get_by_id(channel_id, db)
.await
.map_err(|_| InteractionError::InteractionNotFound)?;
let call_id = channel
.and_then(|c| c.call_id)
.ok_or(InteractionError::InteractionNotFound)?;
// Fetch the message from the database
let message_row = MessageRow::get_by_call_and_message_id(call_id, target_message_id, db)
.await
.map_err(|_| InteractionError::InteractionNotFound)?
.ok_or(InteractionError::InteractionNotFound)?;
// If the message is a reply, fetch the replied-to message and store in replying_to
let replying_to = if let Some(replying_to_id) = message_row.data.replying_to_id {
if let Ok(Some(reply_msg_row)) =
MessageRow::get_by_call_and_message_id(call_id, replying_to_id as u64, db).await
{
Some(reply_msg_row.data)
} else {
None
}
} else {
None
};
// Build the report using the new flat message and replying_to
let report = Report {
report_id: get_random_u64(),
message: message_row.data,
replying_to,
created_at: time::OffsetDateTime::now_utc(),
reviewed_at: None,
created_by_ids: vec![ctx.event.author_id().map(|id| id.get()).unwrap_or(0)],
reviewed_by_id: None,
resolution: None,
};
// Insert the report
report
.insert(db)
.await
.map_err(InteractionError::DatabaseError)?;
// Respond to the user
Arc::new(ctx)
.send()
.content("Message has been reported to the moderators.")
.await?;
Ok(())
}
}
#[derive(Default, Debug)]
pub struct CallCommand {
/// A set of channel IDs that are currently searching for a call. This works as a lock for two
+2 -3
View File
@@ -1,13 +1,12 @@
use std::{collections::HashMap, sync::Arc};
use std::sync::Arc;
use async_trait::async_trait;
use linkify::LinkFinder;
use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id};
use twilight_validate::request::webhook_username;
use crate::{
database::models::{Call, Channel},
discord::{images, sanitizing::SanitizeDiscordMarkdown},
discord::images,
framework::{
events::traits::{EventResult, OnMessageHandler},
runtime::Runtime,
@@ -0,0 +1 @@
slap that ass
+41 -10
View File
@@ -62,11 +62,11 @@ pub fn escape_message(event: &MessageCreate) -> String {
message = message.replace(url, &"\\#".repeat(url.len()));
}
let normalized = normalize(message.clone());
let (censored, rules) = moderate_message(&message);
log::debug!("Escaped and normalized message: {normalized:?}");
log::debug!("Escaped and normalized message: {censored:?}");
message.sanitize(false)
censored
}
#[derive(Debug)]
@@ -113,13 +113,16 @@ pub fn normalize(message: String) -> NormalizedMessage {
for char in unidecoded.chars() {
if char.is_alphanumeric() {
normalized.push(char);
mappings.push(i);
} else {
normalized.push(' ');
}
mappings.push(i);
}
}
NormalizedMessage {
message: normalized,
message: normalized.to_ascii_lowercase(),
mappings,
}
}
@@ -127,8 +130,12 @@ pub fn normalize(message: String) -> NormalizedMessage {
fn regex_from_phrase(phrase: &str) -> String {
let mut pattern = String::new();
for ch in phrase.replace(" ", "").chars() {
pattern.push_str(&format!("{ch}+\\s*"));
for (i, ch) in phrase.replace(" ", "").chars().enumerate() {
if i == phrase.len() - 1 {
pattern.push_str(&format!("{ch}+"));
} else {
pattern.push_str(&format!("{ch}+\\s*"));
}
}
let pattern = format!("\\b{pattern}\\b");
@@ -170,17 +177,41 @@ pub fn censor_message(
) -> String {
let mut censored = original.to_string();
for (findings, rule) in analysis {
// Collect all ranges to censor
let mut ranges = Vec::new();
for (findings, _) in analysis {
for finding in findings {
while let Some(start) = normalized.message.find(finding) {
let end = start + finding.len();
for (index, _) in normalized.message.match_indices(finding) {
let start_index = normalized.mappings.get(index).cloned().unwrap_or(0);
let end_index = normalized.mappings.get(index + finding.len()).cloned().unwrap_or(original.len());
ranges.push((start_index, end_index));
}
}
}
// Sort ranges in reverse order to avoid shifting indices
ranges.sort_by(|a, b| b.0.cmp(&a.0));
for (start_index, end_index) in ranges {
if start_index < end_index && end_index <= censored.len() {
censored.replace_range(start_index..end_index, "[censored]");
}
}
censored
}
pub fn moderate_message(original: &str) -> (String, Vec<Arc<dyn Rule>>) {
let normalized = normalize(original.to_string());
let analysis = analyze_message(&normalized.message);
let censored = censor_message(original, normalized, &analysis);
(censored, analysis.iter().map(|(_, r)| r.clone()).collect())
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Action {
BlockAndPenalty,
+10 -10
View File
@@ -1,16 +1,16 @@
//! Log handling and formatting of the console's output.
//!
//!
//! # Logging
//!
//!
//! Payphone uses the [`log`] crate for logging, and the [`colored`] crate for formatting the
//! output.
//!
//!
//! Any logs that are not coming from the `payphone` crate are ignored.
//!
//!
//! ## Logging Levels
//!
//!
//! There are 5 different logging levels:
//!
//!
//! - `trace`: The most verbose level, used for debugging purposes.
//! - `debug`: Events that are useful to be displayed during development, but not in production.
//! - `info`: Used for general information about the bot's state and milestones.
@@ -19,9 +19,9 @@
//! - `error`: Used for errors, when something goes wrong and the bot cannot continue running.
//!
//! ## Usage
//!
//!
//! Logging is quite simple as it works like any [`println!`] calls.
//!
//!
//! ```
//! log::trace!("This is a trace message.");
//! log::debug!("This is a debug message.");
@@ -29,9 +29,9 @@
//! log::warn!("This is a warning message.");
//! log::error!("This is an error message.");
//! ```
//!
//!
//! Logging supports formatting, so you can use it like any other [`println!`] call:
//!
//!
//! ```
//! let value = 42;
//! log::info!("The value is: {value}");
+7 -8
View File
@@ -107,9 +107,15 @@ use std::{
sync::mpsc,
};
use axum::{Router, response::Redirect, routing};
use clap::Parser;
use notify::{Event, EventKind, Watcher};
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use twilight_gateway::Intents;
use crate::{
cli::{Args, Commands},
groups::calls::commands::ReportMessageCommand,
framework::bot::Bot,
groups::{
about::{commands::HelpCommand, components::HelpGoBackButton},
@@ -122,12 +128,6 @@ use crate::{
},
state::State,
};
use axum::{Router, response::Redirect, routing};
use clap::Parser;
use notify::{Event, EventKind, Watcher};
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use twilight_gateway::Intents;
mod cli;
mod constants;
@@ -185,7 +185,6 @@ async fn start(token: String, database_addr: String) {
.command(CallCommand::new())
.command(HangupCommand)
.component(HelpGoBackButton)
.message_command(ReportMessageCommand)
.on_ready(ReadyHandler)
.on_reconnect(ReconnectHandler)
.on_message(MessageRedirector)
+4 -5
View File
@@ -1,25 +1,24 @@
//! Application state at runtime including shared resources.
//!
//!
//! Currently, it only contains the database connection, but it can be extended in the future to
//! include other resources that need to be shared across the application, such as caches or
//! configuration settings.
//!
//!
//! It is shared around the application using an `Arc`, allowing it to be cloned and passed around
//! without needing to worry about ownership or lifetimes. This is particularly useful in an
//! asynchronous context.
//!
//!
//! ```
//! let state = State {
//! db: database::init("127.0.0.1:9042").await?,
//! };
//!
//!
//! fn handler_which_needs_state(state: Arc<State>) {
//! // Use the state here
//! }
//! ```
use scylla::client::session::Session;
#[derive(Debug)]
pub struct State {
pub db: Session,