modbot 0.9.0

Discord bot for https://mod.io. ModBot provides commands to search for mods and notifications about added & edited mods.
use modio::types::games::Game;
use modio::util::DataFromRequest;
use twilight_http::client::InteractionClient;
use twilight_model::application::command::{
    Command, CommandOptionChoice, CommandOptionChoiceValue,
};
use twilight_model::application::interaction::application_command::{
    CommandData, CommandDataOption, CommandOptionValue,
};
use twilight_model::application::interaction::message_component::MessageComponentInteractionData;
use twilight_model::application::interaction::Interaction;
use twilight_model::channel::message::MessageFlags;
use twilight_model::http::interaction::{
    InteractionResponse, InteractionResponseData, InteractionResponseType,
};
use twilight_util::builder::embed::EmbedBuilder;
use twilight_util::builder::InteractionResponseDataBuilder;

use crate::bot::Context;
use crate::db::autocomplete::{games_by_name, games_by_name_id};
use crate::db::types::{ChannelId, GuildId};
use crate::error::Error;

mod basic;
mod game;
mod help;
pub mod mods;
mod subs;

fn commands() -> Vec<Command> {
    let mut cmds = Vec::new();
    cmds.extend(help::commands());
    cmds.extend(basic::commands());
    cmds.extend(game::commands());
    cmds.extend(mods::commands());
    cmds.extend(subs::commands());
    cmds
}

pub async fn register(client: &InteractionClient<'_>) -> Result<(), Error> {
    client.set_global_commands(&commands()).await?;
    Ok(())
}

pub async fn handle_command(ctx: &Context, interaction: &Interaction, command: &CommandData) {
    ctx.metrics.commands.inc(&command.name);

    let res = match command.name.as_str() {
        "about" => basic::about(ctx, interaction).await,
        "help" => help::help(ctx, interaction, command).await,
        "settings" => basic::settings(ctx, interaction, command).await,
        "games" => game::games(ctx, interaction, command).await,
        "game" => game::game(ctx, interaction).await,
        "mods" => mods::list(ctx, interaction, command).await,
        "popular" => mods::popular(ctx, interaction, command).await,
        "subs" => subs::handle_command(ctx, interaction, command).await,
        _ => Ok(()),
    };
    if let Err(e) = res {
        tracing::error!("{e}");
    }
}

pub async fn handle_component(
    ctx: &Context,
    interaction: &Interaction,
    component: &MessageComponentInteractionData,
) {
    if component.custom_id.starts_with("mods:") {
        if let Err(e) = mods::list_component(ctx, interaction, component).await {
            tracing::error!("{e}");
        }
    }
}

trait EphemeralMessage {
    fn into_ephemeral(self) -> InteractionResponseData;
}

impl EphemeralMessage for &str {
    fn into_ephemeral(self) -> InteractionResponseData {
        InteractionResponseDataBuilder::new()
            .ephemeral(self)
            .build()
    }
}

impl EphemeralMessage for String {
    fn into_ephemeral(self) -> InteractionResponseData {
        InteractionResponseDataBuilder::new()
            .ephemeral(self)
            .build()
    }
}

impl EphemeralMessage for EmbedBuilder {
    fn into_ephemeral(self) -> InteractionResponseData {
        let embed = self.build();
        InteractionResponseDataBuilder::new()
            .flags(MessageFlags::EPHEMERAL)
            .embeds([embed])
            .build()
    }
}

trait InteractionExt {
    fn guild_id(&self) -> Option<GuildId>;
    fn channel_id(&self) -> Option<ChannelId>;
}

impl InteractionExt for Interaction {
    fn guild_id(&self) -> Option<GuildId> {
        self.guild_id.map(GuildId)
    }

    fn channel_id(&self) -> Option<ChannelId> {
        self.channel.as_ref().map(|c| ChannelId(c.id))
    }
}

trait InteractionResponseBuilderExt {
    fn ephemeral(self, content: impl Into<String>) -> InteractionResponseDataBuilder;
}

impl InteractionResponseBuilderExt for InteractionResponseDataBuilder {
    fn ephemeral(self, content: impl Into<String>) -> InteractionResponseDataBuilder {
        self.content(content).flags(MessageFlags::EPHEMERAL)
    }
}

trait SubCommandExt {
    fn subcommand(&self) -> Option<(&str, &[CommandDataOption])>;
}

fn find_subcommand(opts: &[CommandDataOption]) -> Option<(&str, &[CommandDataOption])> {
    opts.iter().find_map(|opt| match &opt.value {
        CommandOptionValue::SubCommandGroup(opts) | CommandOptionValue::SubCommand(opts) => {
            Some((opt.name.as_str(), opts.as_slice()))
        }
        _ => None,
    })
}

