431 lines
14 KiB
Rust
431 lines
14 KiB
Rust
//! Payphone is a Discord bot that allows users to make calls to each other, similar to a phone
|
|
//! system.
|
|
//!
|
|
//! # Setting Up
|
|
//!
|
|
//! First of all, clone the Git repository and navigate to the project directory:
|
|
//!
|
|
//! ```shell
|
|
//! $ git clone --branch rewrite https://git.nyeki.dev/payphone/payphone.git
|
|
//! $ cd payphone
|
|
//! ```
|
|
//!
|
|
//! To boot up Payphone, you'll need both a Discord bot token and a Scylla database cluster with
|
|
//! at least 3 nodes.
|
|
//!
|
|
//! - To get a Discord bot token, create a new bot application on the
|
|
//! [Discord Developer Portal](https://discord.com/developers/applications).
|
|
//!
|
|
//! - To set up a Scylla database cluster, make sure to have Docker and Docker Compose installed
|
|
//! and run `docker compose up -d`. A development cluster will be created with 3 nodes, with one
|
|
//! of them listening at `127.0.0.1:9042`.
|
|
//!
|
|
//! To stop the database cluster at any time, run `docker compose down`. To stop it and delete the
|
|
//! volumes (resetting the database), run `docker compose down -v`.
|
|
//!
|
|
//! Before booting up scylla, you'll need to set the `aio-max-nr` kernel parameter to `1048576` or
|
|
//! higher. You can check [Scylla's documentation](https://hub.docker.com/r/scylladb/scylla) for
|
|
//! the math of the value at which you'll need to set the parameter. In linux, you can set it by
|
|
//! running:
|
|
//!
|
|
//! ```shell
|
|
//! $ sudo sysctl -w fs.aio-max-nr=1048576
|
|
//! ```
|
|
//!
|
|
//! That will set it up for your current session. To make it persistent across reboots, add the
|
|
//! following to your `/etc/sysctl.conf` file (or to a new file in `/etc/sysctl.d/`):
|
|
//!
|
|
//! ```conf
|
|
//! fs.aio-max-nr = 1048576
|
|
//! ```
|
|
//!
|
|
//! ## Booting Up
|
|
//!
|
|
//! Payphone has a CLI interface that allows you to pass the bot token and database URL as CLI
|
|
//! arguments. To get all possible arguments, run the following command:
|
|
//!
|
|
//! ```shell
|
|
//! $ cargo run -- --help
|
|
//! ```
|
|
//!
|
|
//! Payphone also supports configuration via environment variables and `.env` files by default.
|
|
//!
|
|
//! ```env
|
|
//! PAYPHONE_TOKEN="your_discord_bot_token"
|
|
//! ```
|
|
//!
|
|
//! All environment variables are prefixed with `PAYPHONE_` and can be used to override the CLI
|
|
//! arguments.
|
|
//!
|
|
//! # Framework
|
|
//!
|
|
//! Payphone has a custom framework that allows you to create commands, components, and event
|
|
//! handlers easily. The framework is designed to be modular and extensible, allowing you to add
|
|
//! new features and commands without modifying the core codebase. The documentation for the
|
|
//! framework is all in the [`framework`] module.
|
|
//!
|
|
//! # Database
|
|
//!
|
|
//! The database Payphone runs on is ScyllaDB, a high-performance NoSQL database compatible with
|
|
//! Apache Cassandra. For documentation on how to interact with the database, check the
|
|
//! [`database`] module's documentation.
|
|
//!
|
|
//! # Application Code
|
|
//!
|
|
//! Payphone's application code is under the [`groups`] module. It contains all commands, events,
|
|
//! embeds, and other configurations for the bot grouped in submodules (e.g. [`groups::calls`],
|
|
//! [`groups::about`], etc.), all which can be found at the [`groups`] module's documentation.
|
|
//!
|
|
//! Each submodule contains files like `commands.rs`, `events.rs`, `embeds.rs`, and `components.rs`
|
|
//! that define the commands, events, embeds, and components for that group. Some common file names
|
|
//! are:
|
|
//!
|
|
//! - `commands.rs`: Contains the commands for the group.
|
|
//! - `events.rs`: Contains the event handlers for the group.
|
|
//! - `embeds.rs`: Contains the embeds for the group.
|
|
//! - `components.rs`: Contains the components for the group (action rows, buttons, soon also
|
|
//! select menus).
|
|
//!
|
|
//! Always try to follow the same structure and patterns as the existing code to maintain
|
|
//! consistency and readability.
|
|
//!
|
|
//! More documentation about each group can be found in the respective submodule's documentation.
|
|
//!
|
|
//! # 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
|
|
//! ```
|
|
|
|
use std::{
|
|
error::Error,
|
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
path::Path,
|
|
process::Command,
|
|
sync::Arc,
|
|
};
|
|
|
|
use async_trait::async_trait;
|
|
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_cache_inmemory::ResourceType;
|
|
use twilight_gateway::{Intents, ShardId};
|
|
use twilight_model::{
|
|
gateway::{
|
|
payload::outgoing::update_presence::UpdatePresencePayload,
|
|
presence::{Activity, ActivityType, Status},
|
|
},
|
|
user::User as TwilightUser,
|
|
};
|
|
|
|
use crate::{
|
|
cli::{Args, Commands},
|
|
colors::init_color_fetching_queue_listener,
|
|
constants::{DEFAULT_PREFIX, VERSION},
|
|
database::models::{Server, User},
|
|
extras::Extras,
|
|
framework::{
|
|
bot::{Bot, PrefixGetter},
|
|
commands::extras::ExtrasGetter,
|
|
runtime::Runtime,
|
|
},
|
|
groups::{
|
|
about::{commands::HelpCommand, components::HelpGoBackButton},
|
|
calls::{
|
|
commands::{
|
|
CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand,
|
|
},
|
|
events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler},
|
|
},
|
|
gateway::events::{ReadyHandler, ReconnectHandler},
|
|
leveling::{commands::LeaderboardCommand, events::MessageLevelingHandler},
|
|
moderation::commands::{IncidentsCommand, ModeratorCommand, StatsCommand},
|
|
servers::{
|
|
commands::PrefixCommand,
|
|
events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler},
|
|
},
|
|
},
|
|
state::State,
|
|
};
|
|
|
|
mod cli;
|
|
mod colors;
|
|
mod constants;
|
|
mod database;
|
|
mod discord;
|
|
mod extras;
|
|
mod framework;
|
|
mod groups;
|
|
mod logging;
|
|
mod redis;
|
|
mod state;
|
|
mod utils;
|
|
|
|
/// Payphone's main entrypoint function.
|
|
///
|
|
/// Parses ENV variables and CLI arguments, sets up the logger, and runs [`start`] or [`docs`]
|
|
/// depending on the command.
|
|
#[tokio::main]
|
|
pub async fn main() {
|
|
dotenvy::dotenv().ok();
|
|
|
|
let args = Args::parse();
|
|
|
|
logging::init(args.logging.into());
|
|
|
|
if let Some(command) = args.command {
|
|
match command {
|
|
Commands::Start {
|
|
token,
|
|
database_addr,
|
|
database_username,
|
|
database_password,
|
|
shard,
|
|
redis_urls,
|
|
bot_owner_id,
|
|
} => {
|
|
start(
|
|
token,
|
|
database_addr,
|
|
database_username,
|
|
database_password,
|
|
shard.into(),
|
|
redis_urls,
|
|
bot_owner_id,
|
|
)
|
|
.await;
|
|
}
|
|
Commands::Docs { bind, open } => {
|
|
docs(bind, open).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ServerPrefixOrPayphoneMention;
|
|
|
|
#[async_trait]
|
|
impl PrefixGetter<State, Extras> for ServerPrefixOrPayphoneMention {
|
|
async fn get(
|
|
&self,
|
|
rt: &Runtime<State, Extras>,
|
|
server_id: Option<u64>,
|
|
_user_id: Option<u64>,
|
|
) -> Vec<String> {
|
|
let mention = format!("<@{}>", rt.app.bot.as_ref().unwrap().id);
|
|
|
|
let default_prefixes = vec![DEFAULT_PREFIX.to_string(), mention.clone()];
|
|
|
|
if let Some(server_id) = server_id {
|
|
if let Ok(Some(server)) = Server::get_by_id(server_id, &rt.state.db).await {
|
|
return vec![server.prefix, mention];
|
|
}
|
|
}
|
|
|
|
default_prefixes
|
|
}
|
|
}
|
|
|
|
struct PayphoneExtrasGetter;
|
|
|
|
#[async_trait]
|
|
impl ExtrasGetter<State> for PayphoneExtrasGetter {
|
|
type Extras = Extras;
|
|
|
|
async fn get_extra(
|
|
&self,
|
|
user: &TwilightUser,
|
|
rt: &Runtime<State, Self::Extras>,
|
|
) -> Result<Self::Extras, Box<dyn Error>> {
|
|
log::trace!("Getting extras!");
|
|
|
|
let user = User::create_or_update(user.clone(), &rt.state.db, &rt.state.color_queue_sender)
|
|
.await?;
|
|
|
|
Ok(Extras { user })
|
|
}
|
|
}
|
|
|
|
/// Starts a Payphone shard.
|
|
async fn start(
|
|
token: String,
|
|
database_addr: String,
|
|
database_username: Option<String>,
|
|
database_password: Option<String>,
|
|
shard: ShardId,
|
|
redis_urls: Vec<String>,
|
|
bot_owner_id: u64,
|
|
) {
|
|
log::info!("Welcome to Payphone, Nyeki!");
|
|
log::info!("Payphone is running on version v{VERSION}");
|
|
|
|
let db = database::init(database_addr, database_username, database_password)
|
|
.await
|
|
.expect("Could not connect to the database!");
|
|
|
|
log::info!("Database connection established successfully.");
|
|
|
|
let redis_pool = redis::init(redis_urls).expect("Could not connect to Redis!");
|
|
|
|
log::info!("Redis connection established successfully.");
|
|
|
|
let (color_queue_sender, color_queue_receiver) = tokio::sync::mpsc::channel(100);
|
|
|
|
let state = Arc::new(State {
|
|
db,
|
|
redis: redis_pool,
|
|
bot_owner_id,
|
|
color_queue_sender,
|
|
});
|
|
|
|
init_color_fetching_queue_listener(color_queue_receiver, state.clone());
|
|
|
|
let bot = Bot::new(state, ServerPrefixOrPayphoneMention, PayphoneExtrasGetter)
|
|
.shard(shard)
|
|
.intent(Intents::GUILDS)
|
|
.intent(Intents::GUILD_MESSAGES)
|
|
.intent(Intents::MESSAGE_CONTENT)
|
|
.command(HelpCommand)
|
|
.command(CallCommand::new())
|
|
.command(SkipCommand)
|
|
.command(HangupCommand)
|
|
.command(FriendRequestCommand)
|
|
.command(MaskCommand)
|
|
.command(LeaderboardCommand)
|
|
.command(PrefixCommand)
|
|
.command(ModeratorCommand)
|
|
.command(IncidentsCommand)
|
|
.command(StatsCommand)
|
|
.component(HelpGoBackButton)
|
|
.cache_resource_type(ResourceType::GUILD)
|
|
.cache_resource_type(ResourceType::CHANNEL)
|
|
.cache_resource_type(ResourceType::MEMBER)
|
|
.cache_resource_type(ResourceType::ROLE)
|
|
.on_ready(ReadyHandler)
|
|
.on_reconnect(ReconnectHandler)
|
|
.on_message(CallMessageHandler)
|
|
.on_message(MessageLevelingHandler)
|
|
.on_message_edit(CallMessageEditHandler)
|
|
.on_message_delete(CallMessageDeleteHandler)
|
|
.on_server_create(ServerCreateHandler)
|
|
.on_server_update(ServerUpdateHandler)
|
|
.on_server_delete(ServerDeleteHandler)
|
|
.presence(
|
|
UpdatePresencePayload::new(
|
|
vec![Activity {
|
|
application_id: None,
|
|
assets: None,
|
|
buttons: vec![],
|
|
created_at: None,
|
|
details: None,
|
|
emoji: None,
|
|
flags: None,
|
|
id: None,
|
|
instance: None,
|
|
kind: ActivityType::Listening,
|
|
name: format!("p.help - {}/{}", shard.number() + 1, shard.total()),
|
|
party: None,
|
|
secrets: None,
|
|
state: None,
|
|
timestamps: None,
|
|
url: None,
|
|
}],
|
|
false,
|
|
None,
|
|
Status::Online,
|
|
)
|
|
.expect("Could not set bot presence since the parameters passed were invalid"),
|
|
);
|
|
|
|
bot.run(&token).await.expect("Could not run bot!");
|
|
}
|
|
|
|
/// Boots up a documentation server.
|
|
///
|
|
/// It compiles the crate's documentation and recompiles it automatically when the source code
|
|
/// changes.
|
|
async fn docs(bind: SocketAddr, open_on_start: bool) {
|
|
log::info!("Compiling documentation...");
|
|
|
|
Command::new("cargo")
|
|
.args(["doc", "--no-deps"])
|
|
.output()
|
|
.expect("Failed to run `cargo doc`");
|
|
|
|
tokio::spawn(async {
|
|
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
|
|
|
|
let mut watcher = notify::recommended_watcher(tx).expect("Failed to create watcher");
|
|
|
|
watcher
|
|
.watch(Path::new("src/"), notify::RecursiveMode::Recursive)
|
|
.expect("Failed to watch src directory");
|
|
|
|
for res in rx {
|
|
match res {
|
|
Ok(event) => {
|
|
match event.kind {
|
|
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
|
log::info!("Source files changed, recompiling...");
|
|
}
|
|
_ => continue,
|
|
}
|
|
|
|
Command::new("cargo")
|
|
.args(["doc", "--no-deps"])
|
|
.output()
|
|
.expect("Failed to run `cargo doc`");
|
|
}
|
|
Err(e) => {
|
|
log::error!("Watch error: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
log::info!("Starting documentation server on address {bind}...");
|
|
|
|
let docs: Router<()> = Router::new()
|
|
.route(
|
|
"/",
|
|
routing::get(|| async { Redirect::permanent("/payphone/") }),
|
|
)
|
|
.fallback_service(ServeDir::new(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/target/doc/"
|
|
)));
|
|
|
|
let openable_address = if bind.ip() == IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)) {
|
|
format!("http://localhost:{}", bind.port())
|
|
} else {
|
|
format!("http://{bind}/")
|
|
};
|
|
|
|
let listener = TcpListener::bind(bind)
|
|
.await
|
|
.expect("Could not bind to address!");
|
|
|
|
log::info!("Documentation server is running on {openable_address}");
|
|
|
|
let handle = tokio::spawn(async move {
|
|
axum::serve(listener, docs)
|
|
.await
|
|
.expect("Could not start documentation server!")
|
|
});
|
|
|
|
if open_on_start {
|
|
match open::that(openable_address) {
|
|
Ok(_) => log::info!("Opened documentation in browser!"),
|
|
Err(e) => log::error!("Failed to open documentation in browser: {e}"),
|
|
}
|
|
}
|
|
|
|
handle.await.expect("Documentation server task failed!");
|
|
}
|