Progress
This commit is contained in:
parent
65d04be7f3
commit
14c28762bf
12 changed files with 223 additions and 38 deletions
7
holycow_backend/Cargo.lock
generated
7
holycow_backend/Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE players DROP COLUMN IF EXISTS competitive;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE players ADD COLUMN competitive BOOLEAN NOT NULL DEFAULT FALSE;
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE matches ALTER COLUMN player_a_id TYPE BIGINT;
|
||||
ALTER TABLE matches ALTER COLUMN player_b_id TYPE BIGINT;
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE matches ALTER COLUMN player_a_id TYPE INTEGER;
|
||||
ALTER TABLE matches ALTER COLUMN player_b_id TYPE INTEGER;
|
|
@ -1,4 +1,6 @@
|
|||
micronfig::config! {
|
||||
DATABASE_URL,
|
||||
BIND_ADDRESS,
|
||||
}
|
||||
DATABASE_URL: String,
|
||||
BIND_ADDRESS: String > std::net::SocketAddr,
|
||||
TELEGRAM_API_KEY: String,
|
||||
TELEGRAM_WEBHOOK_URL: String > url::Url,
|
||||
}
|
||||
|
|
|
@ -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<Infallible> {
|
||||
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<Infallible> {
|
|||
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::<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...");
|
||||
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<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]
|
||||
async fn results_handler() -> Result<String, StatusCode> {
|
||||
todo!()
|
||||
async fn results_by_id_handler(
|
||||
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(())
|
||||
}
|
|
@ -19,10 +19,10 @@ diesel::table! {
|
|||
id -> Int4,
|
||||
instant -> Timestamptz,
|
||||
name -> Nullable<Varchar>,
|
||||
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<Int8>,
|
||||
competitive -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<TelegramId>,
|
||||
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<Utc>,
|
||||
pub name: Option<String>,
|
||||
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<Utc>,
|
||||
pub name: Option<String>,
|
||||
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<sql::BigInt, Pg> for TelegramId {
|
||||
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -10,6 +10,7 @@ interface TelegramWebApp {
|
|||
interface TelegramWebAppInitData {
|
||||
user: TelegramWebAppUser,
|
||||
receiver: TelegramWebAppUser,
|
||||
start_param?: string,
|
||||
}
|
||||
|
||||
interface TelegramWebAppUser {
|
||||
|
|
Loading…
Reference in a new issue