Sfoglia il codice sorgente

Bang commands now show an edit dialog when not given a definition

tags/2.0.0
Rocketsoup 1 mese fa
parent
commit
d58ae3c16c

+ 324
- 154
rocketbot/cogs/bangcommandcog.py Vedi File

@@ -1,170 +1,340 @@
1 1
 import re
2 2
 from typing import Optional, TypedDict
3 3
 
4
-from discord import Interaction, Guild, Message, TextChannel
5
-from discord.app_commands import Group
4
+from discord import Interaction, Guild, Message, TextChannel, SelectOption, TextStyle
5
+from discord.app_commands import Group, Choice, autocomplete
6 6
 from discord.ext.commands import Cog
7
+from discord.ui import Modal, Label, Select, TextInput
7 8
 
8 9
 from config import CONFIG
9 10
 from rocketbot.bot import Rocketbot
10 11
 from rocketbot.cogs.basecog import BaseCog
11 12
 from rocketbot.cogsetting import CogSetting
12 13
 from rocketbot.ui.pagedcontent import PAGE_BREAK, update_paged_content, paginate
13
-from rocketbot.utils import MOD_PERMISSIONS, blockquote_markdown, indent_markdown
14
+from rocketbot.utils import MOD_PERMISSIONS, blockquote_markdown, indent_markdown, dump_stacktrace
14 15
 
15 16
 _CURRENT_DATA_VERSION = 1
17
+_MAX_CONTENT_LENGTH = 2000
16 18
 
17 19
 class BangCommand(TypedDict):
18
-    content: str
19
-    mod_only: bool
20
-    version: int
20
+	content: str
21
+	mod_only: bool
22
+	version: int
23
+
24
+async def command_autocomplete(interaction: Interaction, text: str) -> list[Choice[str]]:
25
+	cmds = BangCommandCog.shared.get_saved_commands(interaction.guild)
26
+	return [
27
+		Choice(name=f'!{name}', value=name)
28
+		for name, cmd in sorted(cmds.items())
29
+		if len(text) == 0 or text.lower() in name
30
+	]
21 31
 
22 32
 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
