Compare commits

...

14 commits

Author SHA1 Message Date
1db7ce877e feat(discord): Added /avatar command
- Added formattable error strings
2025-12-31 23:31:41 +03:00
2640821a05 feat!: Added limit field to quests
- Bump version to 0.12.0
- lib: Changed Quest::complete_for_account behavior
- cli: Added limit field for quest create and quest update
- discord: Quests are checked for limit on /answer
- discord: Added limit field for /quest create and /quest update
- discord: Changed behavior of fetch_or_init_account
2025-12-30 15:44:23 +03:00
d188bba16e feat: Implemented guild check
- Also added more error logging
2025-12-24 17:46:22 +03:00
d584340f01 merge: Merged message context and rocket api 2025-12-24 14:35:15 +03:00
c22787792d feat: Added API for web map in discord bot
- Bump version to 0.11.0
- Added data table to quests, accounts and rooms
- Discord bot now adds "avatar" and "name" data to accounts on init
- Added CLI "map data" command
2025-12-24 14:30:40 +03:00
81a9ec0c50 feat: Added message context to strings.quest.publish 2025-12-21 11:08:08 +03:00
0ab777d898 build: Added unfinished build-deb.sh 2025-12-19 17:15:13 +03:00
66cbd23013 style: Changed name in license 2025-12-19 16:58:50 +03:00
9d1261b74d build: Preparing stuff to create debian package
- Added deb binary target to generate incomplete control file
- Added CLI init option to insert impl_path in config
2025-12-19 16:22:02 +03:00
46af205aef style: Fixed several minor things in text
- Changed error in /move to CannotReach instead of RoomNotFound
2025-12-19 14:17:02 +03:00
cc916c06ce feat: Implemented daily timer
- Bump version to 0.10.0
- Added /timer command
2025-12-18 15:58:18 +03:00
60aa5fcb34 feat(discord): Commands description
- Added english commands description
- Added russian commands description
- Changed override option on /quest update to reset dates
- Commented out all deadline functionality
2025-12-18 13:33:42 +03:00
787118309a feat(discord): Moved most strings to Strings
- Added Error::AccountIsSelf variant
- /balance give to self now returns error
2025-12-17 14:43:40 +03:00
aec4ef8339 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
2025-12-16 16:42:18 +03:00
32 changed files with 2653 additions and 480 deletions

1153
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,10 @@
members = ["cli", "discord"]
[workspace.package]
version = "0.9.0"
version = "0.12.0"
edition = "2024"
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
license = "MIT"
[package]
@ -13,6 +14,7 @@ edition.workspace = true
version.workspace = true
repository.workspace = true
license.workspace = true
homepage.workspace = true
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }

View file

@ -1,4 +1,4 @@
Copyright 2025 (c) 2ndbeam
Copyright 2025 (c) Alexey Mirenkov <2ndbeam@disroot.org>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

23
build-deb.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
cargo build --workspace --release
install -dvm755 target/release/dpkg/etc/squad_quest target/release/dpkg/usr/bin target/release/dpkg/DEBIAN target/release/dpkg/usr/share/doc/squad-quest
strip target/release/squad-quest-cli
strip target/release/squad-quest-discord
install -vm755 target/release/squad-quest-cli target/release/squad-quest-discord target/release/dpkg/usr/bin
install -vm 644 LICENSE target/release/dpkg/usr/share/doc/squad-quest/copyright
target/release/squad-quest-cli -qc nil init -i discord.toml -p target/release/dpkg/etc/squad_quest
cargo build --bin deb --release
target/release/deb > target/release/dpkg/DEBIAN/control
echo -n "" > target/release/dpkg/DEBIAN/conffiles
for file in $(ls target/release/dpkg/etc/squad_quest); do
if [ -f target/release/dpkg/etc/squad_quest/$file ]; then
echo "/etc/squad_quest/$file" >> target/release/dpkg/DEBIAN/conffiles
fi
done
dpkg-deb --root-owner-group --build target/release/dpkg target/release/squad-quest.deb

View file

@ -9,5 +9,5 @@ license.workspace = true
chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
squad-quest = { version = "0.9.0", path = ".." }
squad-quest = { version = "0.12.0", path = ".." }
toml = "0.9.8"

View file

@ -14,6 +14,8 @@ pub enum MapCommands {
Delete(MapDeleteArgs),
/// Update room data
Update(MapUpdateArgs),
/// Get room implementation data
Data(MapDataArgs),
}
#[derive(Args)]
@ -55,3 +57,9 @@ pub struct MapUpdateArgs {
#[arg(short,long)]
pub value: Option<u32>,
}
#[derive(Args)]
pub struct MapDataArgs {
/// Room ID
pub id: u16,
}

View file

@ -40,4 +40,6 @@ pub enum Objects {
pub struct InitArgs {
#[arg(long,short)]
pub path: Option<PathBuf>,
#[arg(long,short)]
pub implpath: Option<PathBuf>,
}

View file

@ -83,6 +83,9 @@ pub struct QuestCreateArgs {
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(short,long,value_parser = parse_date)]
pub deadline: Option<Date>,
/// Limit on how many users can solve the quest (0 = no limit)
#[arg(short,long)]
pub limit: Option<u8>,
}
#[derive(Args)]
@ -113,6 +116,9 @@ pub struct QuestUpdateArgs {
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(long,value_parser = parse_date)]
pub deadline: Option<Date>,
/// Limit on how many users can solve the quest (0 = no limit)
#[arg(long)]
pub limit: Option<u8>,
}
#[derive(Args)]

View file

@ -71,6 +71,7 @@ fn main() {
let config = Config {
path: path.clone(),
impl_path: args.implpath.clone(),
..Default::default()
};
@ -146,7 +147,9 @@ fn main() {
answer: args.answer.clone(),
public: args.public,
available_on: args.available.clone(),
deadline: args.deadline.clone()
deadline: args.deadline.clone(),
limit: args.limit.unwrap_or_default(),
..Default::default()
};
do_and_log(quest.save(path), !cli.quiet, format!("Created quest #{}.", quest.id));
@ -168,7 +171,9 @@ fn main() {
answer: args.answer.clone().unwrap_or(quest.answer.clone()),
public: args.public.unwrap_or(quest.public),
available_on: args.available.clone().or(quest.available_on.clone()),
deadline: args.deadline.clone().or(quest.deadline.clone())
deadline: args.deadline.clone().or(quest.deadline.clone()),
limit: args.limit.unwrap_or_default(),
..Default::default()
};
do_and_log(quest.save(path), !cli.quiet, format!("Updated quest #{}.", quest.id));
@ -281,10 +286,6 @@ fn main() {
do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id));
},
AccountCommands::Complete(args) => {
let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else {
if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); }
return;
};
let quests = config.load_quests();
@ -296,9 +297,17 @@ fn main() {
},
};
match quest.complete_for_account(account) {
let result = quest.complete_for_account(&args.account, &mut accounts);
match result {
Err(error) if !cli.quiet => println!("Error: {error}"),
Ok(_) => do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id)),
Ok(_) => {
let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else {
if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); }
return;
};
do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id));
},
_ => {},
}
},
@ -444,6 +453,15 @@ fn main() {
let connected = if connect { "Connected" } else { "Disconnected" };
do_and_log(map_save(map, map_path), !cli.quiet, format!("{connected} rooms #{} <-> #{}.", args.first, args.second));
},
MapCommands::Data(args) => {
if let Some(room) = map.room.iter().find(|r| r.id == args.id) {
if let Some(data) = &room.data {
for (key, value) in data {
println!("{key} = {value}");
}
}
}
},
}
}
}

View file

@ -6,10 +6,13 @@ repository.workspace = true
license.workspace = true
[dependencies]
chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] }
dotenvy = "0.15.7"
poise = "0.6.1"
rocket = { version = "0.5.1", features = ["json"] }
serde = "1.0.228"
squad-quest = { version = "0.9.0", path = ".." }
serde_json = "1.0.146"
squad-quest = { version = "0.12.0", path = ".." }
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
toml = "0.9.8"

8
discord/Rocket.toml Normal file
View file

@ -0,0 +1,8 @@
[default]
address = "127.0.0.1" # should be local only because frontend runs on the same machine
port = 2526
log_level = "critical"
[default.shutdown]
ctrlc = false

View file

