234 lines
8.5 KiB
Python
234 lines
8.5 KiB
Python
"""
|
|
This blueprint deals with everything under /api/boop/.
|
|
|
|
Used environment variables:
|
|
BOOPER_TARGETS: A list of comma (`,`) separated names for valid booper targets.
|
|
These names are stripped to only contain alphanumeric characters.
|
|
BOOPER_PRECISION: An integer (default 60) to define how precise the booper is.
|
|
Shorter values are more precise, but take up more space in the database.
|
|
"""
|
|
|
|
from threading import Thread
|
|
import logging
|
|
import string
|
|
import time
|
|
import os
|
|
|
|
import flask
|
|
|
|
import db
|
|
|
|
_log = logging.getLogger('blueprints.booper')
|
|
|
|
def stripName(name: str) -> str:
|
|
"""
|
|
Strips a name to only include ascii characters & digits.
|
|
"""
|
|
|
|
return ''.join([c for c in name if c in string.ascii_letters+string.digits])
|
|
|
|
class Blueprint(flask.Blueprint):
|
|
"""
|
|
Holds all relavent data for this blueprint to function as well as interacting
|
|
directly with the database to store and load data live.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self._currentBoops = {} # Holds all boops in the current precision
|
|
self._totalBoops = {} # Holds full boop totals
|
|
self._boops24h = {} # Holds all boops that happened in the past 24h
|
|
# The format of this is as follows:
|
|
# {'name': [{'ts': ts, 'count': count}, {...}], ...}
|
|
self.precision = int(os.getenv('BOOPER_PRECISION', '60'))
|
|
self._currentTS = 0
|
|
self._runWorker = True
|
|
self._workerThread = None
|
|
|
|
# Load and filter names from `BOOPER_TARGETS` environment variable.
|
|
for name in os.getenv('BOOPER_TARGETS', '').split(','):
|
|
if name == '':
|
|
continue # Ignore empty names
|
|
name = stripName(name)
|
|
self._currentBoops.update({name: 0})
|
|
self._totalBoops.update({name: 0})
|
|
|
|
# Throw a warning if no names are detected
|
|
if not self._currentBoops:
|
|
_log.warning('No names are configured. This blueprint will not function.')
|
|
_log.warning('Please configure BOOPER_TARGETS in your .env file.')
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def _worker(self):
|
|
"""
|
|
This function gets ran as a thread and it handles all time splitting capabilities.
|
|
"""
|
|
|
|
while self._runWorker:
|
|
# Check if we've past the precision threshold
|
|
if time.time() - self._currentTS >= self.precision:
|
|
# Save everything to the database
|
|
self._save()
|
|
# Reset variables
|
|
for name in self._currentBoops:
|
|
self._currentBoops[name] = 0
|
|
self._currentTS = round(time.time())
|
|
# Purge old data from 24h
|
|
self._boops24h = {name: [data for data in self._boops24h[name]
|
|
if time.time() - data['ts'] < 86400]
|
|
for name in self._boops24h}
|
|
time.sleep(1)
|
|
|
|
def _save(self):
|
|
"""
|
|
Saves everything to the database.
|
|
"""
|
|
|
|
for name, count in self._currentBoops.items():
|
|
# Check if this start time exists already
|
|
if db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND startTime=?',
|
|
(name, self._currentTS)).fetchone() is None:
|
|
# If not, create a new row
|
|
db.GLOBAL.execute('INSERT INTO booper (name, startTime, count) VALUES (?, ?, ?)',
|
|
(name, self._currentTS, count))
|
|
else:
|
|
# If it does, edit the existing row
|
|
db.GLOBAL.execute('UPDATE booper SET count=? WHERE name=? AND startTime=?',
|
|
(count, name, self._currentTS))
|
|
|
|
# Save the new totals
|
|
db.GLOBAL.execute('UPDATE booper SET count=? WHERE name=? AND total=1',
|
|
(self._totalBoops[name], name))
|
|
|
|
# Commit everything
|
|
db.GLOBAL.commit()
|
|
|
|
def boop(self, name: str) -> [int, int]:
|
|
"""
|
|
Boops `name`.
|
|
"""
|
|
|
|
self._currentBoops[name] += 1
|
|
self._totalBoops[name] += 1
|
|
self._boops24h[name][-1]['count'] += 1
|
|
|
|
# Override the `register` function from `flask.Blueprint`
|
|
# so we can initialise the database at an appropriate time.
|
|
def register(self, *args, **kwargs):
|
|
super().register(*args, **kwargs)
|
|
|
|
# Initialise the database
|
|
db.GLOBAL.createTable('booper')
|
|
db.GLOBAL.createColumn('booper', 'name STRING')
|
|
db.GLOBAL.createColumn('booper', 'total BOOLEAN DEFAULT false')
|
|
db.GLOBAL.createColumn('booper', 'startTime INTEGER')
|
|
db.GLOBAL.createColumn('booper', 'count INTEGER DEFAULT 0')
|
|
|
|
for name in self._currentBoops:
|
|
# Get the latest data for this name
|
|
latestData = db.GLOBAL.execute('SELECT * FROM booper WHERE name=?'+
|
|
'ORDER BY id DESC LIMIT 1', (name,)).fetchone()
|
|
# If there is data,
|
|
if latestData is not None:
|
|
# Set the current time to the latest time known
|
|
startTime = latestData['startTime']
|
|
if startTime is not None and startTime > self._currentTS:
|
|
self._currentTS = startTime
|
|
# Update the current boop counter
|
|
self._currentBoops[name] = latestData['count']
|
|
# Otherwise,
|
|
else:
|
|
# Set the current timestamp to now
|
|
self._currentTS = round(time.time())
|
|
|
|
# Check for total row
|
|
if db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND total=1',
|
|
(name,)).fetchone() is None:
|
|
# If none is found, create one
|
|
_log.debug('Creating total counter for "%s"...', name)
|
|
db.GLOBAL.addRow('booper',
|
|
{'name': name, 'startTime': round(time.time()), 'total': True})
|
|
self._totalBoops[name] = 0
|
|
else:
|
|
# If one is found, load the data and set the totals
|
|
totalData = db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND total=1 ',
|
|
(name,)).fetchone()
|
|
self._totalBoops[name] = totalData['count']
|
|
|
|
# Load all boops from the past 24h
|
|
boops24h = db.GLOBAL.execute('SELECT * FROM booper WHERE total=0 AND startTime>=?',
|
|
(time.time()-86400,)).fetchall()
|
|
for row in boops24h:
|
|
name = row['name']
|
|
count = row['count']
|
|
ts = row['startTime']
|
|
|
|
if name not in self._boops24h:
|
|
self._boops24h.update({name: []})
|
|
|
|
self._boops24h[name].append({'ts': ts, 'count': count})
|
|
|
|
self._workerThread = Thread(target=self._worker)
|
|
self._workerThread.start()
|
|
|
|
def getTotalBoops(self, name: str) -> int:
|
|
"""
|
|
Returns the total number of boops the user has recieved.
|
|
"""
|
|
|
|
if name not in self.registeredNames:
|
|
raise ValueError(f'Name "{name}" is not registered.')
|
|
|
|
return self._totalBoops[name]
|
|
|
|
def get24hBoops(self, name):
|
|
"""
|
|
Calculates how many boops `name` has received in the past 24h.
|
|
"""
|
|
|
|
if name not in self.registeredNames:
|
|
raise ValueError(f'Name "{name}" is not registered.')
|
|
|
|
return sum(data['count'] for data in self._boops24h[name])
|
|
|
|
def onClose(self):
|
|
"""
|
|
Safely saves and closes the booper.
|
|
Automatically ran by the backend.
|
|
"""
|
|
|
|
_log.info('Stopping worker...')
|
|
self._runWorker = False
|
|
self._workerThread.join()
|
|
_log.info('Saving to database...')
|
|
self._save()
|
|
_log.info('Booper closed successfully.')
|
|
|
|
@property
|
|
def registeredNames(self) -> list[str]:
|
|
"""
|
|
Returns a list of all registered names.
|
|
"""
|
|
|
|
return list(self._currentBoops.keys())
|
|
|
|
booperBP = Blueprint('booperAPI', __name__)
|
|
|
|
@booperBP.route('/api/boop/<name>', methods=['GET', 'POST'], strict_slashes=False)
|
|
def boops(name: str):
|
|
"""
|
|
Handles both GETting and POSTing boops through the boop API endpoint.
|
|
"""
|
|
|
|
name = stripName(name)
|
|
|
|
if name not in booperBP.registeredNames:
|
|
return {'success': False, 'message': f'Name "{name}" is not configured as a target.'}, 404
|
|
|
|
if flask.request.method == 'POST':
|
|
booperBP.boop(name)
|
|
|
|
return {'success': True,
|
|
'total': booperBP.getTotalBoops(name),
|
|
'24h': booperBP.get24hBoops(name)}
|