From a098106468ab4bf71e32747cb02f7ce6da8586a1 Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 28 Jul 2026 09:20:02 +0300 Subject: [PATCH 1/5] routine: Add campaign module --- .gitmodules | 3 +++ src/modules/pluscampaign | 1 + 2 files changed, 4 insertions(+) create mode 160000 src/modules/pluscampaign diff --git a/.gitmodules b/.gitmodules index 1ab45a8..a881eb8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [submodule "src/modules/profiles"] path = src/modules/profiles url = git@thewisewolf.dev:HoloTech/profiles-plugin.git +[submodule "src/modules/pluscampaign"] + path = src/modules/pluscampaign + url = git@thewisewolf.dev:CarmiCoven/pluscampaign-plugin.git diff --git a/src/modules/pluscampaign b/src/modules/pluscampaign new file mode 160000 index 0000000..de64f91 --- /dev/null +++ b/src/modules/pluscampaign @@ -0,0 +1 @@ +Subproject commit de64f9166b79589a7aff5936b4fac8d34de0f219 From 447672d9728fb1b549e96b534ee77cf48ff17db2 Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 28 Jul 2026 09:20:29 +0300 Subject: [PATCH 2/5] data: Add current_module_version SQL function --- data/schema.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/data/schema.sql b/data/schema.sql index 3d7ba1b..61f149a 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -18,6 +18,24 @@ BEGIN RETURN NEW; END; $$ language 'plpgsql'; + + +CREATE OR REPLACE FUNCTION current_module_version(module_name TEXT) +RETURNS INTEGER +AS $$ + SELECT + to_version + FROM version_history + WHERE + component = $1 + ORDER BY _timestamp DESC + LIMIT 1; +$$ LANGUAGE SQL; + +CREATE TABLE app_config( + appname TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); -- }}} -- App metadata {{{ From 52cbd687780d3de021ebe7d5e42a2c2a2d1a8ec1 Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 28 Jul 2026 09:21:07 +0300 Subject: [PATCH 3/5] fix: Update submodule URLS to use public URLS --- .gitmodules | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index a881eb8..b46e493 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,7 +15,7 @@ url = https://git.thewisewolf.dev/HoloTech/psqlmapper.git [submodule "src/modules/profiles"] path = src/modules/profiles - url = git@thewisewolf.dev:HoloTech/profiles-plugin.git + url = https://git.thewisewolf.dev/HoloTech/profiles-plugin.git [submodule "src/modules/pluscampaign"] path = src/modules/pluscampaign - url = git@thewisewolf.dev:CarmiCoven/pluscampaign-plugin.git + url = https://git.thewisewolf.dev/CarmiCoven/pluscampaign-plugin.git From 384820370aa9f8cbb1360dbfc146b57231e85c23 Mon Sep 17 00:00:00 2001 From: Interitio Date: Wed, 29 Jul 2026 16:11:17 +0300 Subject: [PATCH 4/5] feat: Add pluscampaign submodule --- src/modules/pluscampaign | 2 +- src/modules/profiles | 2 +- src/utils/ui/pagers.py | 456 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 src/utils/ui/pagers.py diff --git a/src/modules/pluscampaign b/src/modules/pluscampaign index de64f91..0923f32 160000 --- a/src/modules/pluscampaign +++ b/src/modules/pluscampaign @@ -1 +1 @@ -Subproject commit de64f9166b79589a7aff5936b4fac8d34de0f219 +Subproject commit 0923f32a82d8f8eded9459ee4f46d9466d0049b0 diff --git a/src/modules/profiles b/src/modules/profiles index c9b6ce8..8818263 160000 --- a/src/modules/profiles +++ b/src/modules/profiles @@ -1 +1 @@ -Subproject commit c9b6ce8f60325e98180983887ebd83d091bc83d4 +Subproject commit 8818263d88c7f75c4f817b77ab6f6bf2584128a1 diff --git a/src/utils/ui/pagers.py b/src/utils/ui/pagers.py new file mode 100644 index 0000000..83e5522 --- /dev/null +++ b/src/utils/ui/pagers.py @@ -0,0 +1,456 @@ +from typing import List, Coroutine, Optional, Any, Type, TypeVar, Callable, Dict +from collections import defaultdict + +import discord +from discord.ui.button import Button, button +from discord import app_commands as appcmds + +from meta.logger import log_action_stack, logging_context +from meta.errors import SafeCancellation +from meta.config import conf + +from babel.translator import ctx_translator + +from ..lib import MessageArgs, error_embed +from .. import util_babel + +from .leo import LeoUI + +_p = util_babel._p + + +__all__ = ( + 'BasePager', + 'Pager', +) + + +class BasePager(LeoUI): + """ + An ABC describing the common interface for a Paging UI. + + A paging UI represents a sequence of pages, accessible by `next` and `previous` buttons, + and possibly by a dropdown (not implemented). + + A `Page` is represented as a `MessageArgs` object, which is passable to `send` and `edit` methods as required. + + Each page of a paging UI is accessed through the coroutine `get_page`. + This allows for more complex paging schemes where the pages are expensive to compute, + and not generally needed simultaneously. + In general, `get_page` should cache expensive pages, + perhaps simply with a `cached` decorator, but this is not enforced. + + The state of the base UI is represented as the current `page_num` and the `current_page`. + + This class also maintains an `active_pagers` cache, + representing all `BasePager`s that are currently running. + This allows access from external page controlling utilities, e.g. the `/page` command. + """ + # List of valid keys indicating movement to the next page + next_list = _p('cmd:page|pager:Pager|options:next', "n, nxt, next, forward, +") + + # List of valid keys indicating movement to the previous page + prev_list = _p('cmd:page|pager:Pager|options:prev', "p, prev, back, -") + + # List of valid keys indicating movement to the first page + first_list = _p('cmd:page|pager:Pager|options:first', "f, first, one, start") + + # List of valid keys indicating movement to the last page + last_list = _p('cmd:page|pager:Pager|options:last', "l, last, end") + # channelid -> pager.id -> list of active pagers in this channel + active_pagers: dict[int, dict[int, 'BasePager']] = defaultdict(dict) + + page_num: int + current_page: MessageArgs + _channelid: Optional[int] + + @classmethod + def get_active_pager(self, channelid, userid): + """ + Get the last active pager in the `destinationid`, which may be accessed by `userid`. + Returns None if there are no matching pagers. + """ + for pager in reversed(self.active_pagers[channelid].values()): + if pager.access_check(userid): + return pager + + def set_active(self): + if self._channelid is None: + raise ValueError("Cannot set active without a channelid.") + self.active_pagers[self._channelid][self.id] = self + + def set_inactive(self): + self.active_pagers[self._channelid].pop(self.id, None) + + def access_check(self, userid): + """ + Check whether the given userid is allowed to use this UI. + Must be overridden by subclasses. + """ + raise NotImplementedError + + async def get_page(self, page_id) -> MessageArgs: + """ + `get_page` returns the specified page number, starting from 0. + An implementation of `get_page` must: + - Always return a page (if no data is a valid state, must return a placeholder page). + - Always accept out-of-range `page_id` values. + - There is no behaviour specified for these, although they will usually be modded into the correct + range. + - In some cases (e.g. stream data where we don't have a last page), + they may simply return the last correct page instead. + + """ + raise NotImplementedError + + async def page_cmd(self, interaction: discord.Interaction, value: str): + """ + Command implementation for the paging command. + Pager subclasses should override this if they use `active_pagers`. + Default implementation is essentially a no-op, + simply replying to the interaction. + """ + await interaction.response.defer() + return + + async def page_acmpl(self, interaction: discord.Interaction, partial: str): + """ + Command autocompletion for the paging command. + Pager subclasses should override this if they use `active_pagers`. + """ + return [] + + @button(emoji=conf.emojis.getemoji('forward')) + async def next_page_button(self, interaction: discord.Interaction, press): + await interaction.response.defer() + self.page_num += 1 + await self.redraw() + + @button(emoji=conf.emojis.getemoji('backward')) + async def prev_page_button(self, interaction: discord.Interaction, press): + await interaction.response.defer() + self.page_num -= 1 + await self.redraw() + + async def refresh(self): + """ + Recalculate current computed state. + (E.g. fetch current page, set layout, disable components, etc.) + """ + self.current_page = await self.get_page(self.page_num) + + async def redraw(self): + """ + This should refresh the current state and redraw the UI. + Not implemented here, as the implementation depends on whether this is a reaction response ephemeral UI + or a message=based one. + """ + raise NotImplementedError + + +class Pager(BasePager): + """ + MicroUI to display a sequence of static pages, + supporting paging reaction and paging commands. + + Parameters + ---------- + pages: list[MessageArgs] + A non-empty list of message arguments to page. + start_from: int + The page number to display first. + Default: 0 + locked: bool + Whether to only allow the author to use the paging interface. + """ + + def __init__(self, pages: list[MessageArgs], + start_from=0, + show_cancel=False, delete_on_cancel=True, delete_after=False, **kwargs): + super().__init__(**kwargs) + self._pages = pages + self.page_num = start_from + self.current_page = pages[self.page_num] + + self._locked = True + self._ownerid: Optional[int] = None + self._channelid: Optional[int] = None + + if not pages: + raise ValueError("Cannot run Pager with no pages.") + + self._original: Optional[discord.Interaction] = None + self._is_followup: bool = False + self._message: Optional[discord.Message] = None + + self.show_cancel = show_cancel + self._delete_on_cancel = delete_on_cancel + self._delete_after = delete_after + + @property + def ownerid(self): + if self._ownerid is not None: + return self._ownerid + elif self._original: + return self._original.user.id + else: + return None + + def access_check(self, userid): + return not self._locked or (userid == self.ownerid) + + async def interaction_check(self, interaction: discord.Interaction): + return self.access_check(interaction.user.id) + + @button(emoji=conf.emojis.getemoji('cancel')) + async def cancel_button(self, interaction: discord.Interaction, press: Button): + await interaction.response.defer() + if self._delete_on_cancel: + self._delete_after = True + await self.close() + + async def cleanup(self): + self.set_inactive() + + # If we still have a message, delete it or clear the view + try: + if self._is_followup: + if self._message: + if self._delete_after: + await self._message.delete() + else: + await self._message.edit(view=None) + else: + if self._original and not self._original.is_expired(): + if self._delete_after: + await self._original.delete_original_response() + else: + await self._original.edit_original_response(view=None) + except discord.HTTPException: + # Nothing we can do here + pass + + async def get_page(self, page_id): + page_id %= len(self._pages) + return self._pages[page_id] + + def page_count(self): + return len(self.pages) + + async def page_cmd(self, interaction: discord.Interaction, value: str): + """ + `/page` command for the `Pager` MicroUI. + """ + await interaction.response.defer(ephemeral=True) + t = ctx_translator.get().t + nexts = {word.strip() for word in t(self.next_list).split(',')} + prevs = {word.strip() for word in t(self.prev_list).split(',')} + firsts = {word.strip() for word in t(self.first_list).split(',')} + lasts = {word.strip() for word in t(self.last_list).split(',')} + + if value: + value = value.lower().strip() + if value.isdigit(): + # Assume value is page number + self.page_num = int(value) - 1 + if self.page_num == -1: + self.page_num = 0 + elif value in firsts: + self.page_num = 0 + elif value in nexts: + self.page_num += 1 + elif value in prevs: + self.page_num -= 1 + elif value in lasts: + self.page_num = -1 + elif value.startswith('-') and value[1:].isdigit(): + self.page_num = - int(value[1:]) + else: + await interaction.edit_original_response( + embed=error_embed( + t(_p( + 'cmd:page|pager:Pager|error:parse', + "Could not understand page specification `{value}`." + )).format(value=value) + ) + ) + return + await interaction.delete_original_response() + await self.redraw() + + async def page_acmpl(self, interaction: discord.Interaction, partial: str): + """ + `/page` command autocompletion for the `Pager` MicroUI. + """ + t = ctx_translator.get().t + nexts = {word.strip() for word in t(self.next_list).split(',')} + prevs = {word.strip() for word in t(self.prev_list).split(',')} + firsts = {word.strip() for word in t(self.first_list).split(',')} + lasts = {word.strip() for word in t(self.last_list).split(',')} + + total = len(self._pages) + num = self.page_num + page_choices: dict[int, str] = {} + + # TODO: Support page names and hints? + + if len(self._pages) > 10: + # First add the general choices + if num < total-1: + page_choices[total-1] = t(_p( + 'cmd:page|acmpl|pager:Pager|choice:last', + "Last: Page {page}/{total}" + )).format(page=total, total=total) + + page_choices[num] = t(_p( + 'cmd:page|acmpl|pager:Pager|choice:current', + "Current: Page {page}/{total}" + )).format(page=num+1, total=total) + choices = [ + appcmds.Choice(name=string[:100], value=str(num+1)) + for num, string in sorted(page_choices.items(), key=lambda t: t[0]) + ] + else: + # Particularly support page names here + choices = [ + appcmds.Choice( + name=('> ' * (i == num) + t(_p( + 'cmd:page|acmpl|pager:Pager|choice:general', + "Page {page}" + )).format(page=i+1))[:100], + value=str(i+1) + ) + for i in range(0, total) + ] + + partial = partial.strip() + + if partial: + value = partial.lower().strip() + if value.isdigit(): + # Assume value is page number + page_num = int(value) - 1 + if page_num == -1: + page_num = 0 + elif value in firsts: + page_num = 0 + elif value in nexts: + page_num = self.page_num + 1 + elif value in prevs: + page_num = self.page_num - 1 + elif value in lasts: + page_num = -1 + elif value.startswith('-') and value[1:].isdigit(): + page_num = - int(value[1:]) + else: + page_num = None + + if page_num is not None: + page_num %= total + choice = appcmds.Choice( + name=t(_p( + 'cmd:page|acmpl|pager:Page|choice:select', + "Selected: Page {page}/{total}" + )).format(page=page_num+1, total=total)[:100], + value=str(page_num + 1) + ) + return [choice, *choices] + else: + return [ + appcmds.Choice( + name=t(_p( + 'cmd:page|acmpl|pager:Page|error:parse', + "No matching pages!" + )).format(page=page_num, total=total)[:100], + value=partial + ) + ] + else: + return choices + + @property + def page_row(self): + if self.show_cancel: + if len(self._pages) > 1: + return (self.prev_page_button, self.cancel_button, self.next_page_button) + else: + return (self.cancel_button,) + else: + if len(self._pages) > 1: + return (self.prev_page_button, self.next_page_button) + else: + return () + + async def refresh(self): + await super().refresh() + self.set_layout(self.page_row) + + async def redraw(self): + await self.refresh() + + if not self._original: + raise ValueError("Running run pager manually without interaction.") + + try: + if self._message: + await self._message.edit(**self.current_page.edit_args, view=self) + else: + if self._original.is_expired(): + raise SafeCancellation("This interface has expired, please try again.") + await self._original.edit_original_response(**self.current_page.edit_args, view=self) + except discord.HTTPException: + raise SafeCancellation("Could not page your results! Please try again.") + + async def run(self, interaction: discord.Interaction, ephemeral=False, locked=True, ownerid=None, **kwargs): + """ + Display the UI. + Attempts to reply to the interaction if it has not already been replied to, + otherwise send a follow-up. + + An ephemeral response must be sent as an initial interaction response. + On the other hand, a semi-persistent response (expected to last longer than the lifetime of the interaction) + must be sent as a followup. + + Extra kwargs are combined with the first page arguments and given to the relevant send method. + + Parameters + ---------- + interaction: discord.Interaction + The interaction to send the pager in response to. + ephemeral: bool + Whether to send the interaction ephemerally. + If this is true, the interaction *must* be fresh (i.e. no response done). + Default: False + locked: bool + Whether this interface is locked to the user `self.ownerid`. + Irrelevant for ephemeral messages. + Use `ownerid` to override the default owner id. + Defaults to true for fail-safety. + Default: True + ownerid: Optional[int] + The userid allowed to use this interaction. + By default, this will be the `interaction.user.id`, + presuming that this is the user which originally triggered this message. + An override may be useful if a user triggers a paging UI for someone else. + """ + if not interaction.channel_id: + raise ValueError("Cannot run pager on a channelless interaction.") + + self._original = interaction + self._ownerid = ownerid + self._locked = locked + self._channelid = interaction.channel_id + + await self.refresh() + args = self.current_page.send_args | kwargs + + if interaction.response.is_done(): + if ephemeral: + raise ValueError("Ephemeral response requires fres interaction.") + self._message = await interaction.followup.send(**args, view=self) + self._is_followup = True + else: + self._is_followup = False + await interaction.response.send_message(**args, view=self) + + self.set_active() From ff78f7d5bf7e7cf8bbf137ee1afcb446515dc12a Mon Sep 17 00:00:00 2001 From: Interitio Date: Thu, 30 Jul 2026 10:25:22 +0300 Subject: [PATCH 5/5] Update and link campaign module --- src/modules/__init__.py | 11 +- src/modules/pluscampaign | 2 +- src/utils/ui/pagers.py | 456 --------------------------------------- 3 files changed, 7 insertions(+), 462 deletions(-) delete mode 100644 src/utils/ui/pagers.py diff --git a/src/modules/__init__.py b/src/modules/__init__.py index f990b18..000ed45 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -1,12 +1,13 @@ this_package = "modules" active = [ - ".sysadmin", + ".sysadmin", ".profiles", - ".voicefix", - ".messagelogger", - ".voicelog", - ".yarn" + ".voicefix", + ".messagelogger", + ".voicelog", + ".yarn", + ".pluscampaign", ] diff --git a/src/modules/pluscampaign b/src/modules/pluscampaign index 0923f32..a188855 160000 --- a/src/modules/pluscampaign +++ b/src/modules/pluscampaign @@ -1 +1 @@ -Subproject commit 0923f32a82d8f8eded9459ee4f46d9466d0049b0 +Subproject commit a18885511abab940d149989f7bcd6d3d0996d2c4 diff --git a/src/utils/ui/pagers.py b/src/utils/ui/pagers.py deleted file mode 100644 index 83e5522..0000000 --- a/src/utils/ui/pagers.py +++ /dev/null @@ -1,456 +0,0 @@ -from typing import List, Coroutine, Optional, Any, Type, TypeVar, Callable, Dict -from collections import defaultdict - -import discord -from discord.ui.button import Button, button -from discord import app_commands as appcmds - -from meta.logger import log_action_stack, logging_context -from meta.errors import SafeCancellation -from meta.config import conf - -from babel.translator import ctx_translator - -from ..lib import MessageArgs, error_embed -from .. import util_babel - -from .leo import LeoUI - -_p = util_babel._p - - -__all__ = ( - 'BasePager', - 'Pager', -) - - -class BasePager(LeoUI): - """ - An ABC describing the common interface for a Paging UI. - - A paging UI represents a sequence of pages, accessible by `next` and `previous` buttons, - and possibly by a dropdown (not implemented). - - A `Page` is represented as a `MessageArgs` object, which is passable to `send` and `edit` methods as required. - - Each page of a paging UI is accessed through the coroutine `get_page`. - This allows for more complex paging schemes where the pages are expensive to compute, - and not generally needed simultaneously. - In general, `get_page` should cache expensive pages, - perhaps simply with a `cached` decorator, but this is not enforced. - - The state of the base UI is represented as the current `page_num` and the `current_page`. - - This class also maintains an `active_pagers` cache, - representing all `BasePager`s that are currently running. - This allows access from external page controlling utilities, e.g. the `/page` command. - """ - # List of valid keys indicating movement to the next page - next_list = _p('cmd:page|pager:Pager|options:next', "n, nxt, next, forward, +") - - # List of valid keys indicating movement to the previous page - prev_list = _p('cmd:page|pager:Pager|options:prev', "p, prev, back, -") - - # List of valid keys indicating movement to the first page - first_list = _p('cmd:page|pager:Pager|options:first', "f, first, one, start") - - # List of valid keys indicating movement to the last page - last_list = _p('cmd:page|pager:Pager|options:last', "l, last, end") - # channelid -> pager.id -> list of active pagers in this channel - active_pagers: dict[int, dict[int, 'BasePager']] = defaultdict(dict) - - page_num: int - current_page: MessageArgs - _channelid: Optional[int] - - @classmethod - def get_active_pager(self, channelid, userid): - """ - Get the last active pager in the `destinationid`, which may be accessed by `userid`. - Returns None if there are no matching pagers. - """ - for pager in reversed(self.active_pagers[channelid].values()): - if pager.access_check(userid): - return pager - - def set_active(self): - if self._channelid is None: - raise ValueError("Cannot set active without a channelid.") - self.active_pagers[self._channelid][self.id] = self - - def set_inactive(self): - self.active_pagers[self._channelid].pop(self.id, None) - - def access_check(self, userid): - """ - Check whether the given userid is allowed to use this UI. - Must be overridden by subclasses. - """ - raise NotImplementedError - - async def get_page(self, page_id) -> MessageArgs: - """ - `get_page` returns the specified page number, starting from 0. - An implementation of `get_page` must: - - Always return a page (if no data is a valid state, must return a placeholder page). - - Always accept out-of-range `page_id` values. - - There is no behaviour specified for these, although they will usually be modded into the correct - range. - - In some cases (e.g. stream data where we don't have a last page), - they may simply return the last correct page instead. - - """ - raise NotImplementedError - - async def page_cmd(self, interaction: discord.Interaction, value: str): - """ - Command implementation for the paging command. - Pager subclasses should override this if they use `active_pagers`. - Default implementation is essentially a no-op, - simply replying to the interaction. - """ - await interaction.response.defer() - return - - async def page_acmpl(self, interaction: discord.Interaction, partial: str): - """ - Command autocompletion for the paging command. - Pager subclasses should override this if they use `active_pagers`. - """ - return [] - - @button(emoji=conf.emojis.getemoji('forward')) - async def next_page_button(self, interaction: discord.Interaction, press): - await interaction.response.defer() - self.page_num += 1 - await self.redraw() - - @button(emoji=conf.emojis.getemoji('backward')) - async def prev_page_button(self, interaction: discord.Interaction, press): - await interaction.response.defer() - self.page_num -= 1 - await self.redraw() - - async def refresh(self): - """ - Recalculate current computed state. - (E.g. fetch current page, set layout, disable components, etc.) - """ - self.current_page = await self.get_page(self.page_num) - - async def redraw(self): - """ - This should refresh the current state and redraw the UI. - Not implemented here, as the implementation depends on whether this is a reaction response ephemeral UI - or a message=based one. - """ - raise NotImplementedError - - -class Pager(BasePager): - """ - MicroUI to display a sequence of static pages, - supporting paging reaction and paging commands. - - Parameters - ---------- - pages: list[MessageArgs] - A non-empty list of message arguments to page. - start_from: int - The page number to display first. - Default: 0 - locked: bool - Whether to only allow the author to use the paging interface. - """ - - def __init__(self, pages: list[MessageArgs], - start_from=0, - show_cancel=False, delete_on_cancel=True, delete_after=False, **kwargs): - super().__init__(**kwargs) - self._pages = pages - self.page_num = start_from - self.current_page = pages[self.page_num] - - self._locked = True - self._ownerid: Optional[int] = None - self._channelid: Optional[int] = None - - if not pages: - raise ValueError("Cannot run Pager with no pages.") - - self._original: Optional[discord.Interaction] = None - self._is_followup: bool = False - self._message: Optional[discord.Message] = None - - self.show_cancel = show_cancel - self._delete_on_cancel = delete_on_cancel - self._delete_after = delete_after - - @property - def ownerid(self): - if self._ownerid is not None: - return self._ownerid - elif self._original: - return self._original.user.id - else: - return None - - def access_check(self, userid): - return not self._locked or (userid == self.ownerid) - - async def interaction_check(self, interaction: discord.Interaction): - return self.access_check(interaction.user.id) - - @button(emoji=conf.emojis.getemoji('cancel')) - async def cancel_button(self, interaction: discord.Interaction, press: Button): - await interaction.response.defer() - if self._delete_on_cancel: - self._delete_after = True - await self.close() - - async def cleanup(self): - self.set_inactive() - - # If we still have a message, delete it or clear the view - try: - if self._is_followup: - if self._message: - if self._delete_after: - await self._message.delete() - else: - await self._message.edit(view=None) - else: - if self._original and not self._original.is_expired(): - if self._delete_after: - await self._original.delete_original_response() - else: - await self._original.edit_original_response(view=None) - except discord.HTTPException: - # Nothing we can do here - pass - - async def get_page(self, page_id): - page_id %= len(self._pages) - return self._pages[page_id] - - def page_count(self): - return len(self.pages) - - async def page_cmd(self, interaction: discord.Interaction, value: str): - """ - `/page` command for the `Pager` MicroUI. - """ - await interaction.response.defer(ephemeral=True) - t = ctx_translator.get().t - nexts = {word.strip() for word in t(self.next_list).split(',')} - prevs = {word.strip() for word in t(self.prev_list).split(',')} - firsts = {word.strip() for word in t(self.first_list).split(',')} - lasts = {word.strip() for word in t(self.last_list).split(',')} - - if value: - value = value.lower().strip() - if value.isdigit(): - # Assume value is page number - self.page_num = int(value) - 1 - if self.page_num == -1: - self.page_num = 0 - elif value in firsts: - self.page_num = 0 - elif value in nexts: - self.page_num += 1 - elif value in prevs: - self.page_num -= 1 - elif value in lasts: - self.page_num = -1 - elif value.startswith('-') and value[1:].isdigit(): - self.page_num = - int(value[1:]) - else: - await interaction.edit_original_response( - embed=error_embed( - t(_p( - 'cmd:page|pager:Pager|error:parse', - "Could not understand page specification `{value}`." - )).format(value=value) - ) - ) - return - await interaction.delete_original_response() - await self.redraw() - - async def page_acmpl(self, interaction: discord.Interaction, partial: str): - """ - `/page` command autocompletion for the `Pager` MicroUI. - """ - t = ctx_translator.get().t - nexts = {word.strip() for word in t(self.next_list).split(',')} - prevs = {word.strip() for word in t(self.prev_list).split(',')} - firsts = {word.strip() for word in t(self.first_list).split(',')} - lasts = {word.strip() for word in t(self.last_list).split(',')} - - total = len(self._pages) - num = self.page_num - page_choices: dict[int, str] = {} - - # TODO: Support page names and hints? - - if len(self._pages) > 10: - # First add the general choices - if num < total-1: - page_choices[total-1] = t(_p( - 'cmd:page|acmpl|pager:Pager|choice:last', - "Last: Page {page}/{total}" - )).format(page=total, total=total) - - page_choices[num] = t(_p( - 'cmd:page|acmpl|pager:Pager|choice:current', - "Current: Page {page}/{total}" - )).format(page=num+1, total=total) - choices = [ - appcmds.Choice(name=string[:100], value=str(num+1)) - for num, string in sorted(page_choices.items(), key=lambda t: t[0]) - ] - else: - # Particularly support page names here - choices = [ - appcmds.Choice( - name=('> ' * (i == num) + t(_p( - 'cmd:page|acmpl|pager:Pager|choice:general', - "Page {page}" - )).format(page=i+1))[:100], - value=str(i+1) - ) - for i in range(0, total) - ] - - partial = partial.strip() - - if partial: - value = partial.lower().strip() - if value.isdigit(): - # Assume value is page number - page_num = int(value) - 1 - if page_num == -1: - page_num = 0 - elif value in firsts: - page_num = 0 - elif value in nexts: - page_num = self.page_num + 1 - elif value in prevs: - page_num = self.page_num - 1 - elif value in lasts: - page_num = -1 - elif value.startswith('-') and value[1:].isdigit(): - page_num = - int(value[1:]) - else: - page_num = None - - if page_num is not None: - page_num %= total - choice = appcmds.Choice( - name=t(_p( - 'cmd:page|acmpl|pager:Page|choice:select', - "Selected: Page {page}/{total}" - )).format(page=page_num+1, total=total)[:100], - value=str(page_num + 1) - ) - return [choice, *choices] - else: - return [ - appcmds.Choice( - name=t(_p( - 'cmd:page|acmpl|pager:Page|error:parse', - "No matching pages!" - )).format(page=page_num, total=total)[:100], - value=partial - ) - ] - else: - return choices - - @property - def page_row(self): - if self.show_cancel: - if len(self._pages) > 1: - return (self.prev_page_button, self.cancel_button, self.next_page_button) - else: - return (self.cancel_button,) - else: - if len(self._pages) > 1: - return (self.prev_page_button, self.next_page_button) - else: - return () - - async def refresh(self): - await super().refresh() - self.set_layout(self.page_row) - - async def redraw(self): - await self.refresh() - - if not self._original: - raise ValueError("Running run pager manually without interaction.") - - try: - if self._message: - await self._message.edit(**self.current_page.edit_args, view=self) - else: - if self._original.is_expired(): - raise SafeCancellation("This interface has expired, please try again.") - await self._original.edit_original_response(**self.current_page.edit_args, view=self) - except discord.HTTPException: - raise SafeCancellation("Could not page your results! Please try again.") - - async def run(self, interaction: discord.Interaction, ephemeral=False, locked=True, ownerid=None, **kwargs): - """ - Display the UI. - Attempts to reply to the interaction if it has not already been replied to, - otherwise send a follow-up. - - An ephemeral response must be sent as an initial interaction response. - On the other hand, a semi-persistent response (expected to last longer than the lifetime of the interaction) - must be sent as a followup. - - Extra kwargs are combined with the first page arguments and given to the relevant send method. - - Parameters - ---------- - interaction: discord.Interaction - The interaction to send the pager in response to. - ephemeral: bool - Whether to send the interaction ephemerally. - If this is true, the interaction *must* be fresh (i.e. no response done). - Default: False - locked: bool - Whether this interface is locked to the user `self.ownerid`. - Irrelevant for ephemeral messages. - Use `ownerid` to override the default owner id. - Defaults to true for fail-safety. - Default: True - ownerid: Optional[int] - The userid allowed to use this interaction. - By default, this will be the `interaction.user.id`, - presuming that this is the user which originally triggered this message. - An override may be useful if a user triggers a paging UI for someone else. - """ - if not interaction.channel_id: - raise ValueError("Cannot run pager on a channelless interaction.") - - self._original = interaction - self._ownerid = ownerid - self._locked = locked - self._channelid = interaction.channel_id - - await self.refresh() - args = self.current_page.send_args | kwargs - - if interaction.response.is_done(): - if ephemeral: - raise ValueError("Ephemeral response requires fres interaction.") - self._message = await interaction.followup.send(**args, view=self) - self._is_followup = True - else: - self._is_followup = False - await interaction.response.send_message(**args, view=self) - - self.set_active()