@ -1,12 +1,50 @@
use squad_quest::{account::Account, config::Config};
use std::collections::HashMap;
pub fn fetch_or_init_account(conf: &Config, id: String) -> Account {
use poise::serenity_prelude::{User, UserId};
use squad_quest::{account::Account, config::Config, map::Map};
/// Returns Ok(account) if account was found or Err(new_account) if not
pub fn fetch_or_init_account(conf: &Config, id: &str, user: Option<&User>) -> Result<Account, Account> {
let accounts = conf.load_accounts();
match accounts.iter().find(|a| a.id == id) {
Some(a) => a.clone(),
None => Account {
id,
..Default::default()
},
if let Some(account) = accounts.iter().find(|a| a.id == id) {
return Ok(account.clone());
}
let mut data: HashMap<String, String> = HashMap::new();
if let Some(user) = user {
let avatar = user.avatar_url().unwrap_or("null".to_string());
let name = user.display_name().to_string();
data.insert("avatar".to_string(), avatar);
data.insert("name".to_string(), name);
}
let new_account = Account {
id: id.to_string(),
data: Some(data),
..Default::default()
};
Err(new_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"))
}

119
discord/src/api.rs Normal file
View file

@ -0,0 +1,119 @@
use rocket::{Build, Response, Rocket, State, http::{Header, hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN}, response::Responder, serde::json::Json};
use serde::Serialize;
use squad_quest::{SquadObject, account::Account, config::Config, map::{Map, Room}};
struct RocketData {
pub config: Config,
}
#[derive(Serialize)]
struct UserData {
pub id: String,
pub avatar: String,
pub name: String,
}
#[derive(Serialize)]
struct RoomData {
pub id: u16,
pub value: u32,
pub name: String,
pub description: String,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub markers: Vec<UserData>,
}
struct RoomDataResponse {
pub data: Vec<RoomData>
}
impl From<Vec<RoomData>> for RoomDataResponse {
fn from(value: Vec<RoomData>) -> Self {
Self {
data: value,
}
}
}
impl<'r> Responder<'r, 'static> for RoomDataResponse {
fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
Response::build_from(Json(&self.data).respond_to(request)?)
.header(Header::new(ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), "http://localhost:5173"))
.ok()
}
}
impl From<&Room> for RoomData {
fn from(value: &Room) -> Self {
let data = value.data.clone().unwrap_or_default();
let keys = [ "x", "y", "w", "h" ];
let mut values = [ 0f32, 0f32, 0f32, 0f32 ];
let mut counter = 0usize;
for key in keys {
values[counter] = data.get(key).map_or(0f32, |v| v.parse::<f32>().unwrap_or_default());
counter += 1;
}
RoomData {
id: value.id,
value: value.value,
name: value.name.clone(),
description: value.description.clone().unwrap_or(String::new()),
x: values[0],
y: values[1],
w: values[2],
h: values[3],
markers: Vec::new(),
}
}
}
fn acc_filt_map(account: &Account, room_id: u16) -> Option<UserData> {
if account.location == room_id {
let data = account.data.clone().unwrap_or_default();
let keys = [ "avatar", "name" ];
let empty = String::new();
let mut values = [ &String::new(), &String::new() ];
let mut counter = 0usize;
for key in keys {
values[counter] = data.get(key).unwrap_or(&empty);
counter += 1;
}
Some(UserData {
id: account.id.clone(),
avatar: values[0].clone(),
name: values[1].clone(),
})
} else { None }
}
#[get("/")]
fn index(rd: &State<RocketData>) -> RoomDataResponse {
let map_path = rd.config.full_map_path();
let Ok(map) = Map::load(map_path) else {
return Vec::new().into();
};
let accounts = rd.config.load_accounts();
let rooms_vec: Vec<RoomData> = map.room.iter()
.map(|r| {
let mut rd = RoomData::from(r);
let markers = accounts.iter()
.filter_map(|a| acc_filt_map(a, r.id))
.collect::<Vec<UserData>>();
rd.markers = markers;
rd
})
.collect();
rooms_vec.into()
}
pub fn rocket(config: Config) -> Rocket<Build> {
rocket::build()
.mount("/", routes![index])
.manage(RocketData{config})
}

View file

