Compare commits

..

5 Commits

Author SHA1 Message Date
445eccccd6 feat(dreams): Add basic document viewer. 2025-06-14 03:26:41 +10:00
6ec500ec87 Add missing CrocBot 2025-06-13 23:57:43 +10:00
8e2bd67efc Skeleton module structure. 2025-06-13 23:50:28 +10:00
092e818990 api: Add event logging via webhook. 2025-06-13 23:47:51 +10:00
2bf95beaae (profiles): Improve logging and error handling. 2025-06-12 23:35:29 +10:00
14 changed files with 1034 additions and 11 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

76
src/meta/CrocBot.py Normal file
View File

@@ -0,0 +1,76 @@
from collections import defaultdict
from typing import TYPE_CHECKING
import logging
import twitchio
from twitchio.ext import commands
from twitchio.ext import pubsub
from twitchio.ext.commands.core import itertools
from data import Database
from .config import Conf
logger = logging.getLogger(__name__)
class CrocBot(commands.Bot):
def __init__(self, *args,
config: Conf,
data: Database,
**kwargs):
super().__init__(*args, **kwargs)
self.config = config
self.data = data
self.pubsub = pubsub.PubSubPool(self)
self._member_cache = defaultdict(dict)
async def event_ready(self):
logger.info(f"Logged in as {self.nick}. User id is {self.user_id}")
async def event_join(self, channel: twitchio.Channel, user: twitchio.User):
self._member_cache[channel.name][user.name] = user
async def event_message(self, message: twitchio.Message):
if message.channel and message.author:
self._member_cache[message.channel.name][message.author.name] = message.author
await self.handle_commands(message)
async def seek_user(self, userstr: str, matching=True, fuzzy=True):
if userstr.startswith('@'):
matching = False
userstr = userstr.strip('@ ')
result = None
if matching and len(userstr) >= 3:
lowered = userstr.lower()
full_matches = []
for user in itertools.chain(*(cmems.values() for cmems in self._member_cache.values())):
matchstr = user.name.lower()
print(matchstr)
if matchstr.startswith(lowered):
result = user
break
if lowered in matchstr:
full_matches.append(user)
if result is None and full_matches:
result = full_matches[0]
print(result)
if result is None:
lookup = userstr
elif result.id is None:
lookup = result.name
else:
lookup = None
if lookup:
found = await self.fetch_users(names=[lookup])
if found:
result = found[0]
# No matches found
return result

View File

@@ -3,6 +3,7 @@ this_package = 'modules'
active = [ active = [
'.profiles', '.profiles',
'.sysadmin', '.sysadmin',
'.dreamspace',
] ]

View File

@@ -0,0 +1,8 @@
import logging
logger = logging.getLogger(__name__)
async def setup(bot):
from .cog import DreamCog
await bot.add_cog(DreamCog(bot))

View File

@@ -0,0 +1,70 @@
import asyncio
import discord
from discord import app_commands as appcmds
from discord.ext import commands as cmds
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from utils.lib import utc_now
from . import logger
from .ui.docviewer import DocumentViewer
class DreamCog(LionCog):
"""
Discord-facting interface for Dreamspace Adventures
"""
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.core.datamodel
async def cog_load(self):
pass
@log_wrap(action="Dreamer migration")
async def migrate_dreamer(self, source_profile, target_profile):
"""
Called when two dreamer profiles need to merge.
For example, when a user links a second twitch profile.
:TODO-MARKER:
Most of the migration logic is simple, e.g. just update the profileid
on the old events to the new profile.
The same applies to transactions and probably to inventory items.
However, there are some subtle choices to make, such as what to do
if both the old and the new profile have an active specimen?
A profile can only have one active specimen at a time.
There is also the question of how to merge user preferences, when those exist.
"""
...
# User command: view their dreamer card, wallet inventory etc
# (Admin): View events/documents matching certain criteria
# (User): View own event cards with info?
# Let's make a demo viewer which lists their event cards and let's them open one via select?
# /documents -> Show a paged list of documents, select option displays the document in a viewer
@cmds.hybrid_command(
name='documents',
description="View your printer log!"
)
async def documents_cmd(self, ctx: LionContext):
profile = await self.bot.get_cog('ProfileCog').fetch_profile_discord(ctx.author)
events = await self.data.Events.fetch_where(user_id=profile.profileid)
docids = [event.document_id for event in events if event.document_id is not None]
if not docids:
await ctx.error_reply("You don't have any documents yet!")
return
view = DocumentViewer(self.bot, ctx.interaction.user.id, filter=(self.data.Document.document_id == docids))
await view.run(ctx.interaction)
# (User): View Specimen information

