Bladeren bron

First draft of /bangcommand

tags/2.0.0
Rocketsoup 1 maand geleden
bovenliggende
commit
639405a23e
5 gewijzigde bestanden met toevoegingen van 317 en 84 verwijderingen
  1. 2
    0
      rocketbot/bot.py
  2. 170
    0
      rocketbot/cogs/bangcommandcog.py
  3. 4
    84
      rocketbot/cogs/helpcog.py
  4. 133
    0
      rocketbot/ui/pagedcontent.py
  5. 8
    0
      rocketbot/utils.py

+ 2
- 0
rocketbot/bot.py Bestand weergeven

@@ -91,6 +91,7 @@ def __create_bot():
91 91
 __create_bot()
92 92
 
93 93
 from rocketbot.cogs.autokickcog import AutoKickCog
94
+from rocketbot.cogs.bangcommandcog import BangCommandCog
94 95
 from rocketbot.cogs.configcog import ConfigCog
95 96
 from rocketbot.cogs.crosspostcog import CrossPostCog
96 97
 from rocketbot.cogs.gamescog import GamesCog
@@ -111,6 +112,7 @@ async def start_bot():
111 112
 
112 113
 	# Optional
113 114
 	await rocketbot.add_cog(AutoKickCog(rocketbot))
115
+	await rocketbot.add_cog(BangCommandCog(rocketbot))
114 116
 	await rocketbot.add_cog(CrossPostCog(rocketbot))
115 117
 	await rocketbot.add_cog(GamesCog(rocketbot))
116 118
 	await rocketbot.add_cog(HelpCog(rocketbot))

+ 170
- 0
rocketbot/cogs/bangcommandcog.py Bestand weergeven