@ -1,58 +1,46 @@
use poise::serenity_prelude::UserId;
use poise::serenity_prelude::User;
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_user_id}, strings::StringFormatter, commands::guild};
async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) -> String {
let rooms_value = account_rooms_value(account, map);
let full_balance = account_full_balance(account, map);
async fn account_balance_string(
ctx: &Context<'_>,
account: &Account,
map: &Map,
mut formatter: StringFormatter
) -> String {
let account_id = account_user_id(&account);
let Ok(user) = account_id
.to_user(ctx)
.await else {
let Ok(user) = account_id.to_user(ctx).await else {
return String::new();
};
let name = user.display_name();
format!("\n{name}: **{full_balance}** points (**{balance}** on balance \
+ **{rooms_value}** unlocked rooms networth)",
balance = account.balance,
)
}
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"))
let strings = &ctx.data().strings;
formatter = formatter.user(&user).balance(account, map);
formatter.fmt(&strings.scoreboard.line_format)
}
/// Reset user account, including balance, unlocked rooms and completed quests
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR",
name_localized("ru", "сбросить"),
description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"),
)]
pub async fn reset(
ctx: Context<'_>,
who: UserId,
#[description = "The user to reset"]
#[name_localized("ru", "кого")]
#[description_localized("ru", "Сбрасываемый пользователь")]
who: User,
) -> Result<(), Error> {
let accounts = ctx.data().config.load_accounts();
let acc_id = format!("{}", who.get());
let acc_id = format!("{}", who.id.get());
if let None = accounts.iter().find(|a| a.id == acc_id) {
return Err(Error::AccountNotFound);
@ -62,16 +50,23 @@ pub async fn reset(
path.push(format!("{acc_id}.toml"));
Account::delete(path)?;
let reply_string = "User was successfully reset.".to_string();
let strings = &ctx.data().strings;
let formatter = strings.formatter().user(&who);
let reply_string = formatter.fmt(&strings.account.reset);
ctx.reply(reply_string).await?;
Ok(())
}
/// Show scoreboard
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
name_localized("ru", "счет"),
description_localized("ru", "Отобразить таблицу лидеров"),
)]
pub async fn scoreboard(
ctx: Context<'_>,
@ -79,6 +74,9 @@ pub async fn scoreboard(
let map_path = ctx.data().config.full_map_path();
let map = Map::load(map_path).expect("map.toml should exist");
let strings = &ctx.data().strings;
let mut formatter = strings.formatter();
let mut accounts = ctx.data().config.load_accounts();
accounts.sort_by(|a,b| {
let a_balance = account_full_balance(a, &map);
@ -88,13 +86,14 @@ pub async fn scoreboard(
let this_user = ctx.author().id;
let mut reply_string = "Current scoreboard:".to_string();
let mut reply_string = formatter.fmt(&strings.scoreboard.header);
for account in accounts {
let user_id = account_user_id(&account);
let mut str = account_balance_string(&ctx, &account, &map).await;
let mut str = account_balance_string(&ctx, &account, &map, formatter.clone()).await;
if user_id == this_user {
str = format!("__{str}__ << You");
formatter = formatter.text(&str);
str = formatter.fmt(&strings.scoreboard.you_format);
}
reply_string.push_str(&str);
@ -109,7 +108,9 @@ pub async fn scoreboard(
prefix_command,
slash_command,
guild_only,
check = "guild",
subcommands("give", "set"),
name_localized("ru", "баланс"),
)]
pub async fn balance(
_ctx: Context<'_>,
@ -117,59 +118,99 @@ pub async fn balance(
Ok(())
}
/// Give points to another user
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
name_localized("ru", "передать"),
description_localized("ru", "Передать очки другому пользователю"),
)]
pub async fn give(
ctx: Context<'_>,
who: UserId,
#[description = "Recipient"]
#[name_localized("ru", "кому")]
#[description_localized("ru", "Получатель")]
who: User,
#[description = "Amount of the points to give"]
#[name_localized("ru", "количество")]
#[description_localized("ru", "Количество очков для передачи")]
amount: u32,
) -> Result<(), Error> {
if ctx.author() == &who {
return Err(Error::AccountIsSelf);
}
let config = &ctx.data().config;
let mut accounts = config.load_accounts();
let user_id = format!("{}", ctx.author().id.get());
let mut user_account = fetch_or_init_account(config, user_id);
let who_id = format!("{}", who.get());
let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
return Err(Error::AccountNotFound);
};
let strings = &ctx.data().strings;
let formatter: StringFormatter;
let accounts_path = config.full_accounts_path();
let who_id = format!("{}", who.id.get());
if user_account.balance < amount {
return Err(Error::InsufficientFunds(amount));
if let None = accounts.iter().find(|a| a.id == who_id ) {
return Err(Error::AccountNotFound);
}
user_account.balance -= amount;
{
let Some(user_account) = accounts.iter_mut().find(|a| a.id == user_id) else {
return Err(Error::AccountNotFound);
};
if user_account.balance < amount {
return Err(Error::InsufficientFunds(amount));
}
user_account.balance -= amount;
user_account.save(accounts_path.clone())?;
formatter = strings.formatter()
.value(amount)
.user(&who)
.current_balance(&user_account);
}
let other_account = accounts.iter_mut().find(|a| a.id == who_id ).expect("We already checked its existence earlier");
other_account.balance += amount;
let accounts_path = config.full_accounts_path();
user_account.save(accounts_path.clone())?;
other_account.save(accounts_path)?;
let reply_string = format!("Given money to user.\n\
Your new balance: {} points.", user_account.balance);
let reply_string = formatter.fmt(&strings.account.give_pt);
ctx.reply(reply_string).await?;
Ok(())
}
/// Set current user balance
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR",
name_localized("ru", "установить"),
description_localized("ru", "Устанавливает текущий баланс пользователя"),
)]
pub async fn set(
ctx: Context<'_>,
who: UserId,
#[description = "User, whose balance will be modified"]
#[name_localized("ru", "чей")]
#[description_localized("ru", "Пользователь, чей баланс будет изменён")]
who: User,
#[description = "New balance of the user"]
#[name_localized("ru", "количество")]
#[description_localized("ru", "Новый баланс пользователя")]
amount: u32,
) -> Result<(), Error> {
let mut accounts = ctx.data().config.load_accounts();
let who_id = format!("{}", who.get());
let who_id = format!("{}", who.id.get());
let Some(account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
return Err(Error::AccountNotFound);
};
@ -177,8 +218,13 @@ pub async fn set(
account.balance = amount;
let accounts_path = ctx.data().config.full_accounts_path();
account.save(accounts_path)?;
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.user(&who)
.current_balance(&account);
let reply_string = format!("Set user balance to {amount}.");
let reply_string = formatter.fmt(&strings.account.set_pt);
ctx.reply(reply_string).await?;
Ok(())

View file

@ -1,39 +1,68 @@
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage, Mentionable};
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage};
use squad_quest::SquadObject;
use crate::{Context, Error, account::fetch_or_init_account};
use crate::{account::fetch_or_init_account, commands::{guild, quest::update_quest_message}, Context, Error};
/// Send an answer to the quest for review
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
name_localized("ru", "ответить"),
description_localized("ru", "Отправить ответ на квест на проверку"),
)]
pub async fn answer(
ctx: Context<'_>,
#[description = "Identifier of the quest to answer to"]
#[name_localized("ru", "ид_квеста")]
#[description_localized("ru", "Идентификатор квеста для ответа")]
quest_id: u16,
#[description = "Text answer to the quest"]
#[name_localized("ru", "текст")]
#[description_localized("ru", "Текст ответа на квест")]
text: Option<String>,
#[description = "Attachment answer to the quest"]
#[name_localized("ru", "файл1")]
#[description_localized("ru", "Вложение к ответу на квест")]
file1: Option<Attachment>,
#[description = "Attachment answer to the quest"]
#[name_localized("ru", "файл2")]
#[description_localized("ru", "Вложение к ответу на квест")]
file2: Option<Attachment>,
#[description = "Attachment answer to the quest"]
#[name_localized("ru", "файл3")]
#[description_localized("ru", "Вложение к ответу на квест")]
file3: Option<Attachment>,
) -> Result<(), Error> {
let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string());
if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) {
return Err(Error::QuestIsCompleted(quest_id));
}
let quests = ctx.data().config.load_quests();
let Some(quest) = quests.iter()
.filter(|q| q.public)
.find(|q| q.id == quest_id) else {
return Err(Error::QuestNotFound(quest_id));
};
{
let accounts = ctx.data().config.load_accounts();
let completed_times = accounts.iter().filter(|a| a.has_completed_quest(&quest)).count() as u8;
if quest.limit > 0 && completed_times >= quest.limit {
return Err(Error::QuestLimitExceeded(quest.id));
}
}
let user_id = ctx.author().id.to_string();
match fetch_or_init_account(&ctx.data().config, &user_id, Some(ctx.author())) {
Ok(account) => {
if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) {
return Err(Error::QuestIsCompleted(quest_id));
}
},
Err(new_account) => {
let path = ctx.data().config.full_accounts_path();
new_account.save(path)?
}
}
let mut files: Vec<Attachment> = Vec::new();
for file in [file1, file2, file3] {
if let Some(f) = file {
@ -45,23 +74,30 @@ pub async fn answer(
return Err(Error::NoContent);
}
let text_ans = match text {
Some(text) => format!("\n### Passed answer:\n{text}"),
let strings = &ctx.data().strings;
let mut formatter = strings.formatter()
.user(ctx.author())
.quest(quest);
let text_ans = match text {
Some(text) => {
formatter = formatter.text(text);
formatter.fmt(&strings.answer.text)
},
None => String::new(),
};
let attachment_notice = if files.len() == 0 { String::new() } else {
"\nPassed answer has attachments.".to_string()
formatter.fmt(&strings.answer.attachment_notice)
};
let content = format!("## From: {user}\n\
### Quest #{quest_id}: {quest_name}\n\
### Expected answer:\n\
||{quest_answer}||{text_ans}{attachment_notice}",
user = ctx.author().mention(),
quest_name = quest.name,
quest_answer = quest.answer,
);
let content = [
formatter.fmt(&strings.answer.from),
formatter.fmt(&strings.answer.quest),
formatter.fmt(&strings.answer.expected),
text_ans,
attachment_notice,
].join("");
let mut attachments: Vec<CreateAttachment> = Vec::new();
@ -91,19 +127,21 @@ pub async fn answer(
let mut message = ans_channel.send_message(ctx, builder).await?;
let reply_string = "Your answer has been posted.".to_string();
let reply_string = formatter.fmt(&strings.answer.reply.initial);
ctx.reply(reply_string).await?;
if let Some(press) = ComponentInteractionCollector::new(ctx)
.filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
.await
{
let admin = press.user.mention();
let admin = press.user;
formatter = formatter.user(&admin).text(&content);
let is_approved = press.data.custom_id == approve_id;
let content = if is_approved {
format!("{content}\nApproved by: {admin}")
formatter.fmt(&strings.answer.accepted_by)
} else {
format!("~~{content}~~\nRejected by: {admin}")
formatter.fmt(&strings.answer.rejected_by)
};
let builder = EditMessage::new().content(content).components(Vec::new());
@ -112,31 +150,29 @@ pub async fn answer(
let content: String;
if is_approved {
let mut no_errors = true;
if let Err(error) = quest.complete_for_account(&mut account) {
let mut accounts = ctx.data().config.load_accounts();
if let Err(error) = quest.complete_for_account(&ctx.author().id.to_string(), &mut accounts) {
eprintln!("{error}");
no_errors = false;
};
let account = accounts.iter_mut().find(|a| a.id == user_id).expect("we done fetch_or_init earlier");
let path = ctx.data().config.full_accounts_path();
if let Err(error) = account.save(path) {
eprintln!("{error}");
no_errors = false;
};
update_quest_message(ctx, &quest).await?;
formatter = formatter.current_balance(&account);
if no_errors {
content = format!("Your answer to the quest #{quest_id} has been approved.\n\
You gained {reward} points.\n\
Your balance is now {balance} points",
reward = quest.reward,
balance = account.balance
);
content = formatter.fmt(&strings.answer.reply.accepted);
} else {
content = format!("Your answer to the quest #{quest_id} has been approved, \
but some server error happened. \
Please contact administrator for details."
);
content = formatter.fmt(&strings.answer.reply.error);
}
} else {
content = format!("Your answer to the quest #{quest_id} has been rejected.");
content = formatter.fmt(&strings.answer.reply.rejected);
};
let dm_builder = CreateMessage::new().content(content);
ctx.author().dm(ctx, dm_builder).await?;

View file

@ -1,25 +1,33 @@
use std::path::Path;
use std::{path::Path, str::FromStr};
use poise::serenity_prelude::{ChannelId};
use poise::{CreateReply, serenity_prelude::ChannelId};
use squad_quest::SquadObject;
use toml::value::Time;
use crate::{Context, Error};
use crate::{Context, Error, timer::DailyTimer, commands::guild};
/// Set channels to post quests and answers to
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "инит"),
description_localized("ru", "Установить каналы для публикации квестов и ответов"),
)]
pub async fn init(
ctx: Context<'_>,
#[description = "Channel to post quests to"]
#[name_localized("ru", "канал_квестов")]
#[description_localized("ru", "Канал для публикации квестов")]
quests_channel: ChannelId,
#[description = "Channel to post answers to check"]
#[description = "Channel to post answers for review"]
#[name_localized("ru", "канал_ответов")]
#[description_localized("ru", "Канал для публикации ответов на проверку")]
answers_channel: ChannelId,
) -> Result<(), Error> {
let dc = ctx.data().discord.clone();
let dc = ctx.data().discord.clone();
{
let mut guard = dc.lock().expect("shouldn't be locked");
let guild = ctx.guild_id().unwrap();
@ -30,8 +38,72 @@ pub async fn init(
let path = &ctx.data().config.full_impl_path().unwrap();
guard.save(path.parent().unwrap_or(Path::new("")).to_owned())?
};
let reply_string = "Settings updated.".to_string();
let strings = &ctx.data().strings;
let formatter = strings.formatter();
let reply_string = formatter.fmt(&strings.init_reply);
ctx.reply(reply_string).await?;
Ok(())
}
#[derive(serde::Deserialize)]
struct TimeWrapper {
time: Time,
}
impl FromStr for TimeWrapper {
type Err = toml::de::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let toml_str = format!("time = {s}");
let wrapper: Self = toml::from_str(&toml_str)?;
Ok(wrapper)
}
}
impl From<TimeWrapper> for Time {
fn from(value: TimeWrapper) -> Self {
value.time
}
}
fn seconds(time: Time) -> u64 {
time.hour as u64 * 3600 + time.minute as u64 * 60 + time.second as u64
}
/// Enable publication timer on given UTC time
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "таймер"),
description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"),
)]
pub async fn timer(
ctx: Context<'_>,
#[description = "UTC time (in format HH:MM:SS, e.g. 9:00:00)"]
#[name_localized("ru", "время")]
#[description_localized("ru", "Время по UTC (МСК -3) в формате ЧЧ:ММ:СС, напр. 9:00:00")]
time: TimeWrapper,
) -> Result<(), Error> {
if ctx.data().has_timer() {
return Err(Error::TimerSet);
}
let time = Time::from(time);
let start_time = seconds(time);
let timer = DailyTimer::new(start_time);
let strings = &ctx.data().strings;
let formatter = strings.formatter().value(time);
let content = formatter.fmt(&strings.timer_reply);
let builder = CreateReply::default().ephemeral(true).content(content);
ctx.send(builder).await?;
ctx.data().timer();
timer.start(ctx).await;
Ok(())
}

View file

@ -1,14 +1,22 @@
use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply};
use squad_quest::{SquadObject, map::Map};
use crate::{Context, account::fetch_or_init_account, error::Error};
use crate::{Context, account::fetch_or_init_account, error::Error, commands::guild};
/// Unlock specified room if it is reachable and you have required amount of points
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
name_localized("ru", "открыть"),
description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"),
)]
pub async fn unlock(
ctx: Context<'_>,
#[description = "Room identifier"]
#[name_localized("ru", "идентификатор")]
#[description_localized("ru", "Идентификатор комнаты")]
id: u16,
) -> Result<(), Error> {
let conf = &ctx.data().config;
@ -21,7 +29,10 @@ pub async fn unlock(
};
let acc_id = format!("{}", ctx.author().id.get());
let mut account = fetch_or_init_account(conf, acc_id);
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) {
Ok(account) => account,
Err(account) => account,
};
if account.balance < room.value {
return Err(Error::InsufficientFunds(room.value));
@ -31,37 +42,154 @@ pub async fn unlock(
let account_path = conf.full_accounts_path();
account.save(account_path)?;
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.user(ctx.author())
.balance(&account, &map)
.value(id);
let reply_string = format!("Unlocked room #{id}. Your balance: {} points", account.balance);
let reply_string = formatter.fmt(&strings.map.room_unlocked);
ctx.reply(reply_string).await?;
Ok(())
}
/// Move to another unlocked room
#[poise::command(
prefix_command,
slash_command,
guild_only,
check = "guild",
name_localized("ru", "пойти"),
description_localized("ru", "Переместиться в другую разблокированную комнату"),
)]
pub async fn r#move(
ctx: Context<'_>,
#[description = "Identifier of the room to move to"]
#[name_localized("ru", "идентификатор")]
#[description_localized("ru", "Идентификатор комнаты, куда переместиться")]
id: u16,
) -> Result<(), Error> {
let conf = &ctx.data().config;
let acc_id = format!("{}", ctx.author().id.get());
let mut account = fetch_or_init_account(conf, acc_id);
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) {
Ok(account) => account,
Err(account) => account,
};
if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) {
return Err(Error::RoomNotFound(id));
return Err(Error::CannotReach(id));
}
account.location = id;
let account_path = conf.full_accounts_path();
account.save(account_path)?;
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.user(ctx.author())
.value(id);
let reply_string = format!("Moved to room #{id}.");
let reply_string = formatter.fmt(&strings.map.moved_to_room);
ctx.reply(reply_string).await?;
Ok(())
}
/// Change avatar on web map
#[poise::command(
slash_command,
prefix_command,
guild_only,
check = "guild",
name_localized("ru", "аватар"),
description_localized("ru", "Сменить аватар на веб карте"),
)]
pub async fn avatar(
ctx: Context<'_>,
#[description = "URL to the avatar"]
#[name_localized("ru", "ссылка")]
#[description_localized("ru", "Ссылка на аватар")]
url: Option<String>,
#[description = "Attachment to use as avatar"]
#[name_localized("ru", "вложение")]
#[description_localized("ru", "Вложение, используемое как аватар")]
attachment: Option<Attachment>,
) -> Result<(), Error> {
let user_id = ctx.author().id.to_string();
let mut accounts = ctx.data().config.load_accounts();
let Some(account) = accounts.iter_mut().find(|a| a.id == user_id) else {
return Err(Error::AccountNotFound);
};
if url.is_none() && attachment.is_none() {
return Err(Error::NoUrlOrAttachment);
}
if url.is_some() && attachment.is_some() {
return Err(Error::BothUrlAndAttachment);
}
let strings = &ctx.data().strings;
let formatter = strings.formatter();
if let Some(url) = url {
let attachment = CreateAttachment::url(ctx, &url).await?;
let reply_string = formatter.fmt(&strings.map.processing_url);
let builder = CreateMessage::new()
.content(reply_string)
.add_file(attachment.clone());
let message = ctx.channel_id().send_message(ctx, builder).await?;
let attachment_check = message.attachments.first().expect("we just sent it");
if attachment_check.width.is_none()
|| !attachment_check.content_type
.as_ref()
.is_some_and(|t| t.starts_with("image/")) {
message.delete(ctx).await?;
return Err(Error::NonImageAttachment);
}
let data = account.data.as_mut().expect("automatically created");
data.insert("avatar".to_string(), url);
message.delete(ctx).await?;
let reply_string = formatter.fmt(&strings.map.updated_avatar);
let builder = CreateReply::default()
.content(reply_string)
.attachment(attachment)
.reply(true);
ctx.send(builder).await?;
} else if let Some(attachment) = attachment {
if attachment.width.is_none()
|| !attachment.content_type
.as_ref()
.is_some_and(|t| t.starts_with("image/")) {
return Err(Error::NonImageAttachment);
}
let reply_string = formatter.fmt(&strings.map.updated_avatar);
let copied_attachment = CreateAttachment::url(ctx, &attachment.url).await?;
let data = account.data.as_mut().expect("automatically created");
data.insert("avatar".to_string(), attachment.url);
let path = ctx.data().config.full_accounts_path();
account.save(path)?;
let builder = CreateReply::default()
.content(reply_string)
.attachment(copied_attachment)
.reply(true);
ctx.send(builder).await?;
}
let path = ctx.data().config.full_accounts_path();
account.save(path)?;
Ok(())
}

