1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-10-16 06:27:27 +00:00

Add Dota guild match monitoring and notifications (#9)

This commit is contained in:
Steffo 2024-07-16 11:35:38 +02:00 committed by GitHub
parent 2b31bb8c0a
commit ba087ab4de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 67853 additions and 62 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
/src/stratz/schema.json linguist-generated

188
Cargo.lock generated
View file

@ -60,6 +60,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "ascii"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -125,15 +131,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.6.0"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
[[package]]
name = "cc"
version = "1.0.104"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490"
checksum = "18e2d530f35b40a84124146478cd16f34225306a8441998836466a2e2961c950"
[[package]]
name = "cfg-if"
@ -155,6 +161,19 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "combine"
version = "3.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680"
dependencies = [
"ascii",
"byteorder",
"either",
"memchr",
"unreachable",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@ -189,12 +208,12 @@ dependencies = [
[[package]]
name = "darling"
version = "0.20.9"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1"
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [
"darling_core 0.20.9",
"darling_macro 0.20.9",
"darling_core 0.20.10",
"darling_macro 0.20.10",
]
[[package]]
@ -213,16 +232,16 @@ dependencies = [
[[package]]
name = "darling_core"
version = "0.20.9"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120"
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -238,13 +257,13 @@ dependencies = [
[[package]]
name = "darling_macro"
version = "0.20.9"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core 0.20.9",
"darling_core 0.20.10",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -257,7 +276,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -283,7 +302,7 @@ dependencies = [
"dsl_auto_type",
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -292,7 +311,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25"
dependencies = [
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -310,12 +329,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0892a17df262a24294c382f0d5997571006e7a4348b4327557c4ff1cd4a8bccc"
dependencies = [
"darling 0.20.9",
"darling 0.20.10",
"either",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -464,7 +483,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -514,6 +533,64 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
[[package]]
name = "graphql-introspection-query"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d"
dependencies = [
"serde",
]
[[package]]
name = "graphql-parser"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ebc8013b4426d5b81a4364c419a95ed0b404af2b82e2457de52d9348f0e474"
dependencies = [
"combine",
"thiserror",
]
[[package]]
name = "graphql_client"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b"
dependencies = [
"graphql_query_derive",
"serde",
"serde_json",
]
[[package]]
name = "graphql_client_codegen"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a"
dependencies = [
"graphql-introspection-query",
"graphql-parser",
"heck 0.4.1",
"lazy_static",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn 1.0.109",
]
[[package]]
name = "graphql_query_derive"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97"
dependencies = [
"graphql_client_codegen",
"proc-macro2",
"syn 1.0.109",
]
[[package]]
name = "h2"
version = "0.3.26"
@ -652,9 +729,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "0.14.29"
version = "0.14.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f361cde2f109281a220d4307746cdfd5ee3f410da58a70377762396775634b33"
checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9"
dependencies = [
"bytes",
"futures-channel",
@ -718,7 +795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper 0.14.29",
"hyper 0.14.30",
"native-tls",
"tokio",
"tokio-native-tls",
@ -850,6 +927,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.155"
@ -891,7 +974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a567fdbbc6155f0a8b18caa80aa8ffa2d661c46455ce8e01ba68a039f9cac979"
dependencies = [
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -1026,7 +1109,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -1081,7 +1164,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -1251,7 +1334,7 @@ dependencies = [
"h2 0.3.26",
"http 0.2.12",
"http-body 0.4.6",
"hyper 0.14.29",
"hyper 0.14.30",
"hyper-tls 0.5.0",
"ipnet",
"js-sys",
@ -1345,6 +1428,7 @@ dependencies = [
"anyhow",
"chrono",
"diesel",
"graphql_client",
"log",
"micronfig",
"once_cell",
@ -1352,9 +1436,10 @@ dependencies = [
"pretty_env_logger",
"rand",
"regex",
"reqwest",
"reqwest 0.12.5",
"serde",
"teloxide",
"thiserror",
"tokio",
]
@ -1502,7 +1587,7 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -1611,9 +1696,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.68"
version = "2.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9"
checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462"
dependencies = [
"proc-macro2",
"quote",
@ -1757,29 +1842,29 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.61"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
checksum = "f2675633b1499176c2dff06b0856a27976a8f9d436737b4cf4f312d4d91d8bbb"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.61"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
checksum = "d20468752b09f49e909e55a5d338caa8bedf615594e9d80bc4c565d30faf798c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
name = "tinyvec"
version = "1.6.1"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
dependencies = [
"tinyvec_macros",
]
@ -1816,7 +1901,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
]
[[package]]
@ -1946,6 +2031,15 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "unreachable"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56"
dependencies = [
"void",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -1966,9 +2060,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.9.1"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"getrandom",
]
@ -1985,6 +2079,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "void"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
[[package]]
name = "want"
version = "0.3.1"
@ -2021,7 +2121,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
"wasm-bindgen-shared",
]
@ -2055,7 +2155,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
"syn 2.0.71",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]

View file

@ -41,6 +41,8 @@ regex = "1.10.5"
once_cell = "1.19.0"
reqwest = { version = "0.12.5", features = ["json"] }
serde = { version = "1.0.204", features = ["derive"] }
graphql_client = "0.14.0"
thiserror = "1.0.62"
[[bin]]
name = "royalnet"

View file

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View file

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS brooch_match;

View file

@ -0,0 +1,3 @@
CREATE TABLE brooch_match (
id BIGINT PRIMARY KEY
);

View file

@ -1,6 +1,6 @@
use diesel::{Identifiable, Insertable, Queryable, Selectable, Associations};
use diesel::pg::Pg;
use super::schema::{users, telegram, discord, steam};
use super::schema::{users, telegram, discord, steam, brooch_match};
#[derive(Debug, Clone, PartialEq, Identifiable, Queryable, Selectable, Insertable)]
@ -22,7 +22,6 @@ pub struct TelegramUser {
pub telegram_id: i64,
}
#[derive(Debug, Clone, PartialEq, Identifiable, Queryable, Selectable, Insertable, Associations)]
#[diesel(belongs_to(RoyalnetUser, foreign_key = user_id))]
#[diesel(table_name = discord)]
@ -43,3 +42,11 @@ pub struct SteamUser {
pub user_id: i32,
pub steam_id: i64,
}
#[derive(Debug, Clone, PartialEq, Identifiable, Queryable, Selectable, Insertable)]
#[diesel(table_name = brooch_match)]
#[diesel(check_for_backend(Pg))]
pub struct BroochMatch {
pub id: i64,
}

View file

@ -1,5 +1,11 @@
// @generated automatically by Diesel CLI.
diesel::table! {
brooch_match (id) {
id -> Int8,
}
}
diesel::table! {
discord (discord_id) {
user_id -> Int4,
@ -33,6 +39,7 @@ diesel::joinable!(steam -> users (user_id));
diesel::joinable!(telegram -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
brooch_match,
discord,
steam,
telegram,

View file

@ -4,7 +4,7 @@ use crate::services::RoyalnetService;
pub(crate) mod database;
pub(crate) mod utils;
mod services;
mod stratz;
#[tokio::main]
async fn main() -> Result<()> {
@ -16,10 +16,15 @@ async fn main() -> Result<()> {
log::trace!("Setting up Telegram bot service...");
let telegram = services::telegram::BotService::from_config();
// Brooch setup
log::trace!("Setting up Brooch service...");
let brooch = services::brooch::BroochService::from_config();
// Run all services concurrently
log::info!("Starting services...");
let result = tokio::try_join![
telegram.run(),
brooch.run(),
];
// This should never happen, but just in case...

View file

@ -0,0 +1,7 @@
use micronfig::config;
config! {
BROOCH_TELEGRAM_BOT_TOKEN,
BROOCH_WATCHED_GUILD_ID: String > i64 -> crate::stratz::GuildId,
BROOCH_NOTIFICATION_CHAT_ID: String > i64 -> crate::utils::hacks::ChatIdConversionHack -> teloxide::types::ChatId
}

494
src/services/brooch/mod.rs Normal file
View file

@ -0,0 +1,494 @@
use std::cmp::PartialEq;
use std::convert::Infallible;
use anyhow::Result;
use std::time::Duration;
use anyhow::Context;
use chrono::{TimeDelta, TimeZone};
use diesel::PgConnection;
use teloxide::Bot;
use teloxide::payloads::SendMessageSetters;
use teloxide::requests::Requester;
use teloxide::types::ChatId;
use tokio::time::sleep;
use crate::database;
use crate::services::RoyalnetService;
use crate::stratz::{GuildId, Match, Player, Role, Lane, query_guild_matches};
use crate::stratz::guild_matches_query::{GameModeEnumType, LobbyTypeEnum};
mod config;
pub struct BroochService {
pub guild_id: GuildId,
pub chat_id: ChatId,
pub bot: Bot,
}
impl BroochService {
const MAX_IMP_WAIT: TimeDelta = TimeDelta::minutes(60);
pub fn from_config() -> Self {
Self {
guild_id: config::BROOCH_WATCHED_GUILD_ID().clone(),
chat_id: config::BROOCH_NOTIFICATION_CHAT_ID().clone(),
bot: Bot::new(config::BROOCH_TELEGRAM_BOT_TOKEN().clone()),
}
}
async fn iteration_request(&self) -> Result<()> {
let client = reqwest::Client::new();
let mut database = database::connect()
.context("Non è stato possibile connettersi al database RYG.")?;
let data = query_guild_matches(&client, &self.guild_id).await
.context("Non è stato possibile recuperare le ultime partite di Dota da STRATZ.")?;
let data = data.data
.context("La richiesta è riuscita, ma la risposta ricevuta da STRATZ era vuota.")?;
let data = data.guild
.context("La richiesta è riuscita, ma non sono state ricevute gilde da STRATZ.")?;
let guild_id: GuildId = data.id.clone()
.context("La richiesta è riuscita, ma non è stato ricevuto l'ID della gilda da STRATZ.")?
.into();
if guild_id != self.guild_id {
anyhow::bail!("La richiesta è riuscita, ma STRATZ ha risposto con le informazioni della gilda sbagliata.");
}
let mut matches = data.matches
.context("La richiesta è riuscita, ma non sono state ricevute informazioni sulle partite della gilda da STRATZ.")?;
// Sort matches chronologically
matches.sort_unstable_by_key(|o| o
.to_owned()
.map(|o| o
.end_date_time
.unwrap_or(0)
)
.unwrap_or(0)
);
let mut results: Vec<Result<(i64, Option<String>)>> = vec![];
for r#match in matches.iter().filter_map(|o| o.to_owned()) {
results.push(
self.iteration_match(&mut database, r#match).await
);
}
let results: Vec<(i64, String)> = results
.into_iter()
.inspect(|f| match f {
Err(e) => log::error!("Error while processing match: {e}"),
Ok((match_id, None)) => log::debug!("Skipping: {match_id}"),
_ => {}
})
.filter_map(|f| f.ok())
.filter_map(|f| f.1.map(|s| (f.0, s)))
.collect();
for result in results {
let (match_id, text) = result;
let msg = self.bot.send_message(self.chat_id, text)
.parse_mode(teloxide::types::ParseMode::Html)
.disable_notification(true)
.disable_web_page_preview(true)
.await;
if let Err(e) = msg {
log::error!("Error while sending notification for match {match_id}: {e}");
continue
}
{
use diesel::prelude::*;
use crate::database::schema::brooch_match::dsl::*;
use crate::database::models::{BroochMatch};
let match_royalnet = BroochMatch { id: result.0 };
let result = diesel::insert_into(brooch_match)
.values(&match_royalnet)
.returning(BroochMatch::as_returning())
.get_result(&mut database);
if let Err(e) = result {
log::error!("Error while inserting in database match {match_id}: {e}");
continue
}
log::trace!("Inserted in database match {match_id}!");
}
}
Ok(())
}
async fn iteration_match(&self, database: &mut PgConnection, r#match: Match) -> Result<(i64, Option<String>)> {
let match_id = r#match.id
.context("La richiesta è riuscita, ma non è stato ricevuto da STRATZ l'ID della partita.")?;
let match_royalnet = {
use diesel::prelude::*;
use diesel::{ExpressionMethods, QueryDsl};
use crate::database::schema::brooch_match::dsl::*;
use crate::database::models::{BroochMatch};
brooch_match
.filter(id.eq(match_id))
.select(BroochMatch::as_select())
.get_result(database)
.optional()
.context("Non è stato possibile recuperare la partita restituita da STRATZ dal database RYG.")?
};
if match_royalnet.is_some() {
log::trace!("Match result was already sent, skipping...");
return Ok((match_id, None));
};
let match_date = r#match.end_date_time
.context("Non è stato ricevuto da STRATZ il momento di termine della partita.")?;
let match_date = chrono::Utc.timestamp_opt(match_date, 0)
.earliest()
.context("È stato ricevuto da STRATZ un momento di termine della partita non valido.")?;
let now = chrono::Utc::now();
// How much time has passed since the match has ended?
let match_offset = match_date - now;
let mut players: Vec<Player> = r#match.players
.context("Non è stato ricevuto da STRATZ l'elenco dei giocatori della partita.")?
.iter()
.filter_map(|o| o.to_owned())
.collect();
if players.len() < 1 {
anyhow::bail!("È stato ricevuto da STRATZ un elenco vuoto di giocatori nella partita.");
}
let match_side: MatchSide = 'side: {
let players_teams = {
let players_teams_inner: Vec<Option<bool>> = players.iter()
.map(|o| o.is_radiant)
.collect();
for player_team in players_teams_inner.iter() {
if player_team.is_none() {
player_team.context("Non è stata ricevuta da STRATZ la squadra di almeno un giocatore nella partita.")?;
}
}
let players_teams_inner: Vec<bool> = players_teams_inner
.iter()
.map(|o| o.unwrap())
.collect();
players_teams_inner
};
let mut predicted_team = None;
for player_team in players_teams {
if predicted_team.is_none() {
predicted_team = Some(player_team)
}
else if predicted_team.unwrap() != player_team {
break 'side MatchSide::Both;
}
}
match predicted_team.unwrap() {
true => MatchSide::Radiant,
false => MatchSide::Dire,
}
};
// Is IMP available?
let imp_is_ready = players.iter()
.map(|o| o.imp)
.map(|o| o.is_some())
.all(|o| o);
// Have we waited too long for IMP to be calculated?
let imp_wait_too_long = match_offset > Self::MAX_IMP_WAIT;
if !(imp_is_ready || imp_wait_too_long) {
log::trace!("IMP is not ready, waiting a bit more...");
// Let's wait some more.
return Ok((match_id, None));
}
let match_radiant_win = r#match.did_radiant_win
.context("Non è stato ricevuto da STRATZ il vincitore della partita.")?;
let match_outcome = MatchOutcome::from(&match_side, match_radiant_win);
let match_outcome_emoji = match_outcome.emoji();
let match_type = r#match.lobby_type.clone()
.context("Non è stato ricevuta da STRATZ il tipo della partita.")?;
let match_type_str = match match_type {
LobbyTypeEnum::UNRANKED => "Normale",
LobbyTypeEnum::PRACTICE => "Torneo",
LobbyTypeEnum::TOURNAMENT => "The International",
LobbyTypeEnum::TUTORIAL => "Tutorial",
LobbyTypeEnum::COOP_VS_BOTS => "Co-op",
LobbyTypeEnum::TEAM_MATCH => "Scontro di Clan",
LobbyTypeEnum::SOLO_QUEUE => "Coda solitaria",
LobbyTypeEnum::RANKED => "Classificata",
LobbyTypeEnum::SOLO_MID => "Duello",
LobbyTypeEnum::BATTLE_CUP => "Battle Cup",
LobbyTypeEnum::EVENT => "Evento",
LobbyTypeEnum::DIRE_TIDE => "Diretide",
LobbyTypeEnum::Other(t) => anyhow::bail!("Il tipo di partita ricevuto da STRATZ è sconosciuto: {}", t)
};
let match_mode = r#match.game_mode.clone()
.context("Non è stata ricevuta da STRATZ la modalità della partita.")?;
let match_mode_str = match match_mode {
GameModeEnumType::NONE => "Sandbox",
GameModeEnumType::ALL_PICK => "All Pick",
GameModeEnumType::CAPTAINS_MODE => "Captains Mode",
GameModeEnumType::RANDOM_DRAFT => "Random Draft",
GameModeEnumType::SINGLE_DRAFT => "Single Draft",
GameModeEnumType::ALL_RANDOM => "All Random",
GameModeEnumType::INTRO => "Tutorial",
GameModeEnumType::THE_DIRETIDE => "Diretide",
GameModeEnumType::REVERSE_CAPTAINS_MODE => "Reverse Captains",
GameModeEnumType::THE_GREEVILING => "The Greeviling",
GameModeEnumType::TUTORIAL => "Tutorial",
GameModeEnumType::MID_ONLY => "Mid Only",
GameModeEnumType::LEAST_PLAYED => "Least Played",
GameModeEnumType::NEW_PLAYER_POOL => "New Player",
GameModeEnumType::COMPENDIUM_MATCHMAKING => "Compendium",
GameModeEnumType::CUSTOM => "Arcade",
GameModeEnumType::CAPTAINS_DRAFT => "Captains Draft",
GameModeEnumType::BALANCED_DRAFT => "Balanced Draft",
GameModeEnumType::ABILITY_DRAFT => "Ability Draft",
GameModeEnumType::EVENT => "Evento",
GameModeEnumType::ALL_RANDOM_DEATH_MATCH => "All Random Deathmatch",
GameModeEnumType::SOLO_MID => "Mid Duel",
GameModeEnumType::ALL_PICK_RANKED => "All Draft",
GameModeEnumType::TURBO => "Turbo",
GameModeEnumType::MUTATION => "Mutation",
GameModeEnumType::UNKNOWN => anyhow::bail!("La modalità di partita ricevuto da STRATZ è sconosciuta."),
GameModeEnumType::Other(t) => anyhow::bail!("Il tipo di partita ricevuto da STRATZ è sconosciuta: {}", t)
};
let match_duration = r#match.duration_seconds
.context("Non è stata ricevuta da STRATZ la durata della partita.")?;
// Let's begin writing the message
let mut text = format!(
"{match_outcome_emoji} <a href=\"https://stratz.com/matches/{match_id}\"><b><u>Partita #{match_id}</u></b></a>\n\
<b>{match_type_str}</b> · {match_mode_str} · <i>{match_duration}</i>\n\
\n\
",
);
// Let's sort players by team...
players.sort_unstable_by_key(|o| match o.is_radiant.unwrap() {
true => 1,
false => 2,
});
for player in players {
let player_steam = player.steam_account.clone()
.context("Non è stato ricevuto da STRATZ l'account Steam di almeno uno dei giocatori della partita.")?;
let player_steam_id = player_steam.id
.context("Non è stato ricevuto da STRATZ lo SteamID di almeno uno dei giocatori della partita.")?;
let player_steam_name = player_steam.name
.context("Non è stato ricevuto da STRATZ il display name di almeno uno dei giocatori della partita.")?;
let player_hero = player.hero.clone()
.context("Non è stato ricevuto da STRATZ l'eroe giocato da almeno uno dei giocatori della partita.")?;
let player_hero_name = player_hero.display_name
.context("Non è stato ricevuto da STRATZ il nome dell'eroe giocato da almeno uno dei giocatori della partita.")?;
let player_telegram = {
use diesel::prelude::*;
use diesel::{ExpressionMethods, QueryDsl};
use crate::database::schema::steam::dsl::*;
use crate::database::schema::users::dsl::*;
use crate::database::schema::telegram::dsl::*;
use crate::database::models::TelegramUser;
steam
.filter(steam_id.eq(player_steam_id))
.inner_join(users
.inner_join(telegram)
)
.select(TelegramUser::as_select())
.get_result(database)
.optional()
.ok()
.flatten()
};
let player_telegram_id = player_telegram
.map(|t| t.telegram_id);
text.push_str(
&match player_telegram_id {
Some(player_telegram_id) => format!(
"<a href=\"tg://user?id={player_telegram_id}\"><b>{player_steam_name}</b></a> ({player_hero_name})\n"
),
None => format!(
"<b>{player_steam_name}</b> ({player_hero_name})\n"
),
});
let player_role: Option<Role> = player.role.clone();
let player_lane: Option<Lane> = player.lane.clone();
if let Some(player_role) = player_role {
if let Some(player_lane) = player_lane {
text.push_str(
match (player_role, player_lane) {
(Role::CORE, Lane::SAFE_LANE) => "— 1⃣ Safe Carry\n",
(Role::CORE, Lane::MID_LANE) => "— 2⃣ Mid Carry\n",
(Role::CORE, Lane::OFF_LANE) => "— 3⃣ Off Carry\n",
(Role::LIGHT_SUPPORT, _) => "— 4⃣ Soft Support\n",
(Role::HARD_SUPPORT, _) => "— 5⃣ Hard Support\n",
(_, Lane::JUNGLE) => "— 🔼 Jungle\n",
(_, Lane::ROAMING) => "— 🔀 Roaming\n",
_ => "",
}
);
}
}
let player_imp = player.imp;
let player_imp_emoji = 'emoji: {
if player_imp.is_none() {
break 'emoji ""
}
let player_imp = player_imp.unwrap();
if player_imp < -50 {
"🔲"
} else if player_imp < -25 {
"⬛️"
} else if player_imp < -18 {
"◼️"
} else if player_imp < -9 {
"◾️"
} else if player_imp < 0 {
"▪️"
} else if player_imp <= 9 {
"▫️"
} else if player_imp <= 18 {
"◽️"
} else if player_imp <= 25 {
"◻️"
} else if player_imp <= 50 {
"⬜️"
} else {
"🔳"
}
};
let player_kills = player.kills
.context("La richiesta è riuscita, ma non è stato ricevuto da STRATZ il numero di uccisioni di almeno uno dei giocatori delle partite.")?;
let player_deaths = player.deaths
.context("La richiesta è riuscita, ma non è stato ricevuto da STRATZ il numero di morti di almeno uno dei giocatori delle partite.")?;
let player_assists = player.assists
.context("La richiesta è riuscita, ma non è stato ricevuto da STRATZ il numero di aiuti di almeno uno dei giocatori delle partite.")?;
text.push_str(&match player_imp {
Some(player_imp) => format!(
"— {player_imp_emoji} {player_imp} IMP ({player_kills}/{player_deaths}/{player_assists})\n"
),
None => format!(
"— ❔ {player_kills}/{player_deaths}/{player_assists}\n"
),
});
if match_outcome == MatchOutcome::Clash {
let player_is_radiant = player.is_radiant.unwrap();
text.push_str(match (match_radiant_win, player_is_radiant) {
(true, true) => "🟢 Vittoria!\n",
(false, false) => "🟢 Vittoria!\n",
(true, false) => "🟥 Sconfitta...\n",
(false, true) => "🟥 Sconfitta...\n",
})
}
let player_stats = player.stats.clone()
.context("La richiesta è riuscita, ma non sono state ricevute da STRATZ le statistiche di almeno uno dei giocatori delle partite.")?;
let player_buffs = player_stats.match_player_buff_event.clone()
.unwrap_or_default();
for _buff in player_buffs.iter().filter_map(|s| s.to_owned()) {
// TODO: Let's do this another time.
}
text.push_str("\n")
}
Ok((match_id, Some(text)))
}
}
impl RoyalnetService for BroochService {
#[allow(unreachable_code)]
async fn run(self) -> Result<Infallible> {
loop {
self.iteration_request().await?;
sleep(Duration::new(60 * 15, 0)).await;
}
anyhow::bail!("Brooch service has exited.")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchSide {
Radiant,
Dire,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchOutcome {
Victory,
Defeat,
Clash,
}
impl MatchOutcome {
pub fn from(side: &MatchSide, radiant_win: bool) -> Self {
match (side, radiant_win) {
(MatchSide::Both, _) => Self::Clash,
(MatchSide::Radiant, true) => Self::Victory,
(MatchSide::Radiant, false) => Self::Defeat,
(MatchSide::Dire, true) => Self::Defeat,
(MatchSide::Dire, false) => Self::Victory,
}
}
pub fn emoji(&self) -> &'static str {
match self {
MatchOutcome::Victory => "🟢",
MatchOutcome::Defeat => "🟥",
MatchOutcome::Clash => "🔶",
}
}
}

View file

@ -2,6 +2,7 @@ use std::convert::Infallible;
use anyhow::Result;
pub mod telegram;
pub mod brooch;
pub trait RoyalnetService {
async fn run(self) -> Result<Infallible>;

View file

@ -3,19 +3,5 @@ use micronfig::config;
// Everything ok, RustRover?
config! {
TELEGRAM_BOT_TOKEN,
TELEGRAM_NOTIFICATION_CHATID?: String > i64 -> ChatIdConversionHack -> teloxide::types::ChatId,
}
struct ChatIdConversionHack(i64);
impl From<i64> for ChatIdConversionHack {
fn from(value: i64) -> Self {
Self(value)
}
}
impl From<ChatIdConversionHack> for teloxide::types::ChatId {
fn from(value: ChatIdConversionHack) -> Self {
Self(value.0)
}
TELEGRAM_NOTIFICATION_CHATID?: String > i64 -> crate::utils::hacks::ChatIdConversionHack -> teloxide::types::ChatId,
}

5
src/stratz/config.rs Normal file
View file

@ -0,0 +1,5 @@
use micronfig::config;
config! {
STRATZ_TOKEN: String,
}

86
src/stratz/mod.rs Normal file
View file

@ -0,0 +1,86 @@
use graphql_client::GraphQLQuery;
use reqwest::Client;
use thiserror::Error;
pub(self) mod config;
const STRATZ_GRAPHQL_API_URL: &str = "https://api.stratz.com/graphql";
// Bind these weird types used in the STRATZ API
type Short = i16;
type Long = i64;
type Byte = u8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GuildId(pub i64);
impl From<i64> for GuildId {
fn from(value: i64) -> Self {
Self(value)
}
}
#[derive(GraphQLQuery)]
#[graphql(schema_path="src/stratz/schema.json", query_path="src/stratz/query_guild_matches.gql", response_derives="Debug, Clone")]
pub struct GuildMatchesQuery;
#[derive(Debug, Clone, Error)]
pub enum QueryError {
#[error("GraphQL request failed")]
Requesting,
#[error("GraphQL response parsing failed")]
Parsing,
}
type GuildMatchesQueryResponse = graphql_client::Response<guild_matches_query::ResponseData>;
#[allow(unused_imports)]
pub use guild_matches_query::LobbyTypeEnum as LobbyType;
#[allow(unused_imports)]
pub use guild_matches_query::GameModeEnumType as GameMode;
#[allow(unused_imports)]
pub use guild_matches_query::GuildMatchesQueryGuild as Guild;
#[allow(unused_imports)]
pub use guild_matches_query::GuildMatchesQueryGuildMatches as Match;
#[allow(unused_imports)]
pub use guild_matches_query::GuildMatchesQueryGuildMatchesPlayers as Player;
#[allow(unused_imports)]
pub use guild_matches_query::GuildMatchesQueryGuildMatchesPlayersHero as Hero;
#[allow(unused_imports)]
pub use guild_matches_query::GuildMatchesQueryGuildMatchesPlayersSteamAccount as Steam;
#[allow(unused_imports)]
pub use guild_matches_query::MatchPlayerRoleType as Role;
#[allow(unused_imports)]
pub use guild_matches_query::MatchLaneType as Lane;
#[allow(unused_imports)]
pub use guild_matches_query::GuildMatchesQueryGuildMatchesPlayersStatsMatchPlayerBuffEvent as Buff;
/// Get the latest 10 matches of a certain Dota 2 guild.
pub async fn query_guild_matches(client: &Client, guild_id: &GuildId) -> Result<GuildMatchesQueryResponse, QueryError> {
log::debug!("Querying guild matches with {client:?} for {guild_id:?}...");
log::trace!("Configuring query variables...");
let params = guild_matches_query::Variables {
guild_id: guild_id.0,
};
log::trace!("Building query...");
let body = GuildMatchesQuery::build_query(params);
log::trace!("Building API URL...");
let url = format!("{}?jwt={}", STRATZ_GRAPHQL_API_URL, config::STRATZ_TOKEN());
log::trace!("STRATZ API URL is: {url:?}");
log::trace!("Making request...");
let response = client.post(url)
.json(&body)
.send().await
.map_err(|_| QueryError::Requesting)?
.json::<GuildMatchesQueryResponse>().await
.map_err(|_| QueryError::Parsing)?;
log::trace!("Request successful!");
Ok(response)
}

View file

@ -0,0 +1,37 @@
query GuildMatchesQuery($guild_id: Int!) {
guild(id: $guild_id) {
id
matches(take: 10) {
id
lobbyType
gameMode
durationSeconds
endDateTime
didRadiantWin
players(steamAccountId: null) {
isRadiant
imp
kills
deaths
assists
lane
role
hero {
displayName
}
steamAccount {
id
name
}
stats {
matchPlayerBuffEvent {
time
itemId
abilityId
stackCount
}
}
}
}
}
}

66979
src/stratz/schema.json generated Normal file

File diff suppressed because it is too large Load diff

27
src/utils/hacks.rs Normal file
View file

@ -0,0 +1,27 @@
use teloxide::types::ChatId;
pub struct ChatIdConversionHack(i64);
impl From<i64> for ChatIdConversionHack {
fn from(value: i64) -> Self {
Self(value)
}
}
impl From<ChatIdConversionHack> for ChatId {
fn from(value: ChatIdConversionHack) -> Self {
Self(value.0)
}
}
impl From<ChatIdConversionHack> for i64 {
fn from(value: ChatIdConversionHack) -> Self {
value.0
}
}
impl From<ChatId> for ChatIdConversionHack {
fn from(value: ChatId) -> Self {
Self(value.0)
}
}

View file

@ -1,2 +1,3 @@
pub mod time;
pub mod version;
pub mod hacks;