Implement quotes.

This commit is contained in:
2025-08-28 15:42:53 +10:00
parent 45e6ed4e29
commit d9fa5c4683
9 changed files with 496 additions and 28 deletions

View File

@@ -0,0 +1 @@
from .quotes import setup

View File

@@ -8,6 +8,7 @@ CREATE TABLE quotes(
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE, communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
content TEXT NOT NULL, content TEXT NOT NULL,
created_by INTEGER REFERENCES user_profiles(profileid) ON UPDATE CASCADE ON DELETE NO ACTION, created_by INTEGER REFERENCES user_profiles(profileid) ON UPDATE CASCADE ON DELETE NO ACTION,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -19,7 +20,8 @@ CREATE VIEW
AS AS
SELECT 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 FROM
quotes quotes
ORDER BY (communityid, created_at); ORDER BY (communityid, created_at);

View File

@@ -1,5 +1,5 @@
from data import Registry, RowModel, Table 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 from weakref import WeakValueDictionary
@@ -10,6 +10,7 @@ class Quote(RowModel):
quoteid = Integer(primary=True) quoteid = Integer(primary=True)
communityid = Integer() communityid = Integer()
content = String() content = String()
deleted_at = Timestamp()
created_by = Integer() created_by = Integer()
created_at = Timestamp() created_at = Timestamp()
_timestamp = Timestamp() _timestamp = Timestamp()
@@ -21,6 +22,7 @@ class QuoteInfo(Quote):
_cache_ = WeakValueDictionary() _cache_ = WeakValueDictionary()
quotelabel = Integer() quotelabel = Integer()
is_deleted = Bool()
class QuotesData(Registry): class QuotesData(Registry):

View File

@@ -1,20 +1,27 @@
from typing import Optional from typing import Optional
import asyncio import asyncio
import random
import discord import discord
from discord.enums import TextStyle
from discord.ext import commands as cmds from discord.ext import commands as cmds
from discord import app_commands as appcmds from discord import app_commands as appcmds
from meta import LionBot, LionCog, LionContext from meta import LionBot, LionCog, LionContext
from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError 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 . import logger
from ..data import QuotesData from .ui import QuoteListUI
from ..quotes import QuoteRegistry
class QuoteCog(LionCog): class QuoteCog(LionCog):
def __init__(self, bot: LionBot): def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(QuotesData()) self.data = bot.db.load_registry(QuotesData())
self.quotes = QuoteRegistry(self.data) self.quotes = QuoteRegistry(self.data)
@@ -24,31 +31,106 @@ class QuoteCog(LionCog):
# ----- API ----- # ----- API -----
async def quote_acmpl(self, interaction: discord.Interaction, partial: str): 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 ------ # ----- Commands ------
@cmds.hybrid_command( @cmds.hybrid_command(
name='quote', name='quote',
description="Display a random quote." description="Display a random quote."
) )
@cmds.guild_only()
async def quote_cmd(self, ctx: LionContext): 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( @cmds.hybrid_group(
name='quotes', name='quotes',
description="Base command group for quotes management.", description="Base command group for quotes management.",
) )
@cmds.has_permissions(manage_guild=True)
@cmds.guild_only()
async def quotes_grp(self, ctx: LionContext): async def quotes_grp(self, ctx: LionContext):
# TODO await self.quotes_list_cmd(ctx)
# Call the quotes list command
...
@cmds.hybrid_command( @cmds.hybrid_command(
name='addquote', name='addquote',
description="Create a new quote. Use without arguments to add a multiline quote." 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( @quotes_grp.command(
name='add', name='add',
description="Create a new quote. Use without arguments to add a multiline quote." description="Create a new quote. Use without arguments to add a multiline quote."
@@ -56,37 +138,121 @@ class QuoteCog(LionCog):
@appcmds.describe( @appcmds.describe(
content="Content of the quote to add" content="Content of the quote to add"
) )
async def quotes_add_cmd(self, ctx: LionContext, content: Optional[str]): async def quotes_add_cmd(self, ctx: LionContext, *, content: Optional[str] = None):
# TODO 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} <content>".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( @quotes_grp.command(
name='del', name='del',
description="Delete a saved quote (WARNING: This will change all quote numbers.)" description="Delete a saved quote."
) )
@appcmds.describe( @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): @appcmds.rename(quotestr='quote')
# TODO 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( @quotes_grp.command(
name='list', name='list',
description="Display the community quotes. Quotes may also be added/edited/deleted here." description="Display the community quotes. Quotes may also be added/edited/deleted here."
) )
async def quotes_list_cmd(self, ctx: LionContext): 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( @quotes_grp.command(
name='edit', name='edit',
description="Edit a saved quote." description="Edit a saved quote."
) )
@appcmds.describe( @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): @appcmds.rename(quotestr='quote')
# TODO: Move quote to QuoteConverter? async def quotes_edit_cmd(self, ctx: LionContext, quotestr: str, *, new_content: Optional[str] = None):
# TODO 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} <quoteid> <new content>".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)

View File

@@ -0,0 +1 @@
from .quotelist import QuoteListUI

View File

@@ -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 [[]]

View File

@@ -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)

View File

@@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
from .data import Quote, QuoteInfo, QuotesData from .data import Quote, QuoteInfo, QuotesData
from .lib import utc_now
class QuoteRegistry: class QuoteRegistry:
@@ -13,7 +14,7 @@ class QuoteRegistry:
await self.data.init() await self.data.init()
async def get_community_quotes(self, communityid: int) -> list[QuoteInfo]: 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]: async def get_quoteinfo(self, quoteid: int) -> Optional[QuoteInfo]:
return await QuoteInfo.fetch(quoteid) return await QuoteInfo.fetch(quoteid)
@@ -22,7 +23,7 @@ class QuoteRegistry:
return await Quote.fetch(quoteid) return await Quote.fetch(quoteid)
async def get_quote_label(self, communityid: int, label: int) -> Optional[QuoteInfo]: 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 return results[0] if results else None
async def create_quote( async def create_quote(
@@ -39,3 +40,6 @@ class QuoteRegistry:
info = await QuoteInfo.fetch(quote.quoteid) info = await QuoteInfo.fetch(quote.quoteid)
assert info is not None assert info is not None
return info return info
async def delete_quote(self, quoteid: int):
await self.data.quotes.update_where(quoteid=quoteid).set(deleted_at=utc_now())

1
src/modules/profiles Submodule

Submodule src/modules/profiles added at fc3bdcbb5c