From 445eccccd6673a9b12d928bed45af3fbfcd186f7 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 14 Jun 2025 03:26:41 +1000 Subject: [PATCH] feat(dreams): Add basic document viewer. --- src/modules/__init__.py | 1 + src/modules/dreamspace/cog.py | 19 +- src/modules/dreamspace/ui/docviewer.py | 171 ++++++++++ src/modules/dreamspace/ui/eventviewer.py | 406 +++++++++++++++++++++++ src/modules/dreamspace/ui/specimen.py | 0 src/modules/profiles/ui/twitchlink.py | 2 + 6 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 src/modules/dreamspace/ui/docviewer.py create mode 100644 src/modules/dreamspace/ui/specimen.py diff --git a/src/modules/__init__.py b/src/modules/__init__.py index 0862cbc..985df0b 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -3,6 +3,7 @@ this_package = 'modules' active = [ '.profiles', '.sysadmin', + '.dreamspace', ] diff --git a/src/modules/dreamspace/cog.py b/src/modules/dreamspace/cog.py index b3d2bf5..976712d 100644 --- a/src/modules/dreamspace/cog.py +++ b/src/modules/dreamspace/cog.py @@ -9,6 +9,7 @@ from meta.logger import log_wrap from utils.lib import utc_now from . import logger +from .ui.docviewer import DocumentViewer class DreamCog(LionCog): @@ -46,8 +47,24 @@ class DreamCog(LionCog): # (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 - # (User): View Specimen + view = DocumentViewer(self.bot, ctx.interaction.user.id, filter=(self.data.Document.document_id == docids)) + await view.run(ctx.interaction) + + # (User): View Specimen information diff --git a/src/modules/dreamspace/ui/docviewer.py b/src/modules/dreamspace/ui/docviewer.py new file mode 100644 index 0000000..1794844 --- /dev/null +++ b/src/modules/dreamspace/ui/docviewer.py @@ -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 [[]] + diff --git a/src/modules/dreamspace/ui/eventviewer.py b/src/modules/dreamspace/ui/eventviewer.py index e69de29..6a4382c 100644 --- a/src/modules/dreamspace/ui/eventviewer.py +++ b/src/modules/dreamspace/ui/eventviewer.py @@ -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() diff --git a/src/modules/dreamspace/ui/specimen.py b/src/modules/dreamspace/ui/specimen.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/profiles/ui/twitchlink.py b/src/modules/profiles/ui/twitchlink.py index 1188437..f7d4734 100644 --- a/src/modules/profiles/ui/twitchlink.py +++ b/src/modules/profiles/ui/twitchlink.py @@ -44,6 +44,7 @@ class TwitchLinkStatic(LeoUI): def __init__(self, **kwargs): super().__init__(**kwargs) self._embed: Optional[discord.Embed] = None + print("INITIALISATION") async def interaction_check(self, interaction: discord.Interaction): return True @@ -76,6 +77,7 @@ class TwitchLinkStatic(LeoUI): async def button_linker(self, interaction: discord.Interaction, btn: Button): # Here we just reply to the interaction with the AuthFlow UI # TODO + print("RESPONDING") flowui = TwitchLinkFlow(interaction.client, interaction.user, callerid=interaction.user.id) await flowui.run(interaction) await flowui.wait()