View file

@ -1,7 +1,8 @@
use std::error::Error as StdError;
use poise::CreateReply;
use poise::{CreateReply, serenity_prelude::GuildId};
use squad_quest::quest::Quest;
use crate::{Context, Data, Error};
use crate::{error, Context, Data, Error};
pub mod quest;
pub mod init;
@ -10,23 +11,36 @@ pub mod social;
pub mod account;
pub mod map;
pub async fn guild(ctx: Context<'_>) -> Result<bool, Error> {
let id = ctx.guild_id().expect("guild-only command");
let guard = ctx.data().discord.lock().expect("shouldn't be locked");
let expected_id = guard.guild;
if expected_id != GuildId::default() && id != expected_id {
return Err(Error::NotThisGuild);
}
Ok(true)
}
#[poise::command(prefix_command)]
pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
poise::builtins::register_application_commands_buttons(ctx).await?;
Ok(())
}
/// Show bot info, such as version and link to web map
#[poise::command(
prefix_command,
slash_command,
name_localized("ru", "инфо"),
description_localized("ru", "Получить информацию о боте и ссылку на веб карту"),
)]
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(())
}
@ -35,17 +49,52 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) {
eprintln!("ERROR:");
print_error_recursively(&error);
if let Some(ctx) = error.ctx() {
let response = match error {
poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"),
_ => format!("Internal server error: {error}"),
let user = ctx.author().display_name();
eprintln!("User: {user} ({id})", id = ctx.author().id);
eprintln!("Invokation string: {}", ctx.invocation_string());
let strings = &ctx.data().strings;
let response = if let poise::FrameworkError::Command { error, .. } = error {
let formatter = match error {
error::Error::QuestNotFound(id) |
error::Error::QuestIsPublic(id) |
error::Error::QuestIsCompleted(id) |
error::Error::QuestLimitExceeded(id) =>
strings.formatter().quest(&Quest { id, ..Default::default() }),
error::Error::InsufficientFunds(amount) =>
strings.formatter().value(amount),
error::Error::RoomNotFound(value) |
error::Error::RoomAlreadyUnlocked(value) |
error::Error::CannotReach(value) =>
strings.formatter().value(value),
error::Error::SerenityError(ref error) =>
strings.formatter().text(error),
error::Error::SquadQuestError(ref error) =>
strings.formatter().text(error),
_ => strings.formatter(),
};
let error_string = error.formattable_string(&strings.error);
formatter.fmt(error_string)
} else {
let formatter = strings.formatter().text(&error);
formatter.fmt(&strings.error.non_command_error)
};
if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await {
eprintln!("Couldn't send error message: {error}");
}
}
}
fn print_error_recursively(error: &impl StdError) {
pub fn print_error_recursively(error: &impl StdError) {
eprintln!("{error}");
if let Some(source) = error.source() {
eprintln!("source:");

View file

@ -1,9 +1,10 @@
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}};
use poise::serenity_prelude as serenity;
use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt};
use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject};
use toml::value::Date;
use crate::{Context, Error};
use crate::{Context, Error,commands::guild};
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
ctx.defer().await?;
@ -24,24 +25,45 @@ 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, accounts: &Option<Vec<Account>>) -> String {
let strings = &ctx.data().strings;
let formatter = match accounts {
Some(accounts) => strings.formatter().quest_full(quest, accounts),
None => strings.formatter().quest(quest),
};
formatter.fmt(&strings.quest.message_format)
}
pub async fn update_quest_message(ctx: Context<'_>, quest: &Quest) -> Result<(), Error> {
let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&quest);
let accounts = ctx.data().config.load_accounts();
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
let builder = EditMessage::new().content(content);
let message = find_quest_message(ctx, quest.id).await?;
if let Some(mut message) = message {
return match message.edit(ctx, builder).await {
Ok(_) => Ok(()),
Err(error) => Err(error.into()),
}
} else {
let reply_string = formatter.fmt(&strings.quest.message_not_found);
match ctx.reply(reply_string).await {
Ok(_) => Ok(()),
Err(error) => Err(error.into()),
}
}
}
#[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<'_>,
@ -49,24 +71,27 @@ pub async fn quest(
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 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(())
@ -80,7 +105,6 @@ pub enum DifficultyWrapper {
Secret,
}
impl From<DifficultyWrapper> for QuestDifficulty {
fn from(value: DifficultyWrapper) -> Self {
match &value {
@ -112,28 +136,52 @@ impl From<DateWrapper> for 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 = "Quest answer, visible to admins"]
#[description = "Expected answer, visible when user posts their answer for review"]
#[name_localized("ru", "ответ")]
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
answer: String,
#[description = "Optional date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[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 = "Optional deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[description = "Limit how many users are allowed to complete the quest"]
#[name_localized("ru", "лимит")]
#[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")]
limit: Option<u8>,
/*
#[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();
@ -148,10 +196,12 @@ pub async fn create(
None => None,
};
/*
let deadline = match deadline {
Some(dl) => Some(dl.into()),
None => None,
};
*/
let quest = Quest {
id: next_id,
@ -162,46 +212,80 @@ pub async fn create(
answer,
public: false,
available_on,
deadline,
limit: limit.unwrap_or_default(),
//deadline,
..Default::default()
};
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?;
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 = "Quest answer, visible to admins"]
#[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 = "Limit how many users are allowed to complete the quest"]
#[name_localized("ru", "лимит")]
#[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")]
limit: Option<u8>,
/*
#[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 = "Clear availability and deadline if checked"]
#[rename = "override"]
should_override: Option<bool>,
#[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();
@ -215,20 +299,22 @@ pub async fn update(
};
let available_on: Option<Date>;
let dead_line: Option<Date>;
let new_limit: u8;
//let dead_line: Option<Date>;
match should_override.unwrap_or(false) {
match reset.unwrap_or(false) {
true => {
available_on = available.map(|v| v.into());
dead_line = deadline.map(|v| v.into());
available_on = None;
new_limit = limit.unwrap_or_default();
//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()));
new_limit = limit.unwrap_or(quest.limit);
//dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
},
}
let new_quest = Quest {
id,
difficulty,
@ -238,40 +324,65 @@ pub async fn update(
answer: answer.unwrap_or(quest.answer.clone()),
public: quest.public,
available_on,
deadline: dead_line,
limit: new_limit,
//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(&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",
);
ctx.reply(reply_string).await?;
}
update_quest_message(ctx, &new_quest).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(())
}
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 accounts = ctx.data().config.load_accounts();
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
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
guild_only,
check = "guild",
name_localized("ru", "опубликовать"),
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
)]
pub async fn publish(
ctx: Context<'_>,
#[description = "Identifier of the quest to publish"]
#[description = "Quest identifier"]
#[name_localized("ru", "идентификатор")]
#[description_localized("ru", "Идентификатор квеста")]
id: u16,
) -> Result<(), Error> {
let mut quests = ctx.data().config.load_quests();
@ -283,46 +394,35 @@ pub async fn publish(
if quest.public {
return Err(Error::QuestIsPublic(id));
}
quest.public = true;
let content = make_quest_message_content(&quest);
let message = publish_inner(ctx, quest).await?;
let builder = CreateMessage::new()
.content(content);
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.quest(&quest)
.message(&message);
let dc = ctx.data().discord.clone();
let channel = {
let guard = dc.lock().expect("shouldn't be locked");
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())?
};
let quests_path = ctx.data().config.full_quests_path();
quest.save(quests_path)?;
let reply_string = format!("Published quest #{id}");
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? {
@ -342,7 +442,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,29 +1,47 @@
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, Mentionable, MessageId, UserId};
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, MessageId, UserId};
use crate::{Context, Error};
use crate::{Context, Error, commands::guild};
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
subcommands("msg", "edit", "undo"),
name_localized("ru", "сообщение"),
)]
pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> {
Ok(())
}
/// Send message to channel or user
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "написать"),
description_localized("ru", "Отправить сообщение пользователю или в канал"),
)]
pub async fn msg (
ctx: Context<'_>,
#[description = "Channel to message to"]
#[name_localized("ru", "канал")]
#[description_localized("ru", "Канал, в который отправится сообщение")]
channel: Option<ChannelId>,
#[description = "User to message to"]
#[name_localized("ru", "пользователь")]
#[description_localized("ru", "Пользователь, которому отправится сообщение")]
user: Option<UserId>,
#[description = "Message text"]
#[name_localized("ru", "содержание")]
#[description_localized("ru", "Текст сообщения")]
content: Option<String>,
#[description = "Message attachment"]
#[name_localized("ru", "файл")]
#[description_localized("ru", "Вложение к сообщению")]
file: Option<Attachment>,
) -> Result<(), Error> {
@ -51,20 +69,22 @@ pub async fn msg (
builder = builder.add_file(attachment);
}
let strings = &ctx.data().strings;
let reply_string = if let Some(channel) = channel {
let message = channel.send_message(ctx, builder).await?;
format!("Sent {message} ({message_id}) to {channel}!",
message = message.link(),
message_id = message.id,
channel = channel.mention(),
)
} else if let Some(user) = user {
let message = user.dm(ctx, builder).await?;
format!("Sent {message} ({message_id}) to {user}",
message = message.link(),
message_id = message.id,
user = user.mention(),
)
let formatter = strings.formatter()
.message(&message);
formatter.fmt(&strings.social.sent_channel)
} else if let Some(user_id) = user {
let message = user_id.dm(ctx, builder).await?;
let user = user_id.to_user(ctx).await?;
let formatter = strings.formatter()
.message(&message)
.user(&user);
formatter.fmt(&strings.social.sent_dm)
} else {
unreachable!();
};
@ -74,19 +94,38 @@ pub async fn msg (
Ok(())
}
/// Edit sent channel or DM message
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "редактировать"),
description_localized("ru", "Редактировать сообщение в канале или в ЛС"),
)]
pub async fn edit (
ctx: Context<'_>,
#[description = "Identifier of the message to edit"]
#[name_localized("ru", "сообщение")]
#[description_localized("ru", "Идентификатор редактируемого сообщения")]
#[rename = "message"]
message_id: MessageId,
#[description = "Channel where the message is"]
#[name_localized("ru", "канал")]
#[description_localized("ru", "Канал, где находится сообщение")]
channel: Option<ChannelId>,
#[description = "User, who received DM"]
#[name_localized("ru", "пользователь")]
#[description_localized("ru", "Пользователь, получивший ЛС")]
user: Option<UserId>,
#[description = "New message text"]
#[name_localized("ru", "содержание")]
#[description_localized("ru", "Новый текст сообщения")]
content: Option<String>,
#[description = "New file (overrides existing if specified)"]
#[name_localized("ru", "файл")]
#[description_localized("ru", "Новое вложение (заменит предыдущее если указано)")]
file: Option<Attachment>,
) -> Result<(), Error> {
if channel.is_none() && user.is_none() {
@ -113,32 +152,52 @@ pub async fn edit (
builder = builder.new_attachment(attachment);
}
let mut message;
if let Some(channel) = channel {
let mut message = channel.message(ctx, message_id).await?;
message = channel.message(ctx, message_id).await?;
message.edit(ctx, builder).await?;
} else if let Some(user) = user {
let channel = user.create_dm_channel(ctx).await?;
let mut message = channel.message(ctx, message_id).await?;
message = channel.message(ctx, message_id).await?;
message.edit(ctx, builder).await?;
} else {
unreachable!()
}
let reply_string = "Successfully edited message.".to_string();
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.message(&message);
let reply_string = formatter.fmt(&strings.social.edited);
ctx.reply(reply_string).await?;
Ok(())
}
/// Delete message in channel or DM
#[poise::command(
prefix_command,
slash_command,
required_permissions = "ADMINISTRATOR",
guild_only,
check = "guild",
name_localized("ru", "удалить"),
description_localized("ru", "Удалить сообщение в канале или в ЛС"),
)]
pub async fn undo(
ctx: Context<'_>,
#[description = "Identifier of the message to delete"]
#[name_localized("ru", "сообщение")]
#[description_localized("ru", "Идентификатор удаляемого сообщения")]
#[rename = "message"]
message_id: MessageId,
#[description = "Channel where the message is"]
#[name_localized("ru", "канал")]
#[description_localized("ru", "Канал, где находится сообщение")]
channel: Option<ChannelId>,
#[description = "User, who received DM"]
#[name_localized("ru", "пользователь")]
#[description_localized("ru", "Пользователь, получивший ЛС")]
user: Option<UserId>,
) -> Result<(), Error> {
@ -149,17 +208,24 @@ pub async fn undo(
if channel.is_some() && user.is_some() {
return Err(Error::BothChannelAndUser);
}
let message;
if let Some(channel) = channel {
let message = channel.message(ctx, message_id).await?;
message = channel.message(ctx, message_id).await?;
message.delete(ctx).await?;
} else if let Some(user) = user {
let channel = user.create_dm_channel(ctx).await?;
let message = channel.message(ctx, message_id).await?;
message = channel.message(ctx, message_id).await?;
message.delete(ctx).await?;
} else {
unreachable!()
}
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.message(&message);
let reply_string = "Successfully deleted message".to_string();
let reply_string = formatter.fmt(&strings.social.deleted);
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

@ -3,6 +3,8 @@ use std::fmt::Display;
use poise::serenity_prelude as serenity;
use squad_quest::error::MapError;
use crate::strings::ErrorStrings;
#[non_exhaustive]
#[derive(Debug)]
pub enum Error {
@ -15,10 +17,17 @@ pub enum Error {
SerenityError(serenity::Error),
SquadQuestError(squad_quest::error::Error),
AccountNotFound,
AccountIsSelf,
InsufficientFunds(u32),
RoomNotFound(u16),
RoomAlreadyUnlocked(u16),
CannotReach(u16),
TimerSet,
NotThisGuild,
QuestLimitExceeded(u16),
BothUrlAndAttachment,
NoUrlOrAttachment,
NonImageAttachment,
}
impl From<serenity::Error> for Error {
@ -51,16 +60,23 @@ impl Display for Error {
Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"),
Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"),
Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"),
Self::NoContent => write!(f, "no text or attachment was specified"),
Self::NoChannelOrUser => write!(f, "no channel or user was specified"),
Self::BothChannelAndUser => write!(f, "both channel and user was specified"),
Self::NoContent => write!(f, "no text or attachment were specified"),
Self::NoChannelOrUser => write!(f, "no channel or user were specified"),
Self::BothChannelAndUser => write!(f, "both channel and user were specified"),
Self::SerenityError(_) => write!(f, "discord interaction error"),
Self::SquadQuestError(_) => write!(f, "internal logic error"),
Self::AccountNotFound => write!(f, "account not found"),
Self::AccountIsSelf => write!(f, "given account is the same as command user"),
Self::InsufficientFunds(amount) => write!(f, "user does not have {amount} points"),
Self::RoomNotFound(id) => write!(f, "room #{id} not found"),
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"),
Self::TimerSet => write!(f, "timer is already set"),
Self::NotThisGuild => write!(f, "cannot be used in this guild"),
Self::QuestLimitExceeded(id) => write!(f, "exceeded limit for quest #{id}"),
Self::BothUrlAndAttachment => write!(f, "both url and attachment were specified"),
Self::NoUrlOrAttachment => write!(f, "no url or attachment were specified"),
Self::NonImageAttachment => write!(f, "attachment is not an image"),
}
}
}
@ -75,14 +91,46 @@ impl std::error::Error for Error {
Self::NoChannelOrUser |
Self::BothChannelAndUser |
Self::AccountNotFound |
Self::AccountIsSelf |
Self::InsufficientFunds(_) |
Self::RoomNotFound(_) |
Self::RoomAlreadyUnlocked(_) |
Self::CannotReach(_) => None,
Self::CannotReach(_) |
Self::TimerSet |
Self::NotThisGuild |
Self::QuestLimitExceeded(_) |
Self::BothUrlAndAttachment |
Self::NoUrlOrAttachment |
Self::NonImageAttachment => None,
Self::SerenityError(error) => Some(error),
Self::SquadQuestError(error) => Some(error),
}
}
}
impl<'a> Error {
pub fn formattable_string(&self, errors: &'a ErrorStrings) -> &'a str {
match self {
Self::QuestNotFound(_) => &errors.quest_not_found,
Self::QuestIsPublic(_) => &errors.quest_is_public,
Self::QuestIsCompleted(_) => &errors.quest_is_completed,
Self::NoContent => &errors.no_content,
Self::NoChannelOrUser => &errors.no_channel_or_user,
Self::BothChannelAndUser => &errors.both_channel_and_user,
Self::SerenityError(_) => &errors.discord_error,
Self::SquadQuestError(_) => &errors.library_error,
Self::AccountNotFound => &errors.account_not_found,
Self::AccountIsSelf => &errors.account_is_self,
Self::InsufficientFunds(_) => &errors.insufficient_funds,
Self::RoomNotFound(_) => &errors.room_not_found,
Self::RoomAlreadyUnlocked(_) => &errors.room_already_unlocked,
Self::CannotReach(_) => &errors.cannot_reach,
Self::TimerSet => &errors.timer_set,
Self::NotThisGuild => &errors.not_this_guild,
Self::QuestLimitExceeded(_) => &errors.quest_limit_exceeded,
Self::BothUrlAndAttachment => &errors.both_url_and_attachment,
Self::NoUrlOrAttachment => &errors.no_url_or_attachment,
Self::NonImageAttachment => &errors.non_image_attachment,
}
}
}

View file

@ -1,25 +1,57 @@
use std::sync::{Arc, Mutex};
#[macro_use] extern crate rocket;
use std::{sync::{Arc, Mutex}};
use clap::Parser;
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, print_error_recursively}, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings};
mod api;
mod commands;
mod cli;
mod config;
mod account;
mod error;
mod strings;
mod timer;
const CONFIG_PATH: &str = "cfg/config.toml";
const DISCORD_TOKEN: &str = "DISCORD_TOKEN";
#[derive(Debug)]
struct InnerBool {
pub value: bool,
}
#[derive(Debug)]
struct Data {
pub config: Config,
pub discord: Arc<Mutex<DiscordConfig>>,
pub strings: Strings,
pub timer_set: Arc<Mutex<InnerBool>>,
}
impl Data {
pub fn timer(&self) {
let tm = self.timer_set.clone();
{
let mut guard = tm.lock().unwrap();
guard.value = true;
}
}
pub fn has_timer(&self) -> bool {
let tm = self.timer_set.clone();
{
let guard = tm.lock().unwrap();
guard.value
}
}
}
type Context<'a> = poise::Context<'a, Data, Error>;
#[tokio::main]
@ -28,22 +60,31 @@ 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 conf1 = config.clone();
tokio::spawn(async {
if let Err(error) = api::rocket(conf1).launch().await {
eprintln!("ERROR ON API LAUNCH");
print_error_recursively(&error);
}
});
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
on_error: |err| Box::pin(error_handler(err)),
commands: vec![
commands::quest::quest(),
commands::register(),
commands::quest::quest(),
commands::info(),
commands::init::init(),
commands::init::timer(),
commands::answer::answer(),
commands::social::social(),
commands::account::scoreboard(),
@ -51,15 +92,20 @@ async fn main() {
commands::account::reset(),
commands::map::unlock(),
commands::map::r#move(),
commands::map::avatar(),
],
..Default::default()
})
.setup(|ctx, _ready, framework| {
.setup(|_ctx, _ready, _framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
//poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data {
config,
discord: Arc::new(Mutex::new(discord)),
timer_set: Arc::new(Mutex::new(InnerBool { value: false })),
strings,
})
})
})

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

@ -0,0 +1,463 @@
use std::{collections::HashMap, fmt::Display, io::Write, path::PathBuf};
use poise::serenity_prelude::{Mentionable, Message, 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 limit = ("{q.limit}".to_string(), quest.limit.to_string());
let new_tags = vec![ id, difficulty, reward, name, description, answer, limit ];
self.with_tags(new_tags)
}
pub fn quest_full(mut self, quest: &Quest, accounts: &Vec<Account>) -> Self {
self = self.quest(&quest);
let completed_times = accounts.iter().filter(|a| a.has_completed_quest(&quest)).count();
let completions = ("{q.completions}".to_string(), completed_times.to_string());
self.with_tags(vec![completions])
}
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(mut self, account: &Account, map: &Map) -> Self {
self = self.current_balance(account);
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![ full_balance, rooms_balance ];
self.with_tags(new_tags)
}
pub fn current_balance(self, account: &Account) -> Self {
let balance = ("{b.current}".to_string(), self.points(account.balance));
self.with_tags(vec![balance])
}
pub fn message(self, message: &Message) -> Self {
let link = ("{m.link}".to_string(), message.link());
let id = ("{m.id}".to_string(), message.id.to_string());
let channel = ("{m.channel}".to_string(), message.channel_id.mention().to_string());
let new_tags = vec![ link, id, channel ];
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 init_reply: String,
pub timer_reply: String,
pub account: AccountReplies,
pub answer: Answer,
pub difficulty: Difficulty,
pub map: MapReplies,
pub scoreboard: Scoreboard,
pub social: Social,
pub quest: QuestStrings,
pub error: ErrorStrings,
}
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(),
init_reply: "Updated linked channels and guild.".to_string(),
timer_reply: "Set daily timer on {value}.".to_string(),
answer: Answer::default(),
difficulty: Difficulty::default(),
scoreboard: Scoreboard::default(),
quest: QuestStrings::default(),
social: Social::default(),
account: AccountReplies::default(),
map: MapReplies::default(),
error: ErrorStrings::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,
pub reply: AnswerReplies,
}
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: "{n}### Passed answer:{n}{text}".to_string(),
attachment_notice: "{n}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(),
reply: AnswerReplies::default(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(default)]
pub struct AnswerReplies {
pub initial: String,
pub accepted: String,
pub rejected: String,
pub error: String,
}
impl Default for AnswerReplies {
fn default() -> Self {
Self {
initial: "Your answer has been posted.".to_string(),
accepted: "Your answer to the quest {q.id} has been approved.{n}\
You gained: {q.reward}{n}\
Your current balance is {b.current}.".to_string(),
rejected: "Your answer to the quest {q.id} has been rejected.".to_string(),
error: "Your answer to the quest {q.id} has been approved, \
but some server error happened. \
Please contact administator for details.".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)]
#[serde(default)]
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)]
#[serde(default)]
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}: {m.link}".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(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(default)]
pub struct Social {
pub sent_channel: String,
pub sent_dm: String,
pub edited: String,
pub deleted: String,
}
impl Default for Social {
fn default() -> Self {
Self {
sent_channel: "Sent {m.link} ({m.id}) to {m.channel}".to_string(),
sent_dm: "Sent {m.link} ({m.id}) to {u.mention}".to_string(),
edited: "Edited message {m.id}".to_string(),
deleted: "Deleted message {m.id}".to_string(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(default)]
pub struct AccountReplies {
pub reset: String,
pub give_pt: String,
pub set_pt: String,
}
impl Default for AccountReplies {
fn default() -> Self {
Self {
reset: "Reset {u.name} account".to_string(),
give_pt: "Given {value} {pt} to {u.name}{n}\
Your current balance: {b.current}".to_string(),
set_pt: "Set {u.name} balance to {b.current}".to_string(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(default)]
pub struct MapReplies {
pub room_unlocked: String,
pub moved_to_room: String,
pub updated_avatar: String,
pub processing_url: String,
}
impl Default for MapReplies {
fn default() -> Self {
Self {
room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(),
moved_to_room: "Moved to room #{value}".to_string(),
updated_avatar: "Successfully changed avatar".to_string(),
processing_url: "Processing URL...".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(default)]
pub struct ErrorStrings {
pub non_command_error: String,
pub quest_not_found: String,
pub quest_is_public: String,
pub quest_is_completed: String,
pub no_content: String,
pub no_channel_or_user: String,
pub both_channel_and_user: String,
pub discord_error: String,
pub library_error: String,
pub account_not_found: String,
pub account_is_self: String,
pub insufficient_funds: String,
pub room_not_found: String,
pub room_already_unlocked: String,
pub cannot_reach: String,
pub timer_set: String,
pub not_this_guild: String,
pub quest_limit_exceeded: String,
pub both_url_and_attachment: String,
pub no_url_or_attachment: String,
pub non_image_attachment: String,
}
impl Default for ErrorStrings {
fn default() -> Self {
Self {
non_command_error: "Internal server error: {text}".to_string(),
quest_not_found: "Quest {q.id} not found".to_string(),
quest_is_public: "Quest {q.id} is already public".to_string(),
quest_is_completed: "Quest {q.id} is already completed for this user".to_string(),
no_content: "No text or attachment were specified".to_string(),
no_channel_or_user: "No channel or user were specified".to_string(),
both_channel_and_user: "Both channel and user were specified".to_string(),
discord_error: "Discord interaction error: {text}".to_string(),
library_error: "Some internal logic error: {text}".to_string(),
account_not_found: "Given account was not found".to_string(),
account_is_self: "Given account is the same as command invoker".to_string(),
insufficient_funds: "You don't have {value} points".to_string(),
room_not_found: "Room #{value} not found".to_string(),
room_already_unlocked: "Room #{value} is already unlocked for this account".to_string(),
cannot_reach: "You cannot reach room #{value}".to_string(),
timer_set: "Timer is already set".to_string(),
not_this_guild: "Bot cannot be used in this guild".to_string(),
quest_limit_exceeded: "Exceeded limit for quest {q.id}".to_string(),
both_url_and_attachment: "Both URL and attachment were specified".to_string(),
no_url_or_attachment: "No URL or attachment were specified".to_string(),
non_image_attachment: "Given attachment is not an image".to_string(),
}
}
}

56
discord/src/timer.rs Normal file
View file

@ -0,0 +1,56 @@
use std::time::Duration;
use chrono::{Datelike, Timelike, Utc};
use tokio::time::sleep;
use toml::value::Date as TomlDate;
use crate::{Context, commands::{print_error_recursively, quest::publish_inner}};
const DAY_IN_SECONDS: u64 = 24 * 60 * 60;
#[derive(Debug)]
pub struct DailyTimer {
start_time: u64,
}
impl DailyTimer {
pub fn new(start_time: u64) -> Self {
Self { start_time }
}
fn get_countdown(&self) -> u64 {
let current_time = Utc::now().time();
let seconds = current_time.num_seconds_from_midnight() as u64;
let result = if seconds > self.start_time {
DAY_IN_SECONDS + self.start_time - seconds
} else {
self.start_time - seconds
};
if result == 0 {
return DAY_IN_SECONDS - 1;
}
result
}
pub async fn start(&self, ctx: Context<'_>) {
loop {
let countdown = self.get_countdown();
println!("Daily timer: sleeping for {countdown} seconds.");
sleep(Duration::from_secs(countdown)).await;
let now = Utc::now().date_naive();
let date = TomlDate {
year: now.year() as u16,
month: now.month() as u8,
day: now.day() as u8,
};
let conf = &ctx.data().config;
let quests = conf.load_quests().into_iter().filter(|q| !q.public && q.available_on.is_some_and(|d| d <= date));
for mut quest in quests {
if let Err(error) = publish_inner(ctx, &mut quest).await {
eprintln!("ERROR in timer:");
print_error_recursively(&error);
}
}
}
}
}

View file

@ -1,10 +1,10 @@
//! User accounts
use std::{fs, io::Write, path::PathBuf};
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
use serde::{ Serialize, Deserialize };
use crate::{SquadObject, error::Error};
use crate::{error::Error, quest::Quest, SquadObject};
fn default_id() -> String {
"none".to_string()
@ -29,6 +29,9 @@ pub struct Account {
/// Vec of rooms unlocked by this user
pub rooms_unlocked: Vec<u16>,
/// Additional implementation-defined data
pub data: Option<HashMap<String, String>>,
}
impl Default for Account {
@ -39,6 +42,7 @@ impl Default for Account {
location: u16::default(),
quests_completed: Vec::new(),
rooms_unlocked: Vec::new(),
data: None,
}
}
}
@ -95,3 +99,10 @@ impl SquadObject for Account {
Ok(())
}
}
impl Account {
/// Returns true if given quest is completed on this account
pub fn has_completed_quest(&self, quest: &Quest) -> bool {
self.quests_completed.contains(&quest.id)
}
}

25
src/bin/deb.rs Normal file
View file

@ -0,0 +1,25 @@
//! This binary generates DEBIAN/control text for use in debian package
use std::process::Command;
fn main() {
let version = env!("CARGO_PKG_VERSION");
let homepage = env!("CARGO_PKG_HOMEPAGE");
let dpkg_arch = {
let output = match Command::new("dpkg")
.arg("--print-architecture")
.output() {
Ok(out) => out,
Err(error) => panic!("error running dpkg: {error}"),
};
String::from_utf8(output.stdout).expect("dpkg returned ill UTF-8")
};
println!("Package: squad-quest\n\
Version: {version}-1\n\
Architecture: {dpkg_arch}\
Section: misc\n\
Priority: optional\n\
Homepage: {homepage}\n\
Description: Simple RPG-like system for hosting events\n \
Includes discord bot and CLI\n\
Maintainer: Alexey Mirenkov <2ndbeam@disroot.org>");
}

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::{SquadObject, account::Account, error::Error, quest::Quest};
/// Struct for containing paths to other (de-)serializable things
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)]
pub struct Config {
/// Path to config directory

View file

@ -49,12 +49,18 @@ impl std::error::Error for Error {
pub enum QuestError {
/// Quest (self.0) is already completed for given account (self.1)
AlreadyCompleted(u16, String),
/// Account (self.0) not found
AccountNotFound(String),
/// Limit for quest (self.0) exceeded
LimitExceeded(u16),
}
impl fmt::Display for QuestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""),
Self::AccountNotFound(account_id) => write!(f, "account \"{account_id}\""),
Self::LimitExceeded(quest_id) => write!(f, "exceeded limit for quest #{quest_id}"),
}
}
}

View file

@ -1,6 +1,6 @@
//! Map, a.k.a. a graph of rooms
use std::{fs, io::Write, path::PathBuf};
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
use serde::{Deserialize, Serialize};
@ -11,7 +11,7 @@ use crate::{SquadObject, account::Account, error::{Error, MapError}};
#[serde(default)]
pub struct Map {
/// Rooms go here
pub room: Vec<Room>
pub room: Vec<Room>,
}
impl Default for Map {
@ -131,6 +131,8 @@ pub struct Room {
pub name: String,
/// Room description
pub description: Option<String>,
/// Additional implementation-based data
pub data: Option<HashMap<String, String>>,
}
fn default_name() -> String {
@ -145,6 +147,7 @@ impl Default for Room {
value: u32::default(),
name: default_name(),
description: None,
data: None,
}
}
}

View file

@ -1,6 +1,6 @@
//! Text-based quests and user solutions for them
use std::{fs, io::Write, path::PathBuf};
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
use serde::{ Serialize, Deserialize };
use crate::{SquadObject, account::Account, error::{Error, QuestError}};
@ -66,7 +66,14 @@ pub struct Quest {
pub available_on: Option<Date>,
/// When quest expires
pub deadline: Option<Date>
pub deadline: Option<Date>,
/// Additional implementation-defined data
pub data: Option<HashMap<String, String>>,
/// Limit how many users can complete the quest.
/// If set to 0, quest is not limited.
pub limit: u8,
}
impl Default for Quest {
@ -80,7 +87,9 @@ impl Default for Quest {
answer: default_answer(),
public: false,
available_on: None,
deadline: None
deadline: None,
data: None,
limit: 0,
}
}
}
@ -155,7 +164,17 @@ impl Quest {
/// // handle error
/// }
/// ```
pub fn complete_for_account(&self, account: &mut Account) -> Result<(),QuestError> {
pub fn complete_for_account(&self, id: &str, accounts: &mut Vec<Account>) -> Result<(),QuestError> {
let completed_times = accounts.iter().filter(|a| a.has_completed_quest(self)).count();
if self.limit > 0 && completed_times as u8 >= self.limit {
return Err(QuestError::LimitExceeded(self.id));
}
let Some(account) = accounts.iter_mut().find(|a| a.id == id) else {
return Err(QuestError::AccountNotFound(id.to_string()));
};
match account.quests_completed.iter().find(|qid| **qid == self.id) {
Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())),
None => {

View file

@ -38,7 +38,8 @@ fn quest_one() {
answer: "Accept the answer if it has no attachments and an empty comment".to_owned(),
public: false,
available_on: None,
deadline: None
deadline: None,
..Default::default()
};
assert_eq!(*quest, expected);
@ -73,7 +74,8 @@ fn account_test() {
balance: 150,
location: 0,
quests_completed: vec![0],
rooms_unlocked: Vec::new()
rooms_unlocked: Vec::new(),
..Default::default()
};
let accounts = config.load_accounts();
@ -92,6 +94,7 @@ fn load_map() {
value: 0,
name: "Entrance".to_string(),
description: Some("Enter the dungeon".to_string()),
..Default::default()
};
let room1 = Room {
@ -100,6 +103,7 @@ fn load_map() {
value: 100,
name: "Kitchen hall".to_string(),
description: None,
..Default::default()
};
let room2 = Room {
@ -108,6 +112,7 @@ fn load_map() {
value: 250,
name: "Room".to_string(),
description: Some("Simple room with no furniture".to_string()),
..Default::default()
};
let room3 = Room {
@ -116,6 +121,7 @@ fn load_map() {
value: 175,
name: "Kitchen".to_string(),
description: Some("Knives are stored here".to_string()),
..Default::default()
};
let expected = Map {