Compare commits

...

10 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
30 changed files with 1688 additions and 303 deletions

1152
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,10 @@
members = ["cli", "discord"] members = ["cli", "discord"]
[workspace.package] [workspace.package]
version = "0.10.0" version = "0.12.0"
edition = "2024" edition = "2024"
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
license = "MIT" license = "MIT"
[package] [package]
@ -13,6 +14,7 @@ edition.workspace = true
version.workspace = true version.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true license.workspace = true
homepage.workspace = true
[dependencies] [dependencies]
serde = { version = "1.0.228", features = ["derive"] } 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: 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" chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
squad-quest = { version = "0.10.0", path = ".." } squad-quest = { version = "0.12.0", path = ".." }
toml = "0.9.8" toml = "0.9.8"

View file

@ -14,6 +14,8 @@ pub enum MapCommands {
Delete(MapDeleteArgs), Delete(MapDeleteArgs),
/// Update room data /// Update room data
Update(MapUpdateArgs), Update(MapUpdateArgs),
/// Get room implementation data
Data(MapDataArgs),
} }
#[derive(Args)] #[derive(Args)]
@ -55,3 +57,9 @@ pub struct MapUpdateArgs {
#[arg(short,long)] #[arg(short,long)]
pub value: Option<u32>, 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 { pub struct InitArgs {
#[arg(long,short)] #[arg(long,short)]
pub path: Option<PathBuf>, 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) /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(short,long,value_parser = parse_date)] #[arg(short,long,value_parser = parse_date)]
pub deadline: Option<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)] #[derive(Args)]
@ -113,6 +116,9 @@ pub struct QuestUpdateArgs {
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(long,value_parser = parse_date)] #[arg(long,value_parser = parse_date)]
pub deadline: Option<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)] #[derive(Args)]

View file

@ -71,6 +71,7 @@ fn main() {
let config = Config { let config = Config {
path: path.clone(), path: path.clone(),
impl_path: args.implpath.clone(),
..Default::default() ..Default::default()
}; };
@ -146,7 +147,9 @@ fn main() {
answer: args.answer.clone(), answer: args.answer.clone(),
public: args.public, public: args.public,
available_on: args.available.clone(), 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)); 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()), answer: args.answer.clone().unwrap_or(quest.answer.clone()),
public: args.public.unwrap_or(quest.public), public: args.public.unwrap_or(quest.public),
available_on: args.available.clone().or(quest.available_on.clone()), 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)); 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)); do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id));
}, },
AccountCommands::Complete(args) => { 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(); 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}"), 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" }; let connected = if connect { "Connected" } else { "Disconnected" };
do_and_log(map_save(map, map_path), !cli.quiet, format!("{connected} rooms #{} <-> #{}.", args.first, args.second)); 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

