use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::Write;
use modio::request::filter::prelude::*;
use modio::request::mods::filters::Popular;
use modio::types::games::{ApiAccessOptions, Game};
use modio::types::id::{GameId, ModId};
use modio::types::mods::{Mod, Statistics};
use modio::util::DataFromRequest;
use serde_derive::{Deserialize, Serialize};
use twilight_model::application::command::{Command, CommandType};
use twilight_model::application::interaction::application_command::{
CommandData, CommandDataOption, CommandOptionValue,
};
use twilight_model::application::interaction::message_component::MessageComponentInteractionData;
use twilight_model::application::interaction::{Interaction, InteractionContextType};
use twilight_model::channel::message::component::{ActionRow, Button, ButtonStyle, Component};
use twilight_model::channel::message::embed::{Embed, EmbedField};
use twilight_util::builder::command::{CommandBuilder, StringBuilder};
use twilight_util::builder::embed::{
EmbedAuthorBuilder, EmbedBuilder, EmbedFooterBuilder, ImageSource,
};
use super::{
autocomplete_games, create_response, defer_component_response, defer_response, search_game,
update_response_content, AutocompleteExt, EphemeralMessage, InteractionExt,
};
use crate::bot::Context;
use crate::error::Error;
use crate::util::{format_timestamp, Page};
pub fn commands() -> Vec<Command> {
vec![
CommandBuilder::new(
"mods",
"List mods or show the details for a single mod.",
CommandType::ChatInput,
)
.contexts([InteractionContextType::Guild])
.option(StringBuilder::new("mod", "ID or search"))
.option(StringBuilder::new("game", "ID or search").autocomplete(true))
.build(),
CommandBuilder::new("popular", "List popular mods.", CommandType::ChatInput)
.contexts([InteractionContextType::Guild])
.option(
StringBuilder::new("game", "ID or search game instead of the default game.")
.autocomplete(true),
)
.build(),
]
}
pub async fn list(
ctx: &Context,
interaction: &Interaction,
command: &CommandData,
) -> Result<(), Error> {
if let Some(("game", value)) = command.autocomplete() {
return autocomplete_games(ctx, interaction, value).await;
}
let mut search = None;
let mut game_id = None;
defer_response(ctx, interaction).await?;
for opt in &command.options {
match &opt.value {
CommandOptionValue::String(s) if opt.name == "mod" => {
search = Some(s);
}
CommandOptionValue::String(s) if opt.name == "game" => {
let game = search_game(ctx, s).await?;
if game.is_none() {
let data = "Game not found.".into_ephemeral();
return create_response(ctx, interaction, data).await;
}
game_id = game.map(|g| g.id);
}
_ => {}
}
}
let game_id = match (game_id, interaction.guild_id()) {
(Some(game_id), _) => Some(game_id),
(_, Some(guild_id)) => ctx.settings.game(guild_id)?.map(|id| id.0),
_ => None,
};
let Some(game_id) = game_id else {
let content = "Default game is not set.";
return update_response_content(ctx, interaction, content).await;
};
let (filter, title): (Filter, Cow<'_, _>) = if let Some(search) = search {
match (search.strip_prefix('@'), search.parse::<ModId>()) {
(Some(name_id), _) => (NameId::eq(name_id), "Mods".into()),
(_, Ok(id)) => (Id::eq(id), "Mods".into()),
(_, Err(_)) => (
Fulltext::eq(search),
format!("Mods matching: '{search}'").into(),
),
}
} else {
(Filter::default(), "Mods".into())
};
let page = ctx
.modio
.get_mods(game_id)
.filter(filter.limit(20))
.data()
.await?;
if page.is_empty() {
let content = "No mods found.";
return update_response_content(ctx, interaction, content).await;
}
let (embeds, components) = match page.data.as_slice() {
[mod_] => {
let game = ctx.modio.get_game(game_id).data().await?;
let embed = create_mod_embed(&game, mod_).build();
(Some(vec![embed]), None)
}
list => {
let embed = create_list_embed(list, &title, page.current(), page.page_count());
let components = if page.total() > page.len() {
Some(vec![create_browse_buttons(
game_id,
search.map(String::as_str),
0,
20,
page.current(),
page.page_count(),
)])
} else {
None
};
(Some(vec![embed]), components)
}
};
ctx.interaction()
.update_response(&interaction.token)
.embeds(embeds.as_deref())
.components(components.as_deref())
.await?;
Ok(())
}
#[derive(Deserialize, Serialize)]
struct CustomId<'a> {
#[serde(rename = "b")]
button: &'a str,
#[serde(rename = "g")]
game_id: GameId,
#[serde(rename = "q")]
search: Option<&'a str>,
#[serde(rename = "o")]
offset: usize,
#[serde(rename = "l")]
limit: usize,
#[serde(rename = "s")]
sort: Option<&'a str>,
}
pub async fn list_component(
ctx: &Context,
interaction: &Interaction,
component: &MessageComponentInteractionData,
) -> Result<(), Error> {
let custom_id = component
.custom_id
.strip_prefix("mods:")
.unwrap_or(&component.custom_id);
let CustomId {
game_id,
search,
offset,
limit,
..
} = serde_urlencoded::from_str(custom_id).unwrap();
let (filter, title): (Filter, Cow<'_, _>) = if let Some(search) = search {
(
Fulltext::eq(search),
format!("Mods matching: '{search}'").into(),
)
} else {
(Filter::default(), "Mods".into())
};
let filter = filter.offset(offset).limit(20);
defer_component_response(ctx, interaction).await?;
let page = ctx.modio.get_mods(game_id).filter(filter).data().await?;
if !page.is_empty() {
let embed = create_list_embed(&page.data, &title, page.current(), page.page_count());
let components = create_browse_buttons(
game_id,
search,
offset,
limit,
page.current(),
page.page_count(),
);
ctx.interaction()
.update_response(&interaction.token)
.embeds(Some(&[embed]))
.components(Some(&[components]))
.await?;
}
Ok(())
}
pub async fn popular(
ctx: &Context,
interaction: &Interaction,
command: &CommandData,
) -> Result<(), Error> {
if let Some(("game", value)) = command.autocomplete() {
return autocomplete_games(ctx, interaction, value).await;
}
defer_response(ctx, interaction).await?;
let game_id = match command.options.as_slice() {
[CommandDataOption {
value: CommandOptionValue::String(s),
..
}] => {
let game = search_game(ctx, s).await?;
if game.is_none() {
let content = "Game not found.";
return update_response_content(ctx, interaction, content).await;
}
game.map(|g| g.id)
}
_ => None,
};
let game_id = match (game_id, interaction.guild_id()) {
(Some(game_id), _) => Some(game_id),
(_, Some(guild_id)) => ctx.settings.game(guild_id)?.map(|id| id.0),
_ => None,
};
let Some(game_id) = game_id else {
let content = "Default game is not set.";
return update_response_content(ctx, interaction, content).await;
};
let filter = with_limit(10).order_by(Popular::desc());
let mods = ctx.modio.get_mods(game_id).filter(filter).data().await?;
if mods.data.is_empty() {
let content = "No mods founds.";
return update_response_content(ctx, interaction, content).await;
}
let mut content = String::new();
for mod_ in mods.data {
_ = writeln!(
content,
"{:02}. [{}]({}) ({}) +{}/-{}",
mod_.stats.popularity.rank_position,
mod_.name,
mod_.profile_url,
mod_.id,
mod_.stats.ratings.positive,
mod_.stats.ratings.negative,
);
}
let game = ctx.modio.get_game(game_id).data().await?;
let embed = EmbedBuilder::new()
.title("Popular Mods")
.description(content)
.author(
EmbedAuthorBuilder::new(game.name)
.url(game.profile_url)
.icon_url(ImageSource::url(game.icon.thumb_64x64.to_string()).unwrap()),
)
.build();
ctx.interaction()
.update_response(&interaction.token)
.embeds(Some(&[embed]))
.await?;
Ok(())
}
fn create_list_embed(mods: &[Mod], title: &str, page: usize, page_count: usize) -> Embed {
let mut content = String::new();
for mod_ in mods {
_ = writeln!(content, "`{}.` {}", mod_.id, mod_.name);
}
EmbedBuilder::new()
.title(title)
.description(content)
.footer(EmbedFooterBuilder::new(format!(
"Page: {page}/{page_count}"
)))
.build()
}
fn create_browse_buttons(
game_id: GameId,
search: Option<&'_ str>,
offset: usize,
limit: usize,
page: usize,
page_count: usize,
) -> Component {
fn create_custom_id(id: &CustomId<'_>) -> String {
String::from("mods:") + &serde_urlencoded::to_string(id).unwrap()
}
let custom_id = CustomId {
button: "prev",
game_id,
search,
offset: offset.saturating_sub(limit),
limit,
sort: None,
};
let prev = Button {
id: None,
custom_id: Some(create_custom_id(&custom_id)),
style: ButtonStyle::Primary,
label: Some("prev".to_owned()),
disabled: page == 1,
emoji: None,
url: None,
sku_id: None,
};
let custom_id = CustomId {
button: "next",
offset: offset + limit,
..custom_id
};
let next = Button {
id: None,
custom_id: Some(create_custom_id(&custom_id)),
style: ButtonStyle::Primary,
label: Some("next".to_owned()),
disabled: page == page_count,
emoji: None,
url: None,
sku_id: None,
};
let row = ActionRow {
id: None,
components: vec![prev.into(), next.into()],
};
row.into()
}
fn create_mod_embed(game: &Game, mod_: &Mod) -> EmbedBuilder {
let with_ddl = game
.api_access_options
.contains(ApiAccessOptions::ALLOW_DIRECT_DOWNLOAD);
let mut footer = EmbedFooterBuilder::new(&mod_.submitted_by.username);
if let Some(avatar) = &mod_.submitted_by.avatar {
footer = footer.icon_url(ImageSource::url(avatar.thumb_50x50.to_string()).unwrap());
}
let builder = EmbedBuilder::new()
.title(&mod_.name)
.url(mod_.profile_url.to_string())
.description(&mod_.summary)
.thumbnail(ImageSource::url(mod_.logo.thumb_320x180.to_string()).unwrap())
.author(
EmbedAuthorBuilder::new(&game.name)
.url(game.profile_url.to_string())
.icon_url(ImageSource::url(game.icon.thumb_64x64.to_string()).unwrap()),
)
.footer(footer);
create_fields(builder, game, mod_, false, with_ddl)
}
#[allow(clippy::too_many_lines)]
pub fn create_fields(
mut builder: EmbedBuilder,
g: &Game,
m: &Mod,
is_new: bool,
with_ddl: bool,
) -> EmbedBuilder {
fn ratings(stats: &Statistics) -> EmbedField {
EmbedField {
name: "Rating".to_owned(),
value: format!(
r"Rank: {}/{}
Downloads: {}
Subscribers: {}
Votes: +{}/-{}",
stats.popularity.rank_position,
stats.popularity.rank_total,
stats.downloads_total,
stats.subscribers_total,
stats.ratings.positive,
stats.ratings.negative,
),
inline: true,
}
}
#[allow(clippy::cast_possible_wrap)]
fn dates(m: &Mod) -> EmbedField {
let added = format_timestamp(m.date_added.as_secs());
let updated = format_timestamp(m.date_updated.as_secs());
EmbedField {
name: "Dates".to_owned(),
value: format!("Created: {added}\nUpdated: {updated}"),
inline: true,
}
}
fn info(m: &Mod, with_ddl: bool) -> Option<EmbedField> {
let mut info = if with_ddl {
String::from("Links: ")
} else {
String::new()
};
if let Some(homepage) = &m.homepage_url {
_ = write!(info, "[Homepage]({homepage}), ");
}
if let Some(f) = &m.modfile {
if with_ddl {
_ = writeln!(info, "[Download]({})", f.download.binary_url);
}
if let Some(version) = &f.version {
_ = writeln!(info, "Version: {version}");
}
let size = bytesize::ByteSize::b(f.filesize).display().si_short();
_ = writeln!(info, "Size: {size}");
}
if info.len() > 7 {
Some(EmbedField {
name: "Info".to_owned(),
value: info,
inline: true,
})
} else {
None
}
}
fn tags(g: &Game, m: &Mod) -> Option<EmbedField> {
if m.tags.is_empty() {
return None;
}
let game_tags = g
.tag_options
.iter()
.filter(|t| !t.hidden)
.flat_map(|t| &t.tags)
.collect::<HashSet<_>>();
let tags = m
.tags
.iter()
.filter(|t| game_tags.contains(&t.name))
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
if tags.is_empty() {
return None;
}
Some(EmbedField {
name: "Tags".to_owned(),
value: tags,
inline: true,
})
}
if is_new {
if let Some(field) = info(m, with_ddl) {
builder = builder.field(field);
}
if let Some(field) = tags(g, m) {
builder = builder.field(field);
}
} else {
builder = builder.field(ratings(&m.stats));
if let Some(field) = info(m, with_ddl) {
builder = builder.field(field);
}
builder = builder.field(dates(m));
if let Some(field) = tags(g, m) {
builder = builder.field(field);
}
}
builder
}