From d9fa5c46836feb09277c06195854797c5b2aa587 Mon Sep 17 00:00:00 2001 From: Interitio Date: Thu, 28 Aug 2025 15:42:53 +1000 Subject: [PATCH] Implement quotes. --- __init__.py | 1 + data/{schema.sql => quotes.sql} | 4 +- quotes/data.py | 4 +- quotes/discord/cog.py | 214 ++++++++++++++++++++++--- quotes/discord/ui/__init__.py | 1 + quotes/discord/ui/quotelist.py | 272 ++++++++++++++++++++++++++++++++ quotes/lib.py | 19 +++ quotes/quotes.py | 8 +- src/modules/profiles | 1 + 9 files changed, 496 insertions(+), 28 deletions(-) rename data/{schema.sql => quotes.sql} (82%) create mode 100644 quotes/discord/ui/__init__.py create mode 100644 quotes/discord/ui/quotelist.py create mode 160000 src/modules/profiles diff --git a/__init__.py b/__init__.py index e69de29..c3d2020 100644 --- a/__init__.py +++ b/__init__.py @@ -0,0 +1 @@ +from .quotes import setup diff --git a/data/schema.sql b/data/quotes.sql similarity index 82% rename from data/schema.sql rename to data/quotes.sql index 2a7f988..4363a45 100644 --- a/data/schema.sql +++ b/data/quotes.sql @@ -8,6 +8,7 @@ CREATE TABLE quotes( communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE, content TEXT NOT NULL, created_by INTEGER REFERENCES user_profiles(profileid) ON UPDATE CASCADE ON DELETE NO ACTION, + deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -19,7 +20,8 @@ CREATE VIEW AS SELECT *, - row_number() + 1 OVER (PARITION BY communityid ORDER BY created_at ASC) + (deleted_at is not NULL) AS is_deleted, + (row_number() OVER (PARTITION BY communityid ORDER BY created_at ASC)) as quotelabel FROM quotes ORDER BY (communityid, created_at); diff --git a/quotes/data.py b/quotes/data.py index ab6ad21..7b90a53 100644 --- a/quotes/data.py +++ b/quotes/data.py @@ -1,5 +1,5 @@ from data import Registry, RowModel, Table -from data.columns import Integer, Timestamp, String +from data.columns import Bool, Integer, Timestamp, String from weakref import WeakValueDictionary @@ -10,6 +10,7 @@ class Quote(RowModel): quoteid = Integer(primary=True) communityid = Integer() content = String() + deleted_at = Timestamp() created_by = Integer() created_at = Timestamp() _timestamp = Timestamp() @@ -21,6 +22,7 @@ class QuoteInfo(Quote): _cache_ = WeakValueDictionary() quotelabel = Integer() + is_deleted = Bool() class QuotesData(Registry): diff --git a/quotes/discord/cog.py b/quotes/discord/cog.py index 8034905..ddb498e 100644 --- a/quotes/discord/cog.py +++ b/quotes/discord/cog.py @@ -1,20 +1,27 @@ from typing import Optional import asyncio +import random import discord +from discord.enums import TextStyle from discord.ext import commands as cmds from discord import app_commands as appcmds from meta import LionBot, LionCog, LionContext from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError +from utils.ui import input + +from ..data import QuoteInfo, QuotesData +from ..quotes import QuoteRegistry +from ..lib import minify from . import logger -from ..data import QuotesData -from ..quotes import QuoteRegistry +from .ui import QuoteListUI class QuoteCog(LionCog): def __init__(self, bot: LionBot): + self.bot = bot self.data = bot.db.load_registry(QuotesData()) self.quotes = QuoteRegistry(self.data) @@ -24,31 +31,106 @@ class QuoteCog(LionCog): # ----- API ----- async def quote_acmpl(self, interaction: discord.Interaction, partial: str): - # TODO - ... + """ + Autocomplete for a community-local quote selection. + """ + if not interaction.guild: + return [] + + choices = [] + community = await self.bot.profiles.fetch_community( + interaction.guild, interaction=interaction, + touch=False + ) + quotes = await self.quotes.get_community_quotes(community.communityid) + if not quotes: + nullchoice = appcmds.Choice( + name="No quotes have been created!", + value="0", + ) + choices.append(nullchoice) + else: + for quote in quotes: + labelstr = f"#{quote.quotelabel}:" + if partial.lower() in labelstr + quote.content.lower(): + minified = minify(quote.content, 100 - len(labelstr) - 1, strip=' >') + displayed = f"{labelstr} {minified}" + choice = appcmds.Choice( + name=displayed, + value=str(quote.quotelabel), + ) + choices.append(choice) + if not choices: + nullchoice = appcmds.Choice( + name="No quotes matching your input!", + value="0", + ) + choices.append(nullchoice) + + return choices + + async def resolve_quote(self, ctx: LionContext, quotestr: str) -> QuoteInfo: + """ + Resolve a quote string provided as an argument. + + Essentially only accepts integer quote labels. + """ + quotestr = quotestr.strip('# ') + if not quotestr.isdigit(): + raise UserInputError( + "Could not parse desired quote! Please enter the number or select from autocomplete options." + ) + elif (label := int(quotestr)) == 0: + raise UserInputError( + "Invalid option selected!" + ) + else: + quote = await self.quotes.get_quote_label(ctx.community.communityid, label) + if not quote: + raise UserInputError( + f"Quote #{label} does not exist!" + ) + else: + return quote # ----- Commands ------ @cmds.hybrid_command( name='quote', description="Display a random quote." ) + @cmds.guild_only() async def quote_cmd(self, ctx: LionContext): - # TODO - ... + quotes = await self.quotes.get_community_quotes(ctx.community.communityid) + if quotes: + # Select a random quote + quote = random.choice(quotes) + if '\n' in quote.content: + formatted = f"**#{quote.quotelabel}:**\n{quote.content}" + else: + formatted = f"**#{quote.quotelabel}:** {quote.content}" + await ctx.reply(formatted) + else: + await ctx.reply("There are no quotes to display!") @cmds.hybrid_group( name='quotes', description="Base command group for quotes management.", ) + @cmds.has_permissions(manage_guild=True) + @cmds.guild_only() async def quotes_grp(self, ctx: LionContext): - # TODO - # Call the quotes list command - ... + await self.quotes_list_cmd(ctx) @cmds.hybrid_command( name='addquote', description="Create a new quote. Use without arguments to add a multiline quote." ) + @appcmds.describe( + content="Content of the quote to add" + ) + async def addquote_cmd(self, ctx: LionContext, *, content: Optional[str] = None): + await self.quotes_add_cmd(ctx, content=content) + @quotes_grp.command( name='add', description="Create a new quote. Use without arguments to add a multiline quote." @@ -56,37 +138,121 @@ class QuoteCog(LionCog): @appcmds.describe( content="Content of the quote to add" ) - async def quotes_add_cmd(self, ctx: LionContext, content: Optional[str]): - # TODO - ... + async def quotes_add_cmd(self, ctx: LionContext, *, content: Optional[str] = None): + if content is not None: + interaction = ctx.interaction + to_create = content + elif not ctx.interaction: + invoked = ' '.join( + (*ctx.invoked_parents, ctx.invoked_with or '') + ) + await ctx.error_reply( + "**USAGE:** {prefix}{cmd} ".format( + prefix=ctx.prefix, + cmd=invoked + ) + ) + raise SafeCancellation + else: + try: + interaction, to_create = await input( + interaction=ctx.interaction, + title="Create Quote", + question="Quote Content", + timeout=300, + style=TextStyle.long, + min_length=1, + max_length=2000 - 8 + ) + except asyncio.TimeoutError: + raise SafeCancellation + + if interaction: + sender = interaction.response.send_message + else: + sender = ctx.reply + + quote = await self.quotes.create_quote( + communityid=ctx.community.communityid, + content=to_create, + created_by=ctx.profile.profileid, + ) + await sender(f"Quote #{quote.quotelabel} created!") @quotes_grp.command( name='del', - description="Delete a saved quote (WARNING: This will change all quote numbers.)" + description="Delete a saved quote." ) @appcmds.describe( - quote="Select the quote to delete, or write the number." + quotestr="Select the quote to delete, or write the number." ) - async def quotes_del_cmd(self, ctx: LionContext, quote: str): - # TODO - ... + @appcmds.rename(quotestr='quote') + async def quotes_del_cmd(self, ctx: LionContext, quotestr: str): + # TODO: Double check group permission inheritance + quote = await self.resolve_quote(ctx, quotestr) + label = quote.quotelabel + await self.quotes.delete_quote(quote.quoteid) + await ctx.reply(f"Quote #{label} was deleted.") + + quotes_del_cmd.autocomplete('quotestr')(quote_acmpl) @quotes_grp.command( name='list', description="Display the community quotes. Quotes may also be added/edited/deleted here." ) async def quotes_list_cmd(self, ctx: LionContext): - # TODO - ... + view = QuoteListUI(self.bot, self.quotes, ctx.community.communityid, ctx.profile.profileid, ctx.author.id) + if ctx.interaction is None: + await view.send(ctx.channel) + else: + await view.run(ctx.interaction) + await view.wait() @quotes_grp.command( name='edit', description="Edit a saved quote." ) @appcmds.describe( - quote="Select the quote to delete, or write the number." + quotestr="Select the quote to edit, or write the number.", + new_content="New content for the quote. Leave unselected to edit in multiline modal." ) - async def quotes_edit_cmd(self, ctx: LionContext, quote: str): - # TODO: Move quote to QuoteConverter? - # TODO - ... + @appcmds.rename(quotestr='quote') + async def quotes_edit_cmd(self, ctx: LionContext, quotestr: str, *, new_content: Optional[str] = None): + quote = await self.resolve_quote(ctx, quotestr) + if new_content is not None: + interaction = ctx.interaction + elif not ctx.interaction: + invoked = ' '.join( + (*ctx.invoked_parents, ctx.invoked_with or '') + ) + await ctx.error_reply( + "**USAGE:** {prefix}{cmd} ".format( + prefix=ctx.prefix, + cmd=invoked + ) + ) + raise SafeCancellation + else: + try: + interaction, new_content = await input( + interaction=ctx.interaction, + title="Edit Quote", + question="Quote Content", + timeout=300, + style=TextStyle.long, + default=quote.content, + min_length=1, + max_length=2000 - 8 + ) + except asyncio.TimeoutError: + raise SafeCancellation + + if interaction: + sender = interaction.response.send_message + else: + sender = ctx.reply + + await self.data.quotes.update_where(quoteid=quote.quoteid).set(content=new_content) + await sender(f"Quote #{quote.quotelabel} Updated!") + + quotes_edit_cmd.autocomplete('quotestr')(quote_acmpl) diff --git a/quotes/discord/ui/__init__.py b/quotes/discord/ui/__init__.py new file mode 100644 index 0000000..a40e812 --- /dev/null +++ b/quotes/discord/ui/__init__.py @@ -0,0 +1 @@ +from .quotelist import QuoteListUI diff --git a/quotes/discord/ui/quotelist.py b/quotes/discord/ui/quotelist.py new file mode 100644 index 0000000..2db857a --- /dev/null +++ b/quotes/discord/ui/quotelist.py @@ -0,0 +1,272 @@ +import asyncio + +import discord +from discord.ui.select import select, Select +from discord.components import SelectOption +from discord.enums import ButtonStyle, TextStyle +from discord.ui.button import button, Button +from discord.ui.text_input import TextInput + +from meta import LionBot, conf +from meta.errors import SafeCancellation + +from utils.ui import MessageUI, input +from utils.lib import MessageArgs + +from .. import logger +from ...quotes import QuoteRegistry +from ...lib import minify + +class QuoteListUI(MessageUI): + block_len = 10 + def __init__(self, bot: LionBot, quotes: QuoteRegistry, communityid: int, profileid: int, callerid: int, **kwargs): + super().__init__(callerid=callerid, **kwargs) + + self.bot = bot + self.quotes = quotes + self.communityid = communityid + self.caller_profileid = profileid + + # Paging state + self._pagen = 0 + self.blocks = [[]] + + @property + def page_count(self): + return len(self.blocks) + + @property + def pagen(self): + self._pagen = 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] + + # ----- API ----- + + # ----- UI Components ----- + @button( + label="New Quote", + style=ButtonStyle.green + ) + async def new_quote_button(self, press: discord.Interaction, pressed: Button): + # Show the create quote modal + # Create quote flow. + try: + interaction, to_create = await input( + interaction=press, + title="Create Quote", + question="Quote Content", + timeout=300, + style=TextStyle.long, + min_length=1, + max_length=2000 - 8 + ) + except asyncio.TimeoutError: + raise SafeCancellation + await interaction.response.defer(thinking=True, ephemeral=True) + + quote = await self.quotes.create_quote( + communityid=self.communityid, + content=to_create, + created_by=self.caller_profileid + ) + self.pagen = -1 + await self.refresh(thinking=interaction) + + # Quote edit and delete selectors + async def make_page_options(self): + options = [] + for quote in self.current_page: + labelstr = f"#{quote.quotelabel}: " + minified = minify(quote.content, 100 - len(labelstr) - 1, strip=' >') + option = SelectOption( + label=f"{labelstr} {minified}", + value=str(quote.quoteid) + ) + options.append(option) + return options + + @select( + cls=Select, + placeholder="Select to delete", + min_values=1, max_values=1 + ) + async def delete_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + if selected.values: + quoteid = int(selected.values[0]) + await self.quotes.delete_quote(quoteid) + await self.refresh(thinking=selection) + + async def refresh_delete_menu(self): + self.delete_menu.options = await self.make_page_options() + + @select( + cls=Select, + placeholder="Select to edit", + min_values=1, max_values=1 + ) + async def edit_menu(self, selection: discord.Interaction, selected: Select): + if selected.values: + quoteid = int(selected.values[0]) + quote = await self.quotes.get_quote(quoteid) + assert quote is not None + try: + interaction, new_content = await input( + interaction=selection, + title="Edit Quote", + question="Quote Content", + timeout=300, + style=TextStyle.long, + default=quote.content, + min_length=1, + max_length=2000 - 8 + ) + await interaction.response.defer(thinking=True, ephemeral=True) + except asyncio.TimeoutError: + raise SafeCancellation + + await quote.update(content=new_content) + await self.refresh(thinking=interaction) + + async def refresh_edit_menu(self): + self.edit_menu.options = await self.make_page_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="Jump to page", + question="Page number to jump to" + ) + value = value.strip() + except asyncio.TimeoutError: + return + + if not value.lstrip('- ').isdigit(): + error_embed = discord.Embed( + title="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() + await self.quit() + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + if self.current_page: + lines = [] + for quote in self.current_page: + labelstr = f"**#{quote.quotelabel}:** " + minified = minify(quote.content, 100 - len(labelstr) - 1, strip=' >') + lines.append(f"{labelstr} {minified}") + embed = discord.Embed( + title="Community Quotes", + description='\n'.join(lines) + ) + else: + embed = discord.Embed( + title="Community Quotes", + description="No quotes have been created! Use `/addquote` or the 'New Quote' button below to make a quote!" + ) + return MessageArgs(embed=embed) + + async def refresh_layout(self): + # Refresh menus + to_refresh = ( + self.refresh_edit_menu(), + self.refresh_delete_menu(), + self.jump_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + if self.page_count > 1: + # Multiple page case + # < Add Jump Close > + # edit + # delete + self.set_layout( + (self.prev_button, + self.new_quote_button, + self.jump_button, + self.quit_button, + self.next_button), + (self.edit_menu,), + (self.delete_menu,), + ) + elif self.current_page: + # Single page case + # Add Close + # Edit + # Delete + self.set_layout( + (self.new_quote_button, + self.jump_button, + self.quit_button), + (self.edit_menu,), + (self.delete_menu,), + ) + else: + # Add Close + self.set_layout( + (self.new_quote_button, + self.jump_button), + ) + + async def reload(self): + quotes = await self.quotes.get_community_quotes(self.communityid) + blocks = [ + quotes[i:i+self.block_len] + for i in range(0, len(quotes), self.block_len) + ] + self.blocks = blocks or [[]] + diff --git a/quotes/lib.py b/quotes/lib.py index e69de29..4217ef5 100644 --- a/quotes/lib.py +++ b/quotes/lib.py @@ -0,0 +1,19 @@ +from typing import Optional +import datetime as dt +from datetime import datetime + + +def minify(content: str, maxlength: int, strip: Optional[str] = ' ', newlines: str = ' '): + content.replace('\n', newlines) + if strip: + content = content.strip(strip) + + if len(content) > maxlength: + new_content = content[maxlength-3] + '...' + else: + new_content = content + return content + + +def utc_now(): + return datetime.now(dt.UTC) diff --git a/quotes/quotes.py b/quotes/quotes.py index b2e1e4a..ac7c85a 100644 --- a/quotes/quotes.py +++ b/quotes/quotes.py @@ -1,5 +1,6 @@ from typing import Optional from .data import Quote, QuoteInfo, QuotesData +from .lib import utc_now class QuoteRegistry: @@ -13,7 +14,7 @@ class QuoteRegistry: await self.data.init() async def get_community_quotes(self, communityid: int) -> list[QuoteInfo]: - return await QuoteInfo.fetch_where(communityid=communityid) + return await QuoteInfo.fetch_where(communityid=communityid, is_deleted=False) async def get_quoteinfo(self, quoteid: int) -> Optional[QuoteInfo]: return await QuoteInfo.fetch(quoteid) @@ -22,7 +23,7 @@ class QuoteRegistry: return await Quote.fetch(quoteid) async def get_quote_label(self, communityid: int, label: int) -> Optional[QuoteInfo]: - results = await QuoteInfo.fetch_where(communityid=communityid, quotelabel=label) + results = await QuoteInfo.fetch_where(communityid=communityid, quotelabel=label, is_deleted=False) return results[0] if results else None async def create_quote( @@ -39,3 +40,6 @@ class QuoteRegistry: info = await QuoteInfo.fetch(quote.quoteid) assert info is not None return info + + async def delete_quote(self, quoteid: int): + await self.data.quotes.update_where(quoteid=quoteid).set(deleted_at=utc_now()) diff --git a/src/modules/profiles b/src/modules/profiles new file mode 160000 index 0000000..fc3bdcb --- /dev/null +++ b/src/modules/profiles @@ -0,0 +1 @@ +Subproject commit fc3bdcbb5c7e1c865b9c32716fdc94a1b7f3b95a