33
+	SETTING_COMMANDS = CogSetting(
34
+		name='commands',
35
+		datatype=dict[str, BangCommand],
36
+		default_value={},
37
+	)
38
+
39
+	shared: Optional['BangCommandCog'] = None
40
+
41
+	def __init__(self, bot: Rocketbot):
42
+		super().__init__(
43
+			bot,
44
+			config_prefix='bangcommand',
45
+			short_description='Provides custom informational chat !commands.',
46
+			long_description='Bang commands are simple one-word messages starting with an exclamation '
47
+							 '(bang) that will make the bot respond with simple informational replies. '
48
+							 'Useful for posting answers to frequently asked questions, reminding users '
49
+							 'of rules, and similar.'
50
+		)
51
+		BangCommandCog.shared = self
52
+
53
+	def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
54
+		return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
55
+
56
+	def get_saved_command(self, guild: Guild, name: str) -> Optional[BangCommand]:
57
+		cmds = self.get_saved_commands(guild)
58
+		name = BangCommandCog._normalize_name(name)
59
+		return cmds.get(name, None)
60
+
61
+	def set_saved_commands(self, guild: Guild, commands: dict[str, BangCommand]) -> None:
62
+		self.set_guild_setting(guild, BangCommandCog.SETTING_COMMANDS, commands)
63
+
64
+	bang = Group(
65
+		name='command',
66
+		description='Provides custom informational chat !commands.',
67
+		guild_only=True,
68
+		default_permissions=MOD_PERMISSIONS,
69
+	)
70
+
71
+	@bang.command(
72
+		name='define',
73
+		extras={
74
+			'long_description': 'Simple one-line content can be specified in the command. '
75
+								'For multi-line content, run the command without content '
76
+								'specified to use the editor popup.'
77
+		}
78
+	)
79
+	@autocomplete(name=command_autocomplete)
80
+	async def define_command(self, interaction: Interaction, name: str, definition: Optional[str] = None, mod_only: bool = False) -> None:
81
+		"""
82
+		Defines or redefines a bang command.
83
+
84
+		Parameters
85
+		----------
86
+		interaction: Interaction
87
+		name: string
88
+			name of the command (lowercase a-z, underscores, and hyphens)
89
+		definition: string
90
+			content of the command
91
+		mod_only: bool
92
+			whether the command will only be recognized when a mod uses it
93
+		"""
94
+		self.log(interaction.guild, f'{interaction.user.name} used command /bangcommand define {name} {definition} {mod_only}')
95
+		name = BangCommandCog._normalize_name(name)
96
+		if definition is None:
97
+			cmd = self.get_saved_command(interaction.guild, name)
98
+			await interaction.response.send_modal(
99
+				_EditModal(
100
+					name,
101
+					content=cmd['content'] if cmd else None,
102
+					mod_only=cmd['mod_only'] if cmd else None,
103
+					exists=cmd is not None,
104
+				)
105
+			)
106
+			return
107
+		try:
108
+			self.define(interaction.guild, name, definition, mod_only)
109
+			await interaction.response.send_message(
110
+				f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(definition)}',
111
+				ephemeral=True,
112
+			)
113
+		except ValueError as e:
114
+			await interaction.response.send_message(
115
+				f'{CONFIG["failure_emoji"]} {e}',
116
+				ephemeral=True,
117
+			)
118
+			return
119
+
120
+	@bang.command(
121
+		name='undefine'
122
+	)
123
+	@autocomplete(name=command_autocomplete)
124
+	async def undefine_command(self, interaction: Interaction, name: str) -> None:
125
+		"""
126
+		Removes a bang command.
127
+
128
+		Parameters
129
+		----------
130
+		interaction: Interaction
131
+		name: string
132
+			name of the previously defined command
133
+		"""
134
+		try:
135
+			self.undefine(interaction.guild, name)
136
+			await interaction.response.send_message(
137
+				f'{CONFIG["success_emoji"]} Command `!{name}` removed.',
138
+				ephemeral=True,
139
+			)
140
+		except ValueError as e:
141
+			await interaction.response.send_message(
142
+				f'{CONFIG["failure_emoji"]} {e}',
143
+				ephemeral=True,
144
+			)
145
+
146
+	@bang.command(
147
+		name='list'
148
+	)
149
+	async def list_command(self, interaction: Interaction) -> None:
150
+		"""
151
+		Lists all defined bang commands.
152
+
153
+		Parameters
154
+		----------
155
+		interaction: Interaction
156
+		"""
157
+		cmds = self.get_saved_commands(interaction.guild)
158
+		if cmds is None or len(cmds) == 0:
159
+			await interaction.response.send_message(
160
+				f'{CONFIG["info_emoji"]} No commands defined.',
161
+				ephemeral=True,
162
+				delete_after=15,
163
+			)
164
+			return
165
+		text = '## Commands'
166
+		for name, cmd in sorted(cmds.items()):
167
+			text += PAGE_BREAK + f'\n- `!{name}`'
168
+			if cmd['mod_only']:
169
+				text += ' - **mod only**'
170
+			text += f'\n{indent_markdown(cmd["content"])}'
171
+		pages = paginate(text)
172
+		await update_paged_content(interaction, None, 0, pages)
173
+
174
+	@bang.command(
175
+		name='invoke',
176
+		extras={
177
+			'long_description': 'Useful when you do not want the command to show up in chat.',
178
+		}
179
+	)
180
+	@autocomplete(name=command_autocomplete)
181
+	async def invoke_command(self, interaction: Interaction, name: str) -> None:
182
+		"""
183
+		Invokes a bang command without typing it in chat.
184
+
185
+		Parameters
186
+		----------
187
+		interaction: Interaction
188
+		name: string
189
+			the bang command name
190
+		"""
191
+		cmd = self.get_saved_command(interaction.guild, name)
192
+		if cmd is None:
193
+			await interaction.response.send_message(
194
+				f'{CONFIG["failure_emoji"]} Command `!{name}` does not exist.',
195
+				ephemeral=True,
196
+			)
197
+			return
198
+		resp = await interaction.response.defer(ephemeral=True, thinking=False)
199
+		await interaction.channel.send(
200
+			cmd['content']
201
+		)
202
+		if resp.resource:
203
+			await resp.resource.delete()
204
+
205
+	def define(self, guild: Guild, name: str, content: str, mod_only: bool, check_exists: bool = False) -> None:
206
+		if not BangCommandCog._is_valid_name(name):
207
+			raise ValueError('Invalid command name. Must consist of lowercase letters, underscores, and hyphens (no spaces).')
208
+		name = BangCommandCog._normalize_name(name)
209
+		if len(content) < 1 or len(content) > 2000:
210
+			raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
211
+		cmds = self.get_saved_commands(guild)
212
+		if check_exists:
213
+			if cmds.get(name, None) is not None:
214
+				raise ValueError(f'Command with name "{name}" already exists.')
215
+		cmds[name] = {
216
+			'content': content,
217
+			'mod_only': mod_only,
218
+			'version': _CURRENT_DATA_VERSION,
219
+		}
220
+		self.set_saved_commands(guild, cmds)
221
+
222
+	def undefine(self, guild: Guild, name: str) -> None:
223
+		name = BangCommandCog._normalize_name(name)
224
+		cmds = self.get_saved_commands(guild)
225
+		if cmds.get(name, None) is None:
226
+			raise ValueError(f'Command with name "{name}" does not exist.')
227
+		del cmds[name]
228
+		self.set_saved_commands(guild, cmds)
229
+
230
+	@Cog.listener()
231
+	async def on_message(self, message: Message) -> None:
232
+		if message.guild is None or message.channel is None or not isinstance(message.channel, TextChannel):
233
+			return
234
+		content = message.content
235
+		if content is None or not content.startswith('!') or not BangCommandCog._is_valid_name(content):
236
+			return
237
+		name = BangCommandCog._normalize_name(content)
238
+		cmd = self.get_saved_command(message.guild, name)
239
+		if cmd is None:
240
+			return
241
+		if cmd['mod_only'] and not message.author.guild_permissions.ban_members:
242
+			return
243
+		text = cmd["content"]
244
+		# text = f'{text}\n\n-# {message.author.name} used `!{name}`'
245
+		await message.channel.send(
246
+			text,
247
+		)
248
+
249
+	@staticmethod
250
+	def _normalize_name(name: str) -> str:
251
+		name = name.lower().strip()
252
+		if name.startswith('!'):
253
+			name = name[1:]
254
+		return name
255
+
256
+	@staticmethod
257
+	def _is_valid_name(name: Optional[str]) -> bool:
258
+		if name is None:
259
+			return False
260
+		return re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
261
+
262
+class _EditModal(Modal, title='Edit Command'):
263
+	name_label = Label(
264
+		text='Command name',
265
+		description='What gets typed in chat to trigger the command. Must be a-z, underscores, and hyphens (no spaces).',
266
+		component=TextInput(
267
+			style=TextStyle.short, # one line
268
+			placeholder='!command_name',
269
+			min_length=1,
270
+			max_length=100,
271
+		)
272
+	)
273
+	content_label = Label(
274
+		text='Content',
275
+		description='The text the bot will respond with when someone uses the command. Can contain markdown.',
276
+		component=TextInput(
277
+			style=TextStyle.paragraph,
278
+			placeholder='Lorem ipsum dolor...',
279
+			min_length=1,
280
+			max_length=2000,
281
+		)
282
+	)
283
+	mod_only_label = Label(
284
+		text='Mod only?',
285
+		description='Whether mods are the only users who can invoke this command.',
286
+		component=Select(
287
+			options=[
288
+				SelectOption(label='No', value='False',
289
+							 description='Anyone can invoke this command.'),
290
+				SelectOption(label='Yes', value='True',
291
+							 description='Only mods can invoke this command.'),
292
+			],
293
+		)
294
+	)
295
+
296
+	def __init__(self, name: Optional[str] = None, content: Optional[str] = None, mod_only: Optional[bool] = None, exists: bool = False):
297
+		super().__init__()
298
+		self.exists = exists
299
+		# noinspection PyTypeChecker
300
+		name_input: TextInput = self.name_label.component
301
+		# noinspection PyTypeChecker
302
+		content_input: TextInput = self.content_label.component
303
+		# noinspection PyTypeChecker
304
+		mod_only_input: Select = self.mod_only_label.component
305
+		name_input.default = name
306
+		content_input.default = content
307
+		mod_only_input.options[0].default = mod_only != True
308
+		mod_only_input.options[1].default = mod_only == True
309
+
310
+	async def on_submit(self, interaction: Interaction) -> None:
311
+		# noinspection PyTypeChecker
312
+		name_input: TextInput = self.name_label.component
313
+		# noinspection PyTypeChecker
314
+		content_input: TextInput = self.content_label.component
315
+		# noinspection PyTypeChecker
316
+		mod_only_input: Select = self.mod_only_label.component
317
+		name = name_input.value
318
+		content = content_input.value
319
+		mod_only = mod_only_input.values[0] == 'True'
320
+		try:
321
+			BangCommandCog.shared.define(interaction.guild, name, content, mod_only, not self.exists)
322
+			await interaction.response.send_message(
323
+				f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(content)}',
324
+				ephemeral=True,
325
+			)
326
+		except ValueError as e:
327
+			await interaction.response.send_message(
328
+				f'{CONFIG["failure_emoji"]} {e}',
329
+				ephemeral=True,
330
+			)
331
+
332
+	async def on_error(self, interaction: Interaction, error: Exception) -> None:
333
+		dump_stacktrace(error)
334
+		try:
335
+			await interaction.response.send_message(
336
+				f'{CONFIG["failure_emoji"]} Save failed',
337
+				ephemeral=True,
338
+			)
339
+		except:
340
+			pass