@@ -0,0 +1,170 @@
1
+import re
2
+from typing import Optional, TypedDict
3
+
4
+from discord import Interaction, Guild, Message, TextChannel
5
+from discord.app_commands import Group
6
+from discord.ext.commands import Cog
7
+
8
+from config import CONFIG
9
+from rocketbot.bot import Rocketbot
10
+from rocketbot.cogs.basecog import BaseCog
11
+from rocketbot.cogsetting import CogSetting
12
+from rocketbot.ui.pagedcontent import PAGE_BREAK, update_paged_content, paginate
13
+from rocketbot.utils import MOD_PERMISSIONS, blockquote_markdown, indent_markdown
14
+
15
+_CURRENT_DATA_VERSION = 1
16
+
17
+class BangCommand(TypedDict):
18
+    content: str
19
+    mod_only: bool
20
+    version: int
21
+
22
+class BangCommandCog(BaseCog, name='Bang Commands'):
23
+    SETTING_COMMANDS = CogSetting(
24
+        name='commands',
25
+        datatype=dict[str, BangCommand],
26
+        default_value={},
27
+    )
28
+
29
+    def __init__(self, bot: Rocketbot):
30
+        super().__init__(
31
+            bot,
32
+            config_prefix='bangcommand',
33
+            short_description='Provides custom informational chat !commands.',
34
+            long_description='Bang commands are simple one-word messages starting with an exclamation '
35
+                             '(bang) that will make the bot respond with simple informational replies. '
36
+                             'Useful for posting answers to frequently asked questions, reminding users '
37
+                             'of rules, and similar.'
38
+        )
39
+
40
+    def _get_commands(self, guild: Guild) -> dict[str, BangCommand]:
41
+        return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
42
+
43
+    def _set_commands(self, guild: Guild, commands: dict[str, BangCommand]) -> None:
44
+        self.set_guild_setting(guild, BangCommandCog.SETTING_COMMANDS, commands)
45
+
46
+    bang = Group(
47
+        name='bangcommand',
48
+        description='Provides custom informational chat !commands.',
49
+        guild_only=True,
50
+        default_permissions=MOD_PERMISSIONS,
51
+    )
52
+
53
+    @bang.command()
54
+    async def define(self, interaction: Interaction, name: str, definition: str, mod_only: bool = False) -> None:
55
+        """
56
+        Defines or redefines a bang command.
57
+
58
+        Parameters
59
+        ----------
60
+        interaction: Interaction
61
+        name: string
62
+            name of the command (lowercase a-z, underscores, and hyphens)
63
+        definition: string
64
+            content of the command
65
+        mod_only: bool
66
+            whether the command will only be recognized when a mod uses it
67
+        """
68
+        if not BangCommandCog._is_valid_name(name):
69
+            self.log(interaction.guild, f'{interaction.user.name} used command /bangcommand define {name} {definition}')
70
+            await interaction.response.send_message(
71
+                f'{CONFIG["failure_emoji"]} Invalid command name. Names must consist of lowercase letters, underscores, and hyphens (no spaces).',
72
+                ephemeral=True,
73
+            )
74
+            return
75
+        name = BangCommandCog._normalize_name(name)
76
+        cmds = self._get_commands(interaction.guild)
77
+        cmds[name] = {
78
+            'content': definition,
79
+            'mod_only': mod_only,
80
+            'version': _CURRENT_DATA_VERSION,
81
+        }
82
+        self._set_commands(interaction.guild, cmds)
83
+        await interaction.response.send_message(
84
+            f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(definition)}',
85
+            ephemeral=True,
86
+        )
87
+
88
+    @bang.command()
89
+    async def undefine(self, interaction: Interaction, name: str) -> None:
90
+        """
91
+        Removes a bang command.
92
+
93
+        Parameters
94
+        ----------
95
+        interaction: Interaction
96
+        name: string
97
+            name of the previously defined command
98
+        """
99
+        name = BangCommandCog._normalize_name(name)
100
+        cmds = self._get_commands(interaction.guild)
101
+        if name not in cmds:
102
+            await interaction.response.send_message(
103
+                f'{CONFIG["failure_emoji"]} Command `!{name}` does not exist.',
104
+                ephemeral=True,
105
+            )
106
+            return
107
+        del cmds[name]
108
+        self._set_commands(interaction.guild, cmds)
109
+        await interaction.response.send_message(
110
+            f'{CONFIG["success_emoji"]} Command `!{name}` removed.',
111
+            ephemeral=True,
112
+        )
113
+
114
+    @bang.command()
115
+    async def list(self, interaction: Interaction) -> None:
116
+        """
117
+        Lists all defined bang commands.
118
+
119
+        Parameters
120
+        ----------
121
+        interaction: Interaction
122
+        """
123
+        cmds = self._get_commands(interaction.guild)
124
+        if cmds is None or len(cmds) == 0:
125
+            await interaction.response.send_message(
126
+                f'{CONFIG["info_emoji"]} No commands defined.',
127
+                ephemeral=True,
128
+            )
129
+            return
130
+        text = '## Commands'
131
+        for name, cmd in sorted(cmds.items()):
132
+            text += PAGE_BREAK + f'\n- `!{name}`'
133
+            if cmd['mod_only']:
134
+                text += ' - **mod only**'
135
+            text += f'\n{indent_markdown(cmd["content"])}'
136
+        pages = paginate(text)
137
+        await update_paged_content(interaction, None, 0, pages)
138
+
139
+    @Cog.listener()
140
+    async def on_message(self, message: Message) -> None:
141
+        if message.guild is None or message.channel is None or not isinstance(message.channel, TextChannel):
142
+            return
143
+        content = message.content
144
+        if content is None or not content.startswith('!') or not BangCommandCog._is_valid_name(content):
145
+            return
146
+        name = BangCommandCog._normalize_name(content)
147
+        cmds = self._get_commands(message.guild)
148
+        cmd = cmds.get(name, None)
149
+        if cmd is None:
150
+            return
151
+        if cmd['mod_only'] and not message.author.guild_permissions.ban_members:
152
+            return
153
+        text = cmd["content"]
154
+        # text = f'{text}\n\n-# {message.author.name} used `!{name}`'
155
+        await message.channel.send(
156
+            text,
157
+        )
158
+
159
+    @staticmethod
160
+    def _normalize_name(name: str) -> str:
161
+        name = name.lower().strip()
162
+        if name.startswith('!'):
163
+            name = name[1:]
164
+        return name
165
+
166
+    @staticmethod
167
+    def _is_valid_name(name: Optional[str]) -> bool:
168
+        if name is None:
169
+            return False
170
+        return re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None

+ 4
- 84
rocketbot/cogs/helpcog.py Bestand weergeven

@@ -3,13 +3,13 @@ import re
3 3
 import time
4 4
 from typing import Union, Optional, TypedDict
5 5
 
6
-from discord import Interaction, Permissions, AppCommandType, ButtonStyle, InteractionResponse
6
+from discord import Interaction, Permissions, AppCommandType
7 7
 from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
8
-from discord.ui import ActionRow, Button, LayoutView, TextDisplay
9 8
 
10 9
 from config import CONFIG
11 10
 from rocketbot.bot import Rocketbot
12 11
 from rocketbot.cogs.basecog import BaseCog
12
+from rocketbot.ui.pagedcontent import update_paged_content, paginate
13 13
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
14 14
 
15 15
 HelpTopic = Union[Command, Group, BaseCog]
@@ -420,88 +420,8 @@ class HelpCog(BaseCog, name='Help'):
420 420
 		await self.__send_paged_help(interaction, text)
421 421
 
422 422
 	async def __send_paged_help(self, interaction: Interaction, text: str) -> None:
