first commit, incomplete

This commit is contained in:
Benji Dial 2026-01-11 20:12:58 -05:00
commit 414d6408b4
7 changed files with 3232 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
database.db
target

2472
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "bracket_bot_v4"
version = "4.0.0-snapshot"
edition = "2024"
[dependencies]
rust-yaml = "0.0.5"
rand = "0.9.2"
rusqlite = { version = "0.38.0", features = [ "bundled" ] }
tokio = { version = "1.49.0", features = [ "rt-multi-thread" ] }
[dependencies.serenity]
version = "0.12.5"
default-features = false
features = [ "builder", "cache", "chrono", "client", "gateway", "model", "rustls_backend" ]

63
src/bin/create-bracket.rs Normal file
View file

@ -0,0 +1,63 @@
use rand::seq::SliceRandom;
fn parse_yaml(yaml: &String) -> Option<bracket_bot_v4::CreateBracketInfo> {
let content = rust_yaml::Yaml::new().load_str(&yaml).ok()?;
let bracket_name = String::from(content.get_str("name")?.as_str()?);
let channel_id: u64 = content.get_str("channel_id")?.as_str()?.parse().ok()?;
let role_id: u64 = content.get_str("role_id")?.as_str()?.parse().ok()?;
let entries = content.get_str("entries")?.as_sequence()?;
let mut entry_infos = Vec::new();
for entry in entries {
let name = String::from(entry.get_str("name")?.as_str()?);
let description = match entry.get_str("description") {
None => None, Some(v) => Some(String::from(v.as_str()?)) };
let emoji = match entry.get_str("emoji") {
None => None, Some(v) => Some(String::from(v.as_str()?)) };
entry_infos.push(bracket_bot_v4::CreateBracketEntryInfo { name, description, emoji });
}
entry_infos.shuffle(&mut rand::rng());
Some(bracket_bot_v4::CreateBracketInfo { bracket_name, channel_id, role_id, entries: entry_infos })
}
#[tokio::main]
async fn main() {
let discord_conn_future = bracket_bot_v4::create_discord_conn();
let mut db_conn = bracket_bot_v4::create_db_conn();
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
eprintln!("This must be run with one argument: the path to the bracket info YAML file (see readme.txt for format).");
std::process::exit(1);
}
let content_str = match std::fs::read_to_string(&args[1]) {
Ok(s) => s, Err(e) => {
eprintln!("Failed to read YAML file: {e}");
std::process::exit(1);
}
};
let create_info = match parse_yaml(&content_str) {
Some(i) => i, None => {
eprintln!("Failed to parse YAML file.");
std::process::exit(1);
}
};
let discord_conn = discord_conn_future.await;
let bracket_id = bracket_bot_v4::create_bracket(&mut db_conn, &discord_conn, &create_info).await;
println!("Bracket ID: {}", bracket_id);
}

43
src/bin/post-polls.rs Normal file
View file

@ -0,0 +1,43 @@
#[tokio::main]
async fn main() {
let dcf = bracket_bot_v4::create_discord_conn();
let mut dbc = bracket_bot_v4::create_db_conn();
let args: Vec<String> = std::env::args().collect();
if args.len() != 3 {
eprintln!("This must be run with two arguments: the ID of the bracket, and the number of polls to post");
std::process::exit(1);
}
let bracket_id: i32 =
match args[1].parse() {
Ok(v) => v, Err(e) => {
eprintln!("failed to parse first argument: {e}");
std::process::exit(1);
}
};
let poll_count: i32 =
match args[2].parse() {
Ok(v) => v, Err(e) => {
eprintln!("failed to parse second argument: {e}");
std::process::exit(1);
}
};
let mut polls = bracket_bot_v4::get_polls_to_post(&mut dbc, bracket_id, poll_count);
let channel_id = bracket_bot_v4::get_channel_id(&mut dbc, bracket_id);
let dc = dcf.await;
if polls.len() == 0 {
bracket_bot_v4::go_to_next_round(&mut dbc, &dc, bracket_id, channel_id).await;
polls = bracket_bot_v4::get_polls_to_post(&mut dbc, bracket_id, poll_count);
}
for poll in polls {
bracket_bot_v4::post_poll(&mut dbc, &dc, bracket_id, channel_id, &poll).await;
}
}

