diff options
-rw-r--r-- | .gitignore | 8 | ||||
-rw-r--r-- | discord.py | 31 | ||||
-rw-r--r-- | make-db.py | 73 | ||||
-rw-r--r-- | makefile | 5 | ||||
-rw-r--r-- | post-poll.py | 79 | ||||
-rw-r--r-- | process-polls.py | 94 | ||||
-rw-r--r-- | readme.txt | 154 | ||||
-rw-r--r-- | requirements.txt | 5 | ||||
-rw-r--r-- | source.cpp | 389 |
9 files changed, 397 insertions, 441 deletions
@@ -1,5 +1,5 @@ -.vscode/ -bracket-bot +__pycache__/ +env/ +cron.sh database.db -entries.txt -json.hpp +entries.csv diff --git a/discord.py b/discord.py new file mode 100644 index 0000000..bca80e6 --- /dev/null +++ b/discord.py @@ -0,0 +1,31 @@ +import requests +import time + +def session(token): + s = requests.Session() + s.headers.update({'Authorization': f'Bot {token}'}) + return s + +def post(s, endpoint, object): + while True: + response = s.post(f'https://discord.com/api/v10{endpoint}', json=object) + if response.status_code == 200: + return response.json() + if response.status_code == 429: + time.sleep(float(response.json()['retry_after'])) + else: + print(response.status_code) + print(response.text) + exit(2) + +def get(s, endpoint): + while True: + response = s.get(f'https://discord.com/api/v10{endpoint}') + if response.status_code == 200: + return response.json() + if response.status_code == 429: + time.sleep(float(response.json()['retry_after'])) + else: + print(response.status_code) + print(response.text) + exit(2) diff --git a/make-db.py b/make-db.py new file mode 100644 index 0000000..e639110 --- /dev/null +++ b/make-db.py @@ -0,0 +1,73 @@ +import argparse +import sqlite3 +import random +import csv + +ap = argparse.ArgumentParser() +ap.add_argument('output', help='the file to output the database to') +ap.add_argument('input', help='a csv file to read from. if a row has one cell, that is the name of the entry. if a row has two cells, the first is the name of the entry, and the second is either a unicode emoji or a discord emoji id for the entry.') +ap.add_argument('-s', '--shuffle', help='shuffle entries. by default, entries are in the order they appear in the csv.', action='store_true') + +a = ap.parse_args() + +entries = [] + +# csv rows are either "name" or "name, emoji" + +with open(a.input, newline='') as file: + reader = csv.reader(file) + for row in reader: + if len(row) == 0: + continue + if len(row) <= 2: + entries.append(list(map(lambda x: x.strip(), row))) + else: + print('encountered a row with more than two cells') + exit(1) + +if a.shuffle: + random.shuffle(entries) + +dbcon = sqlite3.connect(a.output) + +cur = dbcon.cursor() + +cur.execute(''' + CREATE TABLE entries( + name TEXT UNIQUE NOT NULL, + emoji TEXT, + status INT NOT NULL, + last_poll_number INT NOT NULL, + sort_number INT UNIQUE NOT NULL + ) +''') + +cur.execute(''' + CREATE TABLE polls( + poll_number INT UNIQUE NOT NULL, + entry_1 TEXT NOT NULL, + entry_2 TEXT NOT NULL, + posted INT NOT NULL, + expires INT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + voters_1 TEXT, + voters_2 TEXT + ) +''') + +cur.execute(''' + CREATE TABLE users( + id TEXT UNIQUE NOT NULL, + global_display TEXT NOT NULL, + avatar_hash TEXT, + retrieved INT NOT NULL + ) +''') + +for i, row in enumerate(entries): + name = row[0] + emoji = row[1] if len(row) > 0 else None + cur.execute('INSERT INTO entries VALUES(?, ?, 0, 0, ?)', (name, emoji, i)) + +dbcon.commit() diff --git a/makefile b/makefile deleted file mode 100644 index df34d12..0000000 --- a/makefile +++ /dev/null @@ -1,5 +0,0 @@ -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/post-poll.py b/post-poll.py new file mode 100644 index 0000000..8a5bc98 --- /dev/null +++ b/post-poll.py @@ -0,0 +1,79 @@ +from os import environ +import argparse +import discord +import sqlite3 +import time + +ap = argparse.ArgumentParser() +ap.add_argument('database', help='the file with the database') +ap.add_argument('channel', help='the discord id of the channel to post to') +ap.add_argument('hours', help='the number of hours to have the poll open for') + +a = ap.parse_args() + +hours = int(a.hours) + +if 'TOKEN' not in environ: + print('please set an environment variable TOKEN with the bot token') + exit(1) + +token = environ['TOKEN'] + +dbcon = sqlite3.connect(a.database) + +cur = dbcon.cursor() + +last_number = cur.execute(''' + SELECT poll_number FROM polls + ORDER BY poll_number DESC + LIMIT 1 +''').fetchone() + +poll_number = last_number[0] + 1 if last_number else 1 + +rows = cur.execute(''' + SELECT name, emoji FROM entries + WHERE status == 0 + ORDER BY last_poll_number, sort_number + LIMIT 2 +''').fetchall() + +if len(rows) < 2: + print('there aren\'t two entries available') + exit(0) + +def media(name, emoji): + emoji_obj = {'id': emoji} if emoji.isascii() and emoji.isdigit() else {'name': emoji} + return {'text': name, 'emoji': emoji_obj} + +result = discord.post(discord.session(token), f'/channels/{a.channel}/messages', { + 'poll': { + 'question': {'text': f'Poll #{poll_number}'}, + 'answers': [{ + 'poll_media': media(rows[0][0], rows[0][1]) + }, { + 'poll_media': media(rows[1][0], rows[1][1]) + }], + 'duration': hours + } +}) + +posted = int(time.time()) +expires = posted + hours * 3600 +channel_id = result['channel_id'] +message_id = result['id'] + +cur.execute('INSERT INTO polls VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL)', + (poll_number, rows[0][0], rows[1][0], posted, expires, channel_id, message_id)) + +cur.executemany(''' + UPDATE entries + SET status = 1, last_poll_number = ? + WHERE name = ? + LIMIT 1 +''', [ + (poll_number, rows[0][0]), + (poll_number, rows[1][0]) +]) + +dbcon.commit() diff --git a/process-polls.py b/process-polls.py new file mode 100644 index 0000000..6400e15 --- /dev/null +++ b/process-polls.py @@ -0,0 +1,94 @@ +from os import environ +import argparse +import discord +import sqlite3 +import time + +ap = argparse.ArgumentParser() +ap.add_argument('database', help='the file with the database') + +a = ap.parse_args() + +if 'TOKEN' not in environ: + print('please set an environment variable TOKEN with the bot token') + exit(1) + +token = environ['TOKEN'] + +dbcon = sqlite3.connect(a.database) + +cur = dbcon.cursor() + +rows = cur.execute(''' + SELECT entry_1, entry_2, poll_number, expires, channel_id, message_id + FROM polls + WHERE voters_1 IS NULL + ORDER BY expires +''').fetchall() + +s = discord.session(token) + +def get_all_voters(s, channel, message, answer): + endpoint_base = f'/channels/{channel}/polls/{message}/answers/{answer}?limit=100' + users = discord.get(s, endpoint_base)['users'] + if len(users) < 100: + return users + while True: + new_users = discord.get(s, endpoint_base + f'&after={users[-1]['id']}')['users'] + users += new_users + if len(new_users) < 100: + return users + +for row in rows: + + expires = row[3] + sleep_for = expires - time.time() + if sleep_for > 0: + time.sleep(sleep_for) + + result = None + + time_to_sleep = 1 + while True: + result = discord.get(s, f'/channels/{row[4]}/messages/{row[5]}') + if result['poll']['results']['is_finalized']: + break + time.sleep(time_to_sleep) + time_to_sleep *= 2 + + answer_list_1 = list(filter(lambda x: x['poll_media']['text'] == row[0], result['poll']['answers'])) + answer_list_2 = list(filter(lambda x: x['poll_media']['text'] == row[1], result['poll']['answers'])) + + if len(answer_list_1) != 1 or len(answer_list_2) != 1: + exit(1) + + the_time = time.time() + + voters_1 = get_all_voters(s, row[4], row[5], answer_list_1[0]['answer_id']) + voters_2 = get_all_voters(s, row[4], row[5], answer_list_2[0]['answer_id']) + + cur.executemany('INSERT OR REPLACE INTO users VALUES(?, ?, ?, ?)', + list(map(lambda x: (x['id'], x['global_name'], x['avatar'], the_time), voters_1 + voters_2))) + + cur.execute(''' + UPDATE polls + SET voters_1 = ?, voters_2 = ? + WHERE poll_number = ? + LIMIT 1 + ''', ( + ','.join(map(lambda x: x['id'], voters_1)), + ','.join(map(lambda x: x['id'], voters_2)), + row[2] + )) + + cur.executemany(''' + UPDATE entries + SET status = ? + WHERE name = ? + LIMIT 1 + ''', [ + (0 if len(voters_1) >= len(voters_2) else 2, row[0]), + (0 if len(voters_2) >= len(voters_1) else 2, row[1]) + ]) + + dbcon.commit() @@ -1,45 +1,113 @@ === bracket bot === -before you begin, you will need the following: -* a discord bot token - (see e.g. https://github.com/reactiflux/discord-irc/wiki/Creating-a-discord-bot-&-getting-a-token) -* the channel id of the channel where the polls should be posted - (see https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID) -* a list of entries for your bracket - -to build the bracket bot, just run make. the bracket bot uses libcurl, -libsqlite3, and Niels Lohmann's json library. the json library is downloaded -at build time. the required packages to build on debian are the following: -* g++ -* libcurl-dev (this is a virtual package, satisfiable by a few options) -* libsqlite3-dev -* make -* wget - -when the bot is run, it expects the following environment variables: -* BOT_TOKEN: the bot token -* CHANNEL_ID: the channel id -* POLLS_PER_DAY: the number of polls to post each day -* POST_TIME_UTC: the number of seconds past midnight utc to post the polls - (for example, to post at midnight eastern daylight time, set this to 14400) -and it expects to find a list of entries, separated by newlines, in a file -named entries.txt in the directory where it is run. - -the bot creates a sqlite3 database in database.db, with the following tables: -* misc: this contains one column and one row with the current round number -* entries: a list of every entry - * name: the name of the entry - * round: if the entry has been eliminated, a zero. if the entry has passed - the current round, then the number of the next round. if the entry has not - appeared in a poll this round, or is in an active poll, then the number of - the current round. - * in_active_poll: if the entry is in an active poll, a one, otherwise a zero -* active_polls: a list of the message ids for the active polls -* past_polls: a list of every concluded poll - * round: the round where this poll happened - * entry_1: the name of the first entry - * entry_2: the name of the second entry - * users_1: the user ids of the users who voted - for the first entry, separated by commas - * users_2: the user ids of the users who voted - for the second entry, separated by commas + this is a system for running a bracket in discord via polls + +=== usage === + + == requirements == + + this project uses python with the requests library. assuming you have the + pip and venv modules for python, you can set up an environment like so: + + python3 -m venv env + env/bin/pip3 install -r requirements.txt + + == create database == + + next, we need to make a csv file with a list of entries in our bracket. + each line of the csv file corresponds to one entry. if a line has one + column, the text in that column is the name of the entry. if a line has + two columns, the text in the first column is the name of the entry, and + the text in the second column is an associated emoji. this can be a unicode + emoji (at time of writing, discord supports up to unicode 15.0), or it can + be the id of a server emoji that your bot will have access to. + + now, use the make-db.py script to create the database. there are two + positional arguments, first the path where the database will be created, + and second the path to the csv file to read. there is one optional + argument, "-s". if this argument is present, the entries are shuffled, + and otherwise the order from the csv file is preserved. + + for example, if the csv file we made is in entries.csv, we want to put the + database in database.db, and we want to shuffle the entries, we can run: + + python3 make-db.py database.db entries.csv -s + + we do not need to use our venv environment when we run this. + + == posting a poll == + + to post a poll, use the post-poll.py script. this has three positional + arguments. first, the path to the database. second, the id of the channel + to post in. third, the number of hours to keep the poll open for. the + script also expects an environment variable named TOKEN with the bot + token. with the setup above, if we want a bot with token ABC123DEF456 + to post a 24-hour poll in a channel whose id is 1234567890, we can run: + + TOKEN=ABC123DEF456 env/bin/python3 post-poll.py database.db 1234567890 24 + + the script finds two entries that have not been eliminated and are not + currently in any (unprocessed) polls. entries are picked first preferring + entries that have never been in a poll in the order from the database + creation step, and then entries that have been in polls in the order that + the most recent polls they were each in in occurred. for two entries that + have the same most recent poll (i.e. ones where the most recent poll was a + tie), the order from the database creation step is used. if there are not + two qualified entries, the script just prints a message and quits. + + == processing polls == + + to process the posted polls, use the process-polls.py script. this script + has one positional argument, the path to the database. it also expects the + environment variable TOKEN as above. with the setup above, to have the same + bot process all of its polls, we can run: + + TOKEN=ABC123DEF456 env/bin/python3 process-polls.py database.db + + the script waits until every posted poll has both expired and finalized. + + == cron script == + + it is recommended to call a script like this from a crontab once per day: + + start_time=$(date +%s) + cd bracket-bot + export TOKEN=ABC123DEF456 + env/bin/python3 process-polls.py database.db + now_time=$(date +%s) + hours=$(echo \($start_time + 86400 - $now_time\) / 3600 | bc) + env/bin/python3 post-poll.py database.db 1234567890 $hours + + this script posts one poll that lasts as long as it can while expiring + within 24 hours of this script starting. to post more than one poll per + day, you can put the last three lines in a loop that runs some number of + times. change the bot token, the channel, and the paths as needed. + +=== database schema === + + == table entries == + + name TEXT UNIQUE NOT NULL - json string representing the name of the entry + emoji TEXT - either null, a unicode emoji, or a discord emoji id + status INT NOT NULL - 0 = safe, 1 = in poll, 2 = eliminated + last_poll_number INT NOT NULL - poll_number for the last (or current) poll it was in, or 0 if none + sort_number INT UNIQUE NOT NULL - a number used to sort the entries (the values don't matter, just the order) + + == table polls == + + poll_number INT UNIQUE NOT NULL - what number poll it is (starts at 1) + entry_1 TEXT NOT NULL - entries.name for first entry + entry_2 TEXT NOT NULL - entries.name for second entry + posted INT NOT NULL - unix timestamp of when poll was posted + expires INT NOT NULL - unix timestamp of when poll expires / expired + channel_id TEXT NOT NULL - discord channel with poll + message_id TEXT NOT NULL - discord message with poll + voters_1 TEXT - comma-separated user ids who voted for first entry, or null if active + voters_2 TEXT - comma-separated user ids who voted for second entry, or null if active + + == table users == + + id TEXT UNIQUE NOT NULL - discord id + global_display TEXT NOT NULL - global display name, or username if there is none + avatar_hash TEXT - the user's avatar hash on discord's cdn, or null if they haven't set one + retrieved INT NOT NULL - unix timestamp of when this was last updated diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0d79018 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +certifi==2024.8.30 +charset-normalizer==3.3.2 +idna==3.8 +requests==2.32.3 +urllib3==2.2.2 diff --git a/source.cpp b/source.cpp deleted file mode 100644 index 1894a71..0000000 --- a/source.cpp +++ /dev/null @@ -1,389 +0,0 @@ -#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); - - } -} - -int seconds_to_midnight() { - int seconds = (post_time_utc - time(0)) % 86400; - return seconds < 0 ? seconds + 86400 : seconds; -} - -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", seconds_to_midnight() / 3600 } } } }; - - 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()); -} - -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(seconds_to_midnight()); - sleep(2); - - 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:"; - std::string msg_backup = msg; - for (int i = left_for_this_round.size() - 1; i >= 0; --i) - msg += "\n* \"" + left_for_this_round[i] + "\""; - if (msg.size() > 2000) { - msg = std::move(msg_backup); - msg.back() = '.'; - } - 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; - -} |