View File

@@ -0,0 +1,36 @@
from datamodels import DataModel
class Event:
_typs = {}
def __init__(self, event_row: DataModel.Events, **kwargs):
self.row = event_row
def __getattribute__(self, name: str):
...
async def get_document(self):
...
async def get_user(self):
...
class Document:
def as_bytes(self):
...
async def get_stamps(self):
...
async def refresh(self):
...
class User:
...
class Stamp:
...

View File

@@ -0,0 +1,171 @@
import binascii
from itertools import chain
from typing import Optional
from dataclasses import dataclass
import asyncio
import datetime as dt
import discord
from discord.ui.select import select, Select, UserSelect
from discord.ui.button import button, Button
from discord.ui.text_input import TextInput
from discord.enums import ButtonStyle, TextStyle
from discord.components import SelectOption
from datamodels import DataModel
from meta import LionBot, conf
from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError
from data import ORDER, Condition
from utils.ui import MessageUI, input
from utils.lib import MessageArgs, tabulate, utc_now
from .. import logger
class DocumentViewer(MessageUI):
"""
Simple pager which displays a filtered list of Documents.
"""
block_len = 5
def __init__(self, bot: LionBot, callerid: int, filter: Condition, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.data: DataModel = bot.core.datamodel
self.filter = filter
# Paging state
self._pagen = 0
self.blocks = [[]]
@property
def page_count(self):
return len(self.blocks)
@property
def pagen(self):
self._pagen %= self.page_count
return self._pagen
@pagen.setter
def pagen(self, value):
self._pagen = value % self.page_count
@property
def current_page(self):
return self.blocks[self.pagen]
# ----- UI Components -----
# Page backwards
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen -= 1
await self.refresh(thinking=press)
# Jump to page
@button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple)
async def jump_button(self, press: discord.Interaction, pressed: Button):
"""
Jump to page button.
"""
try:
interaction, value = await input(
press,
title="Jump to page",
question="Page number to jump to"
)
value = value.strip()
except asyncio.TimeoutError:
return
if not value.lstrip('- ').isdigit():
error = discord.Embed(title="Invalid page number, please try again!",
colour=discord.Colour.brand_red())
await interaction.response.send_message(embed=error, ephemeral=True)
else:
await interaction.response.defer(thinking=True)
pagen = int(value.lstrip('- '))
if value.startswith('-'):
pagen = -1 * pagen
elif pagen > 0:
pagen = pagen - 1
self.pagen = pagen
await self.refresh(thinking=interaction)
async def jump_button_refresh(self):
component = self.jump_button
component.label = f"{self.pagen + 1}/{self.page_count}"
component.disabled = (self.page_count <= 1)
# Page forwards
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
async def next_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen += 1
await self.refresh(thinking=press)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
# if self.child_viewer:
# await self.child_viewer.quit()
await self.quit()
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
files = []
embeds = []
for doc in self.current_page:
try:
imagedata = doc.to_bytes()
imagedata.seek(0)
except binascii.Error:
continue
fn = f"doc-{doc.document_id}.png"
file = discord.File(imagedata, fn)
embed = discord.Embed()
embed.set_image(url=f"attachment://{fn}")
files.append(file)
embeds.append(embed)
if not embeds:
embed = discord.Embed(description="You don't have any documents yet!")
embeds.append(embed)
print(f"FILES: {files}")
return MessageArgs(files=files, embeds=embeds)
async def refresh_layout(self):
to_refresh = (
self.jump_button_refresh(),
)
await asyncio.gather(*to_refresh)
if self.page_count > 1:
page_line = (
self.prev_button,
self.jump_button,
self.quit_button,
self.next_button,
)
else:
page_line = (self.quit_button,)
self.set_layout(page_line)
async def reload(self):
docs = await self.data.Document.fetch_where(self.filter).order_by('created_at', ORDER.DESC)
blocks = [
docs[i:i+self.block_len]
for i in range(0, len(docs), self.block_len)
]
self.blocks = blocks or [[]]

