From 0c14717bbc73ee8ca62ec0c088e252fb0a580cd7 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 16 Aug 2021 01:30:59 -0400 Subject: [PATCH 01/54] dependencies: add dislash for discord interactions --- poetry.lock | 53 ++++++++++++++++++++++++++++++++++++++------------ pyproject.toml | 1 + 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/poetry.lock b/poetry.lock index 520ef224..05be61ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -255,7 +255,7 @@ toml = ["toml"] [[package]] name = "discord.py" -version = "2.0.0a3467+g08a4db39" +version = "2.0.0a3470+gfeae059c" description = "A Python wrapper for the Discord API" category = "main" optional = false @@ -274,7 +274,18 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] type = "git" url = "https://github.com/Rapptz/discord.py.git" reference = "master" -resolved_reference = "08a4db396118aeda6205ff56c8c8fc565fc338fc" +resolved_reference = "feae059c6858e419552ec4096f1ad2692bb4c484" + +[[package]] +name = "dislash.py" +version = "1.4.6" +description = "A python wrapper for message components and application commands." +category = "main" +optional = false +python-versions = ">=3.8, <4" + +[package.dependencies] +"discord.py" = "*" [[package]] name = "distlib" @@ -426,14 +437,14 @@ flake8 = "*" [[package]] name = "flake8-tidy-imports" -version = "4.3.0" +version = "4.4.1" description = "A flake8 plugin that helps you write tidier imports." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" +flake8 = ">=3.8.0,<4" [[package]] name = "flake8-todo" @@ -1051,7 +1062,7 @@ environs = ["environs"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "ffc92d956a0a41f4495c9f72484a26d0d261836d15b984d686d8e44276bd6244" +content-hash = "fbc4a15f00b4375b285c1b85ec08e8381ff604fb51e473d80a838ff457a9afe4" [metadata.files] aiodns = [ @@ -1211,11 +1222,6 @@ cffi = [ {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, - {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, - {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, - {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, - {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, - {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, @@ -1334,6 +1340,10 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] "discord.py" = [] +"dislash.py" = [ + {file = "dislash.py-1.4.6-py3-none-any.whl", hash = "sha256:518c6c033572da3f56018feb77deb452d57019259266067fd22da727aa6510ff"}, + {file = "dislash.py-1.4.6.tar.gz", hash = "sha256:1cac09d3c6d4316cff5c07765fffc527a3ed598afd4458c674402a6b9773c0b5"}, +] distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, @@ -1382,8 +1392,8 @@ flake8-string-format = [ {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, ] flake8-tidy-imports = [ - {file = "flake8-tidy-imports-4.3.0.tar.gz", hash = "sha256:e66d46f58ed108f36da920e7781a728dc2d8e4f9269e7e764274105700c0a90c"}, - {file = "flake8_tidy_imports-4.3.0-py3-none-any.whl", hash = "sha256:d6e64cb565ca9474d13d5cb3f838b8deafb5fed15906998d4a674daf55bd6d89"}, + {file = "flake8-tidy-imports-4.4.1.tar.gz", hash = "sha256:c18b3351b998787db071e766e318da1f0bd9d5cecc69c4022a69e7aa2efb2c51"}, + {file = "flake8_tidy_imports-4.4.1-py3-none-any.whl", hash = "sha256:631a1ba9daaedbe8bb53f6086c5a92b390e98371205259e0e311a378df8c3dc8"}, ] flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, @@ -1688,19 +1698,38 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] regex = [ + {file = "regex-2021.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8764a78c5464ac6bde91a8c87dd718c27c1cabb7ed2b4beaf36d3e8e390567f9"}, {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4551728b767f35f86b8e5ec19a363df87450c7376d7419c3cac5b9ceb4bce576"}, {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:577737ec3d4c195c4aef01b757905779a9e9aee608fa1cf0aec16b5576c893d3"}, {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c856ec9b42e5af4fe2d8e75970fcc3a2c15925cbcc6e7a9bcb44583b10b95e80"}, {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3835de96524a7b6869a6c710b26c90e94558c31006e96ca3cf6af6751b27dca1"}, {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cea56288eeda8b7511d507bbe7790d89ae7049daa5f51ae31a35ae3c05408531"}, + {file = "regex-2021.8.3-cp36-cp36m-win32.whl", hash = "sha256:a4eddbe2a715b2dd3849afbdeacf1cc283160b24e09baf64fa5675f51940419d"}, + {file = "regex-2021.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:57fece29f7cc55d882fe282d9de52f2f522bb85290555b49394102f3621751ee"}, + {file = "regex-2021.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a5c6dbe09aff091adfa8c7cfc1a0e83fdb8021ddb2c183512775a14f1435fe16"}, {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff4a8ad9638b7ca52313d8732f37ecd5fd3c8e3aff10a8ccb93176fd5b3812f6"}, {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b63e3571b24a7959017573b6455e05b675050bbbea69408f35f3cb984ec54363"}, {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fbc20975eee093efa2071de80df7f972b7b35e560b213aafabcec7c0bd00bd8c"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14caacd1853e40103f59571f169704367e79fb78fac3d6d09ac84d9197cadd16"}, {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb350eb1060591d8e89d6bac4713d41006cd4d479f5e11db334a48ff8999512f"}, + {file = "regex-2021.8.3-cp37-cp37m-win32.whl", hash = "sha256:18fdc51458abc0a974822333bd3a932d4e06ba2a3243e9a1da305668bd62ec6d"}, + {file = "regex-2021.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:026beb631097a4a3def7299aa5825e05e057de3c6d72b139c37813bfa351274b"}, + {file = "regex-2021.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:16d9eaa8c7e91537516c20da37db975f09ac2e7772a0694b245076c6d68f85da"}, {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3905c86cc4ab6d71635d6419a6f8d972cab7c634539bba6053c47354fd04452c"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937b20955806381e08e54bd9d71f83276d1f883264808521b70b33d98e4dec5d"}, {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:28e8af338240b6f39713a34e337c3813047896ace09d51593d6907c66c0708ba"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c09d88a07483231119f5017904db8f60ad67906efac3f1baa31b9b7f7cca281"}, {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:85f568892422a0e96235eb8ea6c5a41c8ccbf55576a2260c0160800dbd7c4f20"}, + {file = "regex-2021.8.3-cp38-cp38-win32.whl", hash = "sha256:bf6d987edd4a44dd2fa2723fca2790f9442ae4de2c8438e53fcb1befdf5d823a"}, + {file = "regex-2021.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:8fe58d9f6e3d1abf690174fd75800fda9bdc23d2a287e77758dc0e8567e38ce6"}, + {file = "regex-2021.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7976d410e42be9ae7458c1816a416218364e06e162b82e42f7060737e711d9ce"}, {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9569da9e78f0947b249370cb8fadf1015a193c359e7e442ac9ecc585d937f08d"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bbe342c5b2dec5c5223e7c363f291558bc27982ef39ffd6569e8c082bdc83"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f421e3cdd3a273bace013751c345f4ebeef08f05e8c10757533ada360b51a39"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea212df6e5d3f60341aef46401d32fcfded85593af1d82b8b4a7a68cd67fdd6b"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a3b73390511edd2db2d34ff09aa0b2c08be974c71b4c0505b4a048d5dc128c2b"}, + {file = "regex-2021.8.3-cp39-cp39-win32.whl", hash = "sha256:f35567470ee6dbfb946f069ed5f5615b40edcbb5f1e6e1d3d2b114468d505fc6"}, + {file = "regex-2021.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:bfa6a679410b394600eafd16336b2ce8de43e9b13f7fb9247d84ef5ad2b45e91"}, {file = "regex-2021.8.3.tar.gz", hash = "sha256:8935937dad2c9b369c3d932b0edbc52a62647c2afb2fafc0c280f14a8bf56a6a"}, ] requests = [ diff --git a/pyproject.toml b/pyproject.toml index c1122012..562f6799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ arrow = "^1.1.1" colorama = "^0.4.3" coloredlogs = "^15.0" "discord.py" = { git = "https://github.com/Rapptz/discord.py.git", rev = "master" } +"dislash.py" = "^1.4.6" environs = { version = "~=9.3.3", optional = true } pydantic = { version = "^1.8.2", extras = ["dotenv"] } toml = "^0.10.2" From c29dd0556f72403d56c027df3b3478d006b02907 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 16 Aug 2021 01:32:24 -0400 Subject: [PATCH 02/54] create a slash client --- modmail/__main__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modmail/__main__.py b/modmail/__main__.py index 4b4aaa84..8342d2da 100644 --- a/modmail/__main__.py +++ b/modmail/__main__.py @@ -1,5 +1,7 @@ import logging +from dislash import InteractionClient + from modmail.bot import ModmailBot from modmail.log import ModmailLogger @@ -18,6 +20,7 @@ def main() -> None: """Run the bot.""" bot = ModmailBot() + InteractionClient(bot) bot.load_extensions() bot.load_plugins() log.notice("Running the bot.") From 2835227a83fe94a52a01a6fae4a056a6c9abe803 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 16 Aug 2021 02:50:28 -0400 Subject: [PATCH 03/54] chore: export dependencies Forgotten in commit 0c14717bbc73ee8ca62ec0c088e252fb0a580cd7 --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index b53d19e4..22d424f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ chardet==4.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or pyt colorama==0.4.4; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") coloredlogs==15.0.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") discord.py @ git+https://github.com/Rapptz/discord.py.git@master ; python_full_version >= "3.8.0" +dislash.py==1.4.6; python_version >= "3.8" and python_version < "4" humanfriendly==9.2; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" idna==3.2; python_version >= "3.6" multidict==5.1.0; python_version >= "3.6" and python_full_version >= "3.8.0" or python_version >= "3.6" From 73729e3685902c016eb4e1f2b46b49a65ea78d60 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 16 Aug 2021 04:11:46 -0400 Subject: [PATCH 04/54] feat: add button pagination --- modmail/extensions/extension_manager.py | 4 +- modmail/utils/__init__.py | 0 modmail/utils/pagination.py | 113 ++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 modmail/utils/__init__.py create mode 100644 modmail/utils/pagination.py diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 2f5c5a41..a6ef896b 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -15,6 +15,7 @@ from modmail.log import ModmailLogger from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog from modmail.utils.extensions import EXTENSIONS, NO_UNLOAD, unqualify, walk_extensions +from modmail.utils.pagination import ButtonPaginator log: ModmailLogger = logging.getLogger(__name__) @@ -186,7 +187,8 @@ async def list_extensions(self, ctx: Context) -> None: log.debug(f"{ctx.author} requested a list of all {self.type}s. " "Returning a paginated list.") # TODO: since we currently don't have a paginator. - await ctx.send("".join(lines) or f"There are no {self.type}s installed.") + # await ctx.send("".join(lines) or f"There are no {self.type}s installed.") + await ButtonPaginator.paginate(ctx, lines) @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) async def resync_extensions(self, ctx: Context) -> None: diff --git a/modmail/utils/__init__.py b/modmail/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py new file mode 100644 index 00000000..b2d30eaa --- /dev/null +++ b/modmail/utils/pagination.py @@ -0,0 +1,113 @@ +import logging +import typing as t + +import discord +import dislash +from discord.ext.commands import Context, Paginator +from dislash import ActionRow, Button, ButtonStyle, ClickListener + +from modmail.log import ModmailLogger + +JUMP_FIRST_EMOJI = "\u23EE" # [:track_previous:] +BACK_EMOJI = "\u2B05" # [:arrow_left:] +FORWARD_EMOJI = "\u27A1" # [:arrow_right:] +JUMP_LAST_EMOJI = "\u23ED" # [:track_next:] +STOP_PAGINATE_EMOJI = "\u274c" # [:x:] + +logger: ModmailLogger = logging.getLogger(__name__) +ephermals = True + + +class ButtonPaginator(Paginator): + """A paginator that has a set of buttons to jump to other pages.""" + + def __init__(self, prefix: str = "", suffix: str = "", max_size: int = 4000, linesep: str = "\n"): + logger.trace("Created a paginator in __init__.") + self.prefix = prefix + self.suffix = suffix + self.max_size = max_size + self.linesep = linesep + self.clear() + + @classmethod + async def paginate( + cls, ctx: Context, lines: t.List[str], *, embed: discord.Embed = None, starting_page: int = 0 + ) -> None: + """Paginate the entries into pages.""" + paginator = cls() + if embed is not None: + paginator.embed = embed + else: + paginator.embed = discord.Embed() + logger.trace("Created a paginator.") + + # add provided lines to the paginator + for line in lines: + # TODO: Handle errors when the a runtime error is raised + paginator.add_line(line) + + row = ActionRow( + Button(style=ButtonStyle.blurple, emoji=JUMP_FIRST_EMOJI, label="", custom_id="jump_to_first"), + Button(style=ButtonStyle.blurple, emoji=BACK_EMOJI, label="", custom_id="prev_page"), + Button(style=ButtonStyle.blurple, emoji=FORWARD_EMOJI, label="", custom_id="next_page"), + Button(style=ButtonStyle.blurple, emoji=JUMP_LAST_EMOJI, label="", custom_id="jump_to_last"), + Button(style=ButtonStyle.gray, emoji=STOP_PAGINATE_EMOJI, label="", custom_id="stop_pagination"), + ) + logger.trace("Created an action row") + + if len(paginator.pages) <= 2: + # disable buttons to jump pages since there are only two pages + row.disable_buttons(0, 3) + logger.trace("Disabled jump buttons") + + paginator.embed.description = paginator.pages[starting_page] + + if len(paginator.pages) == 1: + logger.debug("Sending without pagination as its only one page.") + try: + msg = await ctx.send(embeds=[paginator.embed]) + except Exception as e: + print(e) + logger.error("Failed to send message to channel.", exc_info=True) + else: + msg = await ctx.send(embeds=[paginator.embed], components=[row]) + + # Time out pagination after 180 seconds + # This will fire an event at the end of the listener + # and allow us to edit the message to delete the interactions + on_click: ClickListener = msg.create_click_listener(timeout=180) + + # @on_click.from_user(ctx.author, cancel_others=True) + @on_click.matching_id("jump_to_first", cancel_others=True) + async def _jump_to_first(inter: dislash.MessageInteraction) -> None: + logger.debug("_jump_to_first") + await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + + @on_click.matching_id("prev_page", cancel_others=True) + # @on_click.from_user(ctx.author, cancel_others=True) + async def _prev_page(inter: dislash.MessageInteraction) -> None: + logger.debug("_prev_page") + await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + + # @on_click.from_user(ctx.author, cancel_others=True) + @on_click.matching_id("next_page", cancel_others=True) + async def _next_page(inter: dislash.MessageInteraction) -> None: + logger.debug("_next_page") + await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + + # @on_click.from_user(ctx.author, cancel_others=True) + @on_click.matching_id("jump_to_last", cancel_others=True) + async def _jump_to_last(inter: dislash.MessageInteraction) -> None: + logger.debug("_jump_to_last") + await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + + @on_click.timeout + async def on_timeout() -> None: + # remove all components once we stop listening + await msg.edit(components=[]) + + # @on_click.from_user(ctx.author) + @on_click.matching_id("stop_pagination", cancel_others=True) + async def drop_pagnation(inter: dislash.MessageInteraction) -> None: + logger.debug("drop_pagnation") + await msg.edit(components=[]) From 6448efb7bf163bb38693b1b3e0385857a9aea154 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 16 Aug 2021 04:19:02 -0400 Subject: [PATCH 05/54] chore: update changelog with interaction paginator --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dafbd12..e9933f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Interaction Paginator that uses discord buttons (#50) + ## [0.1.0] - 2021-08-13 ### Added From 9b43d4f5396a0e200c73fa9d398d5e3d50ffd8d2 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 16 Aug 2021 18:34:56 -0400 Subject: [PATCH 06/54] feat: add the pagination logic --- modmail/utils/pagination.py | 55 +++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index b2d30eaa..e3bf02a0 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -4,7 +4,7 @@ import discord import dislash from discord.ext.commands import Context, Paginator -from dislash import ActionRow, Button, ButtonStyle, ClickListener +from dislash import ActionRow, Button, ButtonStyle, ClickListener, ResponseType from modmail.log import ModmailLogger @@ -15,13 +15,13 @@ STOP_PAGINATE_EMOJI = "\u274c" # [:x:] logger: ModmailLogger = logging.getLogger(__name__) -ephermals = True +ephermals = False class ButtonPaginator(Paginator): """A paginator that has a set of buttons to jump to other pages.""" - def __init__(self, prefix: str = "", suffix: str = "", max_size: int = 4000, linesep: str = "\n"): + def __init__(self, prefix: str = "", suffix: str = "", max_size: int = 300, linesep: str = "\n"): logger.trace("Created a paginator in __init__.") self.prefix = prefix self.suffix = suffix @@ -61,7 +61,7 @@ async def paginate( logger.trace("Disabled jump buttons") paginator.embed.description = paginator.pages[starting_page] - + paginator.current_page = starting_page if len(paginator.pages) == 1: logger.debug("Sending without pagination as its only one page.") try: @@ -81,25 +81,64 @@ async def paginate( @on_click.matching_id("jump_to_first", cancel_others=True) async def _jump_to_first(inter: dislash.MessageInteraction) -> None: logger.debug("_jump_to_first") - await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + new_page = 0 + logger.trace(new_page >= 0) + if new_page >= 0: + paginator.embed.description = paginator.pages[new_page] + paginator.current_page -= 1 + paginator.embed.set_footer(text=f"Page {new_page + 1}/{len(paginator.pages)}") + await msg.edit(embeds=[paginator.embed], components=[row]) + await inter.reply(type=ResponseType.DeferredUpdateMessage) + else: + # page is out of range + await inter.reply(content="You're at the first page!", ephemeral=True) @on_click.matching_id("prev_page", cancel_others=True) # @on_click.from_user(ctx.author, cancel_others=True) async def _prev_page(inter: dislash.MessageInteraction) -> None: logger.debug("_prev_page") - await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + new_page = paginator.current_page - 1 + logger.trace(new_page >= 0) + if new_page >= 0: + paginator.embed.description = paginator.pages[new_page] + paginator.current_page -= 1 + paginator.embed.set_footer(text=f"Page {new_page + 1}/{len(paginator.pages)}") + await msg.edit(embeds=[paginator.embed], components=[row]) + await inter.reply(type=ResponseType.DeferredUpdateMessage) + else: + # page is out of range + await inter.reply(content="You're at the first page!", ephemeral=True) # @on_click.from_user(ctx.author, cancel_others=True) @on_click.matching_id("next_page", cancel_others=True) async def _next_page(inter: dislash.MessageInteraction) -> None: logger.debug("_next_page") - await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + logger.trace(paginator.current_page + 1 < len(paginator.pages)) + if paginator.current_page + 1 < len(paginator.pages): + paginator.embed.description = paginator.pages[paginator.current_page + 1] + paginator.current_page += 1 + paginator.embed.set_footer(text=f"Page {paginator.current_page + 1}/{len(paginator.pages)}") + await msg.edit(embeds=[paginator.embed], components=[row]) + await inter.reply(type=ResponseType.DeferredUpdateMessage) + else: + # page is out of range + await inter.reply(content=f"There's only {len(paginator.pages)} pages!", ephemeral=True) # @on_click.from_user(ctx.author, cancel_others=True) @on_click.matching_id("jump_to_last", cancel_others=True) async def _jump_to_last(inter: dislash.MessageInteraction) -> None: logger.debug("_jump_to_last") - await inter.reply(content=inter.button.custom_id, ephemeral=ephermals) + new_page = len(paginator.pages) - 1 + logger.trace(len(paginator.pages)) + if new_page < len(paginator.pages): + await inter.reply(type=ResponseType.DeferredUpdateMessage) + paginator.embed.description = paginator.pages[new_page] + paginator.current_page = new_page + paginator.embed.set_footer(text=f"Page {new_page + 1}/{len(paginator.pages)}") + await msg.edit(embeds=[paginator.embed], components=[row]) + else: + # page is out of range + await inter.reply(content=f"There's only {len(paginator.pages)} pages!", ephemeral=True) @on_click.timeout async def on_timeout() -> None: From 27c2c51584c06bfd4b9ddf2e09e3318a16156cfc Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 17 Aug 2021 02:17:02 -0400 Subject: [PATCH 07/54] dependencies: get rid of dislash --- modmail/__main__.py | 3 --- poetry.lock | 28 ++++++++-------------------- pyproject.toml | 1 - requirements.txt | 1 - 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/modmail/__main__.py b/modmail/__main__.py index 8342d2da..4b4aaa84 100644 --- a/modmail/__main__.py +++ b/modmail/__main__.py @@ -1,7 +1,5 @@ import logging -from dislash import InteractionClient - from modmail.bot import ModmailBot from modmail.log import ModmailLogger @@ -20,7 +18,6 @@ def main() -> None: """Run the bot.""" bot = ModmailBot() - InteractionClient(bot) bot.load_extensions() bot.load_plugins() log.notice("Running the bot.") diff --git a/poetry.lock b/poetry.lock index 9d233c7e..5f378e80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -196,11 +196,14 @@ unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "7.1.2" +version = "8.0.1" description = "Composable command line interface toolkit" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "codecov" @@ -273,17 +276,6 @@ url = "https://github.com/Rapptz/discord.py.git" reference = "master" resolved_reference = "feae059c6858e419552ec4096f1ad2692bb4c484" -[[package]] -name = "dislash.py" -version = "1.4.6" -description = "A python wrapper for message components and application commands." -category = "main" -optional = false -python-versions = ">=3.8, <4" - -[package.dependencies] -"discord.py" = "*" - [[package]] name = "distlib" version = "0.3.2" @@ -1209,7 +1201,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "36459626692d7c1e522918a466f037ad02feb94e94d6790c8ebff7eeb4f15ee0" +content-hash = "b2fe2e490de66438a4a582dbe69e4b9f1417af90f47d928dc59ef6686a22c01a" [metadata.files] aiodns = [ @@ -1416,8 +1408,8 @@ charset-normalizer = [ {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, ] click = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, + {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, + {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -1487,10 +1479,6 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] "discord.py" = [] -"dislash.py" = [ - {file = "dislash.py-1.4.6-py3-none-any.whl", hash = "sha256:518c6c033572da3f56018feb77deb452d57019259266067fd22da727aa6510ff"}, - {file = "dislash.py-1.4.6.tar.gz", hash = "sha256:1cac09d3c6d4316cff5c07765fffc527a3ed598afd4458c674402a6b9773c0b5"}, -] distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, diff --git a/pyproject.toml b/pyproject.toml index 347a86e6..05f95e8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ arrow = "^1.1.1" colorama = "^0.4.3" coloredlogs = "^15.0" "discord.py" = { git = "https://github.com/Rapptz/discord.py.git", rev = "master" } -"dislash.py" = "^1.4.6" pydantic = { version = "^1.8.2", extras = ["dotenv"] } toml = "^0.10.2" # HACK: Poetry uses requests to install git dependencies, which are not explicitly installed when `--no-dev` diff --git a/requirements.txt b/requirements.txt index 23f44a16..300c869e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,6 @@ charset-normalizer==2.0.4; python_full_version >= "3.6.0" and python_version >= colorama==0.4.4; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") coloredlogs==15.0.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") discord.py @ git+https://github.com/Rapptz/discord.py.git@master ; python_full_version >= "3.8.0" -dislash.py==1.4.6; python_version >= "3.8" and python_version < "4" humanfriendly==9.2; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" idna==3.2; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.5" or python_version >= "3.6" multidict==5.1.0; python_version >= "3.6" and python_full_version >= "3.8.0" or python_version >= "3.6" From 787e52b025aab7c918f4a92d4b527ef3d967debd Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 17 Aug 2021 03:30:26 -0400 Subject: [PATCH 08/54] pagination: rewrite entire system with native dpy previous pagination system with dislash had so many bugs, many of them caused by dislash itself. I've adapted the pagination system from khk4912/EZPaginator and converted it to using button interactions. Nearly all that is left to do is make the paginator pretty with embeds. --- modmail/extensions/extension_manager.py | 4 +- modmail/utils/pagination.py | 345 +++++++++++++++--------- 2 files changed, 217 insertions(+), 132 deletions(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index a6ef896b..6596e6f9 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -15,7 +15,7 @@ from modmail.log import ModmailLogger from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog from modmail.utils.extensions import EXTENSIONS, NO_UNLOAD, unqualify, walk_extensions -from modmail.utils.pagination import ButtonPaginator +from modmail.utils.pagination import Paginator log: ModmailLogger = logging.getLogger(__name__) @@ -188,7 +188,7 @@ async def list_extensions(self, ctx: Context) -> None: # TODO: since we currently don't have a paginator. # await ctx.send("".join(lines) or f"There are no {self.type}s installed.") - await ButtonPaginator.paginate(ctx, lines) + await Paginator.paginate(ctx, lines) @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) async def resync_extensions(self, ctx: Context) -> None: diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index e3bf02a0..f8809185 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -1,10 +1,43 @@ +""" +Paginator. + +Adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 + +MIT License + +Copyright (c) 2020 khk4912 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +CHANGES: +- made this work with buttons instead of reactions +- comply with black and flake8 +""" import logging -import typing as t +from enum import Enum +from typing import List, Optional, Union import discord -import dislash -from discord.ext.commands import Context, Paginator -from dislash import ActionRow, Button, ButtonStyle, ClickListener, ResponseType +from discord import ButtonStyle, Interaction +from discord.ext import commands +from discord.ui import Button, View, button from modmail.log import ModmailLogger @@ -12,141 +45,193 @@ BACK_EMOJI = "\u2B05" # [:arrow_left:] FORWARD_EMOJI = "\u27A1" # [:arrow_right:] JUMP_LAST_EMOJI = "\u23ED" # [:track_next:] -STOP_PAGINATE_EMOJI = "\u274c" # [:x:] +STOP_PAGINATE_EMOJI = "\U0001f6d1" # [:octagonal_sign:] logger: ModmailLogger = logging.getLogger(__name__) -ephermals = False -class ButtonPaginator(Paginator): - """A paginator that has a set of buttons to jump to other pages.""" +class MissingAttributeError(Exception): + """Missing attribute.""" + + pass + + +class TooManyAttributesError(Exception): + """Too many attributes.""" + + pass + + +class InvalidArgumentError(Exception): + """Improper argument.""" - def __init__(self, prefix: str = "", suffix: str = "", max_size: int = 300, linesep: str = "\n"): - logger.trace("Created a paginator in __init__.") - self.prefix = prefix - self.suffix = suffix - self.max_size = max_size - self.linesep = linesep - self.clear() + pass + + +class Types(Enum): + """Types of pagination.""" + + CONTENTS = 0 + EMBEDS = 1 + + +class Paginator(View): + """ + Class for Pagination. + + Attributes + ---------- + ctx: commands.Context + Context of the message. + contents : List[str], optional + List of contents. + embeds : List[Embed], optional + List of embeds. If both contents and embeds are given, the priority is embed. + timeout : float, default 180 + A timeout of receiving Interactions. + only : discord.abc.User, optional + If a parameter is given, the paginator will respond only to the selected user. + basic_emojis : List[Emoji], optional + Custom basic emoji list. There should be 2 emojis. + extended_emojis : List[Emoji], optional + Extended emoji list, There should be 4 emojis. + auto_delete : bool, default False + Whether to delete message after timeout. + """ + + def __init__( + self, + ctx: commands.Context = None, + contents: Optional[List[str]] = None, + embeds: Optional[List[discord.Embed]] = None, + timeout: float = 180, + embed: discord.Embed = None, + only: Optional[discord.abc.User] = None, + ) -> None: + """Creates a new Paginator instance. At least one of ctx or message must be supplied.""" + self.ctx = ctx + self.timeout = timeout + self.only = only + self.index = 0 + self.pages: List[Union[discord.Embed, str]] = [] + + if not (isinstance(timeout, float) or isinstance(timeout, int)): + raise InvalidArgumentError("timeout must be a float") + + if contents is None and embeds is None: + raise MissingAttributeError("Both contents and embeds are None.") + elif contents is not None and embeds is not None: + raise TooManyAttributesError("Both contents and embeds are given. Please choose one.") + if contents: + # contents exist, so embeds is be None + self.pages = contents + self.type = Types.CONTENTS + else: + self.pages = embeds + self.type = Types.EMBEDS + + super().__init__() @classmethod async def paginate( - cls, ctx: Context, lines: t.List[str], *, embed: discord.Embed = None, starting_page: int = 0 + cls, + ctx: commands.Context = None, + contents: Optional[List[str]] = None, + embeds: Optional[List[discord.Embed]] = None, + timeout: float = 180, + only: Optional[discord.abc.User] = None, ) -> None: - """Paginate the entries into pages.""" - paginator = cls() - if embed is not None: - paginator.embed = embed - else: - paginator.embed = discord.Embed() - logger.trace("Created a paginator.") - - # add provided lines to the paginator - for line in lines: - # TODO: Handle errors when the a runtime error is raised - paginator.add_line(line) - - row = ActionRow( - Button(style=ButtonStyle.blurple, emoji=JUMP_FIRST_EMOJI, label="", custom_id="jump_to_first"), - Button(style=ButtonStyle.blurple, emoji=BACK_EMOJI, label="", custom_id="prev_page"), - Button(style=ButtonStyle.blurple, emoji=FORWARD_EMOJI, label="", custom_id="next_page"), - Button(style=ButtonStyle.blurple, emoji=JUMP_LAST_EMOJI, label="", custom_id="jump_to_last"), - Button(style=ButtonStyle.gray, emoji=STOP_PAGINATE_EMOJI, label="", custom_id="stop_pagination"), - ) - logger.trace("Created an action row") + """Something.""" + paginator = cls(ctx, contents, embeds, timeout, only) + # remove buttons based on how many pages we have if len(paginator.pages) <= 2: - # disable buttons to jump pages since there are only two pages - row.disable_buttons(0, 3) - logger.trace("Disabled jump buttons") - - paginator.embed.description = paginator.pages[starting_page] - paginator.current_page = starting_page - if len(paginator.pages) == 1: - logger.debug("Sending without pagination as its only one page.") - try: - msg = await ctx.send(embeds=[paginator.embed]) - except Exception as e: - print(e) - logger.error("Failed to send message to channel.", exc_info=True) + pass + # paginator.remove_item() + paginator.modify_disabled() + if paginator.type == Types.CONTENTS: + msg: discord.Message = await ctx.send(content=paginator.pages[paginator.index], view=paginator) + else: + msg: discord.Message = await ctx.send(embeds=paginator.pages[paginator.index], view=paginator) + + await paginator.wait() + await msg.edit(view=None) + + async def interaction_check(self, interaction: Interaction) -> bool: + """Check if the interaction is by the author of the paginatior.""" + if not (is_valid := self.ctx.author.id == interaction.user.id): + await interaction.response.send_message( + content="This is not your message to paginate!", ephemeral=True + ) + return is_valid + + def modify_disabled(self) -> None: + """Disable specific buttons depending on paginator page and length.""" + ids = ["jump_first", "back", "next", "jump_last"] + states = [] + if len(self.pages) > 2: + states = [False for _ in range(4)] + elif len(self.pages) == 2: + # there are two pages + states = [True, False, False, True] + else: + # there is only one page + states = [True for _ in range(4)] + if self.index == 0: + for i in range(2): + states[i] = True + elif self.index == len(self.pages) - 1: + for i in range(2): + states[(-1 * (i + 1))] = True + for item in self.children: + id = item.to_component_dict()["custom_id"] + if id in ids: + item.disabled = states[ids.index(id)] + + async def send_page(self, interaction: Interaction) -> None: + """Send new page.""" + self.modify_disabled() + if self.type == Types.CONTENTS: + await interaction.message.edit(content=self.pages[self.index], view=self) else: - msg = await ctx.send(embeds=[paginator.embed], components=[row]) - - # Time out pagination after 180 seconds - # This will fire an event at the end of the listener - # and allow us to edit the message to delete the interactions - on_click: ClickListener = msg.create_click_listener(timeout=180) - - # @on_click.from_user(ctx.author, cancel_others=True) - @on_click.matching_id("jump_to_first", cancel_others=True) - async def _jump_to_first(inter: dislash.MessageInteraction) -> None: - logger.debug("_jump_to_first") - new_page = 0 - logger.trace(new_page >= 0) - if new_page >= 0: - paginator.embed.description = paginator.pages[new_page] - paginator.current_page -= 1 - paginator.embed.set_footer(text=f"Page {new_page + 1}/{len(paginator.pages)}") - await msg.edit(embeds=[paginator.embed], components=[row]) - await inter.reply(type=ResponseType.DeferredUpdateMessage) - else: - # page is out of range - await inter.reply(content="You're at the first page!", ephemeral=True) - - @on_click.matching_id("prev_page", cancel_others=True) - # @on_click.from_user(ctx.author, cancel_others=True) - async def _prev_page(inter: dislash.MessageInteraction) -> None: - logger.debug("_prev_page") - new_page = paginator.current_page - 1 - logger.trace(new_page >= 0) - if new_page >= 0: - paginator.embed.description = paginator.pages[new_page] - paginator.current_page -= 1 - paginator.embed.set_footer(text=f"Page {new_page + 1}/{len(paginator.pages)}") - await msg.edit(embeds=[paginator.embed], components=[row]) - await inter.reply(type=ResponseType.DeferredUpdateMessage) - else: - # page is out of range - await inter.reply(content="You're at the first page!", ephemeral=True) - - # @on_click.from_user(ctx.author, cancel_others=True) - @on_click.matching_id("next_page", cancel_others=True) - async def _next_page(inter: dislash.MessageInteraction) -> None: - logger.debug("_next_page") - logger.trace(paginator.current_page + 1 < len(paginator.pages)) - if paginator.current_page + 1 < len(paginator.pages): - paginator.embed.description = paginator.pages[paginator.current_page + 1] - paginator.current_page += 1 - paginator.embed.set_footer(text=f"Page {paginator.current_page + 1}/{len(paginator.pages)}") - await msg.edit(embeds=[paginator.embed], components=[row]) - await inter.reply(type=ResponseType.DeferredUpdateMessage) - else: - # page is out of range - await inter.reply(content=f"There's only {len(paginator.pages)} pages!", ephemeral=True) - - # @on_click.from_user(ctx.author, cancel_others=True) - @on_click.matching_id("jump_to_last", cancel_others=True) - async def _jump_to_last(inter: dislash.MessageInteraction) -> None: - logger.debug("_jump_to_last") - new_page = len(paginator.pages) - 1 - logger.trace(len(paginator.pages)) - if new_page < len(paginator.pages): - await inter.reply(type=ResponseType.DeferredUpdateMessage) - paginator.embed.description = paginator.pages[new_page] - paginator.current_page = new_page - paginator.embed.set_footer(text=f"Page {new_page + 1}/{len(paginator.pages)}") - await msg.edit(embeds=[paginator.embed], components=[row]) - else: - # page is out of range - await inter.reply(content=f"There's only {len(paginator.pages)} pages!", ephemeral=True) - - @on_click.timeout - async def on_timeout() -> None: - # remove all components once we stop listening - await msg.edit(components=[]) - - # @on_click.from_user(ctx.author) - @on_click.matching_id("stop_pagination", cancel_others=True) - async def drop_pagnation(inter: dislash.MessageInteraction) -> None: - logger.debug("drop_pagnation") - await msg.edit(components=[]) + await interaction.message.edit(embed=self.pages[self.index], view=self) + + @button(emoji=JUMP_FIRST_EMOJI, custom_id="jump_first") + async def go_first(self, button: Button, interaction: Interaction) -> None: + """Move the paginator to the first page.""" + if self.index == 0: + return + + self.index = 0 + await self.send_page(interaction) + + @button(emoji=BACK_EMOJI, custom_id="back") + async def go_previous(self, button: Button, interaction: Interaction) -> None: + """Move the paginator to the previous page.""" + if self.index == 0: + button.disabled = True + await interaction.message.edit(view=self) + return + + self.index -= 1 + await self.send_page(interaction) + + @button(emoji=FORWARD_EMOJI, custom_id="next") + async def go_next(self, button: Button, interaction: Interaction) -> None: + """Move the paginator to the next page.""" + if self.index < len(self.pages) - 1: + self.index += 1 + await self.send_page(interaction) + + @button(emoji=JUMP_LAST_EMOJI, custom_id="jump_last") + async def go_last(self, button: Button, interaction: Interaction) -> None: + """Move the paginator to the last page.""" + if self.index < len(self.pages) - 1: + self.index = len(self.pages) - 1 + await self.send_page(interaction) + + @button(emoji=STOP_PAGINATE_EMOJI, style=ButtonStyle.grey, custom_id="stop_paginate") + async def _stop(self, button: Button, interaction: Interaction) -> None: + """Stop the paginator early.""" + await interaction.response.defer() + self.stop() From 4be462868b3f1fe8d1534fc7ff1e82cad5bfebb1 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 17 Aug 2021 14:55:56 -0400 Subject: [PATCH 09/54] paginate: refactor and review pagination methods use a dict instead of two lists to store pagination states set up color changing depending on enabled vs disabled remove a bunch of duplicated code switch to characters instead of emojis for pagination labels --- modmail/extensions/extension_manager.py | 9 +- modmail/utils/pagination.py | 128 ++++++++++++++---------- 2 files changed, 81 insertions(+), 56 deletions(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 6596e6f9..3715f1ee 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -186,9 +186,12 @@ async def list_extensions(self, ctx: Context) -> None: log.debug(f"{ctx.author} requested a list of all {self.type}s. " "Returning a paginated list.") - # TODO: since we currently don't have a paginator. - # await ctx.send("".join(lines) or f"There are no {self.type}s installed.") - await Paginator.paginate(ctx, lines) + if lines: + # we have stuff installed, lets paginate it. + await Paginator.paginate(ctx.message, lines) + else: + # since we don't have any lines to paginate, nothing is installed. + await ctx.send("There are no {self.type}s installed.") @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) async def resync_extensions(self, ctx: Context) -> None: diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index f8809185..4e3f6f95 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -32,21 +32,25 @@ """ import logging from enum import Enum -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union import discord from discord import ButtonStyle, Interaction -from discord.ext import commands from discord.ui import Button, View, button from modmail.log import ModmailLogger +# Deprecated variables JUMP_FIRST_EMOJI = "\u23EE" # [:track_previous:] BACK_EMOJI = "\u2B05" # [:arrow_left:] FORWARD_EMOJI = "\u27A1" # [:arrow_right:] JUMP_LAST_EMOJI = "\u23ED" # [:track_next:] -STOP_PAGINATE_EMOJI = "\U0001f6d1" # [:octagonal_sign:] +STOP_PAGINATE_EMOJI = "\u274c" # [:x:] +JUMP_FIRST_LABEL = "<<" +BACK_LABEL = "<" +FORWARD_LABEL = ">" +JUMP_LAST_LABEL = ">>" logger: ModmailLogger = logging.getLogger(__name__) @@ -101,7 +105,7 @@ class Paginator(View): def __init__( self, - ctx: commands.Context = None, + message: discord.Message = None, contents: Optional[List[str]] = None, embeds: Optional[List[discord.Embed]] = None, timeout: float = 180, @@ -109,19 +113,21 @@ def __init__( only: Optional[discord.abc.User] = None, ) -> None: """Creates a new Paginator instance. At least one of ctx or message must be supplied.""" - self.ctx = ctx - self.timeout = timeout + self.source_msg = message self.only = only self.index = 0 self.pages: List[Union[discord.Embed, str]] = [] - if not (isinstance(timeout, float) or isinstance(timeout, int)): + if not isinstance(timeout, (int, float)): raise InvalidArgumentError("timeout must be a float") + self.timeout = float(timeout) + if contents is None and embeds is None: raise MissingAttributeError("Both contents and embeds are None.") elif contents is not None and embeds is not None: raise TooManyAttributesError("Both contents and embeds are given. Please choose one.") + if contents: # contents exist, so embeds is be None self.pages = contents @@ -129,74 +135,88 @@ def __init__( else: self.pages = embeds self.type = Types.EMBEDS - + # create the super so the children attributes are set super().__init__() + # store component states for disabling + self.states: Dict[str, Dict[str, Any]] = dict() + for child in self.children: + attrs = child.to_component_dict() + self.states[attrs["custom_id"]] = attrs + @classmethod async def paginate( cls, - ctx: commands.Context = None, + message: discord.Message = None, contents: Optional[List[str]] = None, embeds: Optional[List[discord.Embed]] = None, timeout: float = 180, only: Optional[discord.abc.User] = None, + channel: discord.abc.Messageable = None, ) -> None: """Something.""" - paginator = cls(ctx, contents, embeds, timeout, only) + paginator = cls(message, contents, embeds, timeout, only) - # remove buttons based on how many pages we have - if len(paginator.pages) <= 2: - pass - # paginator.remove_item() - paginator.modify_disabled() + if channel is None and message is None: + raise MissingAttributeError("Both channel and message are None.") + elif channel is None: + channel = message.channel + + paginator.modify_states() if paginator.type == Types.CONTENTS: - msg: discord.Message = await ctx.send(content=paginator.pages[paginator.index], view=paginator) + msg: discord.Message = await channel.send( + content=paginator.pages[paginator.index], view=paginator + ) else: - msg: discord.Message = await ctx.send(embeds=paginator.pages[paginator.index], view=paginator) + msg: discord.Message = await channel.send(embeds=paginator.pages[paginator.index], view=paginator) await paginator.wait() await msg.edit(view=None) async def interaction_check(self, interaction: Interaction) -> bool: """Check if the interaction is by the author of the paginatior.""" - if not (is_valid := self.ctx.author.id == interaction.user.id): + if self.source_msg is None: + return True + if not (is_valid := self.source_msg.author.id == interaction.user.id): await interaction.response.send_message( content="This is not your message to paginate!", ephemeral=True ) return is_valid - def modify_disabled(self) -> None: - """Disable specific buttons depending on paginator page and length.""" - ids = ["jump_first", "back", "next", "jump_last"] - states = [] - if len(self.pages) > 2: - states = [False for _ in range(4)] - elif len(self.pages) == 2: - # there are two pages - states = [True, False, False, True] - else: - # there is only one page - states = [True for _ in range(4)] + def modify_states(self) -> None: + """Disable specific components depending on paginator page and length.""" + less_than_2_pages = len(self.pages) <= 2 + components = { + "jump_first": less_than_2_pages, + "back": False, + "next": False, + "jump_last": less_than_2_pages, + } + if self.index == 0: - for i in range(2): - states[i] = True - elif self.index == len(self.pages) - 1: - for i in range(2): - states[(-1 * (i + 1))] = True - for item in self.children: - id = item.to_component_dict()["custom_id"] - if id in ids: - item.disabled = states[ids.index(id)] + components["jump_first"] = True + components["back"] = True + + if self.index == len(self.pages) - 1: + components["next"] = True + components["jump_last"] = True + + for child in self.children: + if child.custom_id in components.keys(): + if getattr(child, "disabled", None) is not None: + child.disabled = components[child.custom_id] + if getattr(child, "style", None) is not None: + child.style = ButtonStyle.secondary if child.disabled else ButtonStyle.primary async def send_page(self, interaction: Interaction) -> None: """Send new page.""" - self.modify_disabled() + self.modify_states() if self.type == Types.CONTENTS: await interaction.message.edit(content=self.pages[self.index], view=self) else: await interaction.message.edit(embed=self.pages[self.index], view=self) - @button(emoji=JUMP_FIRST_EMOJI, custom_id="jump_first") + @button(label=JUMP_FIRST_LABEL, custom_id="jump_first", style=ButtonStyle.primary) async def go_first(self, button: Button, interaction: Interaction) -> None: """Move the paginator to the first page.""" if self.index == 0: @@ -205,32 +225,34 @@ async def go_first(self, button: Button, interaction: Interaction) -> None: self.index = 0 await self.send_page(interaction) - @button(emoji=BACK_EMOJI, custom_id="back") + @button(label=BACK_LABEL, custom_id="back", style=ButtonStyle.primary) async def go_previous(self, button: Button, interaction: Interaction) -> None: """Move the paginator to the previous page.""" if self.index == 0: - button.disabled = True - await interaction.message.edit(view=self) return self.index -= 1 await self.send_page(interaction) - @button(emoji=FORWARD_EMOJI, custom_id="next") + @button(label=FORWARD_LABEL, custom_id="next", style=ButtonStyle.primary) async def go_next(self, button: Button, interaction: Interaction) -> None: """Move the paginator to the next page.""" - if self.index < len(self.pages) - 1: - self.index += 1 - await self.send_page(interaction) + if self.index == len(self.pages) - 1: + return + + self.index += 1 + await self.send_page(interaction) - @button(emoji=JUMP_LAST_EMOJI, custom_id="jump_last") + @button(label=JUMP_LAST_LABEL, custom_id="jump_last", style=ButtonStyle.primary) async def go_last(self, button: Button, interaction: Interaction) -> None: """Move the paginator to the last page.""" - if self.index < len(self.pages) - 1: - self.index = len(self.pages) - 1 - await self.send_page(interaction) + if self.index == len(self.pages) - 1: + return + + self.index = len(self.pages) - 1 + await self.send_page(interaction) - @button(emoji=STOP_PAGINATE_EMOJI, style=ButtonStyle.grey, custom_id="stop_paginate") + @button(emoji=STOP_PAGINATE_EMOJI, custom_id="stop_paginate", style=ButtonStyle.grey) async def _stop(self, button: Button, interaction: Interaction) -> None: """Stop the paginator early.""" await interaction.response.defer() From 5f6b895ab6e2a27b176a1278ec4452f7fa5c55e8 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 17 Aug 2021 20:19:36 -0400 Subject: [PATCH 10/54] chore: update icons for pagination after internal deliberation, we've updated the pagination icons to be a bit more explantative of what each one does. --- modmail/utils/pagination.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 4e3f6f95..8666e0ef 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -40,17 +40,14 @@ from modmail.log import ModmailLogger -# Deprecated variables -JUMP_FIRST_EMOJI = "\u23EE" # [:track_previous:] -BACK_EMOJI = "\u2B05" # [:arrow_left:] -FORWARD_EMOJI = "\u27A1" # [:arrow_right:] -JUMP_LAST_EMOJI = "\u23ED" # [:track_next:] +# Stop button STOP_PAGINATE_EMOJI = "\u274c" # [:x:] -JUMP_FIRST_LABEL = "<<" -BACK_LABEL = "<" -FORWARD_LABEL = ">" -JUMP_LAST_LABEL = ">>" +# Labels +JUMP_FIRST_LABEL = "\u2590\u276e\u2012" # bar, left arrow, ‒ +BACK_LABEL = " \u276e " # left arrow +FORWARD_LABEL = " \u276f " # right arrow +JUMP_LAST_LABEL = "\u2012\u276f\u258c" # ‒, right arrow, bar logger: ModmailLogger = logging.getLogger(__name__) From 2c583eaf380a4aa75371ed94fb5b90ab1f6bb994 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Fri, 20 Aug 2021 01:31:27 -0400 Subject: [PATCH 11/54] rename paginator to ButtonPaginator --- modmail/extensions/extension_manager.py | 4 ++-- modmail/utils/pagination.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 3715f1ee..c6579d4e 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -15,7 +15,7 @@ from modmail.log import ModmailLogger from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog from modmail.utils.extensions import EXTENSIONS, NO_UNLOAD, unqualify, walk_extensions -from modmail.utils.pagination import Paginator +from modmail.utils.pagination import ButtonPaginator log: ModmailLogger = logging.getLogger(__name__) @@ -188,7 +188,7 @@ async def list_extensions(self, ctx: Context) -> None: if lines: # we have stuff installed, lets paginate it. - await Paginator.paginate(ctx.message, lines) + await ButtonPaginator.paginate(ctx.message, lines) else: # since we don't have any lines to paginate, nothing is installed. await ctx.send("There are no {self.type}s installed.") diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 8666e0ef..a773c681 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -76,7 +76,7 @@ class Types(Enum): EMBEDS = 1 -class Paginator(View): +class ButtonPaginator(View): """ Class for Pagination. From 0b69522110a1a68723e8d928822c4461e555e48e Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Fri, 20 Aug 2021 16:11:54 -0400 Subject: [PATCH 12/54] major: mixin discordpy paginator to the paginator --- modmail/extensions/extension_manager.py | 2 +- modmail/utils/pagination.py | 166 ++++++++++++------------ 2 files changed, 83 insertions(+), 85 deletions(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index c6579d4e..48eae003 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -188,7 +188,7 @@ async def list_extensions(self, ctx: Context) -> None: if lines: # we have stuff installed, lets paginate it. - await ButtonPaginator.paginate(ctx.message, lines) + await ButtonPaginator.paginate(lines, ctx.message) else: # since we don't have any lines to paginate, nothing is installed. await ctx.send("There are no {self.type}s installed.") diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index a773c681..c05749ca 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -31,14 +31,19 @@ - comply with black and flake8 """ import logging -from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import discord -from discord import ButtonStyle, Interaction -from discord.ui import Button, View, button +from discord import ButtonStyle +from discord.ext.commands import Paginator as DpyPaginator +from discord.ui import View, button + +if TYPE_CHECKING: + from discord import Interaction + from discord.ui import Button + + from modmail.log import ModmailLogger -from modmail.log import ModmailLogger # Stop button STOP_PAGINATE_EMOJI = "\u274c" # [:x:] @@ -48,17 +53,13 @@ BACK_LABEL = " \u276e " # left arrow FORWARD_LABEL = " \u276f " # right arrow JUMP_LAST_LABEL = "\u2012\u276f\u258c" # ‒, right arrow, bar -logger: ModmailLogger = logging.getLogger(__name__) -class MissingAttributeError(Exception): - """Missing attribute.""" +logger: "ModmailLogger" = logging.getLogger(__name__) - pass - -class TooManyAttributesError(Exception): - """Too many attributes.""" +class MissingAttributeError(Exception): + """Missing attribute.""" pass @@ -69,14 +70,7 @@ class InvalidArgumentError(Exception): pass -class Types(Enum): - """Types of pagination.""" - - CONTENTS = 0 - EMBEDS = 1 - - -class ButtonPaginator(View): +class ButtonPaginator(View, DpyPaginator): """ Class for Pagination. @@ -84,54 +78,49 @@ class ButtonPaginator(View): ---------- ctx: commands.Context Context of the message. - contents : List[str], optional + contents : List[str] List of contents. - embeds : List[Embed], optional - List of embeds. If both contents and embeds are given, the priority is embed. timeout : float, default 180 A timeout of receiving Interactions. only : discord.abc.User, optional If a parameter is given, the paginator will respond only to the selected user. - basic_emojis : List[Emoji], optional - Custom basic emoji list. There should be 2 emojis. - extended_emojis : List[Emoji], optional - Extended emoji list, There should be 4 emojis. auto_delete : bool, default False Whether to delete message after timeout. """ def __init__( self, - message: discord.Message = None, - contents: Optional[List[str]] = None, - embeds: Optional[List[discord.Embed]] = None, - timeout: float = 180, + contents: List[str], + /, + source_message: Optional[discord.Message] = None, embed: discord.Embed = None, - only: Optional[discord.abc.User] = None, + timeout: float = 180, + *, + prefix: str = "```", + suffix: str = "```", + max_size: int = 2000, + linesep: str = "\n", + only_users: Optional[List[discord.abc.User]] = None, ) -> None: """Creates a new Paginator instance. At least one of ctx or message must be supplied.""" - self.source_msg = message - self.only = only - self.index = 0 - self.pages: List[Union[discord.Embed, str]] = [] + self.only_users = only_users + self._index = 0 + self._pages: List[Union[discord.Embed, str]] = [] + self.source_message = source_message + self.prefix = prefix + self.suffix = suffix + self.max_size = max_size + self.linesep = linesep + self._embed = embed if not isinstance(timeout, (int, float)): raise InvalidArgumentError("timeout must be a float") self.timeout = float(timeout) + self.clear() + for line in contents: + self.add_line(line) - if contents is None and embeds is None: - raise MissingAttributeError("Both contents and embeds are None.") - elif contents is not None and embeds is not None: - raise TooManyAttributesError("Both contents and embeds are given. Please choose one.") - - if contents: - # contents exist, so embeds is be None - self.pages = contents - self.type = Types.CONTENTS - else: - self.pages = embeds - self.type = Types.EMBEDS # create the super so the children attributes are set super().__init__() @@ -144,37 +133,49 @@ def __init__( @classmethod async def paginate( cls, - message: discord.Message = None, contents: Optional[List[str]] = None, - embeds: Optional[List[discord.Embed]] = None, + source_message: discord.Message = None, + /, timeout: float = 180, + embed: discord.Embed = None, + *, only: Optional[discord.abc.User] = None, channel: discord.abc.Messageable = None, + prefix: str = "", + suffix: str = "", + max_size: int = 4000, + linesep: str = "\n", + only_users: Optional[List[discord.abc.User]] = None, ) -> None: - """Something.""" - paginator = cls(message, contents, embeds, timeout, only) - - if channel is None and message is None: + """Create a paginator, and paginate the provided lines.""" + paginator = cls( + contents, + source_message=source_message, + timeout=timeout, + embed=embed, + prefix=prefix, + suffix=suffix, + max_size=max_size, + linesep=linesep, + only_users=only_users, + ) + + if channel is None and source_message is None: raise MissingAttributeError("Both channel and message are None.") elif channel is None: - channel = message.channel + channel = source_message.channel paginator.modify_states() - if paginator.type == Types.CONTENTS: - msg: discord.Message = await channel.send( - content=paginator.pages[paginator.index], view=paginator - ) - else: - msg: discord.Message = await channel.send(embeds=paginator.pages[paginator.index], view=paginator) + msg: discord.Message = await channel.send(content=paginator.pages[paginator._index], view=paginator) await paginator.wait() await msg.edit(view=None) - async def interaction_check(self, interaction: Interaction) -> bool: + async def interaction_check(self, interaction: "Interaction") -> bool: """Check if the interaction is by the author of the paginatior.""" - if self.source_msg is None: + if self.source_message is None: return True - if not (is_valid := self.source_msg.author.id == interaction.user.id): + if not (is_valid := self.source_message.author.id == interaction.user.id): await interaction.response.send_message( content="This is not your message to paginate!", ephemeral=True ) @@ -190,11 +191,11 @@ def modify_states(self) -> None: "jump_last": less_than_2_pages, } - if self.index == 0: + if self._index == 0: components["jump_first"] = True components["back"] = True - if self.index == len(self.pages) - 1: + if self._index == len(self.pages) - 1: components["next"] = True components["jump_last"] = True @@ -205,52 +206,49 @@ def modify_states(self) -> None: if getattr(child, "style", None) is not None: child.style = ButtonStyle.secondary if child.disabled else ButtonStyle.primary - async def send_page(self, interaction: Interaction) -> None: + async def send_page(self, interaction: "Interaction") -> None: """Send new page.""" self.modify_states() - if self.type == Types.CONTENTS: - await interaction.message.edit(content=self.pages[self.index], view=self) - else: - await interaction.message.edit(embed=self.pages[self.index], view=self) + await interaction.message.edit(content=self.pages[self._index], view=self) @button(label=JUMP_FIRST_LABEL, custom_id="jump_first", style=ButtonStyle.primary) - async def go_first(self, button: Button, interaction: Interaction) -> None: + async def go_first(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the first page.""" - if self.index == 0: + if self._index == 0: return - self.index = 0 + self._index = 0 await self.send_page(interaction) @button(label=BACK_LABEL, custom_id="back", style=ButtonStyle.primary) - async def go_previous(self, button: Button, interaction: Interaction) -> None: + async def go_previous(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the previous page.""" - if self.index == 0: + if self._index == 0: return - self.index -= 1 + self._index -= 1 await self.send_page(interaction) @button(label=FORWARD_LABEL, custom_id="next", style=ButtonStyle.primary) - async def go_next(self, button: Button, interaction: Interaction) -> None: + async def go_next(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the next page.""" - if self.index == len(self.pages) - 1: + if self._index == len(self.pages) - 1: return - self.index += 1 + self._index += 1 await self.send_page(interaction) @button(label=JUMP_LAST_LABEL, custom_id="jump_last", style=ButtonStyle.primary) - async def go_last(self, button: Button, interaction: Interaction) -> None: + async def go_last(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the last page.""" - if self.index == len(self.pages) - 1: + if self._index == len(self.pages) - 1: return - self.index = len(self.pages) - 1 + self._index = len(self.pages) - 1 await self.send_page(interaction) @button(emoji=STOP_PAGINATE_EMOJI, custom_id="stop_paginate", style=ButtonStyle.grey) - async def _stop(self, button: Button, interaction: Interaction) -> None: + async def _stop(self, button: "Button", interaction: "Interaction") -> None: """Stop the paginator early.""" await interaction.response.defer() self.stop() From eed2a83ce3575e899132f85b9f5f2b780c12e17e Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Fri, 20 Aug 2021 16:40:15 -0400 Subject: [PATCH 13/54] chore: don't paginate if one page --- modmail/utils/pagination.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index c05749ca..bf230532 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -166,7 +166,13 @@ async def paginate( channel = source_message.channel paginator.modify_states() - msg: discord.Message = await channel.send(content=paginator.pages[paginator._index], view=paginator) + if len(paginator.pages) >= 2: + msg: discord.Message = await channel.send( + content=paginator.pages[paginator._index], view=paginator + ) + else: + await channel.send(content=paginator.pages[paginator._index]) + return await paginator.wait() await msg.edit(view=None) From b11fa3eafdcddf2bbfe88560db3e3ea8d481eff4 Mon Sep 17 00:00:00 2001 From: aru Date: Sat, 21 Aug 2021 04:08:59 -0400 Subject: [PATCH 14/54] chore: better docstring for Paginator class Co-authored-by: Shivansh-007 --- modmail/utils/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index bf230532..ce624780 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -72,7 +72,7 @@ class InvalidArgumentError(Exception): class ButtonPaginator(View, DpyPaginator): """ - Class for Pagination. + A class that helps in paginating long messages/embeds, which can be interacted via discord buttons. Attributes ---------- From 6d0cfaaf14361511f446224ac9bb67a17558fcd4 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 12:25:06 -0400 Subject: [PATCH 15/54] credit: remove most paginator credit as running a diff shows it was roughly 98% rewritten. --- modmail/utils/pagination.py | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index ce624780..c24036b2 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -1,34 +1,7 @@ """ Paginator. -Adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 - -MIT License - -Copyright (c) 2020 khk4912 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -CHANGES: -- made this work with buttons instead of reactions -- comply with black and flake8 +Originally adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 """ import logging from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union From ce3a9cd2b5bd38bf47ba77d08380a0d86a17f9ac Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 12:31:07 -0400 Subject: [PATCH 16/54] chore: refactor imports, move custom errors to designated file --- modmail/utils/errors.py | 10 ++++++++++ modmail/utils/pagination.py | 29 +++++++++-------------------- 2 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 modmail/utils/errors.py diff --git a/modmail/utils/errors.py b/modmail/utils/errors.py new file mode 100644 index 00000000..69a2fda7 --- /dev/null +++ b/modmail/utils/errors.py @@ -0,0 +1,10 @@ +class MissingAttributeError(Exception): + """Missing attribute.""" + + pass + + +class InvalidArgumentError(Exception): + """Improper argument.""" + + pass diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index c24036b2..dc1a959a 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -7,9 +7,10 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import discord -from discord import ButtonStyle +from discord import ButtonStyle, ui from discord.ext.commands import Paginator as DpyPaginator -from discord.ui import View, button + +from modmail.utils.errors import InvalidArgumentError, MissingAttributeError if TYPE_CHECKING: from discord import Interaction @@ -31,19 +32,7 @@ logger: "ModmailLogger" = logging.getLogger(__name__) -class MissingAttributeError(Exception): - """Missing attribute.""" - - pass - - -class InvalidArgumentError(Exception): - """Improper argument.""" - - pass - - -class ButtonPaginator(View, DpyPaginator): +class ButtonPaginator(ui.View, DpyPaginator): """ A class that helps in paginating long messages/embeds, which can be interacted via discord buttons. @@ -190,7 +179,7 @@ async def send_page(self, interaction: "Interaction") -> None: self.modify_states() await interaction.message.edit(content=self.pages[self._index], view=self) - @button(label=JUMP_FIRST_LABEL, custom_id="jump_first", style=ButtonStyle.primary) + @ui.button(label=JUMP_FIRST_LABEL, custom_id="jump_first", style=ButtonStyle.primary) async def go_first(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the first page.""" if self._index == 0: @@ -199,7 +188,7 @@ async def go_first(self, button: "Button", interaction: "Interaction") -> None: self._index = 0 await self.send_page(interaction) - @button(label=BACK_LABEL, custom_id="back", style=ButtonStyle.primary) + @ui.button(label=BACK_LABEL, custom_id="back", style=ButtonStyle.primary) async def go_previous(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the previous page.""" if self._index == 0: @@ -208,7 +197,7 @@ async def go_previous(self, button: "Button", interaction: "Interaction") -> Non self._index -= 1 await self.send_page(interaction) - @button(label=FORWARD_LABEL, custom_id="next", style=ButtonStyle.primary) + @ui.button(label=FORWARD_LABEL, custom_id="next", style=ButtonStyle.primary) async def go_next(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the next page.""" if self._index == len(self.pages) - 1: @@ -217,7 +206,7 @@ async def go_next(self, button: "Button", interaction: "Interaction") -> None: self._index += 1 await self.send_page(interaction) - @button(label=JUMP_LAST_LABEL, custom_id="jump_last", style=ButtonStyle.primary) + @ui.button(label=JUMP_LAST_LABEL, custom_id="jump_last", style=ButtonStyle.primary) async def go_last(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the last page.""" if self._index == len(self.pages) - 1: @@ -226,7 +215,7 @@ async def go_last(self, button: "Button", interaction: "Interaction") -> None: self._index = len(self.pages) - 1 await self.send_page(interaction) - @button(emoji=STOP_PAGINATE_EMOJI, custom_id="stop_paginate", style=ButtonStyle.grey) + @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="stop_paginate", style=ButtonStyle.grey) async def _stop(self, button: "Button", interaction: "Interaction") -> None: """Stop the paginator early.""" await interaction.response.defer() From 93e9b1894afce30aee2a1848336cab680568cb11 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 13:12:06 -0400 Subject: [PATCH 17/54] minor: prefix all pagination interaction ids with pag_ --- modmail/utils/pagination.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index dc1a959a..c4376969 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -153,19 +153,19 @@ def modify_states(self) -> None: """Disable specific components depending on paginator page and length.""" less_than_2_pages = len(self.pages) <= 2 components = { - "jump_first": less_than_2_pages, - "back": False, - "next": False, - "jump_last": less_than_2_pages, + "pag_jump_first": less_than_2_pages, + "pag_back": False, + "pag_next": False, + "pag_jump_last": less_than_2_pages, } if self._index == 0: - components["jump_first"] = True - components["back"] = True + components["pag_jump_first"] = True + components["pag_back"] = True if self._index == len(self.pages) - 1: - components["next"] = True - components["jump_last"] = True + components["pag_next"] = True + components["pag_jump_last"] = True for child in self.children: if child.custom_id in components.keys(): @@ -179,7 +179,7 @@ async def send_page(self, interaction: "Interaction") -> None: self.modify_states() await interaction.message.edit(content=self.pages[self._index], view=self) - @ui.button(label=JUMP_FIRST_LABEL, custom_id="jump_first", style=ButtonStyle.primary) + @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) async def go_first(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the first page.""" if self._index == 0: @@ -188,7 +188,7 @@ async def go_first(self, button: "Button", interaction: "Interaction") -> None: self._index = 0 await self.send_page(interaction) - @ui.button(label=BACK_LABEL, custom_id="back", style=ButtonStyle.primary) + @ui.button(label=BACK_LABEL, custom_id="pag_back", style=ButtonStyle.primary) async def go_previous(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the previous page.""" if self._index == 0: @@ -197,7 +197,7 @@ async def go_previous(self, button: "Button", interaction: "Interaction") -> Non self._index -= 1 await self.send_page(interaction) - @ui.button(label=FORWARD_LABEL, custom_id="next", style=ButtonStyle.primary) + @ui.button(label=FORWARD_LABEL, custom_id="pag_next", style=ButtonStyle.primary) async def go_next(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the next page.""" if self._index == len(self.pages) - 1: @@ -206,7 +206,7 @@ async def go_next(self, button: "Button", interaction: "Interaction") -> None: self._index += 1 await self.send_page(interaction) - @ui.button(label=JUMP_LAST_LABEL, custom_id="jump_last", style=ButtonStyle.primary) + @ui.button(label=JUMP_LAST_LABEL, custom_id="pag_jump_last", style=ButtonStyle.primary) async def go_last(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the last page.""" if self._index == len(self.pages) - 1: @@ -215,7 +215,7 @@ async def go_last(self, button: "Button", interaction: "Interaction") -> None: self._index = len(self.pages) - 1 await self.send_page(interaction) - @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="stop_paginate", style=ButtonStyle.grey) + @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="pag_stop_paginate", style=ButtonStyle.grey) async def _stop(self, button: "Button", interaction: "Interaction") -> None: """Stop the paginator early.""" await interaction.response.defer() From 4ef8f43dcbfe8a3a74595c3ba53fa88f3f60f431 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 13:27:06 -0400 Subject: [PATCH 18/54] interactions: add paginator cleaner --- modmail/extensions/utils/__init__.py | 0 modmail/extensions/utils/paginator_cleaner.py | 56 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 modmail/extensions/utils/__init__.py create mode 100644 modmail/extensions/utils/paginator_cleaner.py diff --git a/modmail/extensions/utils/__init__.py b/modmail/extensions/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modmail/extensions/utils/paginator_cleaner.py b/modmail/extensions/utils/paginator_cleaner.py new file mode 100644 index 00000000..e35809bd --- /dev/null +++ b/modmail/extensions/utils/paginator_cleaner.py @@ -0,0 +1,56 @@ +import asyncio +import logging +from typing import TYPE_CHECKING + +from discord import InteractionType + +from modmail.utils.cogs import ModmailCog + +if TYPE_CHECKING: + from discord import Interaction + + from modmail.bot import ModmailBot + from modmail.log import ModmailLogger + +logger: "ModmailLogger" = logging.getLogger(__name__) + + +class PaginatorCleaner(ModmailCog): + """Handles paginators that were still active when the bot shut down.""" + + def __init__(self, bot: "ModmailBot"): + self.bot = bot + + @ModmailCog.listener() + async def on_interaction(self, interaction: "Interaction") -> None: + """ + Remove components from paginator messages if they fail. + + The paginator handles all interactions while it is active, but if the bot is restarted, + those interactions stop being dealt with. + + This handles all paginator interactions that fail, which should only happen if + the paginator was unable to delete its message. + """ + # paginator only has component interactions + if not interaction.type == InteractionType.component: + return + logger.debug(f"Interaction sent by {interaction.user}.") + logger.trace(f"Interaction data: {interaction.data}") + if ( + interaction.data["custom_id"].startswith("pag_") + and interaction.message.author.id == self.bot.user.id + ): + # sleep for two seconds to give the paginator time to respond. + # this is due to discord requiring a response within 3 seconds, + # and we don't want to let the paginator fail. + await asyncio.sleep(2) + if not interaction.response.is_done(): + await interaction.response.send_message(content="This paginator has expired.", ephemeral=True) + await asyncio.sleep(0.1) # sleep for 1 second so it isn't immediately removed + await interaction.message.edit(view=None) + + +def setup(bot: "ModmailBot") -> None: + """Add the paginator cleaner to the bot.""" + bot.add_cog(PaginatorCleaner(bot)) From 36423299f0f6ab6ce4261db94d3672120215b58a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 13:40:29 -0400 Subject: [PATCH 19/54] nit: reverse if statement for readability --- modmail/utils/pagination.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index c4376969..4ac414eb 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -128,13 +128,13 @@ async def paginate( channel = source_message.channel paginator.modify_states() - if len(paginator.pages) >= 2: + if len(paginator.pages) < 2: + await channel.send(content=paginator.pages[paginator._index]) + return + else: msg: discord.Message = await channel.send( content=paginator.pages[paginator._index], view=paginator ) - else: - await channel.send(content=paginator.pages[paginator._index]) - return await paginator.wait() await msg.edit(view=None) From 00d9d1df5408b9b0525cbe74d7c6c3ead27e8692 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 13:43:42 -0400 Subject: [PATCH 20/54] feat: monkey-patch embeds to give more init param options (cherry picked from commit 7c9e1c28a64014e2d23ae28a73c94307ea8fe418) --- modmail/__main__.py | 2 + modmail/utils/embeds.py | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 modmail/utils/embeds.py diff --git a/modmail/__main__.py b/modmail/__main__.py index 39599e20..3da03d69 100644 --- a/modmail/__main__.py +++ b/modmail/__main__.py @@ -2,6 +2,7 @@ from modmail.bot import ModmailBot from modmail.log import ModmailLogger +from modmail.utils.embeds import patch_embed try: # noinspection PyUnresolvedReferences @@ -17,6 +18,7 @@ def main() -> None: """Run the bot.""" + patch_embed() bot = ModmailBot() bot.run(bot.config.bot.token) diff --git a/modmail/utils/embeds.py b/modmail/utils/embeds.py new file mode 100644 index 00000000..473d93a2 --- /dev/null +++ b/modmail/utils/embeds.py @@ -0,0 +1,92 @@ +from typing import List, Tuple, Union + +import discord +from discord.embeds import EmptyEmbed + +original_init = discord.Embed.__init__ + + +def __init__(self: discord.Embed, **kwargs): # noqa: N807 + """ + Overrides discord.Embed.__init__ to add new arguments. + + Parameters + * thumbnail + * footer_text + * footer_icon + * image + * author (a discord.User), author_name, author_icon, author_url + * fields (a list of ("name", "value") or ("name", "value", inline)) + + Also, the original arguments are still supported: + * title + * type + * color + * colour + * url + * description + * timestamp + """ + original_init( + self, + title=kwargs.pop("title", EmptyEmbed), + description=kwargs.pop("description", kwargs.pop("content", EmptyEmbed)), + type=kwargs.pop("type", "rich"), + url=kwargs.pop("url", EmptyEmbed), + colour=kwargs.pop("color", None) or kwargs.pop("colour", 0xE67E22), + timestamp=kwargs.pop("timestamp", EmptyEmbed), + ) + + self.set_thumbnail(url=kwargs.pop("thumbnail", EmptyEmbed)) + self.set_footer( + text=kwargs.pop("footer_text", EmptyEmbed), + icon_url=kwargs.pop("footer_icon", EmptyEmbed), + ) + self.set_image(url=kwargs.pop("image_url", kwargs.pop("image", EmptyEmbed))) + + author_name = kwargs.pop("author_name", None) + author_icon = kwargs.pop("author_icon", None) + author_url = kwargs.pop("author_url", EmptyEmbed) + author: discord.User = kwargs.pop("author", None) + if author is not None or author_name is not None: + self.set_author( + name=author_name if author_name is not None else author.name, + url=author_url, + icon_url=author_icon or str(author.avatar.url), + ) + + fields: List[Union[Tuple[str, str], Tuple[str, str, bool]]] = kwargs.pop("fields", []) + for field in fields: + if isinstance(field, dict): + self.add_field(**field) + elif len(field) == 3: + name, value, inline = field + self.add_field(name=name, value=value, inline=inline) + else: + name, value = field + self.add_field(name=name, value=value, inline=False) + + if kwargs: + raise TypeError( + "Embed.__init__ received unexpected keyword arguments: {0}".format(list(kwargs.keys())) + ) + + +def patch_embed() -> None: + """Modifies discord.Embed to have new arguments for input.""" + discord.Embed.__init__ = __init__ + + +if __name__ == "__main__": + e = discord.Embed( + title="Test title", + description="test description", + footer_text="hi", + fields=[ + ("Field 1", "test"), + ("Field 2", "more test", True), + {"name": "test", "value": "data", "inline": True}, + ], + colour=0xFFF, + ) + print(e.to_dict()) From 33d61bf5e8edb6e53cd95c0cdef3dde918e887f1 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 14:23:50 -0400 Subject: [PATCH 21/54] paginate: add embed support --- modmail/utils/pagination.py | 49 +++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 4ac414eb..b91aa2a1 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -4,10 +4,11 @@ Originally adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 """ import logging -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional import discord from discord import ButtonStyle, ui +from discord.embeds import Embed, EmbedProxy from discord.ext.commands import Paginator as DpyPaginator from modmail.utils.errors import InvalidArgumentError, MissingAttributeError @@ -19,15 +20,12 @@ from modmail.log import ModmailLogger -# Stop button -STOP_PAGINATE_EMOJI = "\u274c" # [:x:] - # Labels JUMP_FIRST_LABEL = "\u2590\u276e\u2012" # bar, left arrow, ‒ BACK_LABEL = " \u276e " # left arrow FORWARD_LABEL = " \u276f " # right arrow JUMP_LAST_LABEL = "\u2012\u276f\u258c" # ‒, right arrow, bar - +STOP_PAGINATE_EMOJI = "\u274c" # [:x:] This is an emoji, which is treated differently from the above logger: "ModmailLogger" = logging.getLogger(__name__) @@ -55,9 +53,10 @@ def __init__( contents: List[str], /, source_message: Optional[discord.Message] = None, - embed: discord.Embed = None, + embed: Embed = None, timeout: float = 180, *, + footer: str = None, prefix: str = "```", suffix: str = "```", max_size: int = 2000, @@ -67,18 +66,26 @@ def __init__( """Creates a new Paginator instance. At least one of ctx or message must be supplied.""" self.only_users = only_users self._index = 0 - self._pages: List[Union[discord.Embed, str]] = [] + self._pages: List[str] = [] self.source_message = source_message self.prefix = prefix self.suffix = suffix self.max_size = max_size self.linesep = linesep - self._embed = embed + self._embed = embed or Embed() if not isinstance(timeout, (int, float)): raise InvalidArgumentError("timeout must be a float") self.timeout = float(timeout) + + # set footer to embed.footer if embed is set + # this is because we will be modifying the footer of this embed + if embed is not None: + if not isinstance(embed.footer, EmbedProxy) and footer is None: + footer = embed.footer + self.footer = footer + logger.debug(self.footer) self.clear() for line in contents: self.add_line(line) @@ -99,8 +106,9 @@ async def paginate( source_message: discord.Message = None, /, timeout: float = 180, - embed: discord.Embed = None, + embed: Embed = None, *, + footer: str = None, only: Optional[discord.abc.User] = None, channel: discord.abc.Messageable = None, prefix: str = "", @@ -115,6 +123,7 @@ async def paginate( source_message=source_message, timeout=timeout, embed=embed, + footer=footer, prefix=prefix, suffix=suffix, max_size=max_size, @@ -128,13 +137,14 @@ async def paginate( channel = source_message.channel paginator.modify_states() + paginator._embed.description = paginator.pages[paginator._index] + paginator._embed.set_footer(text=paginator.get_footer()) + # if there's only one page, don't send hte view if len(paginator.pages) < 2: - await channel.send(content=paginator.pages[paginator._index]) + await channel.send(embeds=[paginator._embed]) return else: - msg: discord.Message = await channel.send( - content=paginator.pages[paginator._index], view=paginator - ) + msg: discord.Message = await channel.send(embeds=[paginator._embed], view=paginator) await paginator.wait() await msg.edit(view=None) @@ -149,6 +159,13 @@ async def interaction_check(self, interaction: "Interaction") -> bool: ) return is_valid + def get_footer(self) -> str: + """Returns the footer text.""" + self._embed.description = self.pages[self._index] + page_indicator = f"(Page {self._index+1}/{len(self.pages)})" + footer_txt = self.footer + page_indicator if self.footer is not None else page_indicator + return footer_txt + def modify_states(self) -> None: """Disable specific components depending on paginator page and length.""" less_than_2_pages = len(self.pages) <= 2 @@ -175,9 +192,11 @@ def modify_states(self) -> None: child.style = ButtonStyle.secondary if child.disabled else ButtonStyle.primary async def send_page(self, interaction: "Interaction") -> None: - """Send new page.""" + """Send new page to discord, after updating the view to have properly disabled buttons.""" self.modify_states() - await interaction.message.edit(content=self.pages[self._index], view=self) + + self._embed.set_footer(text=self.get_footer()) + await interaction.message.edit(embed=self._embed, view=self) @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) async def go_first(self, button: "Button", interaction: "Interaction") -> None: From 42903c823844ea3d8758e8b896875fa661950538 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 14:25:50 -0400 Subject: [PATCH 22/54] minor: make a string an f-string again --- modmail/extensions/extension_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 48eae003..36862f2b 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -191,7 +191,7 @@ async def list_extensions(self, ctx: Context) -> None: await ButtonPaginator.paginate(lines, ctx.message) else: # since we don't have any lines to paginate, nothing is installed. - await ctx.send("There are no {self.type}s installed.") + await ctx.send(f"There are no {self.type}s installed.") @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) async def resync_extensions(self, ctx: Context) -> None: From 9c248c3738d871d16cb83cb5e92f779c2cd8e1bf Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 14:40:49 -0400 Subject: [PATCH 23/54] minor: document_modify_states --- modmail/utils/pagination.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index b91aa2a1..0718c699 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -167,7 +167,13 @@ def get_footer(self) -> str: return footer_txt def modify_states(self) -> None: - """Disable specific components depending on paginator page and length.""" + """ + Disable specific components depending on paginator page and length. + + If the paginatot has less than two pages, the jump buttons will be disabled. + If the paginator is on the first page, the jump first/move back buttons will be disabled. + if the paginator is on the last page, the jump last/move forward buttons will be disabled. + """ less_than_2_pages = len(self.pages) <= 2 components = { "pag_jump_first": less_than_2_pages, From da5946c6cefc15a0a992e5fd7ac2e3f7942083c3 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 14:41:36 -0400 Subject: [PATCH 24/54] minor: remove if statements that should always be false --- modmail/utils/pagination.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 0718c699..7a9caafd 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -207,36 +207,24 @@ async def send_page(self, interaction: "Interaction") -> None: @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) async def go_first(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the first page.""" - if self._index == 0: - return - self._index = 0 await self.send_page(interaction) @ui.button(label=BACK_LABEL, custom_id="pag_back", style=ButtonStyle.primary) async def go_previous(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the previous page.""" - if self._index == 0: - return - self._index -= 1 await self.send_page(interaction) @ui.button(label=FORWARD_LABEL, custom_id="pag_next", style=ButtonStyle.primary) async def go_next(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the next page.""" - if self._index == len(self.pages) - 1: - return - self._index += 1 await self.send_page(interaction) @ui.button(label=JUMP_LAST_LABEL, custom_id="pag_jump_last", style=ButtonStyle.primary) async def go_last(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the last page.""" - if self._index == len(self.pages) - 1: - return - self._index = len(self.pages) - 1 await self.send_page(interaction) From 74947e5e8ebe12cc25db3f58932087dabe0df264 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 14:43:48 -0400 Subject: [PATCH 25/54] nit: remove an errant logging statement --- modmail/utils/pagination.py | 1 - 1 file changed, 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 7a9caafd..4639ddbf 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -85,7 +85,6 @@ def __init__( if not isinstance(embed.footer, EmbedProxy) and footer is None: footer = embed.footer self.footer = footer - logger.debug(self.footer) self.clear() for line in contents: self.add_line(line) From eaeab25727c27c82a3407000301e49f2b5e989f7 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 15:00:42 -0400 Subject: [PATCH 26/54] minor: rename footer to footer_text, modify to only use () around pages when no footer is present --- modmail/utils/pagination.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 4639ddbf..366ce20e 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -56,7 +56,7 @@ def __init__( embed: Embed = None, timeout: float = 180, *, - footer: str = None, + footer_text: str = None, prefix: str = "```", suffix: str = "```", max_size: int = 2000, @@ -82,9 +82,9 @@ def __init__( # set footer to embed.footer if embed is set # this is because we will be modifying the footer of this embed if embed is not None: - if not isinstance(embed.footer, EmbedProxy) and footer is None: - footer = embed.footer - self.footer = footer + if not isinstance(embed.footer, EmbedProxy) and footer_text is None: + footer_text = embed.footer + self.footer_text = footer_text self.clear() for line in contents: self.add_line(line) @@ -107,7 +107,7 @@ async def paginate( timeout: float = 180, embed: Embed = None, *, - footer: str = None, + footer_text: str = None, only: Optional[discord.abc.User] = None, channel: discord.abc.Messageable = None, prefix: str = "", @@ -122,7 +122,7 @@ async def paginate( source_message=source_message, timeout=timeout, embed=embed, - footer=footer, + footer_text=footer_text, prefix=prefix, suffix=suffix, max_size=max_size, @@ -161,8 +161,10 @@ async def interaction_check(self, interaction: "Interaction") -> bool: def get_footer(self) -> str: """Returns the footer text.""" self._embed.description = self.pages[self._index] - page_indicator = f"(Page {self._index+1}/{len(self.pages)})" - footer_txt = self.footer + page_indicator if self.footer is not None else page_indicator + page_indicator = f"Page {self._index+1}/{len(self.pages)}" + footer_txt = ( + f"{self.footer_text} ({page_indicator})" if self.footer_text is not None else page_indicator + ) return footer_txt def modify_states(self) -> None: From bcecc55e7e25f0e578061f0a11dca853a1b275ed Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 15:26:00 -0400 Subject: [PATCH 27/54] fix: reimplement pagination on extension management --- modmail/extensions/extension_manager.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 36862f2b..a3f62f94 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -186,12 +186,9 @@ async def list_extensions(self, ctx: Context) -> None: log.debug(f"{ctx.author} requested a list of all {self.type}s. " "Returning a paginated list.") - if lines: - # we have stuff installed, lets paginate it. - await ButtonPaginator.paginate(lines, ctx.message) - else: - # since we don't have any lines to paginate, nothing is installed. - await ctx.send(f"There are no {self.type}s installed.") + await ButtonPaginator.paginate( + lines or [f"There are no {self.type}s installed."], ctx.message, embed=embed + ) @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) async def resync_extensions(self, ctx: Context) -> None: From 06cb3b7fcbf3370d1ab6121e31a735e3555ebab3 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 15:48:06 -0400 Subject: [PATCH 28/54] minor: modify extensions list to not output modmail --- modmail/extensions/extension_manager.py | 7 ++++++- modmail/extensions/plugin_manager.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index a3f62f94..2e7481f5 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -85,6 +85,7 @@ class ExtensionManager(ModmailCog, name="Extension Manager"): """ type = "extension" + module_name = "extensions" # modmail/extensions def __init__(self, bot: ModmailBot): self.bot = bot @@ -180,6 +181,7 @@ async def list_extensions(self, ctx: Context) -> None: for category, extensions in sorted(categories.items()): # Treat each category as a single line by concatenating everything. # This ensures the paginator will not cut off a page in the middle of a category. + log.trace(f"Extensions in category {category}: {extensions}") category = category.replace("_", " ").title() extensions = "\n".join(sorted(extensions)) lines.append(f"**{category}**\n{extensions}\n") @@ -224,7 +226,10 @@ def group_extension_statuses(self) -> t.Mapping[str, str]: status = ":red_circle:" root, name = ext.rsplit(".", 1) - category = " - ".join(root.split(".")) + if root.split(".", 1)[1] == self.module_name: + category = f"General {self.type}s" + else: + category = " - ".join(root.split(".")[2:]) categories[category].append(f"{status} {name}") return dict(categories) diff --git a/modmail/extensions/plugin_manager.py b/modmail/extensions/plugin_manager.py index 4661a7ae..ea8d7edf 100644 --- a/modmail/extensions/plugin_manager.py +++ b/modmail/extensions/plugin_manager.py @@ -25,6 +25,7 @@ class PluginManager(ExtensionManager, name="Plugin Manager"): """Plugin management commands.""" type = "plugin" + module_name = "plugins" # modmail/plugins def __init__(self, bot: ModmailBot) -> None: super().__init__(bot) From 3cb0fea50d8db8bb90157f9d4b774aaefd85ccc5 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 22:08:16 -0400 Subject: [PATCH 29/54] use '_pages' interally instead of 'pages' --- modmail/utils/pagination.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 366ce20e..33b6e219 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -160,8 +160,8 @@ async def interaction_check(self, interaction: "Interaction") -> bool: def get_footer(self) -> str: """Returns the footer text.""" - self._embed.description = self.pages[self._index] - page_indicator = f"Page {self._index+1}/{len(self.pages)}" + self._embed.description = self._pages[self._index] + page_indicator = f"Page {self._index+1}/{len(self._pages)}" footer_txt = ( f"{self.footer_text} ({page_indicator})" if self.footer_text is not None else page_indicator ) @@ -175,7 +175,7 @@ def modify_states(self) -> None: If the paginator is on the first page, the jump first/move back buttons will be disabled. if the paginator is on the last page, the jump last/move forward buttons will be disabled. """ - less_than_2_pages = len(self.pages) <= 2 + less_than_2_pages = len(self._pages) <= 2 components = { "pag_jump_first": less_than_2_pages, "pag_back": False, @@ -187,7 +187,7 @@ def modify_states(self) -> None: components["pag_jump_first"] = True components["pag_back"] = True - if self._index == len(self.pages) - 1: + if self._index == len(self._pages) - 1: components["pag_next"] = True components["pag_jump_last"] = True @@ -226,7 +226,7 @@ async def go_next(self, button: "Button", interaction: "Interaction") -> None: @ui.button(label=JUMP_LAST_LABEL, custom_id="pag_jump_last", style=ButtonStyle.primary) async def go_last(self, button: "Button", interaction: "Interaction") -> None: """Move the paginator to the last page.""" - self._index = len(self.pages) - 1 + self._index = len(self._pages) - 1 await self.send_page(interaction) @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="pag_stop_paginate", style=ButtonStyle.grey) From b2720bc045350d4e0ab340d03927aaf1ad0bc778 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 23:18:47 -0400 Subject: [PATCH 30/54] chore: keep buttons blue --- modmail/utils/pagination.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 33b6e219..120db931 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -195,8 +195,6 @@ def modify_states(self) -> None: if child.custom_id in components.keys(): if getattr(child, "disabled", None) is not None: child.disabled = components[child.custom_id] - if getattr(child, "style", None) is not None: - child.style = ButtonStyle.secondary if child.disabled else ButtonStyle.primary async def send_page(self, interaction: "Interaction") -> None: """Send new page to discord, after updating the view to have properly disabled buttons.""" From f13e9c8ec40a530cd83d577f4f5cf84df163b94c Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 21 Aug 2021 23:19:47 -0400 Subject: [PATCH 31/54] nit: change jump labels to look like arrows --- modmail/utils/pagination.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 120db931..0f343c82 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -21,10 +21,10 @@ # Labels -JUMP_FIRST_LABEL = "\u2590\u276e\u2012" # bar, left arrow, ‒ +JUMP_FIRST_LABEL = " \u276e\u276e " # bar, left arrow, ‒ BACK_LABEL = " \u276e " # left arrow FORWARD_LABEL = " \u276f " # right arrow -JUMP_LAST_LABEL = "\u2012\u276f\u258c" # ‒, right arrow, bar +JUMP_LAST_LABEL = " \u276f\u276f " # ‒, right arrow, bar STOP_PAGINATE_EMOJI = "\u274c" # [:x:] This is an emoji, which is treated differently from the above logger: "ModmailLogger" = logging.getLogger(__name__) From 85293248eca909ea5ea14f0f5f9122bb3ba4ebbe Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 22 Aug 2021 13:56:38 -0400 Subject: [PATCH 32/54] minor: update comments for paginator labels --- modmail/utils/pagination.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 0f343c82..b6af24a5 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -21,10 +21,11 @@ # Labels -JUMP_FIRST_LABEL = " \u276e\u276e " # bar, left arrow, ‒ -BACK_LABEL = " \u276e " # left arrow -FORWARD_LABEL = " \u276f " # right arrow -JUMP_LAST_LABEL = " \u276f\u276f " # ‒, right arrow, bar +# NOTE: the characters are similar to what is printed, but not exact. +JUMP_FIRST_LABEL = " \u276e\u276e " # << +BACK_LABEL = " \u276e " # < +FORWARD_LABEL = " \u276f " # >> +JUMP_LAST_LABEL = " \u276f\u276f " # >> STOP_PAGINATE_EMOJI = "\u274c" # [:x:] This is an emoji, which is treated differently from the above logger: "ModmailLogger" = logging.getLogger(__name__) From 1062b43d0c540def15bcf65c669de3b3b2725c74 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 22 Aug 2021 17:11:27 -0400 Subject: [PATCH 33/54] pagination: implement role and user restriction --- modmail/utils/pagination.py | 72 ++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index b6af24a5..ce58f191 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -4,7 +4,7 @@ Originally adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 """ import logging -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import discord from discord import ButtonStyle, ui @@ -62,19 +62,48 @@ def __init__( suffix: str = "```", max_size: int = 2000, linesep: str = "\n", - only_users: Optional[List[discord.abc.User]] = None, + only_users: Optional[List[Union[discord.Object, discord.abc.User]]] = None, + only_roles: Optional[List[Union[discord.Object, discord.Role]]] = None, ) -> None: - """Creates a new Paginator instance. At least one of ctx or message must be supplied.""" - self.only_users = only_users + """ + Creates a new Paginator instance. + + If source_message or only_users/only_roles are not provided, the paginator will respond to all users. + If source message is provided and only_users is NOT provided, the paginator will respond + to the author of the source message. To override this, pass an empty list to `only_users`. + + """ self._index = 0 self._pages: List[str] = [] - self.source_message = source_message self.prefix = prefix self.suffix = suffix self.max_size = max_size self.linesep = linesep self._embed = embed or Embed() + # ensure that only_users are all users + if only_users is not None: + if isinstance(only_users, list): + for user in only_users: + if not isinstance(user, (discord.Object, discord.abc.User)): + raise InvalidArgumentError( + "only_users must be a list of discord.Object or discord.abc.User objects." + ) + elif source_message is not None: + logger.debug("Only users not provided, using source message author.") + only_users = [source_message.author] + + if only_roles is not None: + if isinstance(only_roles, list): + for role in only_roles: + if not isinstance(role, (discord.Object, discord.Role)): + raise InvalidArgumentError( + "only_roles must be a list of discord.Object or discord.Role objects." + ) + + self.only_users = only_users + self.only_roles = only_roles + if not isinstance(timeout, (int, float)): raise InvalidArgumentError("timeout must be a float") @@ -115,7 +144,8 @@ async def paginate( suffix: str = "", max_size: int = 4000, linesep: str = "\n", - only_users: Optional[List[discord.abc.User]] = None, + only_users: Optional[List[Union[discord.Object, discord.abc.User]]] = None, + only_roles: Optional[List[Union[discord.Object, discord.abc.Role]]] = None, ) -> None: """Create a paginator, and paginate the provided lines.""" paginator = cls( @@ -129,10 +159,11 @@ async def paginate( max_size=max_size, linesep=linesep, only_users=only_users, + only_roles=only_roles, ) if channel is None and source_message is None: - raise MissingAttributeError("Both channel and message are None.") + raise MissingAttributeError("Both channel and source_message are None.") elif channel is None: channel = source_message.channel @@ -150,14 +181,23 @@ async def paginate( await msg.edit(view=None) async def interaction_check(self, interaction: "Interaction") -> bool: - """Check if the interaction is by the author of the paginatior.""" - if self.source_message is None: - return True - if not (is_valid := self.source_message.author.id == interaction.user.id): - await interaction.response.send_message( - content="This is not your message to paginate!", ephemeral=True - ) - return is_valid + """Check if the interaction is by the author of the paginator.""" + if self.only_users is not None: + logger.trace(f"All allowed users: {self.only_users}") + if any(user.id == interaction.user.id for user in self.only_users): + logger.debug("User is in allowed users") + return True + if self.only_roles is not None: + logger.trace(f"All allowed roles: {self.only_roles}") + if any( + (role.id == user_role.id for user_role in interaction.user.roles) for role in self.only_roles + ): + logger.debug("User has an allowed role.") + return True + await interaction.response.send_message( + content="This is not your message to paginate!", ephemeral=True + ) + return False def get_footer(self) -> str: """Returns the footer text.""" @@ -232,4 +272,4 @@ async def go_last(self, button: "Button", interaction: "Interaction") -> None: async def _stop(self, button: "Button", interaction: "Interaction") -> None: """Stop the paginator early.""" await interaction.response.defer() - self.stop() + super().stop() From fd84bde05493d7d70532f18a4667b39a836f1af5 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 22 Aug 2021 19:08:51 -0400 Subject: [PATCH 34/54] fix: make role whitelist whitelist roles --- modmail/utils/pagination.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index ce58f191..de65a5d9 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -189,13 +189,13 @@ async def interaction_check(self, interaction: "Interaction") -> bool: return True if self.only_roles is not None: logger.trace(f"All allowed roles: {self.only_roles}") - if any( - (role.id == user_role.id for user_role in interaction.user.roles) for role in self.only_roles - ): - logger.debug("User has an allowed role.") - return True + user_roles = [role.id for role in interaction.user.roles] + for role in self.only_roles: + if role.id in user_roles: + logger.debug("User is in allowed roles") + return True await interaction.response.send_message( - content="This is not your message to paginate!", ephemeral=True + content="You are not authorised to use this paginator.", ephemeral=True ) return False From 7d972c52fd36c30da32191e28ab5adff8e7ce884 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 01:55:42 -0400 Subject: [PATCH 35/54] tests: add small paginator stub test --- tests/docs.md | 7 +++++++ tests/modmail/utils/test_pagination.py | 11 +++++++++++ 2 files changed, 18 insertions(+) create mode 100644 tests/modmail/utils/test_pagination.py diff --git a/tests/docs.md b/tests/docs.md index 1719ff47..cf18159b 100644 --- a/tests/docs.md +++ b/tests/docs.md @@ -67,3 +67,10 @@ Test creating an embed with extra parameters errors properly. **Markers:** - dependency (depends_on=patch_embed) +# tests.modmail.utils.test_pagination +## +### test_paginator_init +Test that we can safely create a paginator. + +**Markers:** +- asyncio diff --git a/tests/modmail/utils/test_pagination.py b/tests/modmail/utils/test_pagination.py new file mode 100644 index 00000000..7699a537 --- /dev/null +++ b/tests/modmail/utils/test_pagination.py @@ -0,0 +1,11 @@ +import pytest + +from modmail.utils.pagination import ButtonPaginator + + +@pytest.mark.asyncio +async def test_paginator_init(): + """Test that we can safely create a paginator.""" + content = ["content"] + paginator = ButtonPaginator(content, prefix="", suffix="", linesep="") + assert paginator.pages == content From 01040b51e5a29c52673996691ba0b1a12edbd3fd Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 12:23:50 -0400 Subject: [PATCH 36/54] minor: use feature annotations, underscore unused variables --- modmail/utils/pagination.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index de65a5d9..54b673e7 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -3,6 +3,8 @@ Originally adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 """ +from __future__ import annotations + import logging from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -28,7 +30,7 @@ JUMP_LAST_LABEL = " \u276f\u276f " # >> STOP_PAGINATE_EMOJI = "\u274c" # [:x:] This is an emoji, which is treated differently from the above -logger: "ModmailLogger" = logging.getLogger(__name__) +logger: ModmailLogger = logging.getLogger(__name__) class ButtonPaginator(ui.View, DpyPaginator): @@ -180,7 +182,7 @@ async def paginate( await paginator.wait() await msg.edit(view=None) - async def interaction_check(self, interaction: "Interaction") -> bool: + async def interaction_check(self, interaction: Interaction) -> bool: """Check if the interaction is by the author of the paginator.""" if self.only_users is not None: logger.trace(f"All allowed users: {self.only_users}") @@ -237,7 +239,7 @@ def modify_states(self) -> None: if getattr(child, "disabled", None) is not None: child.disabled = components[child.custom_id] - async def send_page(self, interaction: "Interaction") -> None: + async def send_page(self, interaction: Interaction) -> None: """Send new page to discord, after updating the view to have properly disabled buttons.""" self.modify_states() @@ -245,31 +247,31 @@ async def send_page(self, interaction: "Interaction") -> None: await interaction.message.edit(embed=self._embed, view=self) @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) - async def go_first(self, button: "Button", interaction: "Interaction") -> None: + async def go_first(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the first page.""" self._index = 0 await self.send_page(interaction) @ui.button(label=BACK_LABEL, custom_id="pag_back", style=ButtonStyle.primary) - async def go_previous(self, button: "Button", interaction: "Interaction") -> None: + async def go_previous(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the previous page.""" self._index -= 1 await self.send_page(interaction) @ui.button(label=FORWARD_LABEL, custom_id="pag_next", style=ButtonStyle.primary) - async def go_next(self, button: "Button", interaction: "Interaction") -> None: + async def go_next(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the next page.""" self._index += 1 await self.send_page(interaction) @ui.button(label=JUMP_LAST_LABEL, custom_id="pag_jump_last", style=ButtonStyle.primary) - async def go_last(self, button: "Button", interaction: "Interaction") -> None: + async def go_last(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the last page.""" self._index = len(self._pages) - 1 await self.send_page(interaction) @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="pag_stop_paginate", style=ButtonStyle.grey) - async def _stop(self, button: "Button", interaction: "Interaction") -> None: + async def _stop(self, _: Button, interaction: Interaction) -> None: """Stop the paginator early.""" await interaction.response.defer() - super().stop() + self.stop() From 71e5c1e731ffda3d3b17616e800cb4abb32536e0 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 12:32:47 -0400 Subject: [PATCH 37/54] nit: document correct character in docstring --- modmail/utils/pagination.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 54b673e7..e7fa93e8 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -26,7 +26,7 @@ # NOTE: the characters are similar to what is printed, but not exact. JUMP_FIRST_LABEL = " \u276e\u276e " # << BACK_LABEL = " \u276e " # < -FORWARD_LABEL = " \u276f " # >> +FORWARD_LABEL = " \u276f " # > JUMP_LAST_LABEL = " \u276f\u276f " # >> STOP_PAGINATE_EMOJI = "\u274c" # [:x:] This is an emoji, which is treated differently from the above @@ -149,7 +149,11 @@ async def paginate( only_users: Optional[List[Union[discord.Object, discord.abc.User]]] = None, only_roles: Optional[List[Union[discord.Object, discord.abc.Role]]] = None, ) -> None: - """Create a paginator, and paginate the provided lines.""" + """ + Create a paginator, and paginate the provided lines. + + One of source message or channel is required. + """ paginator = cls( contents, source_message=source_message, @@ -172,7 +176,7 @@ async def paginate( paginator.modify_states() paginator._embed.description = paginator.pages[paginator._index] paginator._embed.set_footer(text=paginator.get_footer()) - # if there's only one page, don't send hte view + # if there's only one page, don't send the view if len(paginator.pages) < 2: await channel.send(embeds=[paginator._embed]) return From a5ac92459c968c0319bf1c780a92eab40d699ba4 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 23:15:40 -0400 Subject: [PATCH 38/54] nit: comment why the _stop method is named as such --- modmail/utils/pagination.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index e7fa93e8..d1a5f826 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -274,6 +274,7 @@ async def go_last(self, _: Button, interaction: Interaction) -> None: self._index = len(self._pages) - 1 await self.send_page(interaction) + # NOTE: This method cannot be named `stop`, due to inheriting the method named stop from ui.View @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="pag_stop_paginate", style=ButtonStyle.grey) async def _stop(self, _: Button, interaction: Interaction) -> None: """Stop the paginator early.""" From f782ba22e9dbfa1a14f06016e35a097f9e5e6f8d Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 23:16:24 -0400 Subject: [PATCH 39/54] minor: import annotations feature and stringify them --- modmail/extensions/utils/paginator_cleaner.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modmail/extensions/utils/paginator_cleaner.py b/modmail/extensions/utils/paginator_cleaner.py index e35809bd..90f860bb 100644 --- a/modmail/extensions/utils/paginator_cleaner.py +++ b/modmail/extensions/utils/paginator_cleaner.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging from typing import TYPE_CHECKING @@ -12,17 +14,17 @@ from modmail.bot import ModmailBot from modmail.log import ModmailLogger -logger: "ModmailLogger" = logging.getLogger(__name__) +logger: ModmailLogger = logging.getLogger(__name__) class PaginatorCleaner(ModmailCog): """Handles paginators that were still active when the bot shut down.""" - def __init__(self, bot: "ModmailBot"): + def __init__(self, bot: ModmailBot): self.bot = bot @ModmailCog.listener() - async def on_interaction(self, interaction: "Interaction") -> None: + async def on_interaction(self, interaction: Interaction) -> None: """ Remove components from paginator messages if they fail. @@ -51,6 +53,6 @@ async def on_interaction(self, interaction: "Interaction") -> None: await interaction.message.edit(view=None) -def setup(bot: "ModmailBot") -> None: +def setup(bot: ModmailBot) -> None: """Add the paginator cleaner to the bot.""" bot.add_cog(PaginatorCleaner(bot)) From fa3ca634c9f69c2a6ede4c8f02b1cf3886bb5901 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 23:18:42 -0400 Subject: [PATCH 40/54] chore: rename paginator cleaner to paginator manager --- .../utils/{paginator_cleaner.py => paginator_manager.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename modmail/extensions/utils/{paginator_cleaner.py => paginator_manager.py} (96%) diff --git a/modmail/extensions/utils/paginator_cleaner.py b/modmail/extensions/utils/paginator_manager.py similarity index 96% rename from modmail/extensions/utils/paginator_cleaner.py rename to modmail/extensions/utils/paginator_manager.py index 90f860bb..293886ac 100644 --- a/modmail/extensions/utils/paginator_cleaner.py +++ b/modmail/extensions/utils/paginator_manager.py @@ -17,7 +17,7 @@ logger: ModmailLogger = logging.getLogger(__name__) -class PaginatorCleaner(ModmailCog): +class PaginatorManager(ModmailCog): """Handles paginators that were still active when the bot shut down.""" def __init__(self, bot: ModmailBot): @@ -55,4 +55,4 @@ async def on_interaction(self, interaction: Interaction) -> None: def setup(bot: ModmailBot) -> None: """Add the paginator cleaner to the bot.""" - bot.add_cog(PaginatorCleaner(bot)) + bot.add_cog(PaginatorManager(bot)) From 44f8dacf0e90cb5d252fee351cb8d5aad23475b4 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 23:35:34 -0400 Subject: [PATCH 41/54] nit: make docstrings more accurate --- modmail/extensions/utils/paginator_manager.py | 2 +- modmail/utils/pagination.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modmail/extensions/utils/paginator_manager.py b/modmail/extensions/utils/paginator_manager.py index 293886ac..fc6efbf0 100644 --- a/modmail/extensions/utils/paginator_manager.py +++ b/modmail/extensions/utils/paginator_manager.py @@ -49,7 +49,7 @@ async def on_interaction(self, interaction: Interaction) -> None: await asyncio.sleep(2) if not interaction.response.is_done(): await interaction.response.send_message(content="This paginator has expired.", ephemeral=True) - await asyncio.sleep(0.1) # sleep for 1 second so it isn't immediately removed + await asyncio.sleep(0.1) # sleep for just a moment so we don't jar the user await interaction.message.edit(view=None) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index d1a5f826..5d2f5a6e 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -23,7 +23,7 @@ # Labels -# NOTE: the characters are similar to what is printed, but not exact. +# NOTE: the characters are similar to what is printed, but not exact. This is to limit encoding issues. JUMP_FIRST_LABEL = " \u276e\u276e " # << BACK_LABEL = " \u276e " # < FORWARD_LABEL = " \u276f " # > From 5811482ab5f7caa7737dde319b17f8b1ac6fd6d8 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 00:03:32 -0400 Subject: [PATCH 42/54] minor: less liberal on the for loops --- modmail/utils/pagination.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 5d2f5a6e..366c2b53 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -86,22 +86,20 @@ def __init__( # ensure that only_users are all users if only_users is not None: if isinstance(only_users, list): - for user in only_users: - if not isinstance(user, (discord.Object, discord.abc.User)): - raise InvalidArgumentError( - "only_users must be a list of discord.Object or discord.abc.User objects." - ) + if not all(isinstance(user, (discord.Object, discord.abc.User)) for user in only_users): + raise InvalidArgumentError( + "only_users must be a list of discord.Object or discord.abc.User objects." + ) elif source_message is not None: logger.debug("Only users not provided, using source message author.") only_users = [source_message.author] if only_roles is not None: if isinstance(only_roles, list): - for role in only_roles: - if not isinstance(role, (discord.Object, discord.Role)): - raise InvalidArgumentError( - "only_roles must be a list of discord.Object or discord.Role objects." - ) + if not all(isinstance(role, (discord.Object, discord.Role)) for role in only_roles): + raise InvalidArgumentError( + "only_roles must be a list of discord.Object or discord.Role objects." + ) self.only_users = only_users self.only_roles = only_roles @@ -196,10 +194,9 @@ async def interaction_check(self, interaction: Interaction) -> bool: if self.only_roles is not None: logger.trace(f"All allowed roles: {self.only_roles}") user_roles = [role.id for role in interaction.user.roles] - for role in self.only_roles: - if role.id in user_roles: - logger.debug("User is in allowed roles") - return True + if any(role.id in user_roles for role in self.only_roles): + logger.debug("User is in allowed roles") + return True await interaction.response.send_message( content="You are not authorised to use this paginator.", ephemeral=True ) From 8781354c2b2a0dcb7f269890a414f6ba321b354e Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 00:09:55 -0400 Subject: [PATCH 43/54] minor: unprivatize embed and index variables --- modmail/utils/pagination.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 366c2b53..a8a2f87f 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -75,13 +75,13 @@ def __init__( to the author of the source message. To override this, pass an empty list to `only_users`. """ - self._index = 0 + self.index = 0 self._pages: List[str] = [] self.prefix = prefix self.suffix = suffix self.max_size = max_size self.linesep = linesep - self._embed = embed or Embed() + self.embed = embed or Embed() # ensure that only_users are all users if only_users is not None: @@ -172,14 +172,14 @@ async def paginate( channel = source_message.channel paginator.modify_states() - paginator._embed.description = paginator.pages[paginator._index] - paginator._embed.set_footer(text=paginator.get_footer()) + paginator.embed.description = paginator.pages[paginator.index] + paginator.embed.set_footer(text=paginator.get_footer()) # if there's only one page, don't send the view if len(paginator.pages) < 2: - await channel.send(embeds=[paginator._embed]) + await channel.send(embeds=[paginator.embed]) return else: - msg: discord.Message = await channel.send(embeds=[paginator._embed], view=paginator) + msg: discord.Message = await channel.send(embeds=[paginator.embed], view=paginator) await paginator.wait() await msg.edit(view=None) @@ -204,8 +204,8 @@ async def interaction_check(self, interaction: Interaction) -> bool: def get_footer(self) -> str: """Returns the footer text.""" - self._embed.description = self._pages[self._index] - page_indicator = f"Page {self._index+1}/{len(self._pages)}" + self.embed.description = self._pages[self.index] + page_indicator = f"Page {self.index+1}/{len(self._pages)}" footer_txt = ( f"{self.footer_text} ({page_indicator})" if self.footer_text is not None else page_indicator ) @@ -227,16 +227,17 @@ def modify_states(self) -> None: "pag_jump_last": less_than_2_pages, } - if self._index == 0: + if self.index == 0: components["pag_jump_first"] = True components["pag_back"] = True - if self._index == len(self._pages) - 1: + if self.index == len(self._pages) - 1: components["pag_next"] = True components["pag_jump_last"] = True for child in self.children: if child.custom_id in components.keys(): + # since its possible disabled is not an attribute, we need to get it with getattr if getattr(child, "disabled", None) is not None: child.disabled = components[child.custom_id] @@ -244,31 +245,31 @@ async def send_page(self, interaction: Interaction) -> None: """Send new page to discord, after updating the view to have properly disabled buttons.""" self.modify_states() - self._embed.set_footer(text=self.get_footer()) - await interaction.message.edit(embed=self._embed, view=self) + self.embed.set_footer(text=self.get_footer()) + await interaction.message.edit(embed=self.embed, view=self) @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) async def go_first(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the first page.""" - self._index = 0 + self.index = 0 await self.send_page(interaction) @ui.button(label=BACK_LABEL, custom_id="pag_back", style=ButtonStyle.primary) async def go_previous(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the previous page.""" - self._index -= 1 + self.index -= 1 await self.send_page(interaction) @ui.button(label=FORWARD_LABEL, custom_id="pag_next", style=ButtonStyle.primary) async def go_next(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the next page.""" - self._index += 1 + self.index += 1 await self.send_page(interaction) @ui.button(label=JUMP_LAST_LABEL, custom_id="pag_jump_last", style=ButtonStyle.primary) async def go_last(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the last page.""" - self._index = len(self._pages) - 1 + self.index = len(self._pages) - 1 await self.send_page(interaction) # NOTE: This method cannot be named `stop`, due to inheriting the method named stop from ui.View From a32cd6c9309e5cb9a3c932e4523cd3d48c35c9f0 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 00:36:06 -0400 Subject: [PATCH 44/54] chore: invert disabled components --- modmail/utils/pagination.py | 46 ++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index a8a2f87f..b3aa6e60 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -140,6 +140,7 @@ async def paginate( footer_text: str = None, only: Optional[discord.abc.User] = None, channel: discord.abc.Messageable = None, + show_jump_buttons_min_pages: int = 3, prefix: str = "", suffix: str = "", max_size: int = 4000, @@ -178,8 +179,13 @@ async def paginate( if len(paginator.pages) < 2: await channel.send(embeds=[paginator.embed]) return - else: - msg: discord.Message = await channel.send(embeds=[paginator.embed], view=paginator) + + if len(paginator.pages) < show_jump_buttons_min_pages or 3: + for item in paginator.children: + if getattr(item, "custom_id", None) in ["pag_jump_first", "pag_jump_last"]: + paginator.remove_item(item) + + msg: discord.Message = await channel.send(embeds=[paginator.embed], view=paginator) await paginator.wait() await msg.edit(view=None) @@ -211,7 +217,7 @@ def get_footer(self) -> str: ) return footer_txt - def modify_states(self) -> None: + def update_components(self) -> None: """ Disable specific components depending on paginator page and length. @@ -219,31 +225,35 @@ def modify_states(self) -> None: If the paginator is on the first page, the jump first/move back buttons will be disabled. if the paginator is on the last page, the jump last/move forward buttons will be disabled. """ - less_than_2_pages = len(self._pages) <= 2 + more_than_two_pages = len(self._pages) > 2 components = { - "pag_jump_first": less_than_2_pages, - "pag_back": False, - "pag_next": False, - "pag_jump_last": less_than_2_pages, + "pag_jump_first": more_than_two_pages, + "pag_prev": True, + "pag_next": True, + "pag_jump_last": more_than_two_pages, } if self.index == 0: - components["pag_jump_first"] = True - components["pag_back"] = True + # first page, disable + logger.trace("Paginator is on the first page, disabling jump to first and previous buttons.") + components["pag_jump_first"] = False + components["pag_prev"] = False - if self.index == len(self._pages) - 1: - components["pag_next"] = True - components["pag_jump_last"] = True + elif self.index == len(self._pages) - 1: + logger.trace("Paginator is on the last page, disabling jump to last and next buttons.") + components["pag_next"] = False + components["pag_jump_last"] = False for child in self.children: - if child.custom_id in components.keys(): - # since its possible disabled is not an attribute, we need to get it with getattr + # since its possible custom_id and disabled are not an attribute + # we need to get them with getattr + if getattr(child, "custom_id", None) in components.keys(): if getattr(child, "disabled", None) is not None: - child.disabled = components[child.custom_id] + child.disabled = not components[child.custom_id] async def send_page(self, interaction: Interaction) -> None: """Send new page to discord, after updating the view to have properly disabled buttons.""" - self.modify_states() + self.update_components() self.embed.set_footer(text=self.get_footer()) await interaction.message.edit(embed=self.embed, view=self) @@ -254,7 +264,7 @@ async def go_first(self, _: Button, interaction: Interaction) -> None: self.index = 0 await self.send_page(interaction) - @ui.button(label=BACK_LABEL, custom_id="pag_back", style=ButtonStyle.primary) + @ui.button(label=BACK_LABEL, custom_id="pag_prev", style=ButtonStyle.primary) async def go_previous(self, _: Button, interaction: Interaction) -> None: """Move the paginator to the previous page.""" self.index -= 1 From 43a2a499a49acaf1bcf952aae3ea3e255e396efd Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 00:38:07 -0400 Subject: [PATCH 45/54] chore: move embed footer to new method --- modmail/utils/pagination.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index b3aa6e60..eb83dd34 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -174,7 +174,7 @@ async def paginate( paginator.modify_states() paginator.embed.description = paginator.pages[paginator.index] - paginator.embed.set_footer(text=paginator.get_footer()) + paginator.update_footer() # if there's only one page, don't send the view if len(paginator.pages) < 2: await channel.send(embeds=[paginator.embed]) @@ -208,6 +208,10 @@ async def interaction_check(self, interaction: Interaction) -> bool: ) return False + def update_footer(self) -> None: + """Update the footer with the new footer.""" + self.embed.set_footer(text=self.get_footer()) + def get_footer(self) -> str: """Returns the footer text.""" self.embed.description = self._pages[self.index] @@ -255,7 +259,7 @@ async def send_page(self, interaction: Interaction) -> None: """Send new page to discord, after updating the view to have properly disabled buttons.""" self.update_components() - self.embed.set_footer(text=self.get_footer()) + self.update_footer() await interaction.message.edit(embed=self.embed, view=self) @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) From 47836fecc9a0bf062a08aff8edac27924433855d Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 00:38:27 -0400 Subject: [PATCH 46/54] nit: fix paginatot docstring typo --- modmail/utils/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index eb83dd34..13781c05 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -225,7 +225,7 @@ def update_components(self) -> None: """ Disable specific components depending on paginator page and length. - If the paginatot has less than two pages, the jump buttons will be disabled. + If the paginator has less than two pages, the jump buttons will be disabled. If the paginator is on the first page, the jump first/move back buttons will be disabled. if the paginator is on the last page, the jump last/move forward buttons will be disabled. """ From 23f4772e3e73261d77edeb2755a27b58718d8599 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 00:48:28 -0400 Subject: [PATCH 47/54] minor: rename to update_states and fold footer update --- modmail/utils/pagination.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 13781c05..fdbdf32d 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -172,9 +172,8 @@ async def paginate( elif channel is None: channel = source_message.channel - paginator.modify_states() + paginator.update_states() paginator.embed.description = paginator.pages[paginator.index] - paginator.update_footer() # if there's only one page, don't send the view if len(paginator.pages) < 2: await channel.send(embeds=[paginator.embed]) @@ -208,10 +207,6 @@ async def interaction_check(self, interaction: Interaction) -> bool: ) return False - def update_footer(self) -> None: - """Update the footer with the new footer.""" - self.embed.set_footer(text=self.get_footer()) - def get_footer(self) -> str: """Returns the footer text.""" self.embed.description = self._pages[self.index] @@ -221,7 +216,7 @@ def get_footer(self) -> str: ) return footer_txt - def update_components(self) -> None: + def update_states(self) -> None: """ Disable specific components depending on paginator page and length. @@ -229,6 +224,10 @@ def update_components(self) -> None: If the paginator is on the first page, the jump first/move back buttons will be disabled. if the paginator is on the last page, the jump last/move forward buttons will be disabled. """ + # update the footer + self.embed.set_footer(text=self.get_footer()) + + # determine if the jump buttons should be enabled more_than_two_pages = len(self._pages) > 2 components = { "pag_jump_first": more_than_two_pages, @@ -238,12 +237,13 @@ def update_components(self) -> None: } if self.index == 0: - # first page, disable + # on the first page, disable buttons that would go to this page. logger.trace("Paginator is on the first page, disabling jump to first and previous buttons.") components["pag_jump_first"] = False components["pag_prev"] = False elif self.index == len(self._pages) - 1: + # on the last page, disable buttons that would go to this page. logger.trace("Paginator is on the last page, disabling jump to last and next buttons.") components["pag_next"] = False components["pag_jump_last"] = False @@ -257,9 +257,8 @@ def update_components(self) -> None: async def send_page(self, interaction: Interaction) -> None: """Send new page to discord, after updating the view to have properly disabled buttons.""" - self.update_components() + self.update_states() - self.update_footer() await interaction.message.edit(embed=self.embed, view=self) @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) From b7a035608ebcd4dc3997e0f53edbd03df3b250f7 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 01:03:54 -0400 Subject: [PATCH 48/54] minor: support strings for contents list --- modmail/utils/pagination.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index fdbdf32d..134e1663 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -53,7 +53,7 @@ class ButtonPaginator(ui.View, DpyPaginator): def __init__( self, - contents: List[str], + contents: Union[List[str], str], /, source_message: Optional[discord.Message] = None, embed: Embed = None, @@ -83,6 +83,10 @@ def __init__( self.linesep = linesep self.embed = embed or Embed() + # temporary to support strings as contents. This will be changed when we added wrapping. + if isinstance(contents, str): + contents = [contents] + # ensure that only_users are all users if only_users is not None: if isinstance(only_users, list): From e68f5aa2467a4f85681083fbedb84c1d138ea335 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 02:59:05 -0400 Subject: [PATCH 49/54] tests: add footer test --- tests/docs.md | 14 ++++++++++++++ tests/modmail/utils/test_pagination.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/tests/docs.md b/tests/docs.md index cf18159b..58c3798f 100644 --- a/tests/docs.md +++ b/tests/docs.md @@ -74,3 +74,17 @@ Test that we can safely create a paginator. **Markers:** - asyncio +### test_paginator_footer +Test the paginator footer matches what is passed. + +**Markers:** +- asyncio +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world')]) +- xfail (Currently broken.) +### test_paginator_footer +Test the paginator footer matches what is passed. + +**Markers:** +- asyncio +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world')]) +- xfail (Currently broken.) diff --git a/tests/modmail/utils/test_pagination.py b/tests/modmail/utils/test_pagination.py index 7699a537..ddb43e84 100644 --- a/tests/modmail/utils/test_pagination.py +++ b/tests/modmail/utils/test_pagination.py @@ -1,3 +1,5 @@ +from typing import List + import pytest from modmail.utils.pagination import ButtonPaginator @@ -9,3 +11,19 @@ async def test_paginator_init(): content = ["content"] paginator = ButtonPaginator(content, prefix="", suffix="", linesep="") assert paginator.pages == content + + +@pytest.mark.xfail("Currently broken.") +@pytest.mark.parametrize("content, footer_text", [(["5"], "Snap, crackle, pop"), (["Earthly"], "world")]) +@pytest.mark.asyncio +async def test_paginator_footer(content, footer_text): + """Test the paginator footer matches what is passed.""" + pag = ButtonPaginator(content, footer_text=footer_text) + print("index:", pag.index) + print("page len: ", len(pag.pages)) + assert pag.footer_text == footer_text + if footer_text is not None: + assert pag.get_footer().endswith(f"{len(content)})") + else: + assert pag.get_footer().endswith(f"{len(content)})") + assert pag.get_footer().startswith(footer_text) From cd7b5ec806fb6fa728dca2a276a9fa489e3b731a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 03:18:18 -0400 Subject: [PATCH 50/54] fix: make paginator count pages properly again --- modmail/utils/pagination.py | 2 +- tests/docs.md | 6 ++---- tests/modmail/utils/test_pagination.py | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index 134e1663..f9091f3d 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -122,7 +122,7 @@ def __init__( self.clear() for line in contents: self.add_line(line) - + self.close_page() # create the super so the children attributes are set super().__init__() diff --git a/tests/docs.md b/tests/docs.md index 58c3798f..2f2698c2 100644 --- a/tests/docs.md +++ b/tests/docs.md @@ -78,13 +78,11 @@ Test that we can safely create a paginator. Test the paginator footer matches what is passed. **Markers:** -- asyncio - parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world')]) -- xfail (Currently broken.) +- asyncio ### test_paginator_footer Test the paginator footer matches what is passed. **Markers:** -- asyncio - parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world')]) -- xfail (Currently broken.) +- asyncio diff --git a/tests/modmail/utils/test_pagination.py b/tests/modmail/utils/test_pagination.py index ddb43e84..5e306034 100644 --- a/tests/modmail/utils/test_pagination.py +++ b/tests/modmail/utils/test_pagination.py @@ -13,9 +13,8 @@ async def test_paginator_init(): assert paginator.pages == content -@pytest.mark.xfail("Currently broken.") -@pytest.mark.parametrize("content, footer_text", [(["5"], "Snap, crackle, pop"), (["Earthly"], "world")]) @pytest.mark.asyncio +@pytest.mark.parametrize("content, footer_text", [(["5"], "Snap, crackle, pop"), (["Earthly"], "world")]) async def test_paginator_footer(content, footer_text): """Test the paginator footer matches what is passed.""" pag = ButtonPaginator(content, footer_text=footer_text) From 02d0421741f6b2f2a9dc3e1da79197cfcb7a35a8 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 11:51:24 -0400 Subject: [PATCH 51/54] chore: switch extension paginator to a string --- modmail/extensions/extension_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 3dc3ac5a..aacee78e 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -189,7 +189,7 @@ async def list_extensions(self, ctx: Context) -> None: log.debug(f"{ctx.author} requested a list of all {self.type}s. " "Returning a paginated list.") await ButtonPaginator.paginate( - lines or [f"There are no {self.type}s installed."], ctx.message, embed=embed + lines or f"There are no {self.type}s installed.", ctx.message, embed=embed ) @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) From ac392ab07c42ffb17fa4cb024eac5814526ff536 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 12:04:25 -0400 Subject: [PATCH 52/54] nit: make it possible to run individual tests --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 967b5fed..86da8637 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ source_pkgs = ["modmail"] omit = ["modmail/plugins/**.*"] [tool.pytest.ini_options] -addopts = "--cov" +addopts = "--cov= " minversion = "6.0" testpaths = ["tests"] From ba1046be4fb93c3059d5c450aa04ac09c0eac39a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 12:25:00 -0400 Subject: [PATCH 53/54] tests: add a few more tests --- tests/docs.md | 10 ++++++++-- tests/modmail/utils/test_pagination.py | 24 ++++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/docs.md b/tests/docs.md index 2f2698c2..1b8e0ac4 100644 --- a/tests/docs.md +++ b/tests/docs.md @@ -78,11 +78,17 @@ Test that we can safely create a paginator. Test the paginator footer matches what is passed. **Markers:** -- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world')]) +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world'), ('There are no plugins installed.', None)]) - asyncio ### test_paginator_footer Test the paginator footer matches what is passed. **Markers:** -- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world')]) +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world'), ('There are no plugins installed.', None)]) +- asyncio +### test_paginator_footer +Test the paginator footer matches what is passed. + +**Markers:** +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world'), ('There are no plugins installed.', None)]) - asyncio diff --git a/tests/modmail/utils/test_pagination.py b/tests/modmail/utils/test_pagination.py index 5e306034..1993c6ee 100644 --- a/tests/modmail/utils/test_pagination.py +++ b/tests/modmail/utils/test_pagination.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Union import pytest @@ -6,7 +6,7 @@ @pytest.mark.asyncio -async def test_paginator_init(): +async def test_paginator_init() -> None: """Test that we can safely create a paginator.""" content = ["content"] paginator = ButtonPaginator(content, prefix="", suffix="", linesep="") @@ -14,15 +14,27 @@ async def test_paginator_init(): @pytest.mark.asyncio -@pytest.mark.parametrize("content, footer_text", [(["5"], "Snap, crackle, pop"), (["Earthly"], "world")]) -async def test_paginator_footer(content, footer_text): +@pytest.mark.parametrize( + "content, footer_text", + [ + (["5"], "Snap, crackle, pop"), + (["Earthly"], "world"), + ("There are no plugins installed.", None), + ], +) +async def test_paginator_footer(content: Union[str, List[str]], footer_text: str) -> None: """Test the paginator footer matches what is passed.""" pag = ButtonPaginator(content, footer_text=footer_text) print("index:", pag.index) print("page len: ", len(pag.pages)) assert pag.footer_text == footer_text + if isinstance(content, str): + content = [content] + + print(pag.get_footer()) if footer_text is not None: assert pag.get_footer().endswith(f"{len(content)})") + assert pag.get_footer().startswith(footer_text) + else: - assert pag.get_footer().endswith(f"{len(content)})") - assert pag.get_footer().startswith(footer_text) + assert pag.get_footer().endswith(f"{len(content)}") From 435da8f05201f094e8e022018c8aae7fd05fe2e8 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 31 Aug 2021 12:55:25 -0400 Subject: [PATCH 54/54] fix: remove logic bug --- modmail/utils/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py index f9091f3d..f2f98fa8 100644 --- a/modmail/utils/pagination.py +++ b/modmail/utils/pagination.py @@ -183,7 +183,7 @@ async def paginate( await channel.send(embeds=[paginator.embed]) return - if len(paginator.pages) < show_jump_buttons_min_pages or 3: + if len(paginator.pages) < (show_jump_buttons_min_pages or 3): for item in paginator.children: if getattr(item, "custom_id", None) in ["pag_jump_first", "pag_jump_last"]: paginator.remove_item(item)