""" 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/', 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)}