api: Add event logging via webhook.

This commit is contained in:
2025-06-13 23:47:51 +10:00
parent 2bf95beaae
commit 092e818990
5 changed files with 254 additions and 7 deletions

View File

@@ -1,4 +1,8 @@
from io import BytesIO
import base64
from enum import Enum from enum import Enum
from typing import NamedTuple
from data import Registry, RowModel, Table, RegisterEnum from data import Registry, RowModel, Table, RegisterEnum
from data.columns import Integer, String, Timestamp, Column from data.columns import Integer, String, Timestamp, Column
@@ -9,6 +13,52 @@ class EventType(Enum):
CHEER = 'cheer', CHEER = 'cheer',
PLAIN = 'plain', 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): class DataModel(Registry):
_EventType = RegisterEnum(EventType, 'EventType') _EventType = RegisterEnum(EventType, 'EventType')
@@ -118,6 +168,14 @@ class DataModel(Registry):
metadata = String() metadata = String()
created_at = Timestamp() 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): class DocumentStamp(RowModel):
""" """
Schema Schema

View File

@@ -4,14 +4,17 @@
- `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set. - `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set.
""" """
import logging import logging
import binascii
from datetime import datetime from datetime import datetime
from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List
from aiohttp import web from aiohttp import web
import discord
from data import Condition, condition from data import Condition, condition
from data.queries import JOINTYPE from data.queries import JOINTYPE
from datamodels import DataModel 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 from .stamps import Stamp, StampCreateParams, StampEditParams, StampPayload
routes = web.RouteTableDef() routes = web.RouteTableDef()
@@ -153,12 +156,76 @@ class Document:
stamp = await Stamp.create(app, **stampdata) stamp = await Stamp.create(app, **stampdata)
stamps.append(stamp) stamps.append(stamp)
return cls(app, row) self = cls(app, row)
# await self.log_create()
return self
async def get_stamps(self) -> List[Stamp]: 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') 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] 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: async def prepare(self) -> DocPayload:
stamps = await self.get_stamps() stamps = await self.get_stamps()
@@ -192,6 +259,7 @@ class Document:
for stampdata in new_stamps: for stampdata in new_stamps:
stampdata.setdefault('document_id', row.document_id) stampdata.setdefault('document_id', row.document_id)
await Stamp.create(self.app, **stampdata) await Stamp.create(self.app, **stampdata)
await self.log_edit()
async def delete(self) -> DocPayload: async def delete(self) -> DocPayload:
payload = await self.prepare() payload = await self.prepare()

View File

@@ -1,15 +1,18 @@
import binascii
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
from aiohttp import web from aiohttp import web
import discord
from data import Condition, condition from data import Condition, condition
from data.conditions import NULL from data.conditions import NULL
from data.queries import JOINTYPE from data.queries import JOINTYPE
from datamodels import DataModel, EventType from datamodels import DataModel, EventType
from modules.profiles.data import ProfileData 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 from .specimens import Specimen, SpecimenPayload
routes = web.RouteTableDef() routes = web.RouteTableDef()
@@ -196,8 +199,76 @@ class Event:
details = await data.EventDetails.fetch(eventrow.event_id) details = await data.EventDetails.fetch(eventrow.event_id)
assert details is not None 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): async def edit(self, **kwargs):
data = self.data data = self.data
@@ -230,6 +301,7 @@ class Event:
if typparams: if typparams:
await typtab.update_where(event_id=self.row.event_id).set(**typparams) await typtab.update_where(event_id=self.row.event_id).set(**typparams)
await self.log_edit()
await self.row.refresh() await self.row.refresh()
async def delete(self): async def delete(self):

View File

@@ -1,9 +1,11 @@
from typing import NamedTuple, Any, Optional, Self, Unpack, List, TypedDict 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 data.database import Database
from datamodels import DataModel from datamodels import DataModel
from modules.profiles.data import ProfileData from modules.profiles.data import ProfileData
from meta import conf
dbvar = web.AppKey("database", Database) dbvar = web.AppKey("database", Database)
datamodelsv = web.AppKey("datamodels", DataModel) datamodelsv = web.AppKey("datamodels", DataModel)
@@ -16,3 +18,12 @@ class ModelField(NamedTuple):
required: bool required: bool
can_create: bool can_create: bool
can_edit: 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)

View File

@@ -11,14 +11,16 @@ import logging
from datetime import datetime from datetime import datetime
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
from aiohttp import web from aiohttp import web
import discord
from data import Condition, condition from data import Condition, condition
from data.conditions import NULL from data.conditions import NULL
from data.queries import JOINTYPE from data.queries import JOINTYPE
from datamodels import DataModel from datamodels import DataModel
from modules.profiles.data import ProfileData 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 from .specimens import Specimen, SpecimenPayload
routes = web.RouteTableDef() routes = web.RouteTableDef()
@@ -141,6 +143,7 @@ class User:
raise web.HTTPBadRequest(text="Invalid 'twitch_id' passed to user creation!") raise web.HTTPBadRequest(text="Invalid 'twitch_id' passed to user creation!")
# First check if the profile already exists by querying the Dreamer database # 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) rows = await data.Dreamer.fetch_where(twitch_id=twitch_id)
if rows: if rows:
logger.debug(f"Updating Dreamer for {twitch_id=} with {kwargs}") 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: if dreamer.preferences is None and dreamer.name is None:
await data.UserPreferences.fetch_or_create(dreamer.user_id, twitch_name=name, preferences=prefs) await data.UserPreferences.fetch_or_create(dreamer.user_id, twitch_name=name, preferences=prefs)
dreamer = await dreamer.refresh() dreamer = await dreamer.refresh()
edited = 2
# Now compare the existing data against the provided data and update if needed # Now compare the existing data against the provided data and update if needed
if name != dreamer.name: if name != dreamer.name:
@@ -159,6 +163,7 @@ class User:
q.set(preferences=prefs) q.set(preferences=prefs)
await q await q
dreamer = await dreamer.refresh() dreamer = await dreamer.refresh()
edited = 2
else: else:
# Create from scratch # Create from scratch
logger.info(f"Creating Dreamer for {twitch_id=} with {kwargs}") logger.info(f"Creating Dreamer for {twitch_id=} with {kwargs}")
@@ -176,8 +181,16 @@ class User:
) )
dreamer = await data.Dreamer.fetch(user_profile.profileid) dreamer = await data.Dreamer.fetch(user_profile.profileid)
assert dreamer is not None 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]): async def edit(self, **kwargs: Unpack[UserEditParams]):
data = self.data data = self.data
@@ -193,6 +206,9 @@ class User:
logger.info(f"Updating dreamer {self.row=} with {kwargs}") logger.info(f"Updating dreamer {self.row=} with {kwargs}")
await prefs.update(**update_args) 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: async def delete(self) -> UserDetailsPayload:
payload = await self.prepare(details=True) payload = await self.prepare(details=True)
# This will cascade to all other data the user has # This will cascade to all other data the user has
@@ -225,6 +241,28 @@ class User:
async def get_inventory(self): async def get_inventory(self):
return [] 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 @overload
async def prepare(self, details: Literal[True]=True) -> UserDetailsPayload: async def prepare(self, details: Literal[True]=True) -> UserDetailsPayload:
... ...