From 14c28762bfc25871f47cd5ef098e1cedfa4015ed Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Thu, 28 Nov 2024 19:16:03 +0100 Subject: [PATCH] Progress --- holycow_backend/Cargo.lock | 7 +- holycow_backend/Cargo.toml | 5 +- .../down.sql | 1 + .../2024-11-28-152810_add ranked optin/up.sql | 1 + .../2024-11-28-161912_fix fk types/down.sql | 2 + .../2024-11-28-161912_fix fk types/up.sql | 2 + holycow_backend/src/config.rs | 8 +- holycow_backend/src/main.rs | 138 ++++++++++++++++-- holycow_backend/src/schema.rs | 5 +- holycow_backend/src/types.rs | 83 +++++++++-- holycow_frontend/src/app/page.tsx | 8 +- holycow_frontend/src/telegram.ts | 1 + 12 files changed, 223 insertions(+), 38 deletions(-) create mode 100644 holycow_backend/migrations/2024-11-28-152810_add ranked optin/down.sql create mode 100644 holycow_backend/migrations/2024-11-28-152810_add ranked optin/up.sql create mode 100644 holycow_backend/migrations/2024-11-28-161912_fix fk types/down.sql create mode 100644 holycow_backend/migrations/2024-11-28-161912_fix fk types/up.sql diff --git a/holycow_backend/Cargo.lock b/holycow_backend/Cargo.lock index c4d430c..dffa95a 100644 --- a/holycow_backend/Cargo.lock +++ b/holycow_backend/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -221,6 +221,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.6", ] @@ -666,6 +667,7 @@ dependencies = [ "skillratings", "teloxide", "tokio", + "url", ] [[package]] @@ -1674,6 +1676,9 @@ name = "skillratings" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c8196a815d27d6dbd2439058a2cbf6597a549a68ca6368611df240df7c2987" +dependencies = [ + "serde", +] [[package]] name = "slab" diff --git a/holycow_backend/Cargo.toml b/holycow_backend/Cargo.toml index f71addf..bd9ae20 100644 --- a/holycow_backend/Cargo.toml +++ b/holycow_backend/Cargo.toml @@ -11,8 +11,9 @@ diesel_migrations = { version = "2.2.0", features = ["postgres"] } log = "0.4.22" micronfig = "0.3.0" pretty_env_logger = "0.5.0" -skillratings = "0.27.1" +skillratings = { version = "0.27.1", features = ["serde"] } teloxide = { version = "0.13.0", features = ["webhooks-axum"] } tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] } serde = { version = "1.0.215", features = ["derive"] } -chrono = "0.4.38" +chrono = { version = "0.4.38", features = ["serde"] } +url = { version = "2.5.4", features = ["serde"] } diff --git a/holycow_backend/migrations/2024-11-28-152810_add ranked optin/down.sql b/holycow_backend/migrations/2024-11-28-152810_add ranked optin/down.sql new file mode 100644 index 0000000..a3ebecf --- /dev/null +++ b/holycow_backend/migrations/2024-11-28-152810_add ranked optin/down.sql @@ -0,0 +1 @@ +ALTER TABLE players DROP COLUMN IF EXISTS competitive; diff --git a/holycow_backend/migrations/2024-11-28-152810_add ranked optin/up.sql b/holycow_backend/migrations/2024-11-28-152810_add ranked optin/up.sql new file mode 100644 index 0000000..e3c0ce9 --- /dev/null +++ b/holycow_backend/migrations/2024-11-28-152810_add ranked optin/up.sql @@ -0,0 +1 @@ +ALTER TABLE players ADD COLUMN competitive BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/holycow_backend/migrations/2024-11-28-161912_fix fk types/down.sql b/holycow_backend/migrations/2024-11-28-161912_fix fk types/down.sql new file mode 100644 index 0000000..096ebca --- /dev/null +++ b/holycow_backend/migrations/2024-11-28-161912_fix fk types/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE matches ALTER COLUMN player_a_id TYPE BIGINT; +ALTER TABLE matches ALTER COLUMN player_b_id TYPE BIGINT; diff --git a/holycow_backend/migrations/2024-11-28-161912_fix fk types/up.sql b/holycow_backend/migrations/2024-11-28-161912_fix fk types/up.sql new file mode 100644 index 0000000..22739dd --- /dev/null +++ b/holycow_backend/migrations/2024-11-28-161912_fix fk types/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE matches ALTER COLUMN player_a_id TYPE INTEGER; +ALTER TABLE matches ALTER COLUMN player_b_id TYPE INTEGER; diff --git a/holycow_backend/src/config.rs b/holycow_backend/src/config.rs index 7ade8ee..92cde60 100644 --- a/holycow_backend/src/config.rs +++ b/holycow_backend/src/config.rs @@ -1,4 +1,6 @@ micronfig::config! { - DATABASE_URL, - BIND_ADDRESS, -} \ No newline at end of file + DATABASE_URL: String, + BIND_ADDRESS: String > std::net::SocketAddr, + TELEGRAM_API_KEY: String, + TELEGRAM_WEBHOOK_URL: String > url::Url, +} diff --git a/holycow_backend/src/main.rs b/holycow_backend/src/main.rs index 5df52ce..eaded92 100644 --- a/holycow_backend/src/main.rs +++ b/holycow_backend/src/main.rs @@ -1,9 +1,18 @@ use std::convert::Infallible; use std::process::exit; use anyhow::Context; +use axum::extract::Path; use axum::http::StatusCode; +use axum::Json; use diesel::{Connection, PgConnection}; use diesel_migrations::MigrationHarness; +use serde::Serialize; +use teloxide::{dptree}; +use teloxide::dispatching::{DefaultKey, MessageFilterExt, UpdateFilterExt}; +use teloxide::error_handlers::LoggingErrorHandler; +use teloxide::types::{Message, WebAppData}; +use teloxide::update_listeners::webhooks::Options; +use crate::types::TelegramId; mod config; mod schema; @@ -14,12 +23,17 @@ pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations: #[tokio::main] async fn main() -> anyhow::Result { pretty_env_logger::init(); - log::debug!("Logging initialized!"); + log::trace!("Logging initialized!"); log::trace!("Determining database URL..."); let db = config::DATABASE_URL(); + log::trace!("Database URL is: {db:?}"); - log::debug!("Connecting to: {db:?}"); + log::trace!("Determining bind address..."); + let bind_address = config::BIND_ADDRESS(); + log::trace!("Bind address is: {bind_address:?}"); + + log::trace!("Connecting to: {db:?}"); let mut db = match PgConnection::establish(db) { Err(e) => { log::error!("Failed to connect to the PostgreSQL database: {e:#?}"); @@ -28,35 +42,129 @@ async fn main() -> anyhow::Result { Ok(db) => db, }; - log::debug!("Running migrations..."); + log::trace!("Running migrations..."); if let Err(e) = db.run_pending_migrations(MIGRATIONS) { log::error!("Failed to perform migration: {e:#?}"); exit(2); }; + log::trace!("Creating Telegram bot..."); + let bot = teloxide::Bot::new(config::TELEGRAM_API_KEY()); + + log::trace!("Setting up webhooks..."); + let (telegram_listener, _telegram_stop, telegram_router) = teloxide::update_listeners::webhooks::axum_to_router( + bot.clone(), + Options { + address: config::BIND_ADDRESS().clone(), + url: config::TELEGRAM_WEBHOOK_URL().clone(), + path: "/".to_string(), + certificate: None, + max_connections: None, + drop_pending_updates: false, + secret_token: None, + } + ).await?; + log::trace!("Creating Axum router..."); let app = axum::Router::new() - .route("/results/:user", axum::routing::get(results_handler)); - log::trace!("Axum router created successfully!"); + .route("/players/holycow/:player_id/results", axum::routing::get(results_by_id_handler)) + .route("/players/telegram/:telegram_id/results", axum::routing::get(results_by_telegram_id_handler)) + .nest("/telegram/webhook", telegram_router) + ; + + log::trace!("Setting up Telegram dispatcher..."); + let mut telegram_dispatcher = teloxide::dispatching::Dispatcher::::builder( + bot.clone(), + Message::filter_web_app_data() + .endpoint(telegram_web_app_handler) + ) + .default_handler(|u| async move { + log::trace!("Unhandled update: {u:#?}") + }) + .build(); + + log::trace!("Creating Telegram dispatcher future..."); + let telegram_future = telegram_dispatcher.dispatch_with_listener(telegram_listener, LoggingErrorHandler::new()); log::trace!("Creating Tokio listener..."); - let bind_address = config::BIND_ADDRESS(); - let listener = tokio::net::TcpListener::bind(bind_address) + let tokio_listener = tokio::net::TcpListener::bind(bind_address) .await .context("failed to bind listener to address")?; - log::trace!("Tokio listener bound to: {bind_address}"); - log::debug!("Starting server..."); - axum::serve(listener, app) - .await - .context("server exited with error")?; + log::trace!("Creating Axum server future..."); + let axum_future = axum::serve(tokio_listener, app); - log::error!("Server exited with no error, exiting."); + log::info!("Running Axum server future and Telegram dispatcher future!"); + let _ = tokio::join!(axum_future, telegram_future); + + log::error!("Server exited!"); exit(1) } +#[derive(Debug, Clone, Copy, Serialize)] +struct ResultsResponse { + played: i64, + won: i64, + rating: f64, + uncertainty: f64, +} + +fn results( + conn: &mut PgConnection, + player: types::Player, +) -> Result, StatusCode> { + let played = player.played_count(conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let won = player.won_count(conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let rating = match player.competitive { + false => 0.0, + true => player.wenglin.0.rating, + }; + + let uncertainty = match player.competitive { + false => 0.0, + true => player.wenglin.0.uncertainty, + }; + + Ok(Json(ResultsResponse { + played, won, rating, uncertainty + })) +} #[axum::debug_handler] -async fn results_handler() -> Result { - todo!() +async fn results_by_id_handler( + Path(player_id): Path, +) -> Result, StatusCode> { + let mut conn = PgConnection::establish(config::DATABASE_URL()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let player = types::Player::get_by_id(&mut conn, player_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + results(&mut conn, player) } + +#[axum::debug_handler] +async fn results_by_telegram_id_handler( + Path(telegram_id): Path, +) -> Result, StatusCode> { + let mut conn = PgConnection::establish(config::DATABASE_URL()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let player = types::Player::get_by_telegram_id(&mut conn, telegram_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + results(&mut conn, player) +} + +async fn telegram_web_app_handler( + web_app_data: WebAppData, +) -> anyhow::Result<()> { + log::trace!("{web_app_data:#?}"); + Ok(()) +} \ No newline at end of file diff --git a/holycow_backend/src/schema.rs b/holycow_backend/src/schema.rs index f40ae01..8c18f1f 100644 --- a/holycow_backend/src/schema.rs +++ b/holycow_backend/src/schema.rs @@ -19,10 +19,10 @@ diesel::table! { id -> Int4, instant -> Timestamptz, name -> Nullable, - player_a_id -> Int8, + player_a_id -> Int4, player_a_wenglin_before -> WenglinT, player_a_wenglin_after -> WenglinT, - player_b_id -> Int8, + player_b_id -> Int4, player_b_wenglin_before -> WenglinT, player_b_wenglin_after -> WenglinT, outcome -> OutcomeT, @@ -37,6 +37,7 @@ diesel::table! { id -> Int4, wenglin -> WenglinT, telegram_id -> Nullable, + competitive -> Bool, } } diff --git a/holycow_backend/src/types.rs b/holycow_backend/src/types.rs index 62c7745..ad92b9e 100644 --- a/holycow_backend/src/types.rs +++ b/holycow_backend/src/types.rs @@ -1,44 +1,48 @@ use std::io::Write; use chrono::{DateTime, Utc}; -use diesel::{AsExpression, FromSqlRow, Identifiable, Insertable, Queryable, QueryableByName, Selectable}; +use diesel::{AsExpression, BoolExpressionMethods, FromSqlRow, Identifiable, Insertable, OptionalExtension, PgConnection, QueryDsl, QueryResult, Queryable, QueryableByName, RunQueryDsl, Selectable, SelectableHelper}; use diesel::backend::Backend; use diesel::deserialize::FromSql; use diesel::pg::Pg; use diesel::serialize::ToSql; use diesel::sql_types as sql; use diesel::serialize::Output as DieselOutput; +use diesel::ExpressionMethods; +use serde::{Deserialize, Serialize}; use crate::schema; -#[derive(Debug, Clone, FromSqlRow, AsExpression)] +#[derive(Debug, Clone, FromSqlRow, AsExpression, Serialize, Deserialize)] #[diesel(sql_type = sql::BigInt)] #[diesel(check_for_backend(Pg))] #[repr(transparent)] pub struct TelegramId(pub teloxide::types::ChatId); -#[derive(Debug, Clone, FromSqlRow, AsExpression)] +#[derive(Debug, Clone, FromSqlRow, AsExpression, Serialize, Deserialize)] #[diesel(sql_type = schema::sql_types::WenglinT)] #[diesel(check_for_backend(Pg))] #[repr(transparent)] pub struct WengLinRating(pub skillratings::weng_lin::WengLinRating); -#[derive(Debug, Clone, Queryable, QueryableByName, Identifiable, Selectable)] +#[derive(Debug, Clone, Queryable, QueryableByName, Identifiable, Selectable, Serialize, Deserialize)] #[diesel(table_name = schema::players)] #[diesel(check_for_backend(Pg))] pub struct Player { pub id: i32, pub wenglin: WengLinRating, pub telegram_id: Option, + pub competitive: bool, } -#[derive(Debug, Clone, Insertable)] +#[derive(Debug, Clone, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::players)] #[diesel(check_for_backend(Pg))] pub struct PlayerI { pub wenglin: WengLinRating, pub telegram_id: TelegramId, + pub competitive: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, Serialize, Deserialize)] #[diesel(sql_type = schema::sql_types::OutcomeT)] pub enum Outcome { AWins, @@ -46,38 +50,37 @@ pub enum Outcome { Tie, } -#[derive(Debug, Clone, Queryable, QueryableByName, Identifiable, Selectable)] +#[derive(Debug, Clone, Queryable, QueryableByName, Identifiable, Selectable, Serialize, Deserialize)] #[diesel(table_name = schema::matches)] #[diesel(check_for_backend(Pg))] pub struct Match { pub id: i32, pub instant: DateTime, pub name: Option, - pub player_a_id: TelegramId, + pub player_a_id: i32, pub player_a_wenglin_before: WengLinRating, pub player_a_wenglin_after: WengLinRating, - pub player_b_id: TelegramId, + pub player_b_id: i32, pub player_b_wenglin_before: WengLinRating, pub player_b_wenglin_after: WengLinRating, pub outcome: Outcome, } -#[derive(Debug, Clone, Insertable)] +#[derive(Debug, Clone, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::matches)] #[diesel(check_for_backend(Pg))] pub struct MatchI { pub instant: DateTime, pub name: Option, - pub player_a_id: TelegramId, + pub player_a_id: i32, pub player_a_wenglin_before: WengLinRating, pub player_a_wenglin_after: WengLinRating, - pub player_b_id: TelegramId, + pub player_b_id: i32, pub player_b_wenglin_before: WengLinRating, pub player_b_wenglin_after: WengLinRating, pub outcome: Outcome, } - impl FromSql for TelegramId { fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { let s = >::from_sql(bytes)?; @@ -136,4 +139,56 @@ impl ToSql for Outcome { Ok(diesel::serialize::IsNull::No) } -} \ No newline at end of file +} + +impl Player { + pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult> { + schema::players::table + .select(Self::as_select()) + .filter(schema::players::id.eq(player_id)) + .get_result(conn) + .optional() + } + + pub fn get_by_telegram_id(conn: &mut PgConnection, telegram_id: TelegramId) -> QueryResult> { + schema::players::table + .select(Self::as_select()) + .filter(schema::players::telegram_id.eq(telegram_id)) + .get_result(conn) + .optional() + } + + pub fn played_count(&self, conn: &mut PgConnection) -> QueryResult { + Match::played_by_count(conn, self.id) + } + + pub fn won_count(&self, conn: &mut PgConnection) -> QueryResult { + Match::won_by_count(conn, self.id) + } +} + +impl Match { + pub fn total(conn: &mut PgConnection) -> QueryResult { + schema::matches::table + .select(diesel::dsl::count_star()) + .get_result::(conn) + } + + pub fn played_by_count(conn: &mut PgConnection, player_id: i32) -> QueryResult { + schema::matches::table + .select(diesel::dsl::count_star()) + .or_filter(schema::matches::player_a_id.eq(player_id)) + .or_filter(schema::matches::player_b_id.eq(player_id)) + .get_result::(conn) + } + + pub fn won_by_count(conn: &mut PgConnection, player_id: i32) -> QueryResult { + schema::matches::table + .select(diesel::dsl::count_star()) + .or_filter(schema::matches::player_a_id.eq(player_id) + .and(schema::matches::outcome.eq(Outcome::AWins))) + .or_filter(schema::matches::player_b_id.eq(player_id) + .and(schema::matches::outcome.eq(Outcome::BWins))) + .get_result::(conn) + } +} diff --git a/holycow_frontend/src/app/page.tsx b/holycow_frontend/src/app/page.tsx index 21eb6aa..0e85d2e 100644 --- a/holycow_frontend/src/app/page.tsx +++ b/holycow_frontend/src/app/page.tsx @@ -2,7 +2,7 @@ import { StatPanel } from "@/components/StatPanel"; import { useTelegram } from "@/components/useTelegram"; -import { useMemo } from "react"; +import {useEffect, useMemo} from "react" import classNames from "classnames"; export default function Page() { @@ -14,6 +14,12 @@ export default function Page() { const resultsData = undefined const resultsError = undefined + useEffect(() => { + if(telegramData.start_param === "report") { + // TODO + } + }, [telegramData]) + const contents = useMemo(() => { if(resultsError) { return resultsError.toString() diff --git a/holycow_frontend/src/telegram.ts b/holycow_frontend/src/telegram.ts index 839a110..6d5c063 100644 --- a/holycow_frontend/src/telegram.ts +++ b/holycow_frontend/src/telegram.ts @@ -10,6 +10,7 @@ interface TelegramWebApp { interface TelegramWebAppInitData { user: TelegramWebAppUser, receiver: TelegramWebAppUser, + start_param?: string, } interface TelegramWebAppUser {