modbot 0.9.0

Discord bot for https://mod.io. ModBot provides commands to search for mods and notifications about added & edited mods.
#![deny(rust_2018_idioms)]
#![deny(clippy::pedantic)]
#![allow(
    clippy::module_name_repetitions,
    clippy::similar_names,
    clippy::wildcard_imports
)]

use std::convert::Infallible;
use std::path::PathBuf;

use dotenv::dotenv;
use futures_util::future;
use tokio::sync::watch;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::EnvFilter;
use twilight_gateway::{CloseFrame, Event, Shard, StreamExt as _};

mod bot;
mod commands;
mod config;
mod db;
mod error;
mod metrics;
mod tasks;
mod util;

use bot::Context;
use db::init_db;
use metrics::Metrics;
use util::*;

const HELP: &str = "\
🤖 modbot. modbot. modbot.

USAGE:
  modbot [-c <config>]

OPTIONS:
  -c <config>       Path to config file

ENV:
  MODBOT_DEBUG_TIMESTAMP        Start time as Unix timestamp for polling the mod events
";

#[tokio::main]
async fn main() {
    if let Err(e) = try_main().await {
        tracing::error!("{e}");
        std::process::exit(1);
    }
}

async fn try_main() -> CliResult {
    dotenv().ok();
    let filter = EnvFilter::builder()
        .with_default_directive(LevelFilter::INFO.into())
        .from_env_lossy();
    tracing_subscriber::fmt().with_env_filter(filter).init();

    let mut args = pico_args::Arguments::from_env();
    if args.contains(["-h", "--help"]) {
        println!("{HELP}");
        std::process::exit(0);
    }

    let path = args
        .opt_value_from_os_str("-c", |s| Ok::<_, Infallible>(PathBuf::from(s)))?
        .unwrap_or_else(|| PathBuf::from("bot.toml"));

    let config = config::load_from_file(&path)
        .map_err(|e| format!("Failed to load config \"{}\": {e}", path.display()))?;

    let metrics = Metrics::new()?;
    let pool = init_db(&config.bot.database_url)?;
    let modio = init_modio(&config)?;

    let (shards, context) = bot::initialize(&config, modio, pool, metrics.clone()).await?;

    if let Some(cmd) = args.subcommand()? {
        match cmd {
            cmd if cmd == "check" => {
                check_subscriptions(&context).await?;
            }
            cmd => {
                eprintln!("unknown subcommand: {cmd:?}");
            }
        }
        std::process::exit(0);
    }

    tokio::spawn(metrics::serve(config.metrics, metrics));
    tokio::spawn(tasks::events::task(context.clone()));
    tokio::spawn(tasks::games::task(context.clone()));

    let (shutdown_tx, shutdown_rx) = watch::channel(false);
    let mut tasks = Vec::with_capacity(shards.len());

    for shard in shards {
        tasks.push(tokio::spawn(runner(
            context.clone(),
            shard,
            shutdown_rx.clone(),
        )));
    }

    tokio::signal::ctrl_c().await?;

    tracing::info!("Shutting down");
    _ = shutdown_tx.send(true);

    future::join_all(tasks).await;

    Ok(())
}

async fn runner(context: Context, mut shard: Shard, mut shutdown: watch::Receiver<bool>) {
    loop {
        tokio::select! {
            _ = shutdown.changed() => shard.close(CloseFrame::NORMAL),
            Some(event) = shard.next_event(bot::EVENTS) => {
                let event = match event {
                    Ok(Event::GatewayClose(_)) if *shutdown.borrow() => break,
                    Ok(event) => event,
                    Err(source) => {
                        tracing::warn!(?source, "error receiving event");
                        continue;
                    }
                };

                let context = context.clone();
                tokio::spawn(bot::handle_event(event, context));
            }
        }
    }
}