@ -10,7 +10,9 @@ chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
poise = "0.6.1" poise = "0.6.1"
rocket = { version = "0.5.1", features = ["json"] }
serde = "1.0.228" serde = "1.0.228"
squad-quest = { version = "0.10.0", path = ".." } serde_json = "1.0.146"
squad-quest = { version = "0.12.0", path = ".." }
tokio = { version = "1.48.0", features = ["rt-multi-thread"] } tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
toml = "0.9.8" 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,15 +1,32 @@
use poise::serenity_prelude::UserId; use std::collections::HashMap;
use poise::serenity_prelude::{User, UserId};
use squad_quest::{account::Account, config::Config, map::Map}; use squad_quest::{account::Account, config::Config, map::Map};
pub fn fetch_or_init_account(conf: &Config, id: String) -> Account { /// 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(); let accounts = conf.load_accounts();
match accounts.iter().find(|a| a.id == id) {
Some(a) => a.clone(), if let Some(account) = accounts.iter().find(|a| a.id == id) {
None => Account { return Ok(account.clone());
id,
..Default::default()
},
} }
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 { pub fn account_rooms_value(account: &Account, map: &Map) -> u32 {

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,7 +1,7 @@
use poise::serenity_prelude::User; use poise::serenity_prelude::User;
use squad_quest::{SquadObject, account::Account, map::Map}; use squad_quest::{SquadObject, account::Account, map::Map};
use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter}; use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild};
async fn account_balance_string( async fn account_balance_string(
ctx: &Context<'_>, ctx: &Context<'_>,
@ -26,6 +26,7 @@ async fn account_balance_string(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "сбросить"), name_localized("ru", "сбросить"),
description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"), description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"),
@ -63,7 +64,8 @@ pub async fn reset(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
name_localized("ru", "счёт"), check = "guild",
name_localized("ru", "счет"),
description_localized("ru", "Отобразить таблицу лидеров"), description_localized("ru", "Отобразить таблицу лидеров"),
)] )]
pub async fn scoreboard( pub async fn scoreboard(
@ -106,6 +108,7 @@ pub async fn scoreboard(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
subcommands("give", "set"), subcommands("give", "set"),
name_localized("ru", "баланс"), name_localized("ru", "баланс"),
)] )]
@ -120,6 +123,7 @@ pub async fn balance(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "передать"), name_localized("ru", "передать"),
description_localized("ru", "Передать очки другому пользователю"), description_localized("ru", "Передать очки другому пользователю"),
)] )]
@ -142,30 +146,41 @@ pub async fn give(
let mut accounts = config.load_accounts(); let mut accounts = config.load_accounts();
let user_id = format!("{}", ctx.author().id.get()); let user_id = format!("{}", ctx.author().id.get());
let mut user_account = fetch_or_init_account(config, user_id); let strings = &ctx.data().strings;
let formatter: StringFormatter;
let accounts_path = config.full_accounts_path();
let who_id = format!("{}", who.id.get()); let who_id = format!("{}", who.id.get());
let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
return Err(Error::AccountNotFound);
};
if user_account.balance < amount { if let None = accounts.iter().find(|a| a.id == who_id ) {
return Err(Error::InsufficientFunds(amount)); 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; other_account.balance += amount;
let accounts_path = config.full_accounts_path();
user_account.save(accounts_path.clone())?;
other_account.save(accounts_path)?; other_account.save(accounts_path)?;
let strings = &ctx.data().strings;
let formatter = strings.formatter()
.user(&who)
.value(amount)
.current_balance(&user_account);
let reply_string = formatter.fmt(&strings.account.give_pt); let reply_string = formatter.fmt(&strings.account.give_pt);
ctx.reply(reply_string).await?; ctx.reply(reply_string).await?;
@ -177,6 +192,7 @@ pub async fn give(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "установить"), name_localized("ru", "установить"),
description_localized("ru", "Устанавливает текущий баланс пользователя"), description_localized("ru", "Устанавливает текущий баланс пользователя"),

View file

@ -1,13 +1,14 @@
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage}; use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage};
use squad_quest::SquadObject; 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 /// Send an answer to the quest for review
#[poise::command( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "ответить"), name_localized("ru", "ответить"),
description_localized("ru", "Отправить ответ на квест на проверку"), description_localized("ru", "Отправить ответ на квест на проверку"),
)] )]
@ -34,19 +35,34 @@ pub async fn answer(
#[description_localized("ru", "Вложение к ответу на квест")] #[description_localized("ru", "Вложение к ответу на квест")]
file3: Option<Attachment>, file3: Option<Attachment>,
) -> Result<(), Error> { ) -> 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 quests = ctx.data().config.load_quests();
let Some(quest) = quests.iter() let Some(quest) = quests.iter()
.filter(|q| q.public) .filter(|q| q.public)
.find(|q| q.id == quest_id) else { .find(|q| q.id == quest_id) else {
return Err(Error::QuestNotFound(quest_id)); 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(); let mut files: Vec<Attachment> = Vec::new();
for file in [file1, file2, file3] { for file in [file1, file2, file3] {
if let Some(f) = file { if let Some(f) = file {
@ -134,16 +150,20 @@ pub async fn answer(
let content: String; let content: String;
if is_approved { if is_approved {
let mut no_errors = true; 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}"); eprintln!("{error}");
no_errors = false; 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(); let path = ctx.data().config.full_accounts_path();
if let Err(error) = account.save(path) { if let Err(error) = account.save(path) {
eprintln!("{error}"); eprintln!("{error}");
no_errors = false; no_errors = false;
}; };
update_quest_message(ctx, &quest).await?;
formatter = formatter.current_balance(&account); formatter = formatter.current_balance(&account);
if no_errors { if no_errors {

View file

@ -4,7 +4,7 @@ use poise::{CreateReply, serenity_prelude::ChannelId};
use squad_quest::SquadObject; use squad_quest::SquadObject;
use toml::value::Time; use toml::value::Time;
use crate::{Context, Error, timer::DailyTimer}; use crate::{Context, Error, timer::DailyTimer, commands::guild};
/// Set channels to post quests and answers to /// Set channels to post quests and answers to
#[poise::command( #[poise::command(
@ -12,6 +12,7 @@ use crate::{Context, Error, timer::DailyTimer};
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "инит"), name_localized("ru", "инит"),
description_localized("ru", "Установить каналы для публикации квестов и ответов"), description_localized("ru", "Установить каналы для публикации квестов и ответов"),
)] )]
@ -75,11 +76,15 @@ fn seconds(time: Time) -> u64 {
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "таймер"), name_localized("ru", "таймер"),
description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК-3)"), description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"),
)] )]
pub async fn timer( pub async fn timer(
ctx: Context<'_>, 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, time: TimeWrapper,
) -> Result<(), Error> { ) -> Result<(), Error> {
if ctx.data().has_timer() { if ctx.data().has_timer() {

View file

@ -1,12 +1,14 @@
use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply};
use squad_quest::{SquadObject, map::Map}; 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 /// Unlock specified room if it is reachable and you have required amount of points
#[poise::command( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "открыть"), name_localized("ru", "открыть"),
description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"), description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"),
)] )]
@ -27,7 +29,10 @@ pub async fn unlock(
}; };
let acc_id = format!("{}", ctx.author().id.get()); 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 { if account.balance < room.value {
return Err(Error::InsufficientFunds(room.value)); return Err(Error::InsufficientFunds(room.value));
@ -55,6 +60,7 @@ pub async fn unlock(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "пойти"), name_localized("ru", "пойти"),
description_localized("ru", "Переместиться в другую разблокированную комнату"), description_localized("ru", "Переместиться в другую разблокированную комнату"),
)] )]
@ -68,10 +74,13 @@ pub async fn r#move(
let conf = &ctx.data().config; let conf = &ctx.data().config;
let acc_id = format!("{}", ctx.author().id.get()); 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) { if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) {
return Err(Error::RoomNotFound(id)); return Err(Error::CannotReach(id));
} }
account.location = id; account.location = id;
@ -88,3 +97,99 @@ pub async fn r#move(
Ok(()) 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 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 quest;
pub mod init; pub mod init;
@ -10,6 +11,18 @@ pub mod social;
pub mod account; pub mod account;
pub mod map; 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)] #[poise::command(prefix_command)]
pub async fn register(ctx: Context<'_>) -> Result<(), Error> { pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
poise::builtins::register_application_commands_buttons(ctx).await?; poise::builtins::register_application_commands_buttons(ctx).await?;
@ -36,10 +49,45 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) {
eprintln!("ERROR:"); eprintln!("ERROR:");
print_error_recursively(&error); print_error_recursively(&error);
if let Some(ctx) = error.ctx() { if let Some(ctx) = error.ctx() {
let response = match error { let user = ctx.author().display_name();
poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"),
_ => format!("Internal server error: {error}"), 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 { if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await {
eprintln!("Couldn't send error message: {error}"); eprintln!("Couldn't send error message: {error}");
} }

View file

@ -1,9 +1,10 @@
use std::{future, str::FromStr}; use std::{future, str::FromStr};
use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt}; use poise::serenity_prelude as serenity;
use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}}; use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt};
use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject};
use toml::value::Date; 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>{ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
ctx.defer().await?; ctx.defer().await?;
@ -24,19 +25,45 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>
Ok(messages.first().cloned()) Ok(messages.first().cloned())
} }
fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { fn make_quest_message_content(ctx: Context<'_>, quest: &Quest, accounts: &Option<Vec<Account>>) -> String {
let strings = &ctx.data().strings; let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(quest); let formatter = match accounts {
Some(accounts) => strings.formatter().quest_full(quest, accounts),
None => strings.formatter().quest(quest),
};
formatter.fmt(&strings.quest.message_format) 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( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
subcommands("list", "create", "update", "publish", "delete"), subcommands("list", "create", "update", "publish", "delete"),
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "квесты"), name_localized("ru", "квест"),
)] )]
pub async fn quest( pub async fn quest(
_ctx: Context<'_>, _ctx: Context<'_>,
@ -49,6 +76,7 @@ pub async fn quest(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "список"), name_localized("ru", "список"),
description_localized("ru", "Вывести все квесты") description_localized("ru", "Вывести все квесты")
@ -114,6 +142,7 @@ impl From<DateWrapper> for Date {
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "создать"), name_localized("ru", "создать"),
description_localized("ru", "Создать квест и получить его идентификатор"), description_localized("ru", "Создать квест и получить его идентификатор"),
)] )]
@ -143,6 +172,10 @@ pub async fn create(
#[name_localized("ru", "доступен")] #[name_localized("ru", "доступен")]
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
available: Option<DateWrapper>, available: Option<DateWrapper>,
#[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)"] #[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "дедлайн")] #[name_localized("ru", "дедлайн")]
@ -179,6 +212,7 @@ pub async fn create(
answer, answer,
public: false, public: false,
available_on, available_on,
limit: limit.unwrap_or_default(),
//deadline, //deadline,
..Default::default() ..Default::default()
}; };
@ -202,6 +236,7 @@ pub async fn create(
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "обновить"), name_localized("ru", "обновить"),
description_localized("ru", "Обновить выбранные значения указанного квеста"), description_localized("ru", "Обновить выбранные значения указанного квеста"),
)] )]
@ -235,6 +270,10 @@ pub async fn update(
#[name_localized("ru", "доступен")] #[name_localized("ru", "доступен")]
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")]
available: Option<DateWrapper>, 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)"] #[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "дедлайн")] #[name_localized("ru", "дедлайн")]
@ -260,15 +299,18 @@ pub async fn update(
}; };
let available_on: Option<Date>; let available_on: Option<Date>;
let new_limit: u8;
//let dead_line: Option<Date>; //let dead_line: Option<Date>;
match reset.unwrap_or(false) { match reset.unwrap_or(false) {
true => { true => {
available_on = None; available_on = None;
new_limit = limit.unwrap_or_default();
//dead_line = None; //dead_line = None;
}, },
false => { false => {
available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into())); available_on = available.map_or_else(|| quest.available_on.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())); //dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
}, },
} }
@ -282,6 +324,7 @@ pub async fn update(
answer: answer.unwrap_or(quest.answer.clone()), answer: answer.unwrap_or(quest.answer.clone()),
public: quest.public, public: quest.public,
available_on, available_on,
limit: new_limit,
//deadline: dead_line, //deadline: dead_line,
..Default::default() ..Default::default()
}; };
@ -290,16 +333,7 @@ pub async fn update(
let formatter = strings.formatter().quest(&new_quest); let formatter = strings.formatter().quest(&new_quest);
if new_quest.public { if new_quest.public {
let content = make_quest_message_content(ctx, &new_quest); update_quest_message(ctx, &new_quest).await?;
let builder = EditMessage::new().content(content);
let message = find_quest_message(ctx, id).await?;
if let Some(mut message) = message {
message.edit(ctx, builder).await?;
} else {
let reply_string = formatter.fmt(&strings.quest.message_not_found);
ctx.reply(reply_string).await?;
}
} }
let path = conf.full_quests_path(); let path = conf.full_quests_path();
@ -310,13 +344,14 @@ pub async fn update(
Ok(()) Ok(())
} }
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> { pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Message, Error> {
quest.public = true; quest.public = true;
let quests_path = ctx.data().config.full_quests_path(); let quests_path = ctx.data().config.full_quests_path();
quest.save(quests_path)?; quest.save(quests_path)?;
let content = make_quest_message_content(ctx, &quest); let accounts = ctx.data().config.load_accounts();
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
let builder = CreateMessage::new() let builder = CreateMessage::new()
.content(content); .content(content);
@ -327,8 +362,10 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er
guard.quests_channel guard.quests_channel
}; };
channel.send_message(ctx, builder).await?; match channel.send_message(ctx, builder).await {
Ok(()) Ok(m) => Ok(m),
Err(error) => Err(Error::SerenityError(error)),
}
} }
/// Mark quest as public and send its message in quests channel /// Mark quest as public and send its message in quests channel
@ -337,6 +374,7 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "опубликовать"), name_localized("ru", "опубликовать"),
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"), description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
)] )]
@ -357,10 +395,12 @@ pub async fn publish(
return Err(Error::QuestIsPublic(id)); return Err(Error::QuestIsPublic(id));
} }
publish_inner(ctx, quest).await?; let message = publish_inner(ctx, quest).await?;
let strings = &ctx.data().strings; let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&quest); let formatter = strings.formatter()
.quest(&quest)
.message(&message);
let reply_string = formatter.fmt(&strings.quest.publish); let reply_string = formatter.fmt(&strings.quest.publish);
ctx.reply(reply_string).await?; ctx.reply(reply_string).await?;
@ -374,6 +414,7 @@ pub async fn publish(
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "удалить"), name_localized("ru", "удалить"),
description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"), description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"),
)] )]