18
src/bin/process-polls.rs Normal file
View file

@ -0,0 +1,18 @@
#[tokio::main]
async fn main() {
let dcf = bracket_bot_v4::create_discord_conn();
let mut dbc = bracket_bot_v4::create_db_conn();
let unprocessed = bracket_bot_v4::get_unprocessed_polls(&dbc);
let dc = dcf.await;
let mut result_futures = Vec::new();
for u in &unprocessed {
result_futures.push((u, bracket_bot_v4::get_poll_results(&dc, u)));
}
for rf in result_futures {
bracket_bot_v4::process_poll(&mut dbc, &rf.0, &rf.1.await, &mut rand::rng());
}
}

619
src/lib.rs Normal file
View file

@ -0,0 +1,619 @@
use rand::RngCore;
fn db_error(e: &rusqlite::Error) -> ! {
eprintln!("Database error: {e}");
std::process::exit(1);
}
fn discord_error(e: &serenity::Error) -> ! {
eprintln!("Discord error: {e}");
std::process::exit(1);
}
pub struct CreateBracketEntryInfo {
pub name: String,
pub description: Option<String>,
//either an actual unicode emoji or a discord emoji id
pub emoji: Option<String>
}
pub struct CreateBracketInfo {
pub bracket_name: String,
pub channel_id: u64,
pub role_id: u64,
pub entries: Vec<CreateBracketEntryInfo>
}
pub struct UnprocessedPoll {
pub poll_id: i32,
pub channel_id: u64,
pub message_id: u64,
pub bracket_id: i32,
pub from_round: i32,
pub entry_id_1: i32,
pub entry_id_2: i32,
pub entry_answer_id_1: i32,
pub entry_answer_id_2: i32
}
pub struct PollResults {
pub entry_1_votes: u64,
pub entry_2_votes: u64
}
pub struct PollToPost {
pub bracket_id: i32,
pub from_round: i32,
pub entry_id_1: i32,
pub entry_id_2: i32
}
pub struct EntryForPoll {
pub name: String,
pub description: Option<String>,
pub emoji: Option<String>,
}
pub struct PostedPoll {
pub message_id: u64,
pub answer_id_1: i32,
pub answer_id_2: i32
}
pub struct DbConn {
conn: rusqlite::Connection
}
pub struct DiscordConn {
client: serenity::Client
}
fn create_db_conn_core() -> Result<DbConn, rusqlite::Error> {
let mut conn = rusqlite::Connection::open("database.db")?;
if !conn.table_exists(None, "brackets")? {
let trans = conn.transaction()?;
trans.execute(
"CREATE TABLE brackets(
channel_id TEXT NOT NULL,
role_id TEXT NOT NULL,
on_round INTEGER,
next_entry_pos INTEGER NOT NULL)", ())?;
trans.execute(
"CREATE TABLE entries(
bracket_id INTEGER NOT NULL,
bracket_pos INTEGER NOT NULL,
in_round INTEGER,
name TEXT NOT NULL,
description TEXT,
emoji TEXT)", ())?;
trans.execute(
"CREATE TABLE polls(
channel_id TEXT NOT NULL,
message_id TEXT UNIQUE NOT NULL,
timestamp INTEGER NOT NULL,
bracket_id INTEGER NOT NULL,
from_round INTEGER NOT NULL,
entry_id_1 INTEGER NOT NULL,
entry_id_2 INTEGER NOT NULL,
entry_answer_id_1 INTEGER NOT NULL,
entry_answer_id_2 INTEGER NOT NULL,
processed INTEGER NOT NULL)", ())?;
trans.commit()?;
}
Ok(DbConn { conn: conn })
}
pub fn create_db_conn() -> DbConn {
match create_db_conn_core() {
Ok(dbc) => dbc, Err(e) => db_error(&e)
}
}
async fn create_discord_conn_core(bot_token: &String)
-> Result<DiscordConn, serenity::Error> {
let intents = serenity::model::gateway::GatewayIntents::empty();
let builder = serenity::Client::builder(bot_token, intents);
let client = builder.await?;
Ok(DiscordConn { client })
}
pub async fn create_discord_conn() -> DiscordConn {
let token = std::env::var("BOT_TOKEN");
match token {
Err(e) => {
eprintln!("Could not get BOT_TOKEN variable: {e}");
std::process::exit(1);
},
Ok(t) => {
match create_discord_conn_core(&t).await {
Ok(dc) => dc, Err(e) => discord_error(&e)
}
}
}
}
async fn create_bracket_core_discord(dc: &DiscordConn, info: &CreateBracketInfo) -> Result<(), serenity::Error> {
let channel = serenity::model::id::ChannelId::new(info.channel_id);
let mut message = serenity::builder::CreateMessage::new();
message = message.content(format!("# Bracket: {}", info.bracket_name));
let mut button = serenity::builder::CreateButton::new(info.role_id.to_string());
button = button.label("toggle pings");
//message = message.button(button);
let sent = channel.send_message(&dc.client.http, message).await?;
//sent.pin(&dc.client.http).await
Ok(())
}
fn create_bracket_core_db(dbc: &mut DbConn, info: &CreateBracketInfo) -> Result<i32, rusqlite::Error> {
let trans = dbc.conn.transaction()?;
trans.execute(
"INSERT INTO brackets(channel_id, role_id, on_round, next_entry_pos) VALUES(?, ?, 0, ?)",
(&info.channel_id.to_string(), &info.role_id.to_string(), info.entries.len() as i32))?;
let bracket_id = trans.last_insert_rowid() as i32;
let mut next_pos: i32 = 0;
for entry in &info.entries {
trans.execute(
"INSERT INTO entries(bracket_id, bracket_pos, in_round, name, description, emoji) VALUES(?, ?, 1, ?, ?, ?)",
(bracket_id, next_pos, &entry.name, &entry.description, &entry.emoji))?;
next_pos += 1;
}
trans.commit()?;
Ok(bracket_id)
}
pub async fn create_bracket(dbc: &mut DbConn, dc: &DiscordConn, info: &CreateBracketInfo) -> i32 {
let df = create_bracket_core_discord(dc, info);
match create_bracket_core_db(dbc, info) {
Err(e) => db_error(&e),
Ok(id) => {
match df.await {
Err(e) => discord_error(&e),
Ok(()) => id
}
}
}
}
fn get_unprocessed_polls_core(dbc: &DbConn) -> Result<Vec<UnprocessedPoll>, rusqlite::Error> {
let mut statement = dbc.conn.prepare(
"SELECT rowid, channel_id, message_id, bracket_id, from_round, entry_id_1, entry_id_2, entry_answer_id_1, entry_answer_id_2 FROM polls WHERE processed = 0")?;
let mut rows = statement.query([])?;
let mut result = Vec::new();
while let Some(row) = rows.next()? {
result.push(UnprocessedPoll {
poll_id: row.get(0)?,
channel_id: row.get::<usize, String>(1)?.parse().unwrap(),
message_id: row.get::<usize, String>(2)?.parse().unwrap(),
bracket_id: row.get(3)?,
from_round: row.get(4)?,
entry_id_1: row.get(5)?,
entry_id_2: row.get(6)?,
entry_answer_id_1: row.get(7)?,
entry_answer_id_2: row.get(8)?});
}
Ok(result)
}
pub fn get_unprocessed_polls(dbc: &DbConn) -> Vec<UnprocessedPoll> {
match get_unprocessed_polls_core(dbc) {
Ok(p) => p, Err(e) => db_error(&e)
}
}
async fn get_poll_results_core(dc: &DiscordConn, poll: &UnprocessedPoll)
-> Result<PollResults, serenity::Error> {
let mut sleep_interval = 60;
loop {
let channel = serenity::model::id::ChannelId::new(poll.channel_id);
let message_in_channel = serenity::model::id::MessageId::new(poll.message_id);
let message = channel.message(&dc.client.http, message_in_channel).await?;
let results = message.poll.unwrap().results.unwrap();
if results.is_finalized {
let mut entry_1_votes = 0;
let mut entry_2_votes = 0;
for count in results.answer_counts {
if count.id.get() == poll.entry_answer_id_1 as u64 {
entry_1_votes = count.count;
}
else if count.id.get() == poll.entry_answer_id_2 as u64 {
entry_2_votes = count.count;
}
}
return Ok(PollResults { entry_1_votes, entry_2_votes });
}
eprintln!("Poll {} not processed, sleeping for {} seconds...", poll.poll_id, sleep_interval);
tokio::time::sleep(core::time::Duration::from_secs(sleep_interval)).await;
sleep_interval *= 2;
}
}
pub async fn get_poll_results(dc: &DiscordConn, poll: &UnprocessedPoll) -> PollResults {
match get_poll_results_core(dc, poll).await {
Ok(r) => r, Err(e) => discord_error(&e)
}
}
fn process_poll_core(dbc: &mut DbConn, poll: &UnprocessedPoll, results: &PollResults, rng: &mut rand::rngs::ThreadRng)
-> Result<(), rusqlite::Error> {
let trans = dbc.conn.transaction()?;
let next_round = poll.from_round + 1;
if results.entry_1_votes != results.entry_2_votes {
let (winner_id, loser_id) =
if results.entry_1_votes < results.entry_2_votes {
(poll.entry_id_2, poll.entry_id_1)
}
else {
(poll.entry_id_1, poll.entry_id_2)
};
trans.execute(
"UPDATE entries SET in_round = ? WHERE rowid = ?",
(next_round, winner_id))?;
trans.execute(
"UPDATE entries SET in_round = NULL WHERE rowid = ?",
(loser_id,))?;
}
else {
let next_pos: i32 =
trans.query_one(
"SELECT next_entry_pos FROM brackets WHERE rowid = ?",
(poll.bracket_id,),
|r| r.get(0))?;
trans.execute(
"UPDATE brackets SET next_entry_pos = ? WHERE rowid = ?",
(next_pos + 1, poll.bracket_id))?;
let (keep_pos_id, lose_pos_id) =
if rng.next_u32() % 2 == 0 {
(poll.entry_id_2, poll.entry_id_1)
}
else {
(poll.entry_id_1, poll.entry_id_2)
};
trans.execute(
"UPDATE entries SET in_round = ? WHERE rowid = ?",
(next_round, keep_pos_id))?;
trans.execute(
"UPDATE entries SET in_round = ?, bracket_pos = ? WHERE rowid = ?",
(next_round, next_pos, lose_pos_id))?;
}
trans.execute(
"UPDATE polls SET processed = 1 WHERE rowid = ?",
(poll.poll_id,))?;
trans.commit()
}
pub fn process_poll(dbc: &mut DbConn, poll: &UnprocessedPoll, results: &PollResults, rng: &mut rand::rngs::ThreadRng) {
match process_poll_core(dbc, poll, results, rng) {
Ok(()) => {}, Err(e) => db_error(&e)
}
}
fn get_polls_to_post_core(dbc: &mut DbConn, bracket_id: i32, count: i32)
-> Result<Vec<PollToPost>, rusqlite::Error> {
let mut polls = Vec::new();
let trans = dbc.conn.transaction()?;
let round: i32 =
trans.query_one(
"SELECT on_round FROM brackets WHERE rowid = ?",
(bracket_id,),
|r| r.get(0))?;
let mut statement =
trans.prepare(
"SELECT rowid FROM entries
WHERE bracket_id = ? AND in_round = ?
ORDER BY bracket_pos LIMIT ?")?;
let mut rows = statement.query([bracket_id as i64, round as i64, (count * 2) as i64])?;
let mut entry_ids: Vec<i32> = Vec::new();
while let Some(row) = rows.next()? {
entry_ids.push(row.get(0)?);
}
for i in 0..(entry_ids.len() / 2) {
polls.push(PollToPost {
bracket_id,
from_round: round,
entry_id_1: entry_ids[2 * i],
entry_id_2: entry_ids[2 * i + 1] });
}
Ok(polls)
}
pub fn get_polls_to_post(dbc: &mut DbConn, bracket_id: i32, count: i32) -> Vec<PollToPost> {
match get_polls_to_post_core(dbc, bracket_id, count) {
Ok(v) => v, Err(e) => db_error(&e)
}
}
fn get_channel_id_core(dbc: &mut DbConn, bracket_id: i32) -> Result<u64, rusqlite::Error> {
let channel_id_string =
dbc.conn.query_one(
"SELECT channel_id FROM brackets WHERE rowid = ?",
(bracket_id,),
|r| r.get::<usize, String>(0));
Ok(channel_id_string?.parse().unwrap())
}
pub fn get_channel_id(dbc: &mut DbConn, bracket_id: i32) -> u64 {
match get_channel_id_core(dbc, bracket_id) {
Ok(i) => i, Err(e) => db_error(&e)
}
}
fn go_to_next_round_core_db(dbc: &mut DbConn, bracket_id: i32)
-> Result<(i32, Vec<String>), rusqlite::Error> {
let trans = dbc.conn.transaction()?;
let current_round: i32 =
trans.query_one(
"SELECT on_round FROM brackets WHERE rowid = ?",
(bracket_id,),
|r| r.get(0))?;
let next_round = current_round + 1;
trans.execute(
"UPDATE brackets SET on_round = ? WHERE rowid = ?",
(next_round, bracket_id))?;
let mut all_entries: Vec<String> = Vec::new();
let mut last_entry_id: Option<i32> = None;
let mut statement =
trans.prepare(
"SELECT rowid, name FROM entries
WHERE bracket_id = ? AND in_round = ?
ORDER BY bracket_pos")?;
let mut rows = statement.query([bracket_id, next_round])?;
while let Some(row) = rows.next()? {
all_entries.push(row.get(1)?);
last_entry_id = Some(row.get(0)?);
}
drop(rows);
drop(statement);
if all_entries.len() == 1 {
trans.commit()?;
return Ok((next_round, all_entries));
}
if all_entries.len() % 2 == 1 {
trans.execute(
"UPDATE entries SET in_round = ? WHERE rowid = ?",
(next_round + 1, last_entry_id.unwrap()))?;
}
trans.commit()?;
Ok((next_round, all_entries))
}
async fn go_to_next_round_core_discord(dc: &DiscordConn, new_round: i32, channel_id: u64, names: &Vec<String>)
-> Result<(), serenity::Error> {
if names.len() == 0 {
return Ok(())
}
let channel = serenity::model::id::ChannelId::new(channel_id);
if names.len() == 1 {
channel.say(
&dc.client.http,
format!("# WINNER: {}", names[0])).await?;
return Ok(())
}
let mut msg = format!("# Round {}\nPolls this round:", new_round);
for i in 0..(names.len() / 2) {
msg += format!("\n* {} vs {}", names[2 * i], names[2 * i + 1]).as_str();
}
if names.len() % 2 == 1 {
msg += format!("\nThere are an odd number, so {} will advance with no poll.", names[names.len() - 1]).as_str();
}
channel.say(&dc.client.http, msg).await?;
Ok(())
}
pub async fn go_to_next_round(dbc: &mut DbConn, dc: &DiscordConn, bracket_id: i32, channel_id: u64) {
let (new_round, names) =
match go_to_next_round_core_db(dbc, bracket_id) {
Ok(t) => t, Err(e) => db_error(&e)
};
match go_to_next_round_core_discord(&dc, new_round, channel_id, &names).await {
Ok(()) => {}, Err(e) => discord_error(&e)
}
}
fn get_entry(dbc: &DbConn, entry_id: i32) -> Result<EntryForPoll, rusqlite::Error> {
dbc.conn.query_one(
"SELECT name, description, emoji FROM entries WHERE rowid = ?",
(entry_id,),
|r|
Ok(EntryForPoll {
name: r.get(0)?,
description: r.get(1)?,
emoji: r.get(2)?
}))
}
fn all_digits(s: &String) -> bool {
for c in s.chars() {
if !char::is_ascii_digit(&c) {
return false;
}
}
true
}
fn create_poll_answer(entry: &EntryForPoll) -> serenity::builder::CreatePollAnswer {
let mut answer = serenity::builder::CreatePollAnswer::new();
answer = answer.text(entry.name.clone());
match entry.emoji.clone() {
None => answer,
Some(e) => {
answer.emoji(
if all_digits(&e) {
serenity::model::channel::PollMediaEmoji::Id(
serenity::model::id::EmojiId::new(e.parse().unwrap()))
}
else {
serenity::model::channel::PollMediaEmoji::Name(e)
})
}
}
}
pub async fn post_poll_core_discord(
dc: &DiscordConn, channel_id: u64, entry_1: &EntryForPoll, entry_2: &EntryForPoll)
-> Result<PostedPoll, serenity::Error> {
let channel = serenity::model::id::ChannelId::new(channel_id);
let mut message = serenity::builder::CreateMessage::new();
let pollq = serenity::builder::CreatePoll::new();
let polla = pollq.question("Poll");
let mut answers = Vec::new();
answers.push(create_poll_answer(&entry_1));
answers.push(create_poll_answer(&entry_2));
let polld = polla.answers(answers);
let pollr = polld.duration(std::time::Duration::from_hours(1));
message = message.poll(pollr);
let posted = channel.send_message(&dc.client.http, message).await?;
let mut answer_id_1: Option<i32> = None;
let mut answer_id_2: Option<i32> = None;
for answer in posted.poll.unwrap().answers {
let name = answer.poll_media.text.unwrap();
if name == entry_1.name {
answer_id_1 = Some(answer.answer_id.get() as i32);
}
else if name == entry_2.name {
answer_id_2 = Some(answer.answer_id.get() as i32);
}
}
Ok(PostedPoll {
message_id: posted.id.get(),
answer_id_1: answer_id_1.unwrap(),
answer_id_2: answer_id_2.unwrap()
})
}
fn post_poll_core_db(
dbc: &mut DbConn, bracket_id: i32, channel_id: u64, to_post: &PollToPost, posted: &PostedPoll)
-> Result<(), rusqlite::Error> {
dbc.conn.execute(
"INSERT INTO polls(
channel_id, message_id, timestamp, bracket_id, from_round,
entry_id_1, entry_id_2, entry_answer_id_1, entry_answer_id_2, processed)
VALUES(?, ?, unixepoch('now'), ?, ?, ?, ?, ?, ?, 0)",
(channel_id.to_string(), posted.message_id.to_string(), bracket_id, to_post.from_round,
to_post.entry_id_1, to_post.entry_id_2, posted.answer_id_1, posted.answer_id_2))?;
Ok(())
}
pub async fn post_poll(dbc: &mut DbConn, dc: &DiscordConn, bracket_id: i32, channel_id: u64, poll: &PollToPost) {
let entry_1 = match get_entry(dbc, poll.entry_id_1) {
Ok(e) => e, Err(e) => db_error(&e)
};
let entry_2 = match get_entry(dbc, poll.entry_id_2) {
Ok(e) => e, Err(e) => db_error(&e)
};
let posted = match post_poll_core_discord(dc, channel_id, &entry_1, &entry_2).await {
Ok(p) => p, Err(e) => discord_error(&e)
};
match post_poll_core_db(dbc, bracket_id, channel_id, &poll, &posted) {
Ok(()) => (), Err(e) => db_error(&e)
}
}