//! 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::mpsc, }; 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}, constants::DEFAULT_PREFIX, 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, SkipCommand}, events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler}, }, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::{commands::LeaderboardCommand, events::MessageLevelingHandler}, moderation::commands::{IncidentsCommand, ModeratorCommand}, servers::{ commands::PrefixCommand, events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler}, }, }, state::State, }; mod cli; 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 for ServerPrefixOrPayphoneMention { async fn get( &self, rt: &Runtime, server_id: Option, _user_id: Option, ) -> Vec { 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 for PayphoneExtrasGetter { type Extras = Extras; async fn get_extra( &self, user: &TwilightUser, rt: &Runtime, ) -> Result> { log::trace!("Getting extras!"); let user = User::get_or_create_and_set_last_seen(user.clone(), &rt.state.db).await?; Ok(Extras { user }) } } /// Starts a Payphone shard. async fn start( token: String, database_addr: String, database_username: Option, database_password: Option, shard: ShardId, redis_urls: Vec, bot_owner_id: u64, ) { log::info!("Welcome to Payphone, Nyeki!"); log::info!( "Payphone is running on version v{}", env!("CARGO_PKG_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 state = State { db, redis: redis_pool, bot_owner_id }; 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(PrefixCommand) .command(LeaderboardCommand) .command(ModeratorCommand) .command(IncidentsCommand) .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) = mpsc::channel::>(); 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!"); }