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,
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);

View File

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

View File

@@ -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} <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(
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} <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 .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())

1
src/modules/profiles Submodule

Submodule src/modules/profiles added at fc3bdcbb5c