squad-quest/discord/src/commands/quest.rs
2ndbeam d188bba16e feat: Implemented guild check
- Also added more error logging
2025-12-24 17:46:22 +03:00

426 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::{future, str::FromStr};
use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt};
use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}};
use toml::value::Date;
use crate::{Context, Error,commands::guild};
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
ctx.defer().await?;
let dc = ctx.data().discord.clone();
let channel = {
let guard = dc.lock().expect("shouldn't be locked");
guard.quests_channel
};
let messages = channel.messages_iter(ctx)
.filter_map(|m| async move { if m.is_ok() { Some(m.unwrap()) } else {
eprintln!("{}", m.unwrap_err());
None
} })
.filter(|m| {
future::ready(m.content.contains(&format!("#{id}")))
})
.collect::<Vec<_>>().await;
Ok(messages.first().cloned())
}
fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String {
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(quest);
formatter.fmt(&strings.quest.message_format)
}
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
subcommands("list", "create", "update", "publish", "delete"),
required_permissions = "ADMINISTRATOR",
name_localized("ru", "квест"),
)]
pub async fn quest(
_ctx: Context<'_>,
) -> Result<(), Error> {
Ok(())
}
/// List all quests
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR",
name_localized("ru", "список"),
description_localized("ru", "Вывести все квесты")
)]
pub async fn list(
ctx: Context<'_>,
) -> Result<(), Error> {
let conf = &ctx.data().config;
let quests = conf.load_quests();
let strings = &ctx.data().strings;
let mut formatter = strings.formatter().value(quests.len());
let mut reply_string = formatter.fmt(&strings.quest.list);
for quest in quests {
formatter = formatter.quest(&quest);
reply_string.push_str(formatter.fmt(&strings.quest.list_item).as_str());
}
ctx.reply(reply_string).await?;
Ok(())
}
#[derive(Debug, poise::ChoiceParameter)]
pub enum DifficultyWrapper {
Easy,
Normal,
Hard,
Secret,
}
impl From<DifficultyWrapper> for QuestDifficulty {
fn from(value: DifficultyWrapper) -> Self {
match &value {
DifficultyWrapper::Easy => Self::Easy,
DifficultyWrapper::Normal => Self::Normal,
DifficultyWrapper::Hard => Self::Hard,
DifficultyWrapper::Secret => Self::Secret,
}
}
}
#[derive(serde::Deserialize)]
struct DateWrapper {
date: Date,
}
impl FromStr for DateWrapper {
type Err = toml::de::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let toml_str = format!("date = {s}");
let wrapper: Self = toml::from_str(&toml_str)?;
Ok(wrapper)
}
}
impl From<DateWrapper> for Date {
fn from(value: DateWrapper) -> Self {
value.date
}
}
/// Create quest and print its identifier
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "создать"),
description_localized("ru", "Создать квест и получить его идентификатор"),
)]
pub async fn create(
ctx: Context<'_>,
#[description = "Quest difficulty"]
#[name_localized("ru", "сложность")]
#[description_localized("ru", "Сложность квеста")]
difficulty: DifficultyWrapper,
#[description = "Reward for the quest"]
#[name_localized("ru", "награда")]
#[description_localized("ru", "Награда за квест")]
reward: u32,
#[description = "Quest name"]
#[name_localized("ru", "название")]
#[description_localized("ru", "Название квеста")]
name: String,
#[description = "Quest description"]
#[name_localized("ru", "описание")]
#[description_localized("ru", "Описание квеста")]
description: String,
#[description = "Expected answer, visible when user posts their answer for review"]
#[name_localized("ru", "ответ")]
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
answer: String,
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "доступен")]
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
available: Option<DateWrapper>,
/*
#[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "дедлайн")]
#[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД), напр. 2025-12-24")]
deadline: Option<DateWrapper>,
*/
) -> Result<(), Error> {
let conf = &ctx.data().config;
let mut quests = conf.load_quests();
quests.sort_by(|a,b| a.id.cmp(&b.id));
let next_id = match quests.last() {
Some(quest) => quest.id + 1u16,
None => 0u16
};
let available_on = match available {
Some(avail) => Some(avail.into()),
None => None,
};
/*
let deadline = match deadline {
Some(dl) => Some(dl.into()),
None => None,
};
*/
let quest = Quest {
id: next_id,
difficulty: difficulty.into(),
reward,
name,
description,
answer,
public: false,
available_on,
//deadline,
..Default::default()
};
let path = conf.full_quests_path();
quest.save(path)?;
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&quest);
let reply_string = formatter.fmt(&strings.quest.create);
ctx.reply(reply_string).await?;
Ok(())
}
/// Update quest values by its identifier and new given values
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "обновить"),
description_localized("ru", "Обновить выбранные значения указанного квеста"),
)]
pub async fn update(
ctx: Context<'_>,
#[description = "Quest identifier"]
#[name_localized("ru", "идентификатор")]
#[description_localized("ru", "Идентификатор квеста")]
id: u16,
#[description = "Quest difficulty"]
#[name_localized("ru", "сложность")]
#[description_localized("ru", "Сложность квеста")]
difficulty: Option<DifficultyWrapper>,
#[description = "Reward for the quest"]
#[name_localized("ru", "награда")]
#[description_localized("ru", "Награда за квест")]
reward: Option<u32>,
#[description = "Quest name"]
#[name_localized("ru", "название")]
#[description_localized("ru", "Название квеста")]
name: Option<String>,
#[description = "Quest description"]
#[name_localized("ru", "описание")]
#[description_localized("ru", "Описание квеста")]
description: Option<String>,
#[description = "Expected answer, visible when user posts their answer for review"]
#[name_localized("ru", "ответ")]
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
answer: Option<String>,
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "доступен")]
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")]
available: Option<DateWrapper>,
/*
#[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "дедлайн")]
#[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
deadline: Option<DateWrapper>,
#[description = "Reset availability and deadline if checked"]
#[description_localized("ru", "Если выбрано, сбросить доступность и дедлайн")]
*/
#[description = "Reset availability if checked"]
#[description_localized("ru", "Если выбрано, сбросить доступность")]
#[name_localized("ru", "сброс")]
reset: Option<bool>,
) -> Result<(), Error> {
let conf = &ctx.data().config;
let quests = conf.load_quests();
let Some(quest) = quests.iter().find(|q| q.id == id) else {
return Err(Error::QuestNotFound(id));
};
let difficulty = match difficulty {
Some(d) => d.into(),
None => quest.difficulty
};
let available_on: Option<Date>;
//let dead_line: Option<Date>;
match reset.unwrap_or(false) {
true => {
available_on = None;
//dead_line = None;
},
false => {
available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into()));
//dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
},
}
let new_quest = Quest {
id,
difficulty,
reward: reward.unwrap_or(quest.reward),
name: name.unwrap_or(quest.name.clone()),
description: description.unwrap_or(quest.description.clone()),
answer: answer.unwrap_or(quest.answer.clone()),
public: quest.public,
available_on,
//deadline: dead_line,
..Default::default()
};
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&new_quest);
if new_quest.public {
let content = make_quest_message_content(ctx, &new_quest);
let builder = EditMessage::new().content(content);
let message = find_quest_message(ctx, id).await?;
if let Some(mut message) = message {
message.edit(ctx, builder).await?;
} else {
let reply_string = formatter.fmt(&strings.quest.message_not_found);
ctx.reply(reply_string).await?;
}
}
let path = conf.full_quests_path();
new_quest.save(path)?;
let reply_string = formatter.fmt(&strings.quest.update);
ctx.reply(reply_string).await?;
Ok(())
}
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Message, Error> {
quest.public = true;
let quests_path = ctx.data().config.full_quests_path();
quest.save(quests_path)?;
let content = make_quest_message_content(ctx, &quest);
let builder = CreateMessage::new()
.content(content);
let dc = ctx.data().discord.clone();
let channel = {
let guard = dc.lock().expect("shouldn't be locked");
guard.quests_channel
};
match channel.send_message(ctx, builder).await {
Ok(m) => Ok(m),
Err(error) => Err(Error::SerenityError(error)),
}
}
/// Mark quest as public and send its message in quests channel
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "опубликовать"),
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
)]
pub async fn publish(
ctx: Context<'_>,
#[description = "Quest identifier"]
#[name_localized("ru", "идентификатор")]
#[description_localized("ru", "Идентификатор квеста")]
id: u16,
) -> Result<(), Error> {
let mut quests = ctx.data().config.load_quests();
let Some(quest) = quests.iter_mut().find(|q| q.id == id) else {
return Err(Error::QuestNotFound(id));
};
if quest.public {
return Err(Error::QuestIsPublic(id));
}
let message = publish_inner(ctx, quest).await?;
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.quest(&quest)
.message(&message);
let reply_string = formatter.fmt(&strings.quest.publish);
ctx.reply(reply_string).await?;
Ok(())
}
/// Delete quest (and its message, if published)
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "удалить"),
description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"),
)]
pub async fn delete(
ctx: Context<'_>,
#[description = "Quest identifier"]
#[name_localized("ru", "идентификатор")]
#[description_localized("ru", "Идентификатор квеста")]
id: u16,
) -> Result<(), Error> {
if let Some(msg) = find_quest_message(ctx, id).await? {
msg.delete(ctx).await?;
}
let mut path = ctx.data().config.full_quests_path();
path.push(format!("{id}.toml"));
Quest::delete(path)?;
let mut accounts = ctx.data().config.load_accounts();
let accounts_path = ctx.data().config.full_accounts_path();
for account in accounts.iter_mut().filter(|a| a.quests_completed.contains(&id)) {
let index = account.quests_completed.iter().position(|qid| *qid == id).expect("We just filtered it");
account.quests_completed.remove(index);
account.save(accounts_path.clone())?;
}
let mock_quest = Quest {
id,
..Default::default()
};
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&mock_quest);
let reply_string = formatter.fmt(&strings.quest.delete);
ctx.reply(reply_string).await?;
Ok(())
}