feat(discord)!: Added string formatter

- Added string formatter
- Added Strings struct for passing strings from file
- Refactored /info and /quest * to use formatter

BREAKING CHANGE: Changed DiscordConfig fields
This commit is contained in:
Alexey 2025-12-16 16:42:18 +03:00
commit aec4ef8339
7 changed files with 386 additions and 84 deletions

View file

@ -1,4 +1,5 @@
use squad_quest::{account::Account, config::Config};
use poise::serenity_prelude::UserId;
use squad_quest::{account::Account, config::Config, map::Map};
pub fn fetch_or_init_account(conf: &Config, id: String) -> Account {
let accounts = conf.load_accounts();
@ -10,3 +11,23 @@ pub fn fetch_or_init_account(conf: &Config, id: String) -> Account {
},
}
}
pub fn account_rooms_value(account: &Account, map: &Map) -> u32 {
map.room.iter().filter_map(|r| {
if account.rooms_unlocked.contains(&r.id) {
Some(r.value)
} else {
None
}
})
.sum()
}
pub fn account_full_balance(account: &Account, map: &Map) -> u32 {
let rooms_value = account_rooms_value(account, map);
account.balance + rooms_value
}
pub fn account_user_id(account: &Account) -> UserId {
UserId::new(account.id.clone().parse::<u64>().expect("automatically inserted"))
}

View file

@ -1,7 +1,7 @@
use poise::serenity_prelude::UserId;
use squad_quest::{SquadObject, account::Account, map::Map};
use crate::{Context, Error, account::fetch_or_init_account};
use crate::{Context, Error, account::{account_full_balance, account_rooms_value, account_user_id, fetch_or_init_account}};
async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) -> String {
let rooms_value = account_rooms_value(account, map);
@ -20,26 +20,6 @@ async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map)
)
}
fn account_rooms_value(account: &Account, map: &Map) -> u32 {
map.room.iter().filter_map(|r| {
if account.rooms_unlocked.contains(&r.id) {
Some(r.value)
} else {
None
}
})
.sum()
}
fn account_full_balance(account: &Account, map: &Map) -> u32 {
let rooms_value = account_rooms_value(account, map);
account.balance + rooms_value
}
fn account_user_id(account: &Account) -> UserId {
UserId::new(account.id.clone().parse::<u64>().expect("automatically inserted"))
}
#[poise::command(
prefix_command,
slash_command,

View file

@ -21,12 +21,10 @@ pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
slash_command,
)]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let reply_string = format!("\
SquadQuest version {ver}\n\
Find the map here: {url}",
ver = env!("CARGO_PKG_VERSION"),
url = "not implemented yet!",
);
let strings = &ctx.data().strings;
let formatter = strings.formatter();
let reply_string = formatter.fmt(&strings.info);
ctx.say(reply_string).await?;
Ok(())
}

View file

