api: Add event logging via webhook.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
|
||||
Reference in New Issue
Block a user