summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--makefile5
-rw-r--r--source.cpp384
-rw-r--r--source.rkt149
4 files changed, 394 insertions, 149 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cd1dce4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.vscode/
+bracket-bot
+database.db
+entries.txt
+json.hpp
diff --git a/makefile b/makefile
new file mode 100644
index 0000000..df34d12
--- /dev/null
+++ b/makefile
@@ -0,0 +1,5 @@
+bracket-bot: json.hpp source.cpp
+ g++ source.cpp -lcurl -lsqlite3 -Wall -Wextra -O3 -o bracket-bot
+
+json.hpp:
+ wget https://raw.githubusercontent.com/nlohmann/json/v3.11.3/single_include/nlohmann/json.hpp
diff --git a/source.cpp b/source.cpp
new file mode 100644
index 0000000..9b5a122
--- /dev/null
+++ b/source.cpp
@@ -0,0 +1,384 @@
+#include <curl/curl.h>
+#include <algorithm>
+#include <sqlite3.h>
+#include <iostream>
+#include <optional>
+#include <unistd.h>
+#include <fstream>
+#include <random>
+#include <vector>
+
+#include "json.hpp"
+
+std::default_random_engine prng;
+sqlite3 *dbc;
+CURL *curl_handle;
+
+std::string bot_token;
+std::string channel_id;
+int polls_per_day;
+long post_time_utc;
+
+void init_db() {
+
+ sqlite3_exec(dbc,
+ "begin transaction; "
+ "create table misc ("
+ "on_round INTEGER NOT NULL); "
+ "insert into misc (on_round) "
+ "values (0); "
+ "create table entries ("
+ "name TEXT UNIQUE NOT NULL, "
+ "round INTEGER NOT NULL, "
+ "in_active_poll INTEGER NOT NULL); "
+ "create table active_polls ("
+ "msg_id TEXT UNIQUE NOT NULL); "
+ "create table past_polls ("
+ "round INTEGER NOT NULL, "
+ "entry_1 TEXT NOT NULL, "
+ "entry_2 TEXT NOT NULL, "
+ "users_1 TEXT NOT NULL, "
+ "users_2 TEXT NOT NULL)",
+ 0, 0, 0);
+
+ sqlite3_stmt *insert_entry;
+ sqlite3_prepare_v2(dbc,
+ "insert into entries (name, round, in_active_poll)"
+ "values (?, 1, 0)", -1, &insert_entry, 0);
+
+ std::ifstream entries("entries.txt");
+ if (!entries) {
+ std::cerr << "Please put the list of entries into entries.txt, separated by newlines." << std::endl;
+ exit(1);
+ }
+
+ std::string entry;
+ while (std::getline(entries, entry)) {
+ sqlite3_bind_text(insert_entry, 1, entry.c_str(), -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(insert_entry) != SQLITE_DONE)
+ ;
+ sqlite3_reset(insert_entry);
+ }
+
+ sqlite3_exec(dbc, "end transaction", 0, 0, 0);
+ sqlite3_finalize(insert_entry);
+
+}
+
+int current_round_number;
+std::vector<std::string> left_for_this_round;
+
+void load_on_round() {
+ current_round_number = -1;
+ sqlite3_exec(dbc,
+ "select on_round from misc limit 1",
+ [](void *, int, char **row, char **) {
+ current_round_number = atoi(row[0]);
+ return 0;
+ }, 0, 0);
+}
+
+void save_on_round() {
+ sqlite3_stmt *update;
+ sqlite3_prepare_v2(dbc, "update misc set on_round = ?", -1, &update, 0);
+ sqlite3_bind_int(update, 1, current_round_number);
+ while (sqlite3_step(update) != SQLITE_DONE)
+ ;
+ sqlite3_finalize(update);
+}
+
+void load_left_for_round() {
+ sqlite3_stmt *select_left;
+ sqlite3_prepare_v2(dbc,
+ "select name from entries where round = ? and in_active_poll = 0",
+ -1, &select_left, 0);
+ sqlite3_bind_int(select_left, 1, current_round_number);
+
+ left_for_this_round.clear();
+ int result;
+ while ((result = sqlite3_step(select_left)) != SQLITE_DONE)
+ if (result == SQLITE_ROW)
+ left_for_this_round.push_back(
+ std::string((const char *)sqlite3_column_text(select_left, 0)));
+
+ sqlite3_finalize(select_left);
+ std::shuffle(left_for_this_round.begin(), left_for_this_round.end(), prng);
+}
+
+void set_round(const std::string &str, int round) {
+ sqlite3_stmt *update;
+ sqlite3_prepare_v2(dbc, "update entries set round = ? where name = ?", -1, &update, 0);
+ sqlite3_bind_int(update, 1, round);
+ sqlite3_bind_text(update, 2, str.c_str(), -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(update) != SQLITE_DONE)
+ ;
+ sqlite3_finalize(update);
+}
+
+void set_in_poll(const std::string &str, bool value) {
+ sqlite3_stmt *update;
+ sqlite3_prepare_v2(dbc, "update entries set in_active_poll = ? where name = ?", -1, &update, 0);
+ sqlite3_bind_int(update, 1, value ? 1 : 0);
+ sqlite3_bind_text(update, 2, str.c_str(), -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(update) != SQLITE_DONE)
+ ;
+ sqlite3_finalize(update);
+}
+
+std::string curl_reciept;
+
+size_t write_callback(void *ptr, size_t, size_t count, void *) {
+ size_t offset = curl_reciept.size();
+ curl_reciept.resize(offset + count);
+ memcpy(curl_reciept.data() + offset, ptr, count);
+ return count;
+}
+
+nlohmann::json api(const std::string &endpoint, bool is_post, const std::string &read_from = "") {
+ curl_reciept.clear();
+ curl_easy_setopt(curl_handle, CURLOPT_URL, ("https://discord.com/api/v10" + endpoint).c_str());
+ curl_easy_setopt(curl_handle, is_post ? CURLOPT_POST : CURLOPT_HTTPGET, 1);
+ curl_slist *sl = 0;
+ sl = curl_slist_append(sl, ("Authorization: Bot " + bot_token).c_str());
+ if (is_post)
+ sl = curl_slist_append(sl, "Content-Type: application/json");
+ curl_easy_setopt(curl_handle, CURLOPT_HTTPHEADER, sl);
+ curl_easy_setopt(curl_handle, CURLOPT_WRITEFUNCTION, &write_callback);
+ if (is_post)
+ curl_easy_setopt(curl_handle, CURLOPT_POSTFIELDS, read_from.c_str());
+ curl_easy_perform(curl_handle);
+ curl_slist_free_all(sl);
+ std::cout << "\n" << curl_reciept << std::endl;
+ auto as_json = nlohmann::json::parse(curl_reciept);
+ if (as_json.contains("message") && as_json["message"] == "You are being rate limited.") {
+ sleep((int)(as_json["retry_after"].template get<double>() + 1) * 2);
+ return api(endpoint, is_post, read_from);
+ }
+ return as_json;
+}
+
+std::vector<std::string> get_users(const std::string &msg_id, int answer_id) {
+ std::string base = "/channels/" + channel_id + "/polls/" + msg_id + "/answers/" + std::to_string(answer_id) + "?limit=100";
+ std::vector<std::string> all_users;
+ nlohmann::json returned_list = api(base, false)["users"];
+ while (returned_list.size() > 0) {
+ for (auto user : returned_list)
+ all_users.push_back(user["id"].template get<std::string>());
+ returned_list = api(base + "&after=" + all_users.back(), false)["users"];
+ }
+ return all_users;
+}
+
+void process_all_pending() {
+ std::vector<std::string> pending_msgs;
+ sqlite3_exec(dbc,
+ "select msg_id from active_polls",
+ [](void *ptr, int, char **row, char **) {
+ ((std::vector<std::string> *)ptr)->push_back(std::string(row[0]));
+ return 0;
+ }, &pending_msgs, 0);
+
+ for (const std::string &msg_id : pending_msgs) {
+
+ nlohmann::json result;
+ int sleep_time = 1;
+ while (true) {
+ result = api("/channels/" + channel_id + "/messages/" + msg_id, false);
+ if (result["poll"]["results"]["is_finalized"].template get<bool>())
+ break;
+ sleep(sleep_time);
+ sleep_time *= 2;
+ }
+
+ auto e1 = result["poll"]["answers"][0];
+ auto e2 = result["poll"]["answers"][1];
+
+ std::string e1_text = e1["poll_media"]["text"];
+ std::string e2_text = e2["poll_media"]["text"];
+
+ std::vector<std::string> e1_users = get_users(msg_id, e1["answer_id"]);
+ std::vector<std::string> e2_users = get_users(msg_id, e2["answer_id"]);
+
+ std::string e1_users_text = e1_users.size() > 0 ? e1_users[0] : "";
+ for (unsigned i = 1; i < e1_users.size(); ++i)
+ e1_users_text += "," + e1_users[i];
+
+ std::string e2_users_text = e2_users.size() > 0 ? e2_users[0] : "";
+ for (unsigned i = 1; i < e2_users.size(); ++i)
+ e2_users_text += "," + e2_users[i];
+
+ bool e1_advances = e1_users.size() >= e2_users.size();
+ bool e2_advances = e2_users.size() >= e1_users.size();
+
+ sqlite3_exec(dbc, "begin transaction", 0, 0, 0);
+
+ set_round(e1_text, e1_advances ? current_round_number + 1 : 0);
+ set_round(e2_text, e2_advances ? current_round_number + 1 : 0);
+ set_in_poll(e1_text, false);
+ set_in_poll(e2_text, false);
+
+ sqlite3_stmt *delete_active;
+ sqlite3_prepare_v2(dbc,
+ "delete from active_polls where msg_id = ?",
+ -1, &delete_active, 0);
+ sqlite3_bind_text(delete_active, 1, msg_id.c_str(), -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(delete_active) != SQLITE_DONE)
+ ;
+ sqlite3_finalize(delete_active);
+
+ sqlite3_stmt *insert_complete;
+ sqlite3_prepare_v2(dbc,
+ "insert into past_polls (round, entry_1, entry_2, users_1, users_2) "
+ "values (?, ?, ?, ?, ?)",
+ -1, &insert_complete, 0);
+ sqlite3_bind_int(insert_complete, 1, current_round_number);
+ sqlite3_bind_text(insert_complete, 2, e1_text.c_str(), -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(insert_complete, 3, e2_text.c_str(), -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(insert_complete, 4, e1_users_text.c_str(), -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(insert_complete, 5, e2_users_text.c_str(), -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(insert_complete) != SQLITE_DONE)
+ ;
+ sqlite3_finalize(insert_complete);
+
+ sqlite3_exec(dbc, "end transaction", 0, 0, 0);
+
+ }
+}
+
+void post_poll(std::string e1, std::string e2, int poll_no) {
+ nlohmann::json body = {
+ { "poll", {
+ { "question", { { "text", "Today's Poll #" + std::to_string(poll_no) } } },
+ { "answers", {
+ { { "poll_media", { { "text", e1 } } } },
+ { { "poll_media", { { "text", e2 } } } } } },
+ { "duration", 23 } } } };
+
+ std::string msg_id = api("/channels/" + channel_id + "/messages", true, body.dump())["id"];
+
+ sqlite3_exec(dbc, "begin transaction", 0, 0, 0);
+
+ set_in_poll(e1, true);
+ set_in_poll(e2, true);
+
+ sqlite3_stmt *insert_active;
+ sqlite3_prepare_v2(dbc,
+ "insert into active_polls (msg_id) values (?)",
+ -1, &insert_active, 0);
+ sqlite3_bind_text(insert_active, 1, msg_id.c_str(), -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(insert_active) != SQLITE_DONE)
+ ;
+ sqlite3_finalize(insert_active);
+
+ sqlite3_exec(dbc, "end transaction", 0, 0, 0);
+}
+
+void send_message(std::string str) {
+ nlohmann::json body = { { "content", str } };
+ api("/channels/" + channel_id + "/messages", true, body.dump());
+}
+
+void sleep_until_midnight() {
+ sleep(2);
+ long timestamp = time(0);
+ int time = (post_time_utc - timestamp) % 86400;
+ sleep(time < 0 ? time + 86400 : time);
+}
+
+int main() {
+
+ prng.seed(time(0));
+
+ const char *env = getenv("BOT_TOKEN");
+ if (!env) {
+ std::cerr << "Please set the BOT_TOKEN environment variable." << std::endl;
+ exit(1);
+ }
+ bot_token = std::string(env);
+
+ env = getenv("CHANNEL_ID");
+ if (!env) {
+ std::cerr << "Please set the CHANNEL_ID environment variable." << std::endl;
+ exit(1);
+ }
+ channel_id = std::string(env);
+
+ env = getenv("POLLS_PER_DAY");
+ if (!env) {
+ std::cerr << "Please set the POLLS_PER_DAY environment variable." << std::endl;
+ exit(1);
+ }
+ polls_per_day = atoi(env);
+ if (polls_per_day <= 0) {
+ std::cerr << "POLLS_PER_DAY must be a positive integer." << std::endl;
+ exit(1);
+ }
+
+ env = getenv("POST_TIME_UTC");
+ if (!env) {
+ std::cerr << "Please set the POST_TIME_UTC environment variable (seconds past midnight)." << std::endl;
+ exit(1);
+ }
+ post_time_utc = atoi(env);
+
+ curl_global_init(CURL_GLOBAL_DEFAULT);
+ curl_handle = curl_easy_init();
+
+ sqlite3_open("database.db", &dbc);
+
+ bool have_entries = false;
+ sqlite3_exec(dbc,
+ "select 1 from sqlite_master where type = 'table' and name = 'entries'",
+ [](void *ptr, int, char **, char **) { *(bool *)ptr = true; return 0; },
+ &have_entries, 0);
+
+ if (!have_entries)
+ init_db();
+
+ load_on_round();
+ load_left_for_round();
+
+ while (true) {
+ sleep_until_midnight();
+ process_all_pending();
+
+ std::optional<std::string> advanced = {};
+ if (left_for_this_round.size() == 1) {
+ set_round(left_for_this_round[0], current_round_number + 1);
+ advanced = std::move(left_for_this_round[0]);
+ left_for_this_round.clear();
+ }
+
+ if (left_for_this_round.size() == 0) {
+ ++current_round_number;
+ save_on_round();
+ load_left_for_round();
+
+ if (left_for_this_round.size() == 1) {
+ for (char &c : left_for_this_round[0])
+ c = toupper(c);
+ send_message("# WINNER WINNER\n## " + left_for_this_round[0]);
+ exit(0);
+ }
+
+ std::string msg = "# ROUND " + std::to_string(current_round_number) + '\n';
+ if (advanced)
+ msg += "The only entry left in the previous round was \"" + *advanced + "\", so it advanced. ";
+ msg += "There are " + std::to_string(left_for_this_round.size()) + " entries remaining:";
+ for (int i = left_for_this_round.size() - 1; i >= 0; --i)
+ msg += "\n* \"" + left_for_this_round[i] + "\"";
+ send_message(msg);
+ }
+
+ for (int poll_no = 1; poll_no <= polls_per_day && left_for_this_round.size() >= 2; ++poll_no) {
+ std::string e1 = std::move(left_for_this_round.back());
+ left_for_this_round.pop_back();
+ std::string e2 = std::move(left_for_this_round.back());
+ left_for_this_round.pop_back();
+ post_poll(e1, e2, poll_no);
+ }
+ }
+
+ return 0;
+
+}
diff --git a/source.rkt b/source.rkt
deleted file mode 100644
index 1560993..0000000
--- a/source.rkt
+++ /dev/null
@@ -1,149 +0,0 @@
-#lang racket
-
-(require net/url)
-(require json)
-(require db)
-
-(define polls-per-day 4) ; configurable, set to any positive integer
-(define midnight-seconds (* 4 3600)) ; UTC-4, adjust accordingly
-(define channel-id "CHANNEL ID") ;replace this with the channel id, as a string
-(define bot-token "BOT TOKEN") ; replace this with the bot token, as a string
-
-(when (or (eq? channel-id "CHANNEL ID") (eq? bot-token "BOT TOKEN"))
- (display "Please set the channel-id and bot-token variables.")
- (newline)
- (exit))
-
-(define dbc (sqlite3-connect #:database "database.db" #:mode 'create))
-
-(unless (table-exists? dbc "entries")
- (unless (file-exists? "entries.txt")
- (display "Please put the entries into a file named entries.txt, separated by newlines.")
- (newline)
- (exit))
- (start-transaction dbc)
- (query-exec dbc "create table entries (name text unique not null, round int not null)")
- (query-exec dbc "create table open_polls (round int not null, msg_id text not null)")
- (query-exec dbc "create table closed_polls (round int not null, entry_1 text not null, entry_2 text not null, voters_1 text not null, voters_2 text not null)")
- (for ((entry (string-split (port->string (open-input-file "entries.txt") #:close? #t) "\n")))
- (query-exec dbc "insert into entries (name, round) values (?, 1)" entry))
- (commit-transaction dbc))
-
-(define (api-get endpoint)
- (string->jsexpr
- (port->string
- (get-pure-port
- (string->url (string-append "https://discord.com/api/v10" endpoint))
- (list (string-append "Authorization: Bot " bot-token)))
- #:close? #t)))
-
-(define (api-post endpoint content)
- (string->jsexpr
- (port->string
- (post-pure-port
- (string->url (string-append "https://discord.com/api/v10" endpoint))
- (string->bytes/utf-8 (jsexpr->string content))
- (list
- (string-append "Authorization: Bot " bot-token)
- "Content-Type: application/json"))
- #:close? #t)))
-
-(define (get-completed-poll-answers msg-id (sleep-time 1))
- (let ((resp (api-get (string-append "/channels/" channel-id "/messages/" msg-id))))
- (if (hash-ref (hash-ref (hash-ref resp 'poll) 'results) 'is_finalized)
- (hash-ref (hash-ref resp 'poll) 'answers)
- (begin
- (sleep sleep-time)
- (get-completed-poll-answers msg-id (* sleep-time 2))))))
-
-(define (get-users msg-id answer-id (so-far '()) (last-user #f))
- (let* ((base-endpoint
- (string-append
- "/channels/" channel-id "/polls/" msg-id "/answers/" (number->string answer-id) "?limit=100"))
- (resp (api-get (if last-user (string-append base-endpoint "&after=" last-user) base-endpoint)))
- (users (hash-ref resp 'users))
- (user-ids (map (lambda (s) (hash-ref s 'id)) users)))
- (if (empty? user-ids)
- so-far
- (get-users msg-id answer-id (append user-ids so-far) (last user-ids)))))
-
-(define (process-completed-poll round msg-id)
- (api-post (string-append "/channels/" channel-id "/polls/" msg-id "/expire") (make-hash))
- (let* ((answers (get-completed-poll-answers msg-id))
- (e1id (hash-ref (car answers) 'answer_id))
- (e2id (hash-ref (cadr answers) 'answer_id))
- (e1t (hash-ref (hash-ref (car answers) 'poll_media) 'text))
- (e2t (hash-ref (hash-ref (cadr answers) 'poll_media) 'text))
- (e1u (get-users msg-id e1id))
- (e2u (get-users msg-id e2id))
- (e1w (>= (length e1u) (length e2u)))
- (e2w (>= (length e2u) (length e1u))))
- (start-transaction dbc)
- (query-exec dbc
- "insert into closed_polls (round, entry_1, entry_2, voters_1, voters_2) values (?, ?, ?, ?, ?)"
- round e1t e2t (string-join e1u ",") (string-join e2u ","))
- (query-exec dbc "delete from open_polls where msg_id = ?" msg-id)
- (query-exec dbc "update entries set round = ? where name = ?" (if e1w (+ round 1) 0) e1t)
- (query-exec dbc "update entries set round = ? where name = ?" (if e2w (+ round 1) 0) e2t)
- (commit-transaction dbc)))
-
-(define (create-poll e1 e2 round poll-number)
- (let ((resp (api-post
- (string-append "/channels/" channel-id "/messages")
- (hash
- 'poll (hash
- 'question (hash 'text (string-append "Today's Poll #" (number->string poll-number)))
- 'answers (list (hash 'poll_media (hash 'text e1)) (hash 'poll_media (hash 'text e2)))
- 'duration 23)))))
- (query-exec dbc "insert into open_polls (round, msg_id) values (?, ?)" round (hash-ref resp 'id))))
-
-(define (send-message msg)
- (api-post
- (string-append "/channels/" channel-id "/messages")
- (hash 'content msg)))
-
-(define (sleep-until-next)
- (sleep 2)
- (sleep (modulo (- midnight-seconds (current-seconds)) 86400)))
-
-(sleep-until-next)
-(for ((row (query-rows dbc "select * from open_polls")))
- (process-completed-poll (vector-ref row 0) (vector-ref row 1)))
-
-(define on-round (apply min (query-list dbc "select distinct round from entries where round > 0")))
-(define this-round (shuffle (query-list dbc "select name from entries where round = ?" on-round)))
-
-(define (main-loop)
- (define last-remaining #f)
- (when (= 1 (length this-round))
- (set! last-remaining (car this-round))
- (query-exec dbc "update entries set round = ? where name = ?" (+ 1 on-round) (car this-round))
- (set! this-round '()))
- (when (= 0 (length this-round))
- (set! on-round (+ 1 on-round))
- (set! this-round (shuffle (query-list dbc "select name from entries where round = ?" on-round)))
- (when (= 1 (length this-round))
- (send-message (string-append "# WINNER WINNER\n## " (string-upcase (car this-round))))
- (exit))
- (send-message
- (string-append
- "# ROUND " (number->string on-round)
- (if last-remaining
- (string-append
- "\nThe only entry remaining in the previous round was \""
- last-remaining "\", so it advanced. ")
- "\n")
- (number->string (length this-round)) " entries remain:\n* "
- (string-join this-round "\n* "))))
- (if (file-exists? "SKIP-TODAY")
- (send-message "There is no poll today :)")
- (for ((i (range polls-per-day)))
- (when (> (length this-round) 1)
- (create-poll (car this-round) (cadr this-round) on-round (+ 1 i))
- (set! this-round (cddr this-round)))))
- (sleep-until-next)
- (for ((row (query-rows dbc "select * from open_polls")))
- (process-completed-poll (vector-ref row 0) (vector-ref row 1)))
- (main-loop))
-
-(main-loop)