From 092e818990e2c4029ac3e1829a0385067c6f9c4b Mon Sep 17 00:00:00 2001 From: Interitio Date: Fri, 13 Jun 2025 23:47:51 +1000 Subject: [PATCH] api: Add event logging via webhook. --- src/datamodels.py | 58 +++++++++++++++++++++++++++++++ src/routes/documents.py | 72 ++++++++++++++++++++++++++++++++++++-- src/routes/events.py | 76 +++++++++++++++++++++++++++++++++++++++-- src/routes/lib.py | 13 ++++++- src/routes/users.py | 42 +++++++++++++++++++++-- 5 files changed, 254 insertions(+), 7 deletions(-) diff --git a/src/datamodels.py b/src/datamodels.py index 3face79..152cf91 100644 --- a/src/datamodels.py +++ b/src/datamodels.py @@ -1,4 +1,8 @@ +from io import BytesIO +import base64 from enum import Enum +from typing import NamedTuple + from data import Registry, RowModel, Table, RegisterEnum from data.columns import Integer, String, Timestamp, Column @@ -9,6 +13,52 @@ class EventType(Enum): CHEER = 'cheer', PLAIN = 'plain', + def info(self): + if self is EventType.SUBSCRIBER: + info = EventTypeInfo( + EventType.SUBSCRIBER, + DataModel.subscriber_events, + ("tier", "subscribed_length", "message"), + ("tier", "subscribed_length", "message"), + ('subscriber_tier', 'subscriber_length', 'subscriber_message'), + ) + elif self is EventType.RAID: + info = EventTypeInfo( + EventType.RAID, + DataModel.raid_events, + ('visitor_count',), + ('viewer_count',), + ('raid_visitor_count',), + ) + elif self is EventType.CHEER: + info = EventTypeInfo( + EventType.CHEER, + DataModel.cheer_events, + ('amount', 'cheer_type', 'message'), + ('amount', 'cheer_type', 'message'), + ('cheer_amount', 'cheer_type', 'cheer_message'), + ) + elif self is EventType.PLAIN: + info = EventTypeInfo( + EventType.PLAIN, + DataModel.plain_events, + ('message',), + ('message',), + ('plain_message',), + ) + else: + raise ValueError("Unexpected event type.") + return info + + + +class EventTypeInfo(NamedTuple): + typ: EventType + table: Table + columns: tuple[str, ...] + params: tuple[str, ...] + detailcolumns: tuple[str, ...] + class DataModel(Registry): _EventType = RegisterEnum(EventType, 'EventType') @@ -118,6 +168,14 @@ class DataModel(Registry): metadata = String() created_at = Timestamp() + def to_bytes(self): + """ + Helper method to decode the saved document data to a byte string. + This may fail if the saved string is not base64 encoded. + """ + byts = BytesIO(base64.b64decode(self.document_data)) + return byts + class DocumentStamp(RowModel): """ Schema diff --git a/src/routes/documents.py b/src/routes/documents.py index faa8c65..a0fee35 100644 --- a/src/routes/documents.py +++ b/src/routes/documents.py @@ -4,14 +4,17 @@ - `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set. """ import logging +import binascii from datetime import datetime from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List from aiohttp import web +import discord from data import Condition, condition from data.queries import JOINTYPE from datamodels import DataModel +from utils.lib import MessageArgs, tabulate -from .lib import ModelField, datamodelsv +from .lib import ModelField, datamodelsv, event_log from .stamps import Stamp, StampCreateParams, StampEditParams, StampPayload routes = web.RouteTableDef() @@ -153,12 +156,76 @@ class Document: stamp = await Stamp.create(app, **stampdata) stamps.append(stamp) - return cls(app, row) + self = cls(app, row) + # await self.log_create() + return self async def get_stamps(self) -> List[Stamp]: stamprows = await self.data.DocumentStamp.table.fetch_rows_where(document_id=self.row.document_id).order_by('stamp_id') return [Stamp(self.app, row) for row in stamprows] + async def log_create(self, with_image=True): + args = await self.event_log_args(with_image=with_image) + args.kwargs['embed'].title = f"Document #{self.row.document_id} Created!" + try: + await event_log(**args.send_args) + except discord.HTTPException: + if with_image: + # Try again without the image in case that was the issue + await self.log_create(with_image=False) + + async def log_edit(self, with_image=True): + args = await self.event_log_args(with_image=with_image) + args.kwargs['embed'].title = f"Document #{self.row.document_id} Updated!" + try: + await event_log(**args.send_args) + except discord.HTTPException: + if with_image: + # Try again without the image in case that was the issue + await self.log_create(with_image=False) + + async def event_log_args(self, with_image=True) -> MessageArgs: + desc = '\n'.join(await self.tabulate()) + embed = discord.Embed(description=desc, timestamp=self.row.created_at) + embed.set_footer(text='Created At') + args: dict = {'embed': embed} + + if with_image: + try: + imagedata = self.row.to_bytes() + imagedata.seek(0) + embed.set_image(url='attachment://document.png') + args['files'] = [discord.File(imagedata, "document.png")] + except binascii.Error: + # Could not decode base64 + embed.add_field(name='Image', value="Could not decode document data!") + else: + embed.add_field(name='Image', value="Failed to send image!") + return MessageArgs(**args) + + async def tabulate(self): + """ + Present the Document as a discord-readable table. + """ + stamps = await self.get_stamps() + typnames = [] + if stamps: + typs = {stamp.row.stamp_type for stamp in stamps} + for typ in typs: + # Stamp types should be cached so this isn't expensive + typrow = await self.data.StampType.fetch(typ) + typnames.append(typrow.stamp_type_name) + + table = { + 'document_id': f"`{self.row.document_id}`", + 'seal': str(self.row.seal), + 'metadata': f"`{self.row.metadata}`" if self.row.metadata else "No metadata", + 'stamps': ', '.join(f"`{name}`" for name in typnames) if typnames else "No stamps", + 'created_at': discord.utils.format_dt(self.row.created_at, 'F'), + } + return tabulate(*table.items()) + + async def prepare(self) -> DocPayload: stamps = await self.get_stamps() @@ -192,6 +259,7 @@ class Document: for stampdata in new_stamps: stampdata.setdefault('document_id', row.document_id) await Stamp.create(self.app, **stampdata) + await self.log_edit() async def delete(self) -> DocPayload: payload = await self.prepare() diff --git a/src/routes/events.py b/src/routes/events.py index cc0d682..9dcea3d 100644 --- a/src/routes/events.py +++ b/src/routes/events.py @@ -1,15 +1,18 @@ +import binascii import logging from datetime import datetime from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List from aiohttp import web +import discord from data import Condition, condition from data.conditions import NULL from data.queries import JOINTYPE from datamodels import DataModel, EventType from modules.profiles.data import ProfileData +from utils.lib import MessageArgs, tabulate -from .lib import ModelField, datamodelsv, dbvar, profiledatav +from .lib import ModelField, datamodelsv, dbvar, event_log, profiledatav from .specimens import Specimen, SpecimenPayload routes = web.RouteTableDef() @@ -196,8 +199,76 @@ class Event: details = await data.EventDetails.fetch(eventrow.event_id) assert details is not None - return cls(app, details) + self = cls(app, details) + await self.log_create() + return self + async def log_create(self, with_image=True): + args = await self.event_log_args(with_image=with_image) + args.kwargs['embed'].title = f"Event #{self.row.event_id} Created!" + try: + await event_log(**args.send_args) + except discord.HTTPException: + if with_image: + # Try again without the image in case that was the issue + await self.log_create(with_image=False) + + async def log_edit(self, with_image=True): + args = await self.event_log_args(with_image=with_image) + args.kwargs['embed'].title = f"Event #{self.row.event_id} Updated!" + try: + await event_log(**args.send_args) + except discord.HTTPException: + if with_image: + # Try again without the image in case that was the issue + await self.log_create(with_image=False) + + async def event_log_args(self, with_image=True) -> MessageArgs: + desc = '\n'.join(await self.tabulate()) + embed = discord.Embed(description=desc, timestamp=self.row.created_at) + embed.set_footer(text='Created At') + args: dict = {'embed': embed} + + doc = await self.get_document() + if doc is not None: + embed.add_field( + name="Document", + value='\n'.join(await doc.tabulate()), + inline=False + ) + if with_image: + try: + imagedata = doc.row.to_bytes() + imagedata.seek(0) + embed.set_image(url='attachment://document.png') + args['files'] = [discord.File(imagedata, "document.png")] + except binascii.Error: + # Could not decode base64 + embed.add_field(name='Image', value="Could not decode document data!") + else: + embed.add_field(name='Image', value="Failed to send image!") + return MessageArgs(**args) + + async def tabulate(self): + """ + Present the Event as a discord-readable table. + """ + user = await self.get_user() + assert user is not None + + table = { + 'event_id': f"`{self.row.event_id}`", + 'event_type': f"`{self.row.event_type}`", + 'user': f"`{self.row.user_id}` (`{self.row.user_name}`)", + 'document': f"`{self.row.document_id}`", + 'occurred_at': discord.utils.format_dt(self.row.occurred_at, 'F'), + 'created_at': discord.utils.format_dt(self.row.created_at, 'F'), + } + info = self.row.event_type.info() + for col, param in zip(info.detailcolumns, info.params): + value = getattr(self.row, col) + table[param] = f"`{value}`" + return tabulate(*table.items()) async def edit(self, **kwargs): data = self.data @@ -230,6 +301,7 @@ class Event: if typparams: await typtab.update_where(event_id=self.row.event_id).set(**typparams) + await self.log_edit() await self.row.refresh() async def delete(self): diff --git a/src/routes/lib.py b/src/routes/lib.py index 997229e..7776f82 100644 --- a/src/routes/lib.py +++ b/src/routes/lib.py @@ -1,9 +1,11 @@ from typing import NamedTuple, Any, Optional, Self, Unpack, List, TypedDict -from aiohttp import web +from aiohttp import web, ClientSession +from discord import Webhook from data.database import Database from datamodels import DataModel from modules.profiles.data import ProfileData +from meta import conf dbvar = web.AppKey("database", Database) datamodelsv = web.AppKey("datamodels", DataModel) @@ -16,3 +18,12 @@ class ModelField(NamedTuple): required: bool can_create: bool can_edit: bool + + +async def event_log(*args, **kwargs): + # Post the given message to the configured event log, if set + event_log_url = conf.api.get('EVENTLOG') + if event_log_url: + async with ClientSession() as session: + webhook = Webhook.from_url(event_log_url, session=session) + await webhook.send(**kwargs) diff --git a/src/routes/users.py b/src/routes/users.py index ec069c3..4504a32 100644 --- a/src/routes/users.py +++ b/src/routes/users.py @@ -11,14 +11,16 @@ import logging from datetime import datetime from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List from aiohttp import web +import discord from data import Condition, condition from data.conditions import NULL from data.queries import JOINTYPE from datamodels import DataModel from modules.profiles.data import ProfileData +from utils.lib import MessageArgs, tabulate -from .lib import ModelField, datamodelsv, dbvar, profiledatav +from .lib import ModelField, datamodelsv, dbvar, event_log, profiledatav from .specimens import Specimen, SpecimenPayload routes = web.RouteTableDef() @@ -141,6 +143,7 @@ class User: raise web.HTTPBadRequest(text="Invalid 'twitch_id' passed to user creation!") # First check if the profile already exists by querying the Dreamer database + edited = 0 # 0 means not edited, 1 means created, 2 means modified rows = await data.Dreamer.fetch_where(twitch_id=twitch_id) if rows: logger.debug(f"Updating Dreamer for {twitch_id=} with {kwargs}") @@ -150,6 +153,7 @@ class User: if dreamer.preferences is None and dreamer.name is None: await data.UserPreferences.fetch_or_create(dreamer.user_id, twitch_name=name, preferences=prefs) dreamer = await dreamer.refresh() + edited = 2 # Now compare the existing data against the provided data and update if needed if name != dreamer.name: @@ -159,6 +163,7 @@ class User: q.set(preferences=prefs) await q dreamer = await dreamer.refresh() + edited = 2 else: # Create from scratch logger.info(f"Creating Dreamer for {twitch_id=} with {kwargs}") @@ -176,8 +181,16 @@ class User: ) dreamer = await data.Dreamer.fetch(user_profile.profileid) assert dreamer is not None + edited = 1 - return cls(app, dreamer) + self = cls(app, dreamer) + if edited == 1: + args = await self.event_log_args(title=f"User #{dreamer.user_id} created!") + await event_log(**args.send_args) + elif edited == 2: + args = await self.event_log_args(title=f"User #{dreamer.user_id} updated!") + await event_log(**args.send_args) + return self async def edit(self, **kwargs: Unpack[UserEditParams]): data = self.data @@ -193,6 +206,9 @@ class User: logger.info(f"Updating dreamer {self.row=} with {kwargs}") await prefs.update(**update_args) + args = await self.event_log_args(title=f"User #{self.row.user_id} updated!") + await event_log(**args.send_args) + async def delete(self) -> UserDetailsPayload: payload = await self.prepare(details=True) # This will cascade to all other data the user has @@ -225,6 +241,28 @@ class User: async def get_inventory(self): return [] + async def event_log_args(self, **kwargs) -> MessageArgs: + desc = '\n'.join(await self.tabulate()) + embed = discord.Embed(description=desc, timestamp=self.row.created_at, **kwargs) + embed.set_footer(text='Created At') + + # TODO: We could add wallet, specimen, and inventory info here too + return MessageArgs(embed=embed) + + async def tabulate(self): + """ + Present the User as a discord-readable table. + """ + table = { + 'user_id': f"`{self.row.user_id}`", + 'twitch_id': f"`{self.row.twitch_id}`" if self.row.twitch_id else 'No Twitch linked', + 'name': f"`{self.row.name}`", + 'preferences': f"`{self.row.preferences}`", + 'created_at': discord.utils.format_dt(self.row.created_at, 'F'), + } + return tabulate(*table.items()) + + @overload async def prepare(self, details: Literal[True]=True) -> UserDetailsPayload: ...