View File

@@ -0,0 +1,406 @@
from itertools import chain
from typing import Optional
from dataclasses import dataclass
import asyncio
import datetime as dt
import discord
from discord.ui.select import select, Select, UserSelect
from discord.ui.button import button, Button
from discord.ui.text_input import TextInput
from discord.enums import ButtonStyle, TextStyle
from discord.components import SelectOption
from datamodels import DataModel
from meta import LionBot, conf
from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError
from data import ORDER, Condition
from utils.ui import MessageUI, input
from utils.lib import MessageArgs, tabulate, utc_now
from .. import logger
class EventsUI(MessageUI):
block_len = 10
def __init__(self, bot: LionBot, callerid: int, filter: Condition, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.data: DataModel = bot.core.datamodel
self.filter = Condition
# Paging state
self._pagen = 0
self.blocks = [[]]
@property
def page_count(self):
return len(self.blocks)
@property
def pagen(self):
self._pagen %= self.page_count
return self._pagen
@pagen.setter
def pagen(self, value):
self._pagen = value % self.page_count
@property
def current_page(self):
return self.blocks[self.pagen]
# ----- UI Components -----
# Page backwards
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen -= 1
await self.refresh(thinking=press)
# Jump to page
# Page forwards
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
async def next_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen += 1
await self.refresh(thinking=press)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
# if self.child_viewer:
# await self.child_viewer.quit()
await self.quit()
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
...
async def refresh_layout(self):
...
async def reload(self):
...
class TicketListUI(MessageUI):
# Select Ticket
@select(
cls=Select,
placeholder="TICKETS_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def tickets_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
if selected.values:
ticketid = int(selected.values[0])
ticket = await Ticket.fetch_ticket(self.bot, ticketid)
ticketui = TicketUI(self.bot, ticket, self._callerid)
if self.child_ticket:
await self.child_ticket.quit()
self.child_ticket = ticketui
await ticketui.run(selection)
async def tickets_menu_refresh(self):
menu = self.tickets_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:tickets|placeholder',
"Select Ticket"
))
options = []
for ticket in self.current_page:
option = SelectOption(
label=f"Ticket #{ticket.data.guild_ticketid}",
value=str(ticket.data.ticketid)
)
options.append(option)
menu.options = options
# Backwards
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.pagen -= 1
await self.refresh(thinking=press)
# Jump to page
@button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple)
async def jump_button(self, press: discord.Interaction, pressed: Button):
"""
Jump-to-page button.
Loads a page-switch dialogue.
"""
t = self.bot.translator.t
try:
interaction, value = await input(
press,
title=t(_p(
'ui:tickets|button:jump|input:title',
"Jump to page"
)),
question=t(_p(
'ui:tickets|button:jump|input:question',
"Page number to jump to"
))
)
value = value.strip()
except asyncio.TimeoutError:
return
if not value.lstrip('- ').isdigit():
error_embed = discord.Embed(
title=t(_p(
'ui:tickets|button:jump|error:invalid_page',
"Invalid page number, please try again!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=error_embed, ephemeral=True)
else:
await interaction.response.defer(thinking=True)
pagen = int(value.lstrip('- '))
if value.startswith('-'):
pagen = -1 * pagen
elif pagen > 0:
pagen = pagen - 1
self.pagen = pagen
await self.refresh(thinking=interaction)
async def jump_button_refresh(self):
component = self.jump_button
component.label = f"{self.pagen + 1}/{self.page_count}"
component.disabled = (self.page_count <= 1)
# Forward
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
async def next_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen += 1
await self.refresh(thinking=press)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
if self.child_ticket:
await self.child_ticket.quit()
await self.quit()
# ----- UI Flow -----
def _format_ticket(self, ticket) -> str:
"""
Format a ticket into a single embed line.
"""
components = (
"[#{ticketid}]({link})",
"{created}",
"`{type}[{state}]`",
"<@{targetid}>",
"{content}",
)
formatstr = ' | '.join(components)
data = ticket.data
if not data.content:
content = 'No Content'
elif len(data.content) > 100:
content = data.content[:97] + '...'
else:
content = data.content
ticketstr = formatstr.format(
ticketid=data.guild_ticketid,
link=ticket.jump_url or 'https://lionbot.org',
created=discord.utils.format_dt(data.created_at, 'd'),
type=data.ticket_type.name,
state=data.ticket_state.name,
targetid=data.targetid,
content=content,
)
if data.ticket_state is TicketState.PARDONED:
ticketstr = f"~~{ticketstr}~~"
return ticketstr
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
embed = discord.Embed(
title=t(_p(
'ui:tickets|embed|title',
"Moderation Tickets in {guild}"
)).format(guild=self.guild.name),
timestamp=utc_now()
)
tickets = self.current_page
if tickets:
desc = '\n'.join(self._format_ticket(ticket) for ticket in tickets)
else:
desc = t(_p(
'ui:tickets|embed|desc:no_tickets',
"No tickets matching the given criteria!"
))
embed.description = desc
filterstr = self.filters.formatted()
if filterstr:
embed.add_field(
name=t(_p(
'ui:tickets|embed|field:filters|name',
"Filters"
)),
value=filterstr,
inline=False
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
to_refresh = (
self.edit_filter_button_refresh(),
self.select_ticket_button_refresh(),
self.pardon_button_refresh(),
self.tickets_menu_refresh(),
self.filter_type_menu_refresh(),
self.filter_state_menu_refresh(),
self.filter_target_menu_refresh(),
self.jump_button_refresh(),
)
await asyncio.gather(*to_refresh)
action_line = (
self.edit_filter_button,
self.select_ticket_button,
self.pardon_button,
)
if self.page_count > 1:
page_line = (
self.prev_button,
self.jump_button,
self.quit_button,
self.next_button,
)
else:
page_line = ()
action_line = (*action_line, self.quit_button)
if self.show_filters:
menus = (
(self.filter_type_menu,),
(self.filter_state_menu,),
(self.filter_target_menu,),
)
elif self.show_tickets and self.current_page:
menus = ((self.tickets_menu,),)
else:
menus = ()
self.set_layout(
action_line,
*menus,
page_line,
)
async def reload(self):
tickets = await Ticket.fetch_tickets(
self.bot,
*self.filters.conditions(),
guildid=self.guild.id,
)
blocks = [
tickets[i:i+self.block_len]
for i in range(0, len(tickets), self.block_len)
]
self.blocks = blocks or [[]]
class TicketUI(MessageUI):
def __init__(self, bot: LionBot, ticket: Ticket, callerid: int, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.ticket = ticket
# ----- API -----
# ----- UI Components -----
# Pardon Ticket
@button(
label="PARDON_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def pardon_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
modal_title = t(_p(
'ui:ticket|button:pardon|modal:reason|title',
"Pardon Moderation Ticket"
))
input_field = TextInput(
label=t(_p(
'ui:ticket|button:pardon|modal:reason|field|label',
"Why are you pardoning this ticket?"
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, reason = await input(
press, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise ResponseTimedOut
await interaction.response.defer(thinking=True, ephemeral=True)
await self.ticket.pardon(modid=press.user.id, reason=reason)
await self.refresh(thinking=interaction)
async def pardon_button_refresh(self):
button = self.pardon_button
t = self.bot.translator.t
button.label = t(_p(
'ui:ticket|button:pardon|label',
"Pardon"
))
button.disabled = (self.ticket.data.ticket_state is TicketState.PARDONED)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
return await self.ticket.make_message()
async def refresh_layout(self):
await self.pardon_button_refresh()
self.set_layout(
(self.pardon_button, self.quit_button,)
)
async def reload(self):
await self.ticket.data.refresh()

View File

View File

@@ -44,6 +44,7 @@ class TwitchLinkStatic(LeoUI):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._embed: Optional[discord.Embed] = None self._embed: Optional[discord.Embed] = None
print("INITIALISATION")
async def interaction_check(self, interaction: discord.Interaction): async def interaction_check(self, interaction: discord.Interaction):
return True return True
@@ -71,10 +72,12 @@ class TwitchLinkStatic(LeoUI):
def embed(self, value): def embed(self, value):
self._embed = value self._embed = value
@button(label="Link", custom_id="BTN-LINK-TWITCH", style=ButtonStyle.green, emoji='🔗') @button(label="Connect", custom_id="BTN-LINK-TWITCH", style=ButtonStyle.green, emoji='🔗')
@log_wrap(action="link-twitch-btn")
async def button_linker(self, interaction: discord.Interaction, btn: Button): async def button_linker(self, interaction: discord.Interaction, btn: Button):
# Here we just reply to the interaction with the AuthFlow UI # Here we just reply to the interaction with the AuthFlow UI
# TODO # TODO
print("RESPONDING")
flowui = TwitchLinkFlow(interaction.client, interaction.user, callerid=interaction.user.id) flowui = TwitchLinkFlow(interaction.client, interaction.user, callerid=interaction.user.id)
await flowui.run(interaction) await flowui.run(interaction)
await flowui.wait() await flowui.wait()
@@ -116,7 +119,9 @@ class TwitchLinkFlow(MessageUI):
# This can happen if starting the flow failed # This can happen if starting the flow failed
await self.close() await self.close()
@log_wrap(action="start-twitch-flow-ui")
async def _start_flow(self): async def _start_flow(self):
logger.info(f"Starting twitch authentication flow for {self.user}")
try: try:
self.flow = await self.bot.get_cog('TwitchAuthCog').start_auth() self.flow = await self.bot.get_cog('TwitchAuthCog').start_auth()
except aiohttp.ClientError: except aiohttp.ClientError:
@@ -130,6 +135,7 @@ class TwitchLinkFlow(MessageUI):
self._stage = FlowState.WAITING self._stage = FlowState.WAITING
self._auth_task = asyncio.create_task(self._auth_flow()) self._auth_task = asyncio.create_task(self._auth_flow())
@log_wrap(action="run-twitch-flow-ui")
async def _auth_flow(self): async def _auth_flow(self):
""" """
Run the flow and wait for a timeout, cancellation, or callback. Run the flow and wait for a timeout, cancellation, or callback.
@@ -138,12 +144,12 @@ class TwitchLinkFlow(MessageUI):
assert self.flow is not None assert self.flow is not None
try: try:
# TODO: Cancel this in cleanup # TODO: Cancel this in cleanup
authrow = await asyncio.wait_for(self.flow.run(), timeout=180) authrow = await asyncio.wait_for(self.flow.run(), timeout=60)
except asyncio.TimeoutError: except asyncio.TimeoutError:
self._stage = FlowState.TIMEOUT self._stage = FlowState.TIMEOUT
# Link Timed Out! # Link Timed Out!
self._info = ( self._info = (
"We didn't receive a response for three minutes so we closed the uplink " "We didn't receive a response so we closed the uplink "
"to keep your account safe! If you still want to connect, please try again!" "to keep your account safe! If you still want to connect, please try again!"
) )
await self.refresh() await self.refresh()
@@ -161,6 +167,8 @@ class TwitchLinkFlow(MessageUI):
self._stage = FlowState.CANCELLED self._stage = FlowState.CANCELLED
await self.refresh() await self.refresh()
await self.close() await self.close()
except Exception:
logger.exception("Something unexpected went wrong while running the flow!")
else: else:
self._stage = FlowState.WORKING self._stage = FlowState.WORKING
self._info = ( self._info = (
@@ -265,7 +273,7 @@ class TwitchLinkFlow(MessageUI):
if self._stage is FlowState.WAITING: if self._stage is FlowState.WAITING:
# Message should be the initial request page # Message should be the initial request page
dur = discord.utils.format_dt(utc_now() + timedelta(seconds=179), style='R') dur = discord.utils.format_dt(utc_now() + timedelta(seconds=60), style='R')
title = "Press the button to login!" title = "Press the button to login!"
desc = ( desc = (

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:
... ...