Explorar el Código

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

tags/2.0.0
Rocketsoup hace 1 mes
padre
commit
d58ae3c16c

+ 324
- 154
rocketbot/cogs/bangcommandcog.py Ver fichero

1
 import re
1
 import re
2
 from typing import Optional, TypedDict
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
 from discord.ext.commands import Cog
6
 from discord.ext.commands import Cog
7
+from discord.ui import Modal, Label, Select, TextInput
7
 
8
 
8
 from config import CONFIG
9
 from config import CONFIG
9
 from rocketbot.bot import Rocketbot
10
 from rocketbot.bot import Rocketbot
10
 from rocketbot.cogs.basecog import BaseCog
11
 from rocketbot.cogs.basecog import BaseCog
11
 from rocketbot.cogsetting import CogSetting
12
 from rocketbot.cogsetting import CogSetting
12
 from rocketbot.ui.pagedcontent import PAGE_BREAK, update_paged_content, paginate
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
 _CURRENT_DATA_VERSION = 1
16
 _CURRENT_DATA_VERSION = 1
17
+_MAX_CONTENT_LENGTH = 2000
16
 
18
 
17
 class BangCommand(TypedDict):
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
 class BangCommandCog(BaseCog, name='Bang Commands'):
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 Ver fichero

93
 
93
 
94
 	# Config
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
 	def add_setting(self, setting: CogSetting) -> None:
96
 	def add_setting(self, setting: CogSetting) -> None:
110
 		"""
97
 		"""
111
 		Called by a subclass in __init__ to register a mod-configurable
98
 		Called by a subclass in __init__ to register a mod-configurable
138
 			if value is not None:
125
 			if value is not None:
139
 				return value
126
 				return value
140
 		if use_cog_default_if_not_set:
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
 			return setting.default_value
128
 			return setting.default_value
145
 		return None
129
 		return None
146
 
130
 

+ 1
- 1
rocketbot/cogs/crosspostcog.py Ver fichero

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

+ 1
- 1
rocketbot/cogs/generalcog.py Ver fichero

165
 		else:
165
 		else:
166
 			await resp.resource.edit(
166
 			await resp.resource.edit(
167
 				content=f'{CONFIG["success_emoji"]} No messages found for {author.mention} '
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
 		self.log(interaction.guild, f'{interaction.user.name} used /delete_messages {author.id} {age}')
170
 		self.log(interaction.guild, f'{interaction.user.name} used /delete_messages {author.id} {age}')

Loading…
Cancelar
Guardar