modbot 0.5.3

Discord bot for https://mod.io. ModBot provides commands to search for mods and notifications about added & edited mods.
use modio::games::Game;
use twilight_http::client::InteractionClient;
use twilight_model::application::command::{Command, CommandOptionChoice, CommandOptionChoiceData};
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::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) {
    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,
) {
    let res = match interaction
        .message
        .as_ref()
        .and_then(|m| m.interaction.as_ref())
    {
        Some(msg) if msg.name == "mods" => mods::list_component(ctx, interaction, component).await,
        _ => Ok(()),
    };
    if let Err(e) = res {
        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 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)| {
        let data = CommandOptionChoiceData {
            name,
            name_localizations: None,
            value: id.to_string(),
        };
        CommandOptionChoice::String(data)
    });
    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();
    let game = ctx.modio.games().search(filter).first().await?;
    Ok(game)
}