Compare commits
No commits in common. "main" and "v2.x" have entirely different histories.
12 changed files with 451 additions and 460 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -1,5 +1,5 @@
|
||||||
__pycache__/
|
.vscode/
|
||||||
env/
|
bracket-bot
|
||||||
cron.sh
|
|
||||||
database.db
|
database.db
|
||||||
entries.csv
|
entries.txt
|
||||||
|
json.hpp
|
||||||
|
|
31
discord.py
31
discord.py
|
@ -1,31 +0,0 @@
|
||||||
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)
|
|
13
license.txt
Normal file
13
license.txt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
Copyright 2024 Benji Dial
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
|
||||||
|
OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
79
make-db.py
79
make-db.py
|
@ -1,79 +0,0 @@
|
||||||
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) <= 3:
|
|
||||||
entries.append(list(map(lambda x: x.strip(), row)))
|
|
||||||
else:
|
|
||||||
print('encountered a row with more than three 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,
|
|
||||||
description 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) > 1 else None
|
|
||||||
description = row[2] if len(row) > 2 else None
|
|
||||||
if emoji == '':
|
|
||||||
emoji = None
|
|
||||||
if description == '':
|
|
||||||
description = None
|
|
||||||
cur.execute('INSERT INTO entries VALUES(?, ?, ?, 0, 0, ?)', (name, emoji, description, i))
|
|
||||||
|
|
||||||
dbcon.commit()
|
|
5
makefile
Normal file
5
makefile
Normal file
|
@ -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
|
90
post-poll.py
90
post-poll.py
|
@ -1,90 +0,0 @@
|
||||||
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, description 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):
|
|
||||||
if emoji is None:
|
|
||||||
return {'text': name}
|
|
||||||
emoji_obj = {'id': emoji} if emoji.isascii() and emoji.isdigit() else {'name': emoji}
|
|
||||||
return {'text': name, 'emoji': emoji_obj}
|
|
||||||
|
|
||||||
s = discord.session(token)
|
|
||||||
e = f'/channels/{a.channel}/messages'
|
|
||||||
|
|
||||||
result = discord.post(s, e, {
|
|
||||||
'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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if rows[0][2] is not None:
|
|
||||||
discord.post(s, e, {'content': rows[0][2]})
|
|
||||||
|
|
||||||
if rows[1][2] is not None:
|
|
||||||
discord.post(s, e, {'content': rows[1][2]})
|
|
||||||
|
|
||||||
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()
|
|
17
post-text.py
17
post-text.py
|
@ -1,17 +0,0 @@
|
||||||
from os import environ
|
|
||||||
import argparse
|
|
||||||
import discord
|
|
||||||
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument('channel', help='the discord id of the channel to post to')
|
|
||||||
ap.add_argument('text', help='the text to post')
|
|
||||||
|
|
||||||
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']
|
|
||||||
|
|
||||||
discord.post(discord.session(token), f'/channels/{a.channel}/messages', {'content': a.text})
|
|
100
process-polls.py
100
process-polls.py
|
@ -1,100 +0,0 @@
|
||||||
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'])
|
|
||||||
|
|
||||||
def user_to_params(user):
|
|
||||||
name = user['global_name']
|
|
||||||
if name is None:
|
|
||||||
name = user['username']
|
|
||||||
return (user['id'], name, user['avatar'], the_time)
|
|
||||||
|
|
||||||
cur.executemany('INSERT OR REPLACE INTO users VALUES(?, ?, ?, ?)',
|
|
||||||
list(map(user_to_params, 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()
|
|
153
readme.txt
153
readme.txt
|
@ -1,118 +1,45 @@
|
||||||
=== bracket bot ===
|
=== bracket bot ===
|
||||||
|
|
||||||
this is a system for running a bracket in discord via polls. the python files
|
before you begin, you will need the following:
|
||||||
in this repository are released under the unlicense (see unlicense.txt).
|
* 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
|
||||||
|
|
||||||
=== usage ===
|
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
|
||||||
|
|
||||||
== requirements ==
|
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.
|
||||||
|
|
||||||
this project uses python with the requests library. assuming you have the
|
the bot creates a sqlite3 database in database.db, with the following tables:
|
||||||
pip and venv modules for python, you can set up an environment like so:
|
* misc: this contains one column and one row with the current round number
|
||||||
|
* entries: a list of every entry
|
||||||
python3 -m venv env
|
* name: the name of the entry
|
||||||
env/bin/pip3 install -r requirements.txt
|
* 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
|
||||||
== create database ==
|
appeared in a poll this round, or is in an active poll, then the number of
|
||||||
|
the current round.
|
||||||
next, we need to make a csv file with a list of entries in our bracket.
|
* in_active_poll: if the entry is in an active poll, a one, otherwise a zero
|
||||||
each line of the csv file corresponds to one entry. the first column has
|
* active_polls: a list of the message ids for the active polls
|
||||||
the name of the entry. the second column has an associated emoji. this can
|
* past_polls: a list of every concluded poll
|
||||||
be a unicode emoji (at the time of writing, discord supports up to unicode
|
* round: the round where this poll happened
|
||||||
15.0), or it can be the id of a server emoji that your bot will have access
|
* entry_1: the name of the first entry
|
||||||
to. the third column is a description for the entry, which is allowed to
|
* entry_2: the name of the second entry
|
||||||
use discord's markdown-like formatting.
|
* users_1: the user ids of the users who voted
|
||||||
|
for the first entry, separated by commas
|
||||||
now, use the make-db.py script to create the database. there are two
|
* users_2: the user ids of the users who voted
|
||||||
positional arguments, first the path where the database will be created,
|
for the second entry, separated by commas
|
||||||
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. the two
|
|
||||||
descriptions are posted after the poll. if neither entry has a description,
|
|
||||||
no description is posted. if only one entry has a description, only the one
|
|
||||||
description is posted.
|
|
||||||
|
|
||||||
== 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
|
|
||||||
description TEXT - either null or a description to post with any polls that have this entry
|
|
||||||
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
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
certifi==2024.8.30
|
|
||||||
charset-normalizer==3.3.2
|
|
||||||
idna==3.8
|
|
||||||
requests==2.32.3
|
|
||||||
urllib3==2.2.2
|
|
389
source.cpp
Normal file
389
source.cpp
Normal file
|
@ -0,0 +1,389 @@
|
||||||
|
#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;
|
||||||
|
|
||||||
|
}
|
|
@ -1,21 +0,0 @@
|
||||||
This is free and unencumbered software released into the public domain.
|
|
||||||
|
|
||||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
|
|
||||||
software, either in source code form or as a compiled binary, for any purpose,
|
|
||||||
commercial or non-commercial, and by any means.
|
|
||||||
|
|
||||||
In jurisdictions that recognize copyright laws, the author or authors of this
|
|
||||||
software dedicate any and all copyright interest in the software to the public
|
|
||||||
domain. We make this dedication for the benefit of the public at large and to
|
|
||||||
the detriment of our heirs and successors. We intend this dedication to be an
|
|
||||||
overt act of relinquishment in perpetuity of all present and future rights to
|
|
||||||
this software under copyright law.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
||||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
For more information, please refer to <http://unlicense.org/>
|
|
Loading…
Add table
Reference in a new issue