View file

@ -1,12 +1,13 @@
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, 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( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
subcommands("msg", "edit", "undo"), subcommands("msg", "edit", "undo"),
name_localized("ru", "сообщение"), name_localized("ru", "сообщение"),
)] )]
@ -20,6 +21,7 @@ pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> {
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "написать"), name_localized("ru", "написать"),
description_localized("ru", "Отправить сообщение пользователю или в канал"), description_localized("ru", "Отправить сообщение пользователю или в канал"),
)] )]
@ -98,6 +100,7 @@ pub async fn msg (
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "редактировать"), name_localized("ru", "редактировать"),
description_localized("ru", "Редактировать сообщение в канале или в ЛС"), description_localized("ru", "Редактировать сообщение в канале или в ЛС"),
)] )]
@ -177,6 +180,7 @@ pub async fn edit (
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "удалить"), name_localized("ru", "удалить"),
description_localized("ru", "Удалить сообщение в канале или в ЛС"), description_localized("ru", "Удалить сообщение в канале или в ЛС"),
)] )]

View file

@ -3,6 +3,8 @@ use std::fmt::Display;
use poise::serenity_prelude as serenity; use poise::serenity_prelude as serenity;
use squad_quest::error::MapError; use squad_quest::error::MapError;
use crate::strings::ErrorStrings;
#[non_exhaustive] #[non_exhaustive]
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
@ -21,6 +23,11 @@ pub enum Error {
RoomAlreadyUnlocked(u16), RoomAlreadyUnlocked(u16),
CannotReach(u16), CannotReach(u16),
TimerSet, TimerSet,
NotThisGuild,
QuestLimitExceeded(u16),
BothUrlAndAttachment,
NoUrlOrAttachment,
NonImageAttachment,
} }
impl From<serenity::Error> for Error { impl From<serenity::Error> for Error {
@ -53,9 +60,9 @@ impl Display for Error {
Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"), Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"),
Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"), Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"),
Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"), Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"),
Self::NoContent => write!(f, "no text or attachment was specified"), Self::NoContent => write!(f, "no text or attachment were specified"),
Self::NoChannelOrUser => write!(f, "no channel or user was specified"), Self::NoChannelOrUser => write!(f, "no channel or user were specified"),
Self::BothChannelAndUser => write!(f, "both channel and user was specified"), Self::BothChannelAndUser => write!(f, "both channel and user were specified"),
Self::SerenityError(_) => write!(f, "discord interaction error"), Self::SerenityError(_) => write!(f, "discord interaction error"),
Self::SquadQuestError(_) => write!(f, "internal logic error"), Self::SquadQuestError(_) => write!(f, "internal logic error"),
Self::AccountNotFound => write!(f, "account not found"), Self::AccountNotFound => write!(f, "account not found"),
@ -65,6 +72,11 @@ impl Display for Error {
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"), Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"),
Self::TimerSet => write!(f, "timer is already set"), 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"),
} }
} }
} }
@ -84,11 +96,41 @@ impl std::error::Error for Error {
Self::RoomNotFound(_) | Self::RoomNotFound(_) |
Self::RoomAlreadyUnlocked(_) | Self::RoomAlreadyUnlocked(_) |
Self::CannotReach(_) | Self::CannotReach(_) |
Self::TimerSet => None, Self::TimerSet |
Self::NotThisGuild |
Self::QuestLimitExceeded(_) |
Self::BothUrlAndAttachment |
Self::NoUrlOrAttachment |
Self::NonImageAttachment => None,
Self::SerenityError(error) => Some(error), Self::SerenityError(error) => Some(error),
Self::SquadQuestError(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,3 +1,5 @@
#[macro_use] extern crate rocket;
use std::{sync::{Arc, Mutex}}; use std::{sync::{Arc, Mutex}};
use clap::Parser; use clap::Parser;
@ -5,8 +7,9 @@ use dotenvy::dotenv;
use poise::serenity_prelude as serenity; use poise::serenity_prelude as serenity;
use squad_quest::config::Config; use squad_quest::config::Config;
use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings}; use crate::{commands::{error_handler, print_error_recursively}, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings};
mod api;
mod commands; mod commands;
mod cli; mod cli;
mod config; mod config;
@ -65,6 +68,14 @@ async fn main() {
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 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() let framework = poise::Framework::builder()
.options(poise::FrameworkOptions { .options(poise::FrameworkOptions {
on_error: |err| Box::pin(error_handler(err)), on_error: |err| Box::pin(error_handler(err)),
@ -81,12 +92,15 @@ async fn main() {
commands::account::reset(), commands::account::reset(),
commands::map::unlock(), commands::map::unlock(),
commands::map::r#move(), commands::map::r#move(),
commands::map::avatar(),
], ],
..Default::default() ..Default::default()
}) })
.setup(|_ctx, _ready, _framework| { .setup(|_ctx, _ready, _framework| {
Box::pin(async move { Box::pin(async move {
//poise::builtins::register_globally(ctx, &framework.options().commands).await?; //poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data { Ok(Data {
config, config,
discord: Arc::new(Mutex::new(discord)), discord: Arc::new(Mutex::new(discord)),

View file

@ -45,11 +45,20 @@ impl StringFormatter {
let name = ("{q.name}".to_string(), quest.name.clone()); let name = ("{q.name}".to_string(), quest.name.clone());
let description = ("{q.description}".to_string(), quest.description.clone()); let description = ("{q.description}".to_string(), quest.description.clone());
let answer = ("{q.answer}".to_string(), quest.answer.clone()); let answer = ("{q.answer}".to_string(), quest.answer.clone());
let new_tags = vec![ id, difficulty, reward, name, description, answer ]; 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) 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 { pub fn user(self, user: &User) -> Self {
let mention = ("{u.mention}".to_string(), user.mention().to_string()); let mention = ("{u.mention}".to_string(), user.mention().to_string());
let name = ("{u.name}".to_string(), user.display_name().to_string()); let name = ("{u.name}".to_string(), user.display_name().to_string());
@ -133,6 +142,7 @@ pub struct Strings {
pub scoreboard: Scoreboard, pub scoreboard: Scoreboard,
pub social: Social, pub social: Social,
pub quest: QuestStrings, pub quest: QuestStrings,
pub error: ErrorStrings,
} }
impl Default for Strings { impl Default for Strings {
@ -151,6 +161,7 @@ impl Default for Strings {
social: Social::default(), social: Social::default(),
account: AccountReplies::default(), account: AccountReplies::default(),
map: MapReplies::default(), map: MapReplies::default(),
error: ErrorStrings::default(),
} }
} }
} }
@ -328,7 +339,7 @@ impl Default for QuestStrings {
list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(), list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(),
create: "Created quest {q.id}".to_string(), create: "Created quest {q.id}".to_string(),
update: "Updated quest {q.id}".to_string(), update: "Updated quest {q.id}".to_string(),
publish: "Published quest {q.id}: {text}".to_string(), publish: "Published quest {q.id}: {m.link}".to_string(),
delete: "Deleted quest {q.id}".to_string(), delete: "Deleted quest {q.id}".to_string(),
message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\ message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\
Difficulty: *{q.difficulty}*{n}\ Difficulty: *{q.difficulty}*{n}\
@ -382,6 +393,8 @@ impl Default for AccountReplies {
pub struct MapReplies { pub struct MapReplies {
pub room_unlocked: String, pub room_unlocked: String,
pub moved_to_room: String, pub moved_to_room: String,
pub updated_avatar: String,
pub processing_url: String,
} }
impl Default for MapReplies { impl Default for MapReplies {
@ -389,6 +402,62 @@ impl Default for MapReplies {
Self { Self {
room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(), room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(),
moved_to_room: "Moved to room #{value}".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(),
} }
} }
} }

View file

@ -1,10 +1,10 @@
//! User accounts //! User accounts
use std::{fs, io::Write, path::PathBuf}; use std::{collections::HashMap, fs, io::Write, path::PathBuf};
use serde::{ Serialize, Deserialize }; use serde::{ Serialize, Deserialize };
use crate::{SquadObject, error::Error}; use crate::{error::Error, quest::Quest, SquadObject};
fn default_id() -> String { fn default_id() -> String {
"none".to_string() "none".to_string()
@ -29,6 +29,9 @@ pub struct Account {
/// Vec of rooms unlocked by this user /// Vec of rooms unlocked by this user
pub rooms_unlocked: Vec<u16>, pub rooms_unlocked: Vec<u16>,
/// Additional implementation-defined data
pub data: Option<HashMap<String, String>>,
} }
impl Default for Account { impl Default for Account {
@ -39,6 +42,7 @@ impl Default for Account {
location: u16::default(), location: u16::default(),
quests_completed: Vec::new(), quests_completed: Vec::new(),
rooms_unlocked: Vec::new(), rooms_unlocked: Vec::new(),
data: None,
} }
} }
} }
@ -95,3 +99,10 @@ impl SquadObject for Account {
Ok(()) 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}; use crate::{SquadObject, account::Account, error::Error, quest::Quest};
/// Struct for containing paths to other (de-)serializable things /// Struct for containing paths to other (de-)serializable things
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
/// Path to config directory /// Path to config directory

View file

@ -49,12 +49,18 @@ impl std::error::Error for Error {
pub enum QuestError { pub enum QuestError {
/// Quest (self.0) is already completed for given account (self.1) /// Quest (self.0) is already completed for given account (self.1)
AlreadyCompleted(u16, String), AlreadyCompleted(u16, String),
/// Account (self.0) not found
AccountNotFound(String),
/// Limit for quest (self.0) exceeded
LimitExceeded(u16),
} }
impl fmt::Display for QuestError { impl fmt::Display for QuestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""), 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 //! 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}; use serde::{Deserialize, Serialize};
@ -11,7 +11,7 @@ use crate::{SquadObject, account::Account, error::{Error, MapError}};
#[serde(default)] #[serde(default)]
pub struct Map { pub struct Map {
/// Rooms go here /// Rooms go here
pub room: Vec<Room> pub room: Vec<Room>,
} }
impl Default for Map { impl Default for Map {
@ -131,6 +131,8 @@ pub struct Room {
pub name: String, pub name: String,
/// Room description /// Room description
pub description: Option<String>, pub description: Option<String>,
/// Additional implementation-based data
pub data: Option<HashMap<String, String>>,
} }
fn default_name() -> String { fn default_name() -> String {
@ -145,6 +147,7 @@ impl Default for Room {
value: u32::default(), value: u32::default(),
name: default_name(), name: default_name(),
description: None, description: None,
data: None,
} }
} }
} }

View file

@ -1,6 +1,6 @@
//! Text-based quests and user solutions for them //! 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 serde::{ Serialize, Deserialize };
use crate::{SquadObject, account::Account, error::{Error, QuestError}}; use crate::{SquadObject, account::Account, error::{Error, QuestError}};
@ -66,7 +66,14 @@ pub struct Quest {
pub available_on: Option<Date>, pub available_on: Option<Date>,
/// When quest expires /// 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 { impl Default for Quest {
@ -80,7 +87,9 @@ impl Default for Quest {
answer: default_answer(), answer: default_answer(),
public: false, public: false,
available_on: None, available_on: None,
deadline: None deadline: None,
data: None,
limit: 0,
} }
} }
} }
@ -155,7 +164,17 @@ impl Quest {
/// // handle error /// // 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) { match account.quests_completed.iter().find(|qid| **qid == self.id) {
Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())), Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())),
None => { 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(), answer: "Accept the answer if it has no attachments and an empty comment".to_owned(),
public: false, public: false,
available_on: None, available_on: None,
deadline: None deadline: None,
..Default::default()
}; };
assert_eq!(*quest, expected); assert_eq!(*quest, expected);
@ -73,7 +74,8 @@ fn account_test() {
balance: 150, balance: 150,
location: 0, location: 0,
quests_completed: vec![0], quests_completed: vec![0],
rooms_unlocked: Vec::new() rooms_unlocked: Vec::new(),
..Default::default()
}; };
let accounts = config.load_accounts(); let accounts = config.load_accounts();
@ -92,6 +94,7 @@ fn load_map() {
value: 0, value: 0,
name: "Entrance".to_string(), name: "Entrance".to_string(),
description: Some("Enter the dungeon".to_string()), description: Some("Enter the dungeon".to_string()),
..Default::default()
}; };
let room1 = Room { let room1 = Room {
@ -100,6 +103,7 @@ fn load_map() {
value: 100, value: 100,
name: "Kitchen hall".to_string(), name: "Kitchen hall".to_string(),
description: None, description: None,
..Default::default()
}; };
let room2 = Room { let room2 = Room {
@ -108,6 +112,7 @@ fn load_map() {
value: 250, value: 250,
name: "Room".to_string(), name: "Room".to_string(),
description: Some("Simple room with no furniture".to_string()), description: Some("Simple room with no furniture".to_string()),
..Default::default()
}; };
let room3 = Room { let room3 = Room {
@ -116,6 +121,7 @@ fn load_map() {
value: 175, value: 175,
name: "Kitchen".to_string(), name: "Kitchen".to_string(),
description: Some("Knives are stored here".to_string()), description: Some("Knives are stored here".to_string()),
..Default::default()
}; };
let expected = Map { let expected = Map {