Implemented backend for #4

This commit is contained in:
2026-03-07 17:45:42 +00:00
parent fa94974239
commit 21c78ccce5
2 changed files with 239 additions and 0 deletions

237
blueprints/booper.py Normal file
View File

@ -0,0 +1,237 @@
"""
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:
_log.debug('Threshold reached.')
# 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)
# Update the global counter
#db.GLOBAL.execute('UPDATE booper SET count=? WHERE id=1', (booperBP.boops,))
return {'success': True,
'total': booperBP.getTotalBoops(name),
'24h': booperBP.get24hBoops(name)}