v2 refactor to label-based koans, and add discord

This commit is contained in:
2025-09-04 02:29:48 +10:00
parent fda6847671
commit 89173f1676
10 changed files with 679 additions and 76 deletions

View File

@@ -0,0 +1,5 @@
from .. import logger
async def setup(bot):
from .cog import KoanCog
await bot.add_cog(KoanCog(bot))

251
koans/discord/cog.py Normal file
View File

@@ -0,0 +1,251 @@
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 KoanInfo, KoansData
from ..koans import KoanRegistry
from ..lib import minify
from . import logger
from .ui import KoanListUI
class KoanCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(KoansData())
self.koans = KoanRegistry(self.data)
async def cog_load(self):
await self.data.init()
await self.koans.init()
# ----- API -----
async def koan_acmpl(self, interaction: discord.Interaction, partial: str):
"""
Autocomplete for a community-local koan selection.
"""
if not interaction.guild:
return []
choices = []
community = await self.bot.profiles.fetch_community(
interaction.guild, interaction=interaction,
touch=False
)
koans = await self.koans.get_community_koans(community.communityid)
if not koans:
nullchoice = appcmds.Choice(
name="No koans have been created!",
value="0",
)
choices.append(nullchoice)
else:
for koan in koans:
labelstr = f"#{koan.koanlabel}:"
if partial.lower() in labelstr + koan.content.lower():
minified = minify(koan.content, 100 - len(labelstr) - 1, strip=' >')
displayed = f"{labelstr} {minified}"
choice = appcmds.Choice(
name=displayed,
value=str(koan.koanlabel),
)
choices.append(choice)
if not choices:
nullchoice = appcmds.Choice(
name="No koans matching your input!",
value="0",
)
choices.append(nullchoice)
return choices
async def resolve_koan(self, ctx: LionContext, koanstr: str) -> KoanInfo:
"""
Resolve a koan string provided as an argument.
Essentially only accepts integer koan labels.
"""
koanstr = koanstr.strip('# ')
if not koanstr.isdigit():
raise UserInputError(
"Could not parse desired koan! Please enter the number or select from autocomplete options."
)
elif (label := int(koanstr)) == 0:
raise UserInputError(
"Invalid option selected!"
)
else:
koan = await self.koans.get_koan_label(ctx.community.communityid, label)
if not koan:
raise UserInputError(
f"Koan #{label} does not exist!"
)
else:
return koan
# ----- Commands ------
@cmds.hybrid_command(
name='koan',
description="Display a random koan."
)
@cmds.guild_only()
async def koan_cmd(self, ctx: LionContext):
koans = await self.koans.get_community_koans(ctx.community.communityid)
if koans:
# Select a random koan
koan = random.choice(koans)
if '\n' in koan.content:
formatted = f"**#{koan.koanlabel}:**\n{koan.content}"
else:
formatted = f"**#{koan.koanlabel}:** {koan.content}"
await ctx.reply(formatted)
else:
await ctx.reply("There are no koans to display!")
@cmds.hybrid_group(
name='koans',
description="Base command group for koans management.",
)
@cmds.has_permissions(manage_guild=True)
@cmds.guild_only()
async def koans_grp(self, ctx: LionContext):
await self.koans_list_cmd(ctx)
@koans_grp.command(
name='add',
description="Create a new koan. Use without arguments to add a multiline koan."
)
@appcmds.describe(
content="Content of the koan to add"
)
@cmds.has_permissions(manage_guild=True)
async def koans_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 Koan",
question="Koan 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
koan = await self.koans.create_koan(
communityid=ctx.community.communityid,
content=to_create,
created_by=ctx.profile.profileid,
)
await sender(f"Koan #{koan.koanlabel} created!")
@koans_grp.command(
name='del',
description="Delete a saved koan."
)
@appcmds.describe(
koanstr="Select the koan to delete, or write the number."
)
@cmds.has_permissions(manage_guild=True)
@appcmds.rename(koanstr='koan')
async def koans_del_cmd(self, ctx: LionContext, koanstr: str):
koan = await self.resolve_koan(ctx, koanstr)
label = koan.koanlabel
await self.koans.delete_koan(koan.koanid)
await ctx.reply(f"Koan #{label} was deleted.")
koans_del_cmd.autocomplete('koanstr')(koan_acmpl)
@koans_grp.command(
name='list',
description="Display the community koans. Koans may also be added/edited/deleted here."
)
@cmds.has_permissions(manage_guild=True)
async def koans_list_cmd(self, ctx: LionContext):
view = KoanListUI(self.bot, self.koans, 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()
@koans_grp.command(
name='edit',
description="Edit a saved koan."
)
@appcmds.describe(
koanstr="Select the koan to edit, or write the number.",
new_content="New content for the koan. Leave unselected to edit in multiline modal."
)
@appcmds.rename(koanstr='koan')
@cmds.has_permissions(manage_guild=True)
async def koans_edit_cmd(self, ctx: LionContext, koanstr: str, *, new_content: Optional[str] = None):
koan = await self.resolve_koan(ctx, koanstr)
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} <koanid> <new content>".format(
prefix=ctx.prefix,
cmd=invoked
)
)
raise SafeCancellation
else:
try:
interaction, new_content = await input(
interaction=ctx.interaction,
title="Edit Koan",
question="Koan Content",
timeout=300,
style=TextStyle.long,
default=koan.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.koans.update_where(koanid=koan.koanid).set(content=new_content)
await sender(f"Koan #{koan.koanlabel} Updated!")
koans_edit_cmd.autocomplete('koanstr')(koan_acmpl)

View File

@@ -0,0 +1 @@
from .koanlist import KoanListUI

View File

@@ -0,0 +1,271 @@
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 ...koans import KoanRegistry
from ...lib import minify
class KoanListUI(MessageUI):
block_len = 10
def __init__(self, bot: LionBot, koans: KoanRegistry, communityid: int, profileid: int, callerid: int, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.koans = koans
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 Koan",
style=ButtonStyle.green
)
async def new_koan_button(self, press: discord.Interaction, pressed: Button):
# Show the create koan modal
# Create koan flow.
try:
interaction, to_create = await input(
interaction=press,
title="Create Koan",
question="Koan 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)
koan = await self.koans.create_koan(
communityid=self.communityid,
content=to_create,
created_by=self.caller_profileid
)
self.pagen = -1
await self.refresh(thinking=interaction)
# Koan edit and delete selectors
async def make_page_options(self):
options = []
for koan in self.current_page:
labelstr = f"#{koan.koanlabel}: "
minified = minify(koan.content, 100 - len(labelstr) - 1, strip=' >')
option = SelectOption(
label=f"{labelstr} {minified}",
value=str(koan.koanid)
)
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:
koanid = int(selected.values[0])
await self.koans.delete_koan(koanid)
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:
koanid = int(selected.values[0])
koan = await self.koans.get_koan(koanid)
assert koan is not None
try:
interaction, new_content = await input(
interaction=selection,
title="Edit Koan",
question="Koan Content",
timeout=300,
style=TextStyle.long,
default=koan.content,
min_length=1,
max_length=2000 - 8
)
await interaction.response.defer(thinking=True, ephemeral=True)
except asyncio.TimeoutError:
raise SafeCancellation
await koan.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 koan in self.current_page:
labelstr = f"**#{koan.koanlabel}:** "
minified = minify(koan.content, 100 - len(labelstr) - 1, strip=' >')
lines.append(f"{labelstr} {minified}")
embed = discord.Embed(
title="Community Koans",
description='\n'.join(lines)
)
else:
embed = discord.Embed(
title="Community Koans",
description="No koans have been created! Use `/koans add` or the 'New Koan' button below to make a koan!"
)
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_koan_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_koan_button,
self.quit_button),
(self.edit_menu,),
(self.delete_menu,),
)
else:
# Add Close
self.set_layout(
(self.new_koan_button,
self.quit_button),
)
async def reload(self):
koans = await self.koans.get_community_koans(self.communityid)
blocks = [
koans[i:i+self.block_len]
for i in range(0, len(koans), self.block_len)
]
self.blocks = blocks or [[]]