423
-		pages = _paginate(text)
424
-		if len(pages) == 1:
425
-			await interaction.response.send_message(pages[0], ephemeral=True)
426
-		else:
427
-			await _update_paged_help(interaction, None, 0, pages)
428
-
429
-def _paginate(text: str) -> list[str]:
430
-	max_page_size = 2000
431
-	chunks = text.split(PAGE_BREAK)
432
-	pages = [ '' ]
433
-	for chunk in chunks:
434
-		if len(chunk) > max_page_size:
435
-			raise ValueError('Help content needs more page breaks! One chunk is too big for message.')
436
-		if len(pages[-1] + chunk) < max_page_size:
437
-			pages[-1] += chunk
438
-		else:
439
-			pages.append(chunk)
440
-	page_count = len(pages)
441
-	if page_count == 1:
442
-		return pages
443
-
444
-	# Do another pass and try to even out the page lengths
445
-	indices = [ i * len(chunks) // page_count for i in range(page_count + 1) ]
446
-	even_pages = [
447
-		''.join(chunks[indices[i]:indices[i + 1]])
448
-		for i in range(page_count)
449
-	]
450
-	for page in even_pages:
451
-		if len(page) > max_page_size:
452
-			# We made a page too big. Give up.
453
-			return pages
454
-	return even_pages
455
-
456
-async def _update_paged_help(interaction: Interaction, original_interaction: Optional[Interaction], current_page: int, pages: list[str]) -> None:
457
-	try:
458
-		view = _PagingLayoutView(current_page, pages, original_interaction or interaction)
459
-		resolved = interaction
460
-		if original_interaction is not None:
461
-			# We have an original interaction from the initial command and a
462
-			# new one from the button press. Use the original to swap in the
463
-			# new page in place, then acknowledge the new one to satisfy the
464
-			# API that we didn't fail.
465
-			await original_interaction.edit_original_response(
466
-				view=view,
467
-			)
468
-			if interaction is not original_interaction:
469
-				await interaction.response.defer(ephemeral=True, thinking=False)
470
-		else:
471
-			# Initial send
472
-			await resolved.response.send_message(
473
-				view=view,
474
-				ephemeral=True,
475
-				delete_after=60,
476
-			)
477
-	except BaseException as e:
478
-		dump_stacktrace(e)
479
-
480
-class _PagingLayoutView(LayoutView):
481
-	def __init__(self, current_page: int, pages: list[str], original_interaction: Optional[Interaction]):
482
-		super().__init__()
483
-		self.current_page: int = current_page
484
-		self.pages: list[str] = pages
485
-		self.text.content = self.pages[self.current_page]
486
-		self.original_interaction = original_interaction
487
-		if current_page <= 0:
488
-			self.handle_prev_button.disabled = True
489
-		if current_page >= len(self.pages) - 1:
490
-			self.handle_next_button.disabled = True
491
-
492
-	text = TextDisplay('')
493
-
494
-	row = ActionRow()
495
-
496
-	@row.button(label='< Prev')
497
-	async def handle_prev_button(self, interaction: Interaction, button: Button) -> None:
498
-		new_page = max(0, self.current_page - 1)
499
-		await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
500
-
501
-	@row.button(label='Next >')
502
-	async def handle_next_button(self, interaction: Interaction, button: Button) -> None:
503
-		new_page = min(len(self.pages) - 1, self.current_page + 1)
504
-		await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
423
+		pages = paginate(text)
424
+		await update_paged_content(interaction, None, 0, pages, delete_after=60)
505 425
 
506 426
 # Exclusions from keyword indexing
507 427
 trivial_words = {

+ 133
- 0
rocketbot/ui/pagedcontent.py Bestand weergeven

@@ -0,0 +1,133 @@
1
+"""
2
+Provides a means of presenting long messages by paging them. Paging requires the
3
+source message to insert `PAGE_BREAK` characters at meaningful breaks, preferably
4
+at fairly uniform intervals.
5
+"""
6
+
7
+from typing import Optional
8
+
9
+from discord import Interaction
10
+from discord.ui import LayoutView, TextDisplay, ActionRow, Button
11
+
12
+from rocketbot.utils import dump_stacktrace
13
+
14
+PAGE_BREAK = '\f'
15
+
16
+def paginate(text: str) -> list[str]:
17
+	"""
18
+	Breaks long message text into one or more pages, using page break markers as
19
+	potential clean break points.
20
+	"""
21
+	max_page_size = 2000
22
+	chunks = text.split(PAGE_BREAK)
23
+	pages = [ '' ]
24
+	for chunk in chunks:
25
+		if len(chunk) > max_page_size:
26
+			raise ValueError('Help content needs more page breaks! One chunk is too big for message.')
27
+		if len(pages[-1] + chunk) < max_page_size:
28
+			pages[-1] += chunk
29
+		else:
30
+			pages.append(chunk)
31
+	page_count = len(pages)
32
+	if page_count == 1:
33
+		return pages
34
+
35
+	# Do another pass and try to even out the page lengths
36
+	indices = [ i * len(chunks) // page_count for i in range(page_count + 1) ]
37
+	even_pages = [
38
+		''.join(chunks[indices[i]:indices[i + 1]])
39
+		for i in range(page_count)
40
+	]
41
+	for page in even_pages:
42
+		if len(page) > max_page_size:
43
+			# We made a page too big. Give up.
44
+			return pages
45
+	return even_pages
46
+
47
+async def update_paged_content(
48
+		interaction: Interaction,
49
+		original_interaction: Optional[Interaction],
50
+		current_page: int,
51
+		pages: list[str],
52
+		**send_args,
53
+) -> None:
54
+	"""
55
+	Posts and/or updates the content of a message from paged content.
56
+
57
+	Parameters
58
+	----------
59
+	interaction : Interaction
60
+		the current interaction, either the initial one or from a button press
61
+	original_interaction : Interaction
62
+		the first interaction that triggered presentation of the content, or None
63
+		if this already is the first interaction
64
+	current_page : int
65
+		page index to show (assumed to be in bounds)
66
+	pages : list[str]
67
+		array of page content
68
+	**send_args
69
+		additional arguments to pass to the send_message method
70
+	"""
71
+	if len(pages) == 1:
72
+		# No paging needed
73
+		await interaction.response.send_message(
74
+			pages[0],
75
+			ephemeral=True,
76
+		)
77
+		return
78
+
79
+	try:
80
+		view = _PagingLayoutView(current_page, pages, original_interaction or interaction)
81
+		resolved = interaction
82
+		if original_interaction is not None:
83
+			# We have an original interaction from the initial command and a
84
+			# new one from the button press. Use the original to swap in the
85
+			# new page in place, then acknowledge the new one to satisfy the
86
+			# API that we didn't fail.
87
+			await original_interaction.edit_original_response(
88
+				view=view,
89
+			)
90
+			if interaction is not original_interaction:
91
+				await interaction.response.defer(ephemeral=True, thinking=False)
92
+		else:
93
+			# Initial send
94
+			await resolved.response.send_message(
95
+				view=view,
96
+				ephemeral=True,
97
+				**send_args,
98
+			)
99
+	except BaseException as e:
100
+		dump_stacktrace(e)
101
+
102
+class _PagingLayoutView(LayoutView):
103
+	def __init__(
104
+			self,
105
+			current_page: int,
106
+			pages: list[str],
107
+			original_interaction: Optional[Interaction],
108
+			**send_args,
109
+	) -> None:
110
+		super().__init__()
111
+		self.current_page: int = current_page
112
+		self.pages: list[str] = pages
113
+		self.text.content = self.pages[self.current_page] + f'\n\n_Page {self.current_page + 1} of {len(self.pages)}_'
114
+		self.original_interaction = original_interaction
115
+		if current_page <= 0:
116
+			self.handle_prev_button.disabled = True
117
+		if current_page >= len(self.pages) - 1:
118
+			self.handle_next_button.disabled = True
119
+		self.send_args = send_args
120
+
121
+	text = TextDisplay('')
122
+
123
+	row = ActionRow()
124
+
125
+	@row.button(label='< Prev')
126
+	async def handle_prev_button(self, interaction: Interaction, button: Button) -> None:
127
+		new_page = max(0, self.current_page - 1)
128
+		await update_paged_content(interaction, self.original_interaction, new_page, self.pages, **self.send_args)
129
+
130
+	@row.button(label='Next >')
131
+	async def handle_next_button(self, interaction: Interaction, button: Button) -> None:
132
+		new_page = min(len(self.pages) - 1, self.current_page + 1)
133
+		await update_paged_content(interaction, self.original_interaction, new_page, self.pages, **self.send_args)

+ 8
- 0
rocketbot/utils.py Bestand weergeven

@@ -171,6 +171,14 @@ def str_from_quoted_str(val: str) -> str:
171 171
 		raise ValueError(f'Not a quoted string: {val}')
172 172
 	return val[1:-1]
173 173
 
174
+def blockquote_markdown(markdown: str) -> str:
175
+	"""Encloses some Markdown in a blockquote."""
176
+	return '> ' + (markdown.replace('\n', '\n> '))
177
+
178
+def indent_markdown(markdown: str) -> str:
179
+	"""Indents a block of Markdown by one level."""
180
+	return '    ' + (markdown.replace('\n', '\n    '))
181
+
174 182
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
175 183
 
176 184
 from discord import Interaction

Laden…
Annuleren
Opslaan