Quellcode durchsuchen

First draft of /bangcommand

tags/2.0.0
Rocketsoup vor 1 Monat
Ursprung
Commit
639405a23e
5 geänderte Dateien mit 317 neuen und 84 gelöschten Zeilen
  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 Datei anzeigen

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

+ 170
- 0
rocketbot/cogs/bangcommandcog.py Datei anzeigen

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 Datei anzeigen

3
 import time
3
 import time
4
 from typing import Union, Optional, TypedDict
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
 from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
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
 from config import CONFIG
9
 from config import CONFIG
11
 from rocketbot.bot import Rocketbot
10
 from rocketbot.bot import Rocketbot
12
 from rocketbot.cogs.basecog import BaseCog
11
 from rocketbot.cogs.basecog import BaseCog
12
+from rocketbot.ui.pagedcontent import update_paged_content, paginate
13
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
13
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
14
 
14
 
15
 HelpTopic = Union[Command, Group, BaseCog]
15
 HelpTopic = Union[Command, Group, BaseCog]
420
 		await self.__send_paged_help(interaction, text)
420
 		await self.__send_paged_help(interaction, text)
421
 
421
 
422
 	async def __send_paged_help(self, interaction: Interaction, text: str) -> None:
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
 # Exclusions from keyword indexing
426
 # Exclusions from keyword indexing
507
 trivial_words = {
427
 trivial_words = {

+ 133
- 0
rocketbot/ui/pagedcontent.py Datei anzeigen

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 Datei anzeigen

171
 		raise ValueError(f'Not a quoted string: {val}')
171
 		raise ValueError(f'Not a quoted string: {val}')
172
 	return val[1:-1]
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
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
182
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
175
 
183
 
176
 from discord import Interaction
184
 from discord import Interaction

Laden…
Abbrechen
Speichern