1
Fork 0
This commit is contained in:
Steffo 2024-11-28 19:16:03 +01:00
parent 65d04be7f3
commit 14c28762bf
Signed by: steffo
GPG key ID: 5ADA3868646C3FC0
12 changed files with 223 additions and 38 deletions

View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -221,6 +221,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@ -666,6 +667,7 @@ dependencies = [
"skillratings", "skillratings",
"teloxide", "teloxide",
"tokio", "tokio",
"url",
] ]
[[package]] [[package]]
@ -1674,6 +1676,9 @@ name = "skillratings"
version = "0.27.1" version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c8196a815d27d6dbd2439058a2cbf6597a549a68ca6368611df240df7c2987" checksum = "53c8196a815d27d6dbd2439058a2cbf6597a549a68ca6368611df240df7c2987"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "slab" name = "slab"

View file

@ -11,8 +11,9 @@ diesel_migrations = { version = "2.2.0", features = ["postgres"] }
log = "0.4.22" log = "0.4.22"
micronfig = "0.3.0" micronfig = "0.3.0"
pretty_env_logger = "0.5.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"] } teloxide = { version = "0.13.0", features = ["webhooks-axum"] }
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] } tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] }
serde = { version = "1.0.215", features = ["derive"] } 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"] }

View file

@ -0,0 +1 @@
ALTER TABLE players DROP COLUMN IF EXISTS competitive;

View file

@ -0,0 +1 @@
ALTER TABLE players ADD COLUMN competitive BOOLEAN NOT NULL DEFAULT FALSE;

View file

@ -0,0 +1,2 @@
ALTER TABLE matches ALTER COLUMN player_a_id TYPE BIGINT;
ALTER TABLE matches ALTER COLUMN player_b_id TYPE BIGINT;

View file

@ -0,0 +1,2 @@
ALTER TABLE matches ALTER COLUMN player_a_id TYPE INTEGER;
ALTER TABLE matches ALTER COLUMN player_b_id TYPE INTEGER;

View file

@ -1,4 +1,6 @@
micronfig::config! { micronfig::config! {
DATABASE_URL, DATABASE_URL: String,
BIND_ADDRESS, BIND_ADDRESS: String > std::net::SocketAddr,
} TELEGRAM_API_KEY: String,
TELEGRAM_WEBHOOK_URL: String > url::Url,
}

View file