+ 0
- 16
rocketbot/cogs/basecog.py Vedi File

@@ -93,19 +93,6 @@ class BaseCog(Cog):
93 93
 
94 94
 	# Config
95 95
 
96
-	@classmethod
97
-	def get_cog_default(cls, key: str):
98
-		"""
99
-		Convenience method for getting a cog configuration default from
100
-		`CONFIG['cogs'][<cog_name>][<key>]`. These values are used for
101
-		CogSettings when no guild-specific value is configured yet.
102
-		"""
103
-		cogs: dict = CONFIG['cog_defaults']
104
-		cog = cogs.get(cls.__name__)
105
-		if cog is None:
106
-			return None
107
-		return cog.get(key)
108
-
109 96
 	def add_setting(self, setting: CogSetting) -> None:
110 97
 		"""
111 98
 		Called by a subclass in __init__ to register a mod-configurable
@@ -138,9 +125,6 @@ class BaseCog(Cog):
138 125
 			if value is not None:
139 126
 				return value
140 127
 		if use_cog_default_if_not_set:
141
-			config_default = cls.get_cog_default(setting.name)
142
-			if config_default is not None:
143
-				return None
144 128
 			return setting.default_value
145 129
 		return None
146 130
 

+ 1
- 1
rocketbot/cogs/crosspostcog.py Vedi File

@@ -2,7 +2,7 @@
2 2
 Cog for detecting spam messages posted in multiple channels.
3 3
 """
4 4
 import re
5
-from datetime import datetime, timedelta, timezone
5
+from datetime import datetime, timedelta
6 6
 from typing import Optional
7 7
 
8 8
 from discord import Member, Message, utils as discordutils, TextChannel

+ 1
- 1
rocketbot/cogs/generalcog.py Vedi File

@@ -165,6 +165,6 @@ class GeneralCog(BaseCog, name='General'):
165 165
 		else:
166 166
 			await resp.resource.edit(
167 167
 				content=f'{CONFIG["success_emoji"]} No messages found for {author.mention} '
168
-						'from the past {describe_timedelta(age)}.',
168
+						f'from the past {describe_timedelta(age)}.',
169 169
 			)
170 170
 		self.log(interaction.guild, f'{interaction.user.name} used /delete_messages {author.id} {age}')

Loading…
Annulla
Salva