Add transit betting

This commit is contained in:
joeyahines committed 2025-04-20 18:33:33 -06:00
1 parent cda4339386
commit 32fa1a11d1
9 files changed
+445 -55

No files matched your search

+4
View File
@@ -2,6 +2,7 @@ use crate::album_manager::AlbumManager;
use crate::error::Error;
use crate::migrations::{CURRENT_DB_VERSION, do_migration};
use config::{Config, File};
use cta_api::CTAClient;
use j_db::database::Database;
use j_db::metadata::DBMetadata;
use poise::serenity_prelude::model::id::ChannelId;
@@ -42,6 +43,7 @@ pub struct BotConfig {
pub toys: Vec<String>,
pub effect_role_duration: i64,
pub raas_server: String,
pub cta_key: String,
pub picox: PicOxConfig,
}
@@ -75,6 +77,7 @@ pub struct GlobalData {
pub bot_state: Mutex<BotState>,
pub db: Database,
pub picox: AlbumManager,
pub cta: Mutex<CTAClient>,
pub speak_lock: Mutex<()>,
}
@@ -100,6 +103,7 @@ impl GlobalData {
db,
cfg: cfg.clone(),
picox: AlbumManager::new(cfg.picox.api_base_url, &cfg.picox.token),
cta: Mutex::new(CTAClient::new(cfg.cta_key)),
speak_lock: Default::default(),
})
}
+4 -1
View File
@@ -10,6 +10,7 @@ mod little_fren;
mod motivate;
mod role;
pub(crate) mod shop;
mod transit;
pub(crate) mod voices;
use crate::config::GlobalData;
@@ -314,6 +315,7 @@ pub async fn run_bot(global_data: GlobalData) {
shop::sell_item(),
shop::shop(),
shop::use_item(),
transit::cta_bets(),
voices::list_voices(),
voices::list_words(),
voices::say(),
@@ -347,7 +349,8 @@ pub async fn run_bot(global_data: GlobalData) {
let intents = serenity::GatewayIntents::non_privileged()
| serenity::GatewayIntents::DIRECT_MESSAGES
| serenity::GatewayIntents::GUILDS
| serenity::GatewayIntents::MESSAGE_CONTENT;
| serenity::GatewayIntents::MESSAGE_CONTENT
| serenity::GatewayIntents::GUILD_MESSAGE_REACTIONS;
let mut client = serenity::ClientBuilder::new(token, intents)
.framework(framework)
+273
View File
@@ -0,0 +1,273 @@
use crate::discord::Context;
use crate::error::Error;
use crate::user::User;
use chrono::{DateTime, TimeZone, Utc};
use cta_api::models::eta::EtaRequest;
use log::debug;
use poise::serenity_prelude::{
FormattedTimestampStyle, Mentionable, MessageBuilder, ReactionType, Timestamp, UserId,
};
use std::collections::{HashMap, HashSet};
use std::time::Duration;
const CALI_STATION_ID: u32 = 30111;
#[derive(Debug, PartialOrd, PartialEq, Copy, Clone)]
pub enum CtaBetType {
OnTime,
Early,
Late,
}
impl CtaBetType {
pub fn emoji(&self) -> String {
match self {
CtaBetType::OnTime => "🟢".to_string(),
CtaBetType::Early => "🟡".to_string(),
CtaBetType::Late => "🔴".to_string(),
}
}
pub fn outcome(est_time: DateTime<Utc>, actual_time: DateTime<Utc>) -> Self {
let diff = est_time - actual_time;
if diff.num_seconds().abs() <= 1 {
CtaBetType::OnTime
} else if diff.num_seconds().is_positive() {
CtaBetType::Early
} else {
CtaBetType::Late
}
}
pub fn payout_factor(&self) -> f32 {
match self {
CtaBetType::OnTime => 10.0,
CtaBetType::Early => 2.0,
CtaBetType::Late => 1.5,
}
}
}
/// Bet on the CTA
#[poise::command(prefix_command, category = "Transit", guild_only)]
pub async fn cta_bets(ctx: Context<'_>) -> Result<(), Error> {
let mut cta_api = ctx.data().cta.lock().await;
let eta = cta_api
.fetch_eta(EtaRequest {
mapid: None,
stpid: Some(CALI_STATION_ID),
max: None,
rt: None,
})
.await?;
if eta.eta.is_empty() {
ctx.reply("No trains found, either the CTA is closed or borked. 50/50 shot really")
.await?;
return Ok(());
}
let next_train_eta = eta.eta.iter().find(|eta| eta.is_app == "0").unwrap();
let arrival_time =
chrono::NaiveDateTime::parse_from_str(&next_train_eta.arr_t, "%Y-%m-%dT%H:%M:%S").unwrap();
let arrival_time = chrono_tz::America::Chicago
.from_local_datetime(&arrival_time)
.unwrap();
let arrival_time = poise::serenity_prelude::model::Timestamp::from(arrival_time.to_utc());
let mut msg = MessageBuilder::new();
msg.push_line("# It's time to bet on public transit!");
msg.push_line("The next train to arrive to the California station on the Blue Line is:");
msg.push_line(format!("* **Train Run Number**: {}", next_train_eta.rn));
msg.push_line(format!(
"* **Is Train Delayed?**: {}",
next_train_eta.is_dly == "1"
));
msg.push_line(format!(
"* **Expected Arrival Time**: {}",
poise::serenity_prelude::utils::FormattedTimestamp::new(
arrival_time,
Some(FormattedTimestampStyle::LongTime)
)
));
msg.push_line("");
msg.push_line("Bets are 100 Fren Coins");
msg.push_bold_line("Only bet once, I'll know...");
msg.push_line("* React 🟢 if the train will be exactly on time (10x bet)");
msg.push_line("* React 🟡 if the train will be early (2x bet)");
msg.push_line("* React 🔴 if the train will be late (1.5x bet)");
let bet_msg = ctx.reply(msg.build()).await?;
let msg = bet_msg.message().await?;
msg.react(ctx, ReactionType::Unicode(CtaBetType::OnTime.emoji()))
.await?;
msg.react(ctx, ReactionType::Unicode(CtaBetType::Early.emoji()))
.await?;
msg.react(ctx, ReactionType::Unicode(CtaBetType::Late.emoji()))
.await?;
let mut arr_time = None;
// Try for 30 minutes
for _ in 0..(60 * 30) {
let train_status = cta_api
.fetch_train_schedule(next_train_eta.rn.parse().unwrap())
.await?;
let cali_eta = train_status
.eta
.iter()
.find(|schedule| {
let sta_id: u32 = schedule.stp_id.parse().unwrap();
sta_id == CALI_STATION_ID
})
.unwrap();
if cali_eta.is_app == "1" {
debug!("Train has arrived!");
arr_time = Some(Utc::now());
break;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
if arr_time.is_none() {
ctx.reply("Yeah I don't think this train is coming, oh well RIP Chicago")
.await?;
return Ok(());
}
let outcome = CtaBetType::outcome(*arrival_time, arr_time.unwrap());
let user_id = ctx.cache().current_user().id;
let msg = bet_msg.message().await?;
let reactions_on_time = msg
.reaction_users(
ctx,
ReactionType::Unicode(CtaBetType::OnTime.emoji()),
None,
None,
)
.await?;
let reactions_early = msg
.reaction_users(
ctx,
ReactionType::Unicode(CtaBetType::Early.emoji()),
None,
None,
)
.await?;
let reactions_late = msg
.reaction_users(
ctx,
ReactionType::Unicode(CtaBetType::Late.emoji()),
None,
None,
)
.await?;
let mut bets: HashMap<UserId, CtaBetType> = HashMap::new();
let mut punish_users: HashSet<UserId> = HashSet::new();
add_bets(
&user_id,
&CtaBetType::OnTime,
&reactions_on_time,
&mut bets,
&mut punish_users,
);
add_bets(
&user_id,
&CtaBetType::Late,
&reactions_late,
&mut bets,
&mut punish_users,
);
add_bets(
&user_id,
&CtaBetType::Early,
&reactions_early,
&mut bets,
&mut punish_users,
);
for user in bets.keys() {
User::take_funds(&ctx.data().db, *user, 100)?;
}
let winners: Vec<UserId> = bets
.iter()
.filter(|(_, bet)| **bet == outcome)
.filter(|(user, _)| !punish_users.contains(user))
.map(|(id, _)| *id)
.collect();
let actual_timestamp = Timestamp::from(arr_time.unwrap());
ctx.reply(format!(
"# {} Train has arrived @ {}",
outcome.emoji(),
poise::serenity_prelude::utils::FormattedTimestamp::new(
actual_timestamp,
Some(FormattedTimestampStyle::LongTime)
)
))
.await?;
if winners.is_empty() {
ctx.reply("Looks like no one won, too bad...").await?;
} else {
let payout = (100.0 * outcome.payout_factor()) as u32;
let mut winner_msg = MessageBuilder::new();
winner_msg.push_line("## Congrats to our winners:");
winner_msg.push_line(format!("They each get {} Fren Coins!", payout));
for winner in &winners {
User::give_funds(&ctx.data().db, *winner, payout)?;
winner_msg.push_line(format!("* {}", winner.mention()));
}
ctx.reply(winner_msg.build()).await?;
}
if !punish_users.is_empty() {
let mut cheater_msg = MessageBuilder::new();
cheater_msg
.push_line("## NOW FOR YOU CHEATING FUCKS, HOPE YOU ENJOY NOT WINNING ANYTHING:");
for cheater in &punish_users {
cheater_msg.push_line(format!("* {}", cheater.mention()));
}
ctx.reply(cheater_msg.build()).await?;
}
Ok(())
}
fn add_bets(
bot_user: &UserId,
bet_outcome: &CtaBetType,
reactions: &Vec<poise::serenity_prelude::User>,
bets: &mut HashMap<UserId, CtaBetType>,
punish_users: &mut HashSet<UserId>,
) {
for user in reactions {
if &user.id == bot_user {
continue;
}
if bets.contains_key(&user.id) {
debug!("Punishing user id {}", user.id);
punish_users.insert(user.id);
continue;
}
debug!("Adding User id {} to {}", user.id, bet_outcome.emoji());
bets.insert(user.id, *bet_outcome);
}
}
+8
View File
@@ -21,6 +21,7 @@ pub enum Error {
CommandError(String),
IoError(std::io::Error),
VoiceError(VoiceError),
CTAError(cta_api::Error),
}
impl StdError for Error {}
@@ -79,6 +80,12 @@ impl From<VoiceError> for Error {
}
}
impl From<cta_api::Error> for Error {
fn from(value: cta_api::Error) -> Self {
Self::CTAError(value)
}
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
@@ -95,6 +102,7 @@ impl Display for Error {
Error::CommandError(err_msg) => write!(f, "{}", err_msg),
Error::IoError(err) => write!(f, "IO Error: {}", err),
Error::VoiceError(err) => write!(f, "Voice error: {}", err),
Error::CTAError(err) => write!(f, "The CTA had an error, I'm shocked: {}", err),
}
}
}
+1 -2
View File
@@ -21,6 +21,7 @@ static START: Once = Once::new();
#[tokio::main]
async fn main() {
let args: Args = Args::from_args();
tracing_subscriber::fmt::init();
let cfg = match BotConfig::new(&args.cfg_path) {
Ok(cfg) => cfg,
@@ -30,8 +31,6 @@ async fn main() {
}
};
tracing_subscriber::fmt::init();
START.call_once(|| {
magick_wand_genesis();
});
+5 -1
View File
@@ -111,8 +111,12 @@ impl Task {
}
TaskType::CheckBirthdays => {
info!("Checking Birthdays");
let time =
chrono_tz::America::Chicago.from_utc_datetime(&Utc::now().naive_utc());
let todays_birthdays =
BirthdayEntry::todays_birthdays(&data.db, chrono::Utc::now().date_naive())?;
BirthdayEntry::todays_birthdays(&data.db, time.date_naive())?;
for birth in todays_birthdays {
if let Ok(user) =
+10
View File
@@ -97,6 +97,16 @@ impl User {
Ok(())
}
pub fn take_funds(db: &Database, discord_id: UserId, amount: u32) -> Result<(), Error> {
let mut wallet = Self::get_user(db, discord_id)?;
wallet.coin_count -= amount as i64;
db.insert::<User>(wallet)?;
Ok(())
}
pub fn give_item(
db: &Database,
discord_id: UserId,