@ -1,9 +1,18 @@
use std::convert::Infallible; use std::convert::Infallible;
use std::process::exit; use std::process::exit;
use anyhow::Context; use anyhow::Context;
use axum::extract::Path;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use diesel::{Connection, PgConnection}; use diesel::{Connection, PgConnection};
use diesel_migrations::MigrationHarness; 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 config;
mod schema; mod schema;
@ -14,12 +23,17 @@ pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations:
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<Infallible> { async fn main() -> anyhow::Result<Infallible> {
pretty_env_logger::init(); pretty_env_logger::init();
log::debug!("Logging initialized!"); log::trace!("Logging initialized!");
log::trace!("Determining database URL..."); log::trace!("Determining database URL...");
let db = config::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) { let mut db = match PgConnection::establish(db) {
Err(e) => { Err(e) => {
log::error!("Failed to connect to the PostgreSQL database: {e:#?}"); log::error!("Failed to connect to the PostgreSQL database: {e:#?}");
@ -28,35 +42,129 @@ async fn main() -> anyhow::Result<Infallible> {
Ok(db) => db, Ok(db) => db,
}; };
log::debug!("Running migrations..."); log::trace!("Running migrations...");
if let Err(e) = db.run_pending_migrations(MIGRATIONS) { if let Err(e) = db.run_pending_migrations(MIGRATIONS) {
log::error!("Failed to perform migration: {e:#?}"); log::error!("Failed to perform migration: {e:#?}");
exit(2); 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..."); log::trace!("Creating Axum router...");
let app = axum::Router::new() let app = axum::Router::new()
.route("/results/:user", axum::routing::get(results_handler)); .route("/players/holycow/:player_id/results", axum::routing::get(results_by_id_handler))
log::trace!("Axum router created successfully!"); .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::<teloxide::Bot, anyhow::Error, DefaultKey>::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..."); log::trace!("Creating Tokio listener...");
let bind_address = config::BIND_ADDRESS(); let tokio_listener = tokio::net::TcpListener::bind(bind_address)
let listener = tokio::net::TcpListener::bind(bind_address)
.await .await
.context("failed to bind listener to address")?; .context("failed to bind listener to address")?;
log::trace!("Tokio listener bound to: {bind_address}");
log::debug!("Starting server..."); log::trace!("Creating Axum server future...");
axum::serve(listener, app) let axum_future = axum::serve(tokio_listener, app);
.await
.context("server exited with error")?;
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) 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<Json<ResultsResponse>, 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] #[axum::debug_handler]
async fn results_handler() -> Result<String, StatusCode> { async fn results_by_id_handler(
todo!() Path(player_id): Path<i32>,
) -> Result<Json<ResultsResponse>, 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<TelegramId>,
) -> Result<Json<ResultsResponse>, 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(())
}

View file

@ -19,10 +19,10 @@ diesel::table! {
id -> Int4, id -> Int4,
instant -> Timestamptz, instant -> Timestamptz,
name -> Nullable<Varchar>, name -> Nullable<Varchar>,
player_a_id -> Int8, player_a_id -> Int4,
player_a_wenglin_before -> WenglinT, player_a_wenglin_before -> WenglinT,
player_a_wenglin_after -> WenglinT, player_a_wenglin_after -> WenglinT,
player_b_id -> Int8, player_b_id -> Int4,
player_b_wenglin_before -> WenglinT, player_b_wenglin_before -> WenglinT,
player_b_wenglin_after -> WenglinT, player_b_wenglin_after -> WenglinT,
outcome -> OutcomeT, outcome -> OutcomeT,
@ -37,6 +37,7 @@ diesel::table! {
id -> Int4, id -> Int4,
wenglin -> WenglinT, wenglin -> WenglinT,
telegram_id -> Nullable<Int8>, telegram_id -> Nullable<Int8>,
competitive -> Bool,
} }
} }

View file

@ -1,44 +1,48 @@
use std::io::Write; use std::io::Write;
use chrono::{DateTime, Utc}; 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::backend::Backend;
use diesel::deserialize::FromSql; use diesel::deserialize::FromSql;
use diesel::pg::Pg; use diesel::pg::Pg;
use diesel::serialize::ToSql; use diesel::serialize::ToSql;
use diesel::sql_types as sql; use diesel::sql_types as sql;
use diesel::serialize::Output as DieselOutput; use diesel::serialize::Output as DieselOutput;
use diesel::ExpressionMethods;
use serde::{Deserialize, Serialize};
use crate::schema; use crate::schema;
#[derive(Debug, Clone, FromSqlRow, AsExpression)] #[derive(Debug, Clone, FromSqlRow, AsExpression, Serialize, Deserialize)]
#[diesel(sql_type = sql::BigInt)] #[diesel(sql_type = sql::BigInt)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
#[repr(transparent)] #[repr(transparent)]
pub struct TelegramId(pub teloxide::types::ChatId); 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(sql_type = schema::sql_types::WenglinT)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
#[repr(transparent)] #[repr(transparent)]
pub struct WengLinRating(pub skillratings::weng_lin::WengLinRating); 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(table_name = schema::players)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct Player { pub struct Player {
pub id: i32, pub id: i32,
pub wenglin: WengLinRating, pub wenglin: WengLinRating,
pub telegram_id: Option<TelegramId>, pub telegram_id: Option<TelegramId>,
pub competitive: bool,
} }
#[derive(Debug, Clone, Insertable)] #[derive(Debug, Clone, Insertable, Serialize, Deserialize)]
#[diesel(table_name = schema::players)] #[diesel(table_name = schema::players)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct PlayerI { pub struct PlayerI {
pub wenglin: WengLinRating, pub wenglin: WengLinRating,
pub telegram_id: TelegramId, 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)] #[diesel(sql_type = schema::sql_types::OutcomeT)]
pub enum Outcome { pub enum Outcome {
AWins, AWins,
@ -46,38 +50,37 @@ pub enum Outcome {
Tie, Tie,
} }
#[derive(Debug, Clone, Queryable, QueryableByName, Identifiable, Selectable)] #[derive(Debug, Clone, Queryable, QueryableByName, Identifiable, Selectable, Serialize, Deserialize)]
#[diesel(table_name = schema::matches)] #[diesel(table_name = schema::matches)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct Match { pub struct Match {
pub id: i32, pub id: i32,
pub instant: DateTime<Utc>, pub instant: DateTime<Utc>,
pub name: Option<String>, pub name: Option<String>,
pub player_a_id: TelegramId, pub player_a_id: i32,
pub player_a_wenglin_before: WengLinRating, pub player_a_wenglin_before: WengLinRating,
pub player_a_wenglin_after: 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_before: WengLinRating,
pub player_b_wenglin_after: WengLinRating, pub player_b_wenglin_after: WengLinRating,
pub outcome: Outcome, pub outcome: Outcome,
} }
#[derive(Debug, Clone, Insertable)] #[derive(Debug, Clone, Insertable, Serialize, Deserialize)]
#[diesel(table_name = schema::matches)] #[diesel(table_name = schema::matches)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct MatchI { pub struct MatchI {
pub instant: DateTime<Utc>, pub instant: DateTime<Utc>,
pub name: Option<String>, pub name: Option<String>,
pub player_a_id: TelegramId, pub player_a_id: i32,
pub player_a_wenglin_before: WengLinRating, pub player_a_wenglin_before: WengLinRating,
pub player_a_wenglin_after: 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_before: WengLinRating,
pub player_b_wenglin_after: WengLinRating, pub player_b_wenglin_after: WengLinRating,
pub outcome: Outcome, pub outcome: Outcome,
} }
impl FromSql<sql::BigInt, Pg> for TelegramId { impl FromSql<sql::BigInt, Pg> for TelegramId {
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> { fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
let s = <i64 as FromSql<sql::BigInt, Pg>>::from_sql(bytes)?; let s = <i64 as FromSql<sql::BigInt, Pg>>::from_sql(bytes)?;
@ -136,4 +139,56 @@ impl ToSql<schema::sql_types::OutcomeT, Pg> for Outcome {
Ok(diesel::serialize::IsNull::No) Ok(diesel::serialize::IsNull::No)
} }
} }
impl Player {
pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult<Option<Self>> {
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<Option<Self>> {
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<i64> {
Match::played_by_count(conn, self.id)
}
pub fn won_count(&self, conn: &mut PgConnection) -> QueryResult<i64> {
Match::won_by_count(conn, self.id)
}
}
impl Match {
pub fn total(conn: &mut PgConnection) -> QueryResult<i64> {
schema::matches::table
.select(diesel::dsl::count_star())
.get_result::<i64>(conn)
}
pub fn played_by_count(conn: &mut PgConnection, player_id: i32) -> QueryResult<i64> {
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::<i64>(conn)
}
pub fn won_by_count(conn: &mut PgConnection, player_id: i32) -> QueryResult<i64> {
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::<i64>(conn)
}
}

View file

@ -2,7 +2,7 @@
import { StatPanel } from "@/components/StatPanel"; import { StatPanel } from "@/components/StatPanel";
import { useTelegram } from "@/components/useTelegram"; import { useTelegram } from "@/components/useTelegram";
import { useMemo } from "react"; import {useEffect, useMemo} from "react"
import classNames from "classnames"; import classNames from "classnames";
export default function Page() { export default function Page() {
@ -14,6 +14,12 @@ export default function Page() {
const resultsData = undefined const resultsData = undefined
const resultsError = undefined const resultsError = undefined
useEffect(() => {
if(telegramData.start_param === "report") {
// TODO
}
}, [telegramData])
const contents = useMemo(() => { const contents = useMemo(() => {
if(resultsError) { if(resultsError) {
return resultsError.toString() return resultsError.toString()

View file

@ -10,6 +10,7 @@ interface TelegramWebApp {
interface TelegramWebAppInitData { interface TelegramWebAppInitData {
user: TelegramWebAppUser, user: TelegramWebAppUser,
receiver: TelegramWebAppUser, receiver: TelegramWebAppUser,
start_param?: string,
} }
interface TelegramWebAppUser { interface TelegramWebAppUser {