@ -1,4 +1,4 @@
use std::{future, path::Path, str::FromStr};
use std::{future, str::FromStr};
use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt};
use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}};
@ -24,16 +24,10 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>
Ok(messages.first().cloned())
}
fn make_quest_message_content(quest: &Quest) -> String {
format!("### `#{id}` {name} (+{reward})\n\
Difficulty: *{difficulty:?}*\n\
{description}",
id = quest.id,
name = quest.name,
reward = quest.reward,
difficulty = quest.difficulty,
description = quest.description,
)
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(
@ -60,13 +54,12 @@ pub async fn list(
) -> Result<(), Error> {
let conf = &ctx.data().config;
let quests = conf.load_quests();
let mut reply_string = format!("Listing {} quests:", quests.len());
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 {
reply_string.push_str(format!("\n#{}: {}\n\tDescription: {}",
quest.id,
quest.name,
quest.description,
).as_str());
formatter = formatter.quest(&quest);
reply_string.push_str(formatter.fmt(&strings.quest.list_item).as_str());
}
ctx.reply(reply_string).await?;
Ok(())
@ -168,7 +161,10 @@ pub async fn create(
let path = conf.full_quests_path();
quest.save(path)?;
let reply_string = format!("Created quest #{}", quest.id);
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&quest);
let reply_string = formatter.fmt(&strings.quest.create);
ctx.reply(reply_string).await?;
@ -228,7 +224,6 @@ pub async fn update(
},
}
let new_quest = Quest {
id,
difficulty,
@ -241,23 +236,25 @@ pub async fn update(
deadline: dead_line,
};
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&new_quest);
if new_quest.public {
let content = make_quest_message_content(&new_quest);
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 = format!("Quest #{id} is public, but its message was not found in the quest channel",
);
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 = format!("Updated quest #{id}");
let reply_string = formatter.fmt(&strings.quest.update);
ctx.reply(reply_string).await?;
Ok(())
@ -286,7 +283,7 @@ pub async fn publish(
quest.public = true;
let content = make_quest_message_content(&quest);
let content = make_quest_message_content(ctx, &quest);
let builder = CreateMessage::new()
.content(content);
@ -297,19 +294,15 @@ pub async fn publish(
guard.quests_channel
};
let message = channel.send_message(ctx, builder).await?;
{
let mut guard = dc.lock().expect("shouldn't be locked");
guard.quests_messages.push(message.id);
let path = ctx.data().config.full_impl_path().unwrap();
guard.save(path.parent().unwrap_or(Path::new("")).to_owned())?
};
channel.send_message(ctx, builder).await?;
let quests_path = ctx.data().config.full_quests_path();
quest.save(quests_path)?;
let reply_string = format!("Published quest #{id}");
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&quest);
let reply_string = formatter.fmt(&strings.quest.publish);
ctx.reply(reply_string).await?;
Ok(())
@ -342,7 +335,15 @@ pub async fn delete(
account.save(accounts_path.clone())?;
}
let reply_string = format!("Successfully deleted quest #{id}");
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(())

View file

@ -1,35 +1,55 @@
use std::{io::Write, path::{Path, PathBuf}};
use poise::serenity_prelude::{ChannelId, GuildId, MessageId};
use poise::serenity_prelude::{ChannelId, GuildId};
use serde::{Serialize, Deserialize};
use squad_quest::{SquadObject, config::Config, error::Error};
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
use crate::strings::Strings;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DiscordConfig {
pub guild: GuildId,
pub quests_channel: ChannelId,
pub answers_channel: ChannelId,
pub quests_messages: Vec<MessageId>,
pub strings_path: PathBuf,
}
impl Default for DiscordConfig {
fn default() -> Self {
Self {
guild: GuildId::default(),
quests_channel: ChannelId::default(),
answers_channel: ChannelId::default(),
strings_path: "strings.toml".into(),
}
}
}
pub trait ConfigImpl {
fn discord_impl(&self) -> Result<DiscordConfig, Error>;
fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error>;
fn init_impl(&self) -> Result<(), Error>;
}
impl ConfigImpl for Config {
fn discord_impl(&self) -> Result<DiscordConfig, Error> {
let Some(path) = &self.full_impl_path() else {
fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error> {
let Some(path) = self.full_impl_path() else {
return Err(Error::IsNotImplemented);
};
DiscordConfig::load(path.clone())
let discord = DiscordConfig::load(path.clone())?;
let mut strings_path: PathBuf = path.parent().unwrap_or(Path::new("")).to_owned();
strings_path.push(discord.strings_path.clone());
let strings = Strings::load(strings_path)?;
Ok((discord, strings))
}
fn init_impl(&self) -> Result<(), Error> {
let Some(path) = self.full_impl_path() else {
return Err(Error::IsNotImplemented);
};
let folder = path.parent().unwrap_or(Path::new("")).to_owned();
let dc = DiscordConfig::default();
dc.save(path.parent().unwrap_or(Path::new("")).to_owned())
dc.save(folder.clone())?;
let strings = Strings::default();
strings.save(folder)
}
}
@ -46,17 +66,8 @@ impl SquadObject for DiscordConfig {
}
}
fn delete(path: PathBuf) -> Result<(), Error> {
match Self::load(path.clone()) {
Ok(_) => {
if let Err(error) = std::fs::remove_file(path) {
return Err(Error::IoError(error));
}
Ok(())
},
Err(error) => Err(error)
}
fn delete(_path: PathBuf) -> Result<(), Error> {
unimplemented!()
}
fn save(&self, path: PathBuf) -> Result<(), Error> {

View file

@ -5,20 +5,23 @@ use dotenvy::dotenv;
use poise::serenity_prelude as serenity;
use squad_quest::config::Config;
use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error};
use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings};
mod commands;
mod cli;
mod config;
mod account;
mod error;
mod strings;
const CONFIG_PATH: &str = "cfg/config.toml";
const DISCORD_TOKEN: &str = "DISCORD_TOKEN";
#[derive(Debug)]
struct Data {
pub config: Config,
pub discord: Arc<Mutex<DiscordConfig>>,
pub strings: Strings,
}
type Context<'a> = poise::Context<'a, Data, Error>;
@ -28,12 +31,12 @@ async fn main() {
let cli = cli::Cli::parse();
let config = Config::load(cli.config.clone().unwrap_or(CONFIG_PATH.into()));
let discord = config.discord_impl().unwrap_or_else(|_| {
let (discord, strings) = config.discord_impl().unwrap_or_else(|_| {
config.init_impl().unwrap();
config.discord_impl().unwrap()
});
let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN");
let token = std::env::var(DISCORD_TOKEN).expect("missing DISCORD_TOKEN");
let intents = serenity::GatewayIntents::non_privileged();
let framework = poise::Framework::builder()
@ -60,6 +63,7 @@ async fn main() {
Ok(Data {
config,
discord: Arc::new(Mutex::new(discord)),
strings,
})
})
})

287
discord/src/strings.rs Normal file
View file

@ -0,0 +1,287 @@
use std::{collections::HashMap, fmt::Display, io::Write, path::PathBuf};
use poise::serenity_prelude::{Mentionable, User};
use serde::{Deserialize, Serialize};
use squad_quest::{SquadObject, account::Account, map::Map, quest::{Quest, QuestDifficulty}, error::Error};
use crate::account::{account_full_balance, account_rooms_value};
#[derive(Default, Debug, Clone)]
pub struct StringFormatter {
tags: HashMap<String, String>,
difficulty: Difficulty,
}
impl StringFormatter {
pub fn new() -> Self {
let newline = ("{n}".to_string(), '\n'.to_string());
let version = ("{v}".to_string(), env!("CARGO_PKG_VERSION").to_string());
let new_tags = vec![ newline, version ];
Self::default().with_tags(new_tags).to_owned()
}
pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self {
for tag in tags {
self.tags.insert(tag.0, tag.1);
}
self
}
pub fn strings(mut self, strings: &Strings) -> Self {
self.difficulty = strings.difficulty.clone();
let url = ("{url}".to_string(), strings.url.clone());
let points = ("{pt}".to_string(), strings.points.clone());
let new_tags = vec![ url, points ];
self.with_tags(new_tags)
}
pub fn quest(self, quest: &Quest) -> Self {
let id = ("{q.id}".to_string(), id(quest.id));
let difficulty = ("{q.difficulty}".to_string(), self.difficulty.as_string(&quest.difficulty));
let reward = ("{q.reward}".to_string(), self.points(quest.reward.to_string()));
let name = ("{q.name}".to_string(), quest.name.clone());
let description = ("{q.description}".to_string(), quest.description.clone());
let answer = ("{q.answer}".to_string(), quest.answer.clone());
let new_tags = vec![ id, difficulty, reward, name, description, answer ];
self.with_tags(new_tags)
}
pub fn user(self, user: &User) -> Self {
let mention = ("{u.mention}".to_string(), user.mention().to_string());
let name = ("{u.name}".to_string(), user.display_name().to_string());
let new_tags = vec![ mention, name ];
self.with_tags(new_tags)
}
pub fn balance(self, account: &Account, map: &Map) -> Self {
let balance = ("{b.current}".to_string(), self.points(account.balance));
let full_balance = (
"{b.full}".to_string(),
self.points(account_full_balance(account, map)),
);
let rooms_balance = (
"{b.rooms}".to_string(),
self.points(account_rooms_value(account, map)),
);
let new_tags = vec![ balance, full_balance, rooms_balance ];
self.with_tags(new_tags)
}
pub fn text(self, text: impl ToString) -> Self {
let text = ("{text}".to_string(), text.to_string());
self.with_tags(vec![text])
}
pub fn value(self, value: impl ToString) -> Self {
let value = ("{value}".to_string(), value.to_string());
self.with_tags(vec![value])
}
fn points(&self, str: impl Display) -> String {
let template = format!("{str} {pt}", pt = "{pt}");
self.fmt(&template)
}
pub fn fmt(&self, string: &str) -> String {
let mut formatted = string.to_string();
for (tag, replacement) in self.tags.iter() {
formatted = formatted.replace(tag, replacement);
}
formatted
}
}
fn id(str: impl Display) -> String {
format!("#{str}")
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Strings {
pub url: String,
pub points: String,
pub info: String,
pub answer: Answer,
pub difficulty: Difficulty,
pub scoreboard: Scoreboard,
pub quest: QuestStrings,
}
impl Default for Strings {
fn default() -> Self {
Self {
url: "not implemented!".to_string(),
points: "points".to_string(),
info: "SquadQuest version {v}\
{n}Find the map here: {url}".to_string(),
answer: Answer::default(),
difficulty: Difficulty::default(),
scoreboard: Scoreboard::default(),
quest: QuestStrings::default(),
}
}
}
impl SquadObject for Strings {
fn load(path: PathBuf) -> Result<Self, Error> {
match std::fs::read_to_string(path) {
Ok(string) => {
match toml::from_str::<Self>(&string) {
Ok(object) => Ok(object),
Err(error) => Err(Error::TomlDeserializeError(error))
}
},
Err(error) => Err(Error::IoError(error))
}
}
fn delete(_path: PathBuf) -> Result<(), Error> {
unimplemented!()
}
fn save(&self, path: PathBuf) -> Result<(), Error> {
let filename = "strings.toml".to_string();
let mut full_path = path;
full_path.push(filename);
let str = match toml::to_string_pretty(&self) {
Ok(string) => string,
Err(error) => {
return Err(Error::TomlSerializeError(error));
}
};
let mut file = match std::fs::File::create(full_path) {
Ok(f) => f,
Err(error) => {
return Err(Error::IoError(error));
}
};
if let Err(error) = file.write_all(str.as_bytes()) {
return Err(Error::IoError(error));
}
Ok(())
}
}
impl Strings {
pub fn formatter(&self) -> StringFormatter {
StringFormatter::new().strings(self).to_owned()
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(default)]
pub struct Answer {
pub from: String,
pub quest: String,
pub expected: String,
pub text: String,
pub attachment_notice: String,
pub accepted_by: String,
pub rejected_by: String,
}
impl Default for Answer {
fn default() -> Self {
Self {
from: "## From: {u.mention}{n}".to_string(),
quest: "### Quest {q.id}: {q.name}{n}".to_string(),
expected: "### Expected answer:{n}||{q.answer}||".to_string(),
text: "### Passed answer:{n}{text}".to_string(),
attachment_notice: "Passed answer has attachments.".to_string(),
accepted_by: "{text}{n}Accepted by: {u.mention}".to_string(),
rejected_by: "~~{text}~~{n}Rejected by: {u.mention}".to_string(),
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)]
pub struct Difficulty {
pub easy: String,
pub normal: String,
pub hard: String,
pub secret: String,
}
impl Default for Difficulty {
fn default() -> Self {
Self {
easy: "Easy".to_string(),
normal: "Normal".to_string(),
hard: "Hard".to_string(),
secret: "Secret".to_string(),
}
}
}
impl Difficulty {
pub fn as_string(&self, difficulty: &QuestDifficulty) -> String {
match difficulty {
QuestDifficulty::Easy => self.easy.clone(),
QuestDifficulty::Normal => self.normal.clone(),
QuestDifficulty::Hard => self.hard.clone(),
QuestDifficulty::Secret => self.secret.clone(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Scoreboard {
pub header: String,
pub line_format: String,
pub you_format: String,
}
impl Default for Scoreboard {
fn default() -> Self {
Self {
header: "Current scoreboard:".to_string(),
line_format: "{n}{u.name}: **{b.full}** (**{b.current}** on balance\
+ **{b.rooms}** unlocked rooms networth)".to_string(),
you_format: "__{text}__ << You".to_string(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
pub struct QuestStrings {
pub list: String,
pub list_item: String,
pub create: String,
pub update: String,
pub publish: String,
pub delete: String,
pub message_format: String,
pub message_not_found: String,
}
impl Default for QuestStrings {
fn default() -> Self {
Self {
list: "Listing {value} quests:".to_string(),
list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(),
create: "Created quest {q.id}".to_string(),
update: "Updated quest {q.id}".to_string(),
publish: "Published quest {q.id}: {text}".to_string(),
delete: "Deleted quest {q.id}".to_string(),
message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\
Difficulty: *{q.difficulty}*{n}\
{q.description}".to_string(),
message_not_found: "Warning: quest {q.id} message not found".to_string(),
}
}
}