Files
payphone/src/main.rs
T
nyeki fcb1a786be Multiple things
Remove ArgoCD and Helm dependencies, add everything unnecessary to .dockerignore, add support for DB authentication in Payphone's CLI, make Scylla be fully managed by Ansible, add Payphone deployment ansible file
2025-08-18 01:06:03 -03:00

340 lines
11 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::{
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_gateway::{Intents, ShardId};
use crate::{
cli::{Args, Commands},
constants::DEFAULT_PREFIX,
database::models::Server,
framework::{
bot::{Bot, PrefixGetter},
runtime::Runtime,
},
groups::{
about::{commands::HelpCommand, components::HelpGoBackButton},
calls::{
commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand},
events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler},
},
gateway::events::{ReadyHandler, ReconnectHandler},
servers::{
commands::PrefixCommand,
events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler},
},
},
state::State,
};
mod cli;
mod constants;
mod database;
mod discord;
mod framework;
mod groups;
mod logging;
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,
} => {
start(
token,
database_addr,
database_username,
database_password,
shard.into(),
)
.await;
}
Commands::Docs { bind, open } => {
docs(bind, open).await;
}
}
}
}
struct ServerPrefixOrPayphoneMention;
#[async_trait]
impl PrefixGetter<State> for ServerPrefixOrPayphoneMention {
async fn get(
&self,
rt: &Runtime<State>,
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
}
}
/// Starts a Payphone shard.
async fn start(
token: String,
database_addr: String,
database_username: Option<String>,
database_password: Option<String>,
shard: ShardId,
) {
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 state = State { db };
let bot = Bot::new(state, ServerPrefixOrPayphoneMention)
.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)
.component(HelpGoBackButton)
.on_ready(ReadyHandler)
.on_reconnect(ReconnectHandler)
.on_message(CallMessageHandler)
.on_message_edit(CallMessageEditHandler)
.on_message_delete(CallMessageDeleteHandler)
.on_server_create(ServerCreateHandler)
.on_server_update(ServerUpdateHandler)
.on_server_delete(ServerDeleteHandler);
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::<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!");
}