summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--discord.py31
-rw-r--r--make-db.py73
-rw-r--r--makefile5
-rw-r--r--post-poll.py79
-rw-r--r--process-polls.py94
-rw-r--r--readme.txt154
-rw-r--r--requirements.txt5
-rw-r--r--source.cpp389
9 files changed, 397 insertions, 441 deletions
diff --git a/.gitignore b/.gitignore
index cd1dce4..3d0f568 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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()
diff --git a/readme.txt b/readme.txt
index c6c1e59..080d379 100644
--- a/readme.txt
+++ b/readme.txt
@@ -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;
-
-}