impl SubCommandExt for &CommandData {
    fn subcommand(&self) -> Option<(&str, &[CommandDataOption])> {
        find_subcommand(&self.options)
    }
}

impl SubCommandExt for &[CommandDataOption] {
    fn subcommand(&self) -> Option<(&str, &[CommandDataOption])> {
        find_subcommand(self)
    }
}

trait AutocompleteExt {
    fn autocomplete(&self) -> Option<(&str, &str)>;
}

fn find_autocomplete_option(opts: &[CommandDataOption]) -> Option<(&str, &str)> {
    for opt in opts {
        match &opt.value {
            CommandOptionValue::SubCommand(opts) | CommandOptionValue::SubCommandGroup(opts) => {
                return find_autocomplete_option(opts)
            }
            CommandOptionValue::Focused(value, _) => return Some((&opt.name, value)),
            _ => {}
        }
    }
    None
}

impl AutocompleteExt for &CommandData {
    fn autocomplete(&self) -> Option<(&str, &str)> {
        find_autocomplete_option(&self.options)
    }
}

async fn create_response(
    ctx: &Context,
    interaction: &Interaction,
    data: InteractionResponseData,
) -> Result<(), Error> {
    let response = InteractionResponse {
        kind: InteractionResponseType::ChannelMessageWithSource,
        data: Some(data),
    };
    ctx.interaction()
        .create_response(interaction.id, &interaction.token, &response)
        .await?;
    Ok(())
}

async fn defer_ephemeral(ctx: &Context, interaction: &Interaction) -> Result<(), Error> {
    ctx.interaction()
        .create_response(
            interaction.id,
            &interaction.token,
            &InteractionResponse {
                kind: InteractionResponseType::DeferredChannelMessageWithSource,
                data: Some(
                    InteractionResponseDataBuilder::new()
                        .flags(MessageFlags::EPHEMERAL)
                        .build(),
                ),
            },
        )
        .await?;
    Ok(())
}

async fn defer_response(ctx: &Context, interaction: &Interaction) -> Result<(), Error> {
    ctx.interaction()
        .create_response(
            interaction.id,
            &interaction.token,
            &InteractionResponse {
                kind: InteractionResponseType::DeferredChannelMessageWithSource,
                data: None,
            },
        )
        .await?;
    Ok(())
}

async fn defer_component_response(ctx: &Context, interaction: &Interaction) -> Result<(), Error> {
    ctx.interaction()
        .create_response(
            interaction.id,
            &interaction.token,
            &InteractionResponse {
                kind: InteractionResponseType::DeferredUpdateMessage,
                data: None,
            },
        )
        .await?;
    Ok(())
}

async fn update_response_content(
    ctx: &Context,
    interaction: &Interaction,
    content: &str,
) -> Result<(), Error> {
    ctx.interaction()
        .update_response(&interaction.token)
        .content(Some(content))
        .await?;
    Ok(())
}

async fn update_response_from_content(
    ctx: &Context,
    interaction: &Interaction,
    title: &str,
    contents: &[String],
) -> Result<(), Error> {
    let mut contents = contents.iter();
    if let Some(content) = contents.next() {
        let embed = EmbedBuilder::new()
            .title(title)
            .description(content)
            .build();

        ctx.interaction()
            .update_response(&interaction.token)
            .embeds(Some(&[embed]))
            .await?;

        for content in contents {
            let embed = EmbedBuilder::new()
                .title(title)
                .description(content)
                .build();

            ctx.interaction()
                .create_followup(&interaction.token)
                .embeds(&[embed])
                .await?;
        }
    }
    Ok(())
}

async fn autocomplete_games(
    ctx: &Context,
    interaction: &Interaction,
    value: &str,
) -> Result<(), Error> {
    let games = value.strip_prefix('@').map_or_else(
        || games_by_name(&ctx.pool, value),
        |value| games_by_name_id(&ctx.pool, value),
    )?;

    let choices = games.into_iter().map(|(id, name)| CommandOptionChoice {
        name,
        name_localizations: None,
        value: CommandOptionChoiceValue::String(id.to_string()),
    });
    let data = InteractionResponseDataBuilder::new()
        .choices(choices)
        .build();
    let response = InteractionResponse {
        kind: InteractionResponseType::ApplicationCommandAutocompleteResult,
        data: Some(data),
    };
    ctx.interaction()
        .create_response(interaction.id, &interaction.token, &response)
        .await?;
    Ok(())
}

async fn search_game(ctx: &Context, search: &str) -> Result<Option<Game>, Error> {
    use crate::util::IntoFilter;

    let filter = search.into_filter().limit(1);

    let games = ctx.modio.get_games().filter(filter).data().await?;
    let game = games.data.into_iter().next();
    Ok(game)
}