#13 Conversion to slash commands

マージ済み
ialbert が 24 個のコミットを slash から main へマージ 2ヶ月前

+ 2
- 2
config.sample.py ファイルの表示

7
 
7
 
8
 	# Client token obtained from the Discord application dashboard. See setup documentation.
8
 	# Client token obtained from the Discord application dashboard. See setup documentation.
9
 	'client_token': '<REQUIRED>',
9
 	'client_token': '<REQUIRED>',
10
-    # Bot commands will be invoked by posting a message with this prefix.
11
-	'command_prefix': '$rb_',
12
     # Path to a directory where guild-specific preferences will be stored.
10
     # Path to a directory where guild-specific preferences will be stored.
13
     # Each guild's settings will be saved as a JSON file. Path should end with a slash.
11
     # Each guild's settings will be saved as a JSON file. Path should end with a slash.
14
 	'config_path': 'config/',
12
 	'config_path': 'config/',
46
 	'info_emoji': 'ℹ️',
44
 	'info_emoji': 'ℹ️',
47
 	# Logging something that happened on the server (e.g. a user joined the server)
45
 	# Logging something that happened on the server (e.g. a user joined the server)
48
 	'log_emoji': '📋',
46
 	'log_emoji': '📋',
47
+	# A task is in progress
48
+	'wait_emoji': '⏳',
49
 
49
 
50
     # -----------------------------------------------------------------------
50
     # -----------------------------------------------------------------------
51
     # Default values for cog-specific settings that a guild has not overridden
51
     # Default values for cog-specific settings that a guild has not overridden

+ 47
- 15
rocketbot/bot.py ファイルの表示

5
 from discord.ext import commands
5
 from discord.ext import commands
6
 
6
 
7
 from config import CONFIG
7
 from config import CONFIG
8
-from rocketbot.utils import bot_log
8
+from rocketbot.cogsetting import CogSetting
9
+from rocketbot.utils import bot_log, dump_stacktrace
10
+
9
 
11
 
10
 class Rocketbot(commands.Bot):
12
 class Rocketbot(commands.Bot):
11
 	"""
13
 	"""
12
 	Bot subclass
14
 	Bot subclass
13
 	"""
15
 	"""
14
-	def __init__(self, command_prefix, **kwargs):
15
-		super().__init__(command_prefix, **kwargs)
16
+	def __init__(self, **kwargs):
17
+		# Bot requires command_prefix, even though we're only using slash commands,
18
+		# not commands in message content. Giving an unlikely prefix of private-use
19
+		# Unicode characters. This isn't a security thing, just avoiding showing a
20
+		# "command not found" error if someone happens to start a message with
21
+		# something more common.
22
+		super().__init__(command_prefix='\uED1E\uEA75\uF00D', **kwargs)
23
+		self.__commands_set_up = False
24
+		self.__first_ready = True
16
 
25
 
17
 	async def on_command_error(self, context: commands.Context, exception: BaseException) -> None:
26
 	async def on_command_error(self, context: commands.Context, exception: BaseException) -> None:
18
 		bot_log(None, None, f'Command error')
27
 		bot_log(None, None, f'Command error')
19
-		ex = exception
20
-		while ex is not None:
21
-			print(f'Caused by {ex}')
22
-			traceback.print_exception(type(ex), ex, ex.__traceback__)
23
-			ex = ex.__cause__ if ex.__cause__ != ex else None
28
+		dump_stacktrace(exception)
24
 		if context.guild is None or \
29
 		if context.guild is None or \
25
 				context.message.channel is None or \
30
 				context.message.channel is None or \
26
 				context.message.author.bot:
31
 				context.message.author.bot:
43
 			f'	event kwargs: {kwargs}' + \
48
 			f'	event kwargs: {kwargs}' + \
44
 			traceback.format_exc())
49
 			traceback.format_exc())
45
 
50
 
51
+	async def on_ready(self):
52
+		if self.__first_ready:
53
+			self.__first_ready = False
54
+			if not self.__commands_set_up:
55
+				await self.__set_up_commands()
56
+			bot_log(None, None, 'Bot done initializing')
57
+			bot_log(None, None, f"Type /help in Discord for available commands.")
58
+			print('----------------------------------------------------------')
59
+
60
+	async def __set_up_commands(self):
61
+		if self.__commands_set_up:
62
+			return
63
+		self.__commands_set_up = True
64
+		for cog in self.cogs.values():
65
+			from rocketbot.cogs.basecog import BaseCog
66
+			if isinstance(cog, BaseCog):
67
+				bcog: BaseCog = cog
68
+				if len(bcog.settings) > 0:
69
+					CogSetting.set_up_all(bcog, self, bcog.settings)
70
+		try:
71
+			synced_commands = await self.tree.sync()
72
+			for command in synced_commands:
73
+				bot_log(None, None, f'Synced command: /{command.name}')
74
+		except Exception as e:
75
+			dump_stacktrace(e)
76
+
46
 # Current active bot instance
77
 # Current active bot instance
47
 rocketbot: Optional[Rocketbot] = None
78
 rocketbot: Optional[Rocketbot] = None
48
 
79
 
52
 		return
83
 		return
53
 	bot_log(None, None, 'Creating bot...')
84
 	bot_log(None, None, 'Creating bot...')
54
 	intents = Intents.default()
85
 	intents = Intents.default()
55
-	intents.messages = True  # pylint: disable=assigning-non-slot
56
-	intents.message_content = True  # pylint: disable=assigning-non-slot
57
-	intents.members = True  # pylint: disable=assigning-non-slot
86
+	intents.messages = True
87
+	intents.message_content = True
88
+	intents.members = True
58
 	intents.presences = True
89
 	intents.presences = True
59
-	rocketbot = Rocketbot(command_prefix=CONFIG['command_prefix'], intents=intents)
90
+	rocketbot = Rocketbot(intents=intents)
60
 __create_bot()
91
 __create_bot()
61
 
92
 
62
 from rocketbot.cogs.autokickcog import AutoKickCog
93
 from rocketbot.cogs.autokickcog import AutoKickCog
63
 from rocketbot.cogs.configcog import ConfigCog
94
 from rocketbot.cogs.configcog import ConfigCog
64
 from rocketbot.cogs.crosspostcog import CrossPostCog
95
 from rocketbot.cogs.crosspostcog import CrossPostCog
96
+from rocketbot.cogs.gamescog import GamesCog
65
 from rocketbot.cogs.generalcog import GeneralCog
97
 from rocketbot.cogs.generalcog import GeneralCog
66
-from rocketbot.cogs.joinagecog import JoinAgeCog
98
+from rocketbot.cogs.helpcog import HelpCog
67
 from rocketbot.cogs.joinraidcog import JoinRaidCog
99
 from rocketbot.cogs.joinraidcog import JoinRaidCog
68
 from rocketbot.cogs.logcog import LoggingCog
100
 from rocketbot.cogs.logcog import LoggingCog
69
 from rocketbot.cogs.patterncog import PatternCog
101
 from rocketbot.cogs.patterncog import PatternCog
72
 
104
 
73
 async def start_bot():
105
 async def start_bot():
74
 	bot_log(None, None, 'Bot initializing...')
106
 	bot_log(None, None, 'Bot initializing...')
75
-	bot_log(None, None, f"Type {CONFIG['command_prefix']}help in Discord for available commands.")
76
 
107
 
77
 	# Core
108
 	# Core
78
 	await rocketbot.add_cog(GeneralCog(rocketbot))
109
 	await rocketbot.add_cog(GeneralCog(rocketbot))
81
 	# Optional
112
 	# Optional
82
 	await rocketbot.add_cog(AutoKickCog(rocketbot))
113
 	await rocketbot.add_cog(AutoKickCog(rocketbot))
83
 	await rocketbot.add_cog(CrossPostCog(rocketbot))
114
 	await rocketbot.add_cog(CrossPostCog(rocketbot))
84
-	await rocketbot.add_cog(JoinAgeCog(rocketbot))
115
+	await rocketbot.add_cog(GamesCog(rocketbot))
116
+	await rocketbot.add_cog(HelpCog(rocketbot))
85
 	await rocketbot.add_cog(JoinRaidCog(rocketbot))
117
 	await rocketbot.add_cog(JoinRaidCog(rocketbot))
86
 	await rocketbot.add_cog(LoggingCog(rocketbot))
118
 	await rocketbot.add_cog(LoggingCog(rocketbot))
87
 	await rocketbot.add_cog(PatternCog(rocketbot))
119
 	await rocketbot.add_cog(PatternCog(rocketbot))

+ 39
- 35
rocketbot/cogs/autokickcog.py ファイルの表示

1
 from datetime import datetime, timedelta
1
 from datetime import datetime, timedelta
2
 
2
 
3
 from discord import Guild, Member, Status
3
 from discord import Guild, Member, Status
4
-from discord.ext import commands, tasks
4
+from discord.ext import tasks
5
+from discord.ext.commands import Cog
5
 from discord.ext.tasks import Loop
6
 from discord.ext.tasks import Loop
6
 
7
 
7
 from config import CONFIG
8
 from config import CONFIG
32
 	"""
33
 	"""
33
 	Cog for automatically kicking ALL new joins. For temporary use during join raids.
34
 	Cog for automatically kicking ALL new joins. For temporary use during join raids.
34
 	"""
35
 	"""
35
-	SETTING_ENABLED = CogSetting('enabled', bool,
36
+	SETTING_ENABLED = CogSetting(
37
+		'enabled',
38
+		bool,
39
+		default_value=False,
36
 		brief='autokick',
40
 		brief='autokick',
37
-		description='Whether this cog is enabled for a guild.')
38
-	SETTING_BAN_COUNT = CogSetting('bancount', int,
39
-			brief='number of repeat kicks before a ban',
40
-			description='The number of times a user can join and be kicked ' + \
41
-					'before the next rejoin will result in a ban. A value of 0 ' + \
41
+		description='Whether this module is enabled for a guild.',
42
+	)
43
+	SETTING_BAN_COUNT = CogSetting(
44
+		'bancount',
45
+		int,
46
+		default_value=0,
47
+		brief='number of repeat kicks before a ban',
48
+		description='The number of times a user can join and be kicked '
49
+					'before the next rejoin will result in a ban. A value of 0 '
42
 					'disables this feature (only kick, never ban).',
50
 					'disables this feature (only kick, never ban).',
43
-			usage='<count:int>',
44
-			min_value=0)
45
-	SETTING_OFFLINE_ONLY = CogSetting('offlineonly', bool,
46
-			brief='whether to only kick users whose status is offline',
47
-			description='Compromised accounts may have a status of offline. ' + \
48
-					'If this setting is enabled, the user\'s status will be ' + \
49
-					'checked a few seconds after joining. If it is offline ' + \
51
+		min_value=0,
52
+	)
53
+	SETTING_OFFLINE_ONLY = CogSetting(
54
+		'offlineonly',
55
+		bool,
56
+		default_value=False,
57
+		brief='whether to only kick users whose status is offline',
58
+		description='Compromised accounts may have a status of offline. '
59
+					'If this setting is enabled, the user\'s status will be '
60
+					'checked a few seconds after joining. If it is offline '
50
 					'they will be kicked.',
61
 					'they will be kicked.',
51
-			usage='<true|false>')
62
+	)
52
 
63
 
53
 	STATE_KEY_RECENT_KICKS = "AutoKickCog.recent_joins"
64
 	STATE_KEY_RECENT_KICKS = "AutoKickCog.recent_joins"
54
 
65
 
55
 	def __init__(self, bot):
66
 	def __init__(self, bot):
56
-		super().__init__(bot)
67
+		super().__init__(
68
+			bot,
69
+			config_prefix='autokick',
70
+			short_description='Automatically kicks all new users as soon as they join.',
71
+		)
57
 		self.add_setting(AutoKickCog.SETTING_ENABLED)
72
 		self.add_setting(AutoKickCog.SETTING_ENABLED)
58
 		self.add_setting(AutoKickCog.SETTING_BAN_COUNT)
73
 		self.add_setting(AutoKickCog.SETTING_BAN_COUNT)
59
 		self.add_setting(AutoKickCog.SETTING_OFFLINE_ONLY)
74
 		self.add_setting(AutoKickCog.SETTING_OFFLINE_ONLY)
61
 		timer: Loop = self.status_check_timer
76
 		timer: Loop = self.status_check_timer
62
 		timer.start()
77
 		timer.start()
63
 
78
 
64
-	@commands.group(
65
-		brief='Automatically kicks all new users as soon as they join',
66
-	)
67
-	@commands.has_permissions(ban_members=True)
68
-	@commands.guild_only()
69
-	async def autokick(self, context: commands.Context):
70
-		"""Auto-kick"""
71
-		if context.invoked_subcommand is None:
72
-			await context.send_help()
73
-
74
-	@commands.Cog.listener()
79
+	@Cog.listener()
75
 	async def on_member_join(self, member: Member) -> None:
80
 	async def on_member_join(self, member: Member) -> None:
76
 		"""Event handler"""
81
 		"""Event handler"""
77
 		guild: Guild = member.guild
82
 		guild: Guild = member.guild
118
 		else:
123
 		else:
119
 			context.record_kick(datetime.now())
124
 			context.record_kick(datetime.now())
120
 		max_kick_count: int = self.get_guild_setting(guild, self.SETTING_BAN_COUNT)
125
 		max_kick_count: int = self.get_guild_setting(guild, self.SETTING_BAN_COUNT)
121
-		disable_help = f'To disable this feature: `{CONFIG["command_prefix"]}autokick disable`.'
122
-		ban_help = f'To configure ban threshold: `{CONFIG["command_prefix"]}autokick ' + \
123
-			'setbancount #` (0 to disable).'
126
+		disable_help = f'To disable this feature: `/disable autokick`.'
127
+		ban_help = f'To configure ban threshold: `/set autokick_bancount #` (0 to disable)'
124
 		if max_kick_count > 0 and context.kick_count > max_kick_count:
128
 		if max_kick_count > 0 and context.kick_count > max_kick_count:
125
 			await member.ban(reason=f'Rocketbot: Ban after {context.kick_count} joins',
129
 			await member.ban(reason=f'Rocketbot: Ban after {context.kick_count} joins',
126
 				delete_message_days=0)
130
 				delete_message_days=0)
134
 		else:
138
 		else:
135
 			await member.kick(reason='Rocketbot: Autokick enabled.')
139
 			await member.kick(reason='Rocketbot: Autokick enabled.')
136
 			msg = BotMessage(guild,
140
 			msg = BotMessage(guild,
137
-							text=f'Autokicked {member.mention} ({member.id}) ' + \
138
-								f'({AutoKickCog.ordinal(context.kick_count)} time). ' + \
139
-								disable_help + ' ' + ban_help,
140
-							type=BotMessage.TYPE_INFO,
141
-							context=None)
141
+				text=f'Autokicked {member.mention} ({member.id}) ' + \
142
+					f'({AutoKickCog.ordinal(context.kick_count)} time). ' + \
143
+					disable_help + ' ' + ban_help,
144
+				type=BotMessage.TYPE_INFO,
145
+				context=None)
142
 			await self.post_message(msg)
146
 			await self.post_message(msg)
143
 			self.log(guild, f'Autokicked {member.name} ' + \
147
 			self.log(guild, f'Autokicked {member.name} ' + \
144
 				f'({AutoKickCog.ordinal(context.kick_count)} time)')
148
 				f'({AutoKickCog.ordinal(context.kick_count)} time)')

+ 81
- 17
rocketbot/cogs/basecog.py ファイルの表示

4
 from datetime import datetime, timedelta, timezone
4
 from datetime import datetime, timedelta, timezone
5
 from typing import Optional
5
 from typing import Optional
6
 
6
 
7
-from discord import Guild, Member, Message, RawReactionActionEvent, TextChannel
7
+import discord
8
+from discord import Guild, Interaction, Member, Message, RawReactionActionEvent, TextChannel
8
 from discord.abc import GuildChannel
9
 from discord.abc import GuildChannel
9
-from discord.ext import commands
10
+from discord.app_commands import AppCommandError
11
+from discord.app_commands.errors import CommandInvokeError
12
+from discord.ext.commands import Cog
10
 
13
 
11
 from config import CONFIG
14
 from config import CONFIG
12
 from rocketbot.bot import Rocketbot
15
 from rocketbot.bot import Rocketbot
14
 from rocketbot.cogsetting import CogSetting
17
 from rocketbot.cogsetting import CogSetting
15
 from rocketbot.collections import AgeBoundDict
18
 from rocketbot.collections import AgeBoundDict
16
 from rocketbot.storage import Storage
19
 from rocketbot.storage import Storage
17
-from rocketbot.utils import bot_log
20
+from rocketbot.utils import bot_log, dump_stacktrace
18
 
21
 
19
 class WarningContext:
22
 class WarningContext:
20
 	def __init__(self, member: Member, warn_time: datetime):
23
 	def __init__(self, member: Member, warn_time: datetime):
21
 		self.member = member
24
 		self.member = member
22
 		self.last_warned = warn_time
25
 		self.last_warned = warn_time
23
 
26
 
24
-class BaseCog(commands.Cog):
27
+class BaseCog(Cog):
25
 	STATE_KEY_RECENT_WARNINGS = "BaseCog.recent_warnings"
28
 	STATE_KEY_RECENT_WARNINGS = "BaseCog.recent_warnings"
26
 
29
 
27
 	"""
30
 	"""
28
 	Superclass for all Rocketbot cogs. Provides lots of conveniences for
31
 	Superclass for all Rocketbot cogs. Provides lots of conveniences for
29
 	common tasks.
32
 	common tasks.
30
 	"""
33
 	"""
31
-	def __init__(self, bot):
34
+	def __init__(
35
+			self,
36
+			bot: Rocketbot,
37
+			config_prefix: Optional[str],
38
+			short_description: str,
39
+			long_description: Optional[str] = None,
40
+	):
41
+		"""
42
+		Parameters
43
+		----------
44
+		bot: Rocketbot
45
+		config_prefix: str
46
+		    Prefix to show on variables in /set and /get commands to namespace
47
+		    configuration variables. E.g. if config_prefix is "foo", a config
48
+		    variable named "bar" in that cog will show as "foo.bar". If None,
49
+		    config variable acts as a top-level variable with no prefix.
50
+		"""
32
 		self.bot: Rocketbot = bot
51
 		self.bot: Rocketbot = bot
33
-		self.are_settings_setup = False
34
-		self.settings = []
52
+		self.are_settings_setup: bool = False
53
+		self.settings: list[CogSetting] = []
54
+		self.config_prefix: Optional[str] = config_prefix
55
+		self.short_description: str = short_description
56
+		self.long_description: str = long_description
57
+
58
+	async def cog_app_command_error(self, interaction: Interaction, error: AppCommandError) -> None:
59
+		if isinstance(error, CommandInvokeError):
60
+			error = error.original
61
+		dump_stacktrace(error)
62
+		message = f"\nException: {error.__class__.__name__}, "\
63
+				  f"Command: {interaction.command.qualified_name if interaction.command else None}, "\
64
+				  f"User: {interaction.user}, "\
65
+				  f"Time: {discord.utils.format_dt(interaction.created_at, style='F')}"
66
+		try:
67
+			await interaction.response.send_message(f"An error occurred: {message}", ephemeral=True)
68
+		except discord.InteractionResponded:
69
+			await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
70
+
71
+	@property
72
+	def basecogs(self) -> list['BaseCog']:
73
+		"""
74
+		List of BaseCog instances. Cogs that do not inherit from BaseCog are omitted.
75
+		"""
76
+		return [
77
+			bcog
78
+			for bcog in sorted(self.bot.cogs.values(), key=lambda c: c.qualified_name)
79
+			if isinstance(bcog, BaseCog)
80
+		]
81
+
82
+	@property
83
+	def basecog_map(self) -> dict[str, 'BaseCog']:
84
+		"""
85
+		Map of qualified names to BaseCog instances. Cogs that do not inherit
86
+		from BaseCog are omitted.
87
+		"""
88
+		return {
89
+			qname: bcog
90
+			for qname, bcog in self.bot.cogs.items()
91
+			if isinstance(bcog, BaseCog)
92
+		}
35
 
93
 
36
 	# Config
94
 	# Config
37
 
95
 
65
 
123
 
66
 	@classmethod
124
 	@classmethod
67
 	def get_guild_setting(cls,
125
 	def get_guild_setting(cls,
68
-			guild: Guild,
126
+			guild: Optional[Guild],
69
 			setting: CogSetting,
127
 			setting: CogSetting,
70
 			use_cog_default_if_not_set: bool = True):
128
 			use_cog_default_if_not_set: bool = True):
71
 		"""
129
 		"""
74
 		unless the optional `use_cog_default_if_not_set` is `False`, then
132
 		unless the optional `use_cog_default_if_not_set` is `False`, then
75
 		`None` will be returned.
133
 		`None` will be returned.
76
 		"""
134
 		"""
77
-		key = f'{cls.__name__}.{setting.name}'
78
-		value = Storage.get_config_value(guild, key)
79
-		if value is None and use_cog_default_if_not_set:
80
-			value = cls.get_cog_default(setting.name)
81
-		return value
135
+		if guild:
136
+			key = f'{cls.__name__}.{setting.name}'
137
+			value = Storage.get_config_value(guild, key)
138
+			if value is not None:
139
+				return value
140
+		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
145
+		return None
82
 
146
 
83
 	@classmethod
147
 	@classmethod
84
 	def set_guild_setting(cls,
148
 	def set_guild_setting(cls,
96
 		key = f'{cls.__name__}.{setting.name}'
160
 		key = f'{cls.__name__}.{setting.name}'
97
 		Storage.set_config_value(guild, key, new_value)
161
 		Storage.set_config_value(guild, key, new_value)
98
 
162
 
99
-	@commands.Cog.listener()
100
-	async def on_ready(self):
163
+	# @commands.Cog.listener()
164
+	async def __on_ready(self):
101
 		"""Event listener"""
165
 		"""Event listener"""
102
 		if not self.are_settings_setup:
166
 		if not self.are_settings_setup:
103
 			self.are_settings_setup = True
167
 			self.are_settings_setup = True
138
 			BaseCog.STATE_KEY_RECENT_WARNINGS)
202
 			BaseCog.STATE_KEY_RECENT_WARNINGS)
139
 		if recent_warns is None:
203
 		if recent_warns is None:
140
 			recent_warns = AgeBoundDict(timedelta(seconds=CONFIG['squelch_warning_seconds']),
204
 			recent_warns = AgeBoundDict(timedelta(seconds=CONFIG['squelch_warning_seconds']),
141
-				lambda i, context : context.last_warned)
205
+				lambda i, context0 : context0.last_warned)
142
 			Storage.set_state_value(member.guild, BaseCog.STATE_KEY_RECENT_WARNINGS, recent_warns)
206
 			Storage.set_state_value(member.guild, BaseCog.STATE_KEY_RECENT_WARNINGS, recent_warns)
143
 		context: WarningContext = recent_warns.get(member.id)
207
 		context: WarningContext = recent_warns.get(member.id)
144
 		if context is None:
208
 		if context is None:
177
 		await message.update()
241
 		await message.update()
178
 		return message.is_sent()
242
 		return message.is_sent()
179
 
243
 
180
-	@commands.Cog.listener()
244
+	@Cog.listener()
181
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
245
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
182
 		"""Event handler"""
246
 		"""Event handler"""
183
 		# Avoid any unnecessary requests. Gets called for every reaction
247
 		# Avoid any unnecessary requests. Gets called for every reaction

+ 85
- 60
rocketbot/cogs/configcog.py ファイルの表示

1
 """
1
 """
2
 Cog handling general configuration for a guild.
2
 Cog handling general configuration for a guild.
3
 """
3
 """
4
-from discord import Guild, TextChannel
5
-from discord.ext import commands
4
+from typing import Union, Optional
5
+
6
+from discord import Guild, Permissions, TextChannel, Interaction, Role, User
7
+from discord.app_commands import Group
8
+from discord.ext.commands import Bot
6
 
9
 
7
 from config import CONFIG
10
 from config import CONFIG
8
 from rocketbot.storage import ConfigKey, Storage
11
 from rocketbot.storage import ConfigKey, Storage
9
 from rocketbot.cogs.basecog import BaseCog
12
 from rocketbot.cogs.basecog import BaseCog
13
+from rocketbot.utils import MOD_PERMISSIONS
14
+
10
 
15
 
11
 class ConfigCog(BaseCog, name='Configuration'):
16
 class ConfigCog(BaseCog, name='Configuration'):
12
 	"""
17
 	"""
13
 	Cog for handling general bot configuration.
18
 	Cog for handling general bot configuration.
14
 	"""
19
 	"""
15
-	@commands.group(
16
-		brief='Manages general bot configuration'
20
+
21
+	def __init__(self, bot: Bot) -> None:
22
+		super().__init__(
23
+			bot,
24
+			config_prefix='config',
25
+			short_description='Manages general bot configuration.',
26
+		)
27
+
28
+	config = Group(
29
+		name='config',
30
+		description='Manages general bot configuration.',
31
+		guild_only=True,
32
+		default_permissions=MOD_PERMISSIONS,
17
 	)
33
 	)
18
-	@commands.has_permissions(ban_members=True)
19
-	@commands.guild_only()
20
-	async def config(self, context: commands.Context):
21
-		"""General guild configuration command group"""
22
-		if context.invoked_subcommand is None:
23
-			await context.send_help()
24
 
34
 
25
 	@config.command(
35
 	@config.command(
26
-		brief='Sets the mod warning channel',
27
-		description='Run this command in the channel where bot messages ' +
28
-			'intended for server moderators should be sent. Other bot ' +
29
-			'messages may still be posted in the channel a command was ' +
30
-			'invoked in. If no output channel is set, mod-related messages ' +
31
-			'will not be posted!',
36
+		description='Sets mod warnings to post in the current channel.',
37
+		extras={
38
+			'long_description': 'Run this command in the channel where bot messages intended '
39
+								'for server moderators should be sent. Other bot messages may '
40
+								'still be posted in the channel a command was invoked in. **If '
41
+								'no output channel is set, mod-related messages will not be posted!**',
42
+		},
32
 	)
43
 	)
33
-	async def setwarningchannel(self, context: commands.Context) -> None:
34
-		"""Command handler"""
35
-		guild: Guild = context.guild
36
-		channel: TextChannel = context.channel
44
+	async def set_warning_channel(self, interaction: Interaction) -> None:
45
+		"""
46
+		Sets mod warnings to post in the current channel.
47
+
48
+		Run this command in the channel where bot messages intended for server
49
+		moderators should be sent. Other bot messages may still be posted in
50
+		the channel a command was invoked in. If no output channel is set,
51
+		mod-related messages will not be posted!
52
+		"""
53
+		guild: Guild = interaction.guild
54
+		channel: TextChannel = interaction.channel
37
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
55
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
38
-			context.channel.id)
39
-		await context.message.reply(
56
+			interaction.channel.id)
57
+		await interaction.response.send_message(
40
 			f'{CONFIG["success_emoji"]} Warning channel updated to {channel.mention}.',
58
 			f'{CONFIG["success_emoji"]} Warning channel updated to {channel.mention}.',
41
-			mention_author=False)
59
+			ephemeral=True,
60
+		)
42
 
61
 
43
 	@config.command(
62
 	@config.command(
44
-		brief='Shows the mod warning channel',
45
-		description='Shows the configured channel (if any) where mod ' + \
46
-			'warnings will be posted.',
63
+		description='Shows the configured mod warning channel, if any.'
47
 	)
64
 	)
48
-	async def getwarningchannel(self, context: commands.Context) -> None:
49
-		"""Command handler"""
50
-		guild: Guild = context.guild
65
+	async def get_warning_channel(self, interaction: Interaction) -> None:
66
+		guild: Guild = interaction.guild
51
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
67
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
52
 		if channel_id is None:
68
 		if channel_id is None:
53
-			await context.message.reply(
69
+			await interaction.response.send_message(
54
 				f'{CONFIG["info_emoji"]} No warning channel is configured.',
70
 				f'{CONFIG["info_emoji"]} No warning channel is configured.',
55
-				mention_author=False)
71
+				ephemeral=True,
72
+			)
56
 		else:
73
 		else:
57
 			channel = guild.get_channel(channel_id)
74
 			channel = guild.get_channel(channel_id)
58
-			await context.message.reply(
75
+			await interaction.response.send_message(
59
 				f'{CONFIG["info_emoji"]} Warning channel is configured as {channel.mention}.',
76
 				f'{CONFIG["info_emoji"]} Warning channel is configured as {channel.mention}.',
60
-				mention_author=False)
77
+				ephemeral=True,
78
+			)
61
 
79
 
62
 	@config.command(
80
 	@config.command(
63
-		brief='Sets a user/role to mention in warning messages',
64
-		usage='<@user|@role>',
65
-		description='Configures an role or other prefix to include at the ' +
66
-			'beginning of warning messages. If the intent is to get the ' +
67
-			'attention of certain users, be sure to specify a properly ' +
68
-			'formed @ tag, not just the name of the user/role.'
81
+		description='Sets the user or role to tag in warning messages.',
82
+		extras={
83
+			'long_description': 'Calling this without a value disables tagging in warnings.',
84
+		}
69
 	)
85
 	)
70
-	async def setwarningmention(self,
71
-			context: commands.Context,
72
-			mention: str = None) -> None:
73
-		"""Command handler"""
74
-		guild: Guild = context.guild
75
-		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
86
+	async def set_warning_mention(self,
87
+			interaction: Interaction,
88
+			mention: Optional[Union[User, Role]] = None) -> None:
89
+		"""
90
+		Sets a user/role to mention in warning messages.
91
+
92
+		Parameters
93
+		----------
94
+		interaction: Interaction
95
+		mention: User or Role
96
+			the user or role to mention in warning messages
97
+		"""
98
+		guild: Guild = interaction.guild
99
+		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention.mention if mention else None)
76
 		if mention is None:
100
 		if mention is None:
77
-			await context.message.reply(
101
+			await interaction.response.send_message(
78
 				f'{CONFIG["success_emoji"]} Warning messages will not tag anyone.',
102
 				f'{CONFIG["success_emoji"]} Warning messages will not tag anyone.',
79
-				mention_author=False)
103
+				ephemeral=True,
104
+			)
80
 		else:
105
 		else:
81
-			await context.message.reply(
82
-				f'{CONFIG["success_emoji"]} Warning messages will now tag {mention}.',
83
-				mention_author=False)
106
+			await interaction.response.send_message(
107
+				f'{CONFIG["success_emoji"]} Warning messages will now tag {mention.mention}.',
108
+				ephemeral=True,
109
+			)
84
 
110
 
85
 	@config.command(
111
 	@config.command(
86
-		brief='Shows the user/role to mention in warning messages',
87
-		description='Shows the text, if any, that will be prefixed on any ' +
88
-			'warning messages.'
112
+		description='Shows the configured user or role tagged in warning messages.',
89
 	)
113
 	)
90
-	async def getwarningmention(self, context: commands.Context) -> None:
91
-		"""Command handler"""
92
-		guild: Guild = context.guild
114
+	async def get_warning_mention(self, interaction: Interaction) -> None:
115
+		guild: Guild = interaction.guild
93
 		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
116
 		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
94
 		if mention is None:
117
 		if mention is None:
95
-			await context.message.reply(
118
+			await interaction.response.send_message(
96
 				f'{CONFIG["info_emoji"]} No warning mention configured.',
119
 				f'{CONFIG["info_emoji"]} No warning mention configured.',
97
-				mention_author=False)
120
+				ephemeral=True,
121
+			)
98
 		else:
122
 		else:
99
-			await context.message.reply(
123
+			await interaction.response.send_message(
100
 				f'{CONFIG["info_emoji"]} Warning messages will tag {mention}',
124
 				f'{CONFIG["info_emoji"]} Warning messages will tag {mention}',
101
-				mention_author=False)
125
+				ephemeral=True,
126
+			)

+ 63
- 44
rocketbot/cogs/crosspostcog.py ファイルの表示

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
9
-from discord.ext import commands
9
+from discord.ext.commands import Cog
10
 
10
 
11
 from config import CONFIG
11
 from config import CONFIG
12
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
12
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
35
 	"""
35
 	"""
36
 	Detects a user posting in multiple channels in a short period
36
 	Detects a user posting in multiple channels in a short period
37
 	of time: a common pattern for spammers.
37
 	of time: a common pattern for spammers.
38
-
39
-	These used to be identical text, but more recent attacks have had small
40
-	variations, such as different imgur URLs. It's reasonable to treat
41
-	posting in many channels in a short period as suspicious on its own,
42
-	regardless of whether they are identical.
43
-
44
-	Repeated posts in the same channel aren't currently detected, as this can
45
-	often be for a reason or due to trying a failed post when connectivity is
46
-	poor. Minimum message length can be enforced for detection.
47
 	"""
38
 	"""
48
-	SETTING_ENABLED = CogSetting('enabled', bool,
39
+	SETTING_ENABLED = CogSetting(
40
+		'enabled',
41
+		bool,
42
+		default_value=False,
49
 		brief='crosspost detection',
43
 		brief='crosspost detection',
50
-		description='Whether crosspost detection is enabled.')
51
-	SETTING_WARN_COUNT = CogSetting('warncount', int,
44
+		description='Whether crosspost detection is enabled.',
45
+	)
46
+	SETTING_WARN_COUNT = CogSetting(
47
+		'warncount',
48
+		int,
49
+		default_value=5,
52
 		brief='number of messages to trigger a warning',
50
 		brief='number of messages to trigger a warning',
53
 		description='The number of unique channels messages are ' + \
51
 		description='The number of unique channels messages are ' + \
54
 			'posted in by the same user to trigger a mod warning. The ' + \
52
 			'posted in by the same user to trigger a mod warning. The ' + \
55
 			'messages need not be identical (see dupewarncount).',
53
 			'messages need not be identical (see dupewarncount).',
56
-		usage='<count:int>',
57
-		min_value=2)
58
-	SETTING_DUPE_WARN_COUNT = CogSetting('dupewarncount', int,
54
+		min_value=2,
55
+	)
56
+	SETTING_DUPE_WARN_COUNT = CogSetting(
57
+		'dupewarncount',
58
+		int,
59
+		default_value=3,
59
 		brief='number of identical messages to trigger a warning',
60
 		brief='number of identical messages to trigger a warning',
60
 		description='The number of unique channels identical messages are ' + \
61
 		description='The number of unique channels identical messages are ' + \
61
 			'posted in by the same user to trigger a mod warning.',
62
 			'posted in by the same user to trigger a mod warning.',
62
-		usage='<count:int>',
63
-		min_value=2)
64
-	SETTING_BAN_COUNT = CogSetting('bancount', int,
63
+		min_value=2,
64
+	)
65
+	SETTING_BAN_COUNT = CogSetting(
66
+		'bancount',
67
+		int,
68
+		default_value=9999,
65
 		brief='number of messages to trigger a ban',
69
 		brief='number of messages to trigger a ban',
66
 		description='The number of unique channels messages are ' + \
70
 		description='The number of unique channels messages are ' + \
67
 			'posted in by the same user to trigger an automatic ban. The ' + \
71
 			'posted in by the same user to trigger an automatic ban. The ' + \
68
 			'messages need not be identical (see dupebancount). Set ' + \
72
 			'messages need not be identical (see dupebancount). Set ' + \
69
 			'to a large value to effectively disable, e.g. 9999.',
73
 			'to a large value to effectively disable, e.g. 9999.',
70
-		usage='<count:int>',
71
-		min_value=2)
72
-	SETTING_DUPE_BAN_COUNT = CogSetting('dupebancount', int,
74
+		min_value=2,
75
+	)
76
+	SETTING_DUPE_BAN_COUNT = CogSetting(
77
+		'dupebancount',
78
+		int,
79
+		default_value=9999,
73
 		brief='number of identical messages to trigger a ban',
80
 		brief='number of identical messages to trigger a ban',
74
 		description='The number of unique channels identical messages are ' + \
81
 		description='The number of unique channels identical messages are ' + \
75
 			'posted in by the same user to trigger an automatic ban. Set ' + \
82
 			'posted in by the same user to trigger an automatic ban. Set ' + \
76
 			'to a large value to effectively disable, e.g. 9999.',
83
 			'to a large value to effectively disable, e.g. 9999.',
77
-		usage='<count:int>',
78
-		min_value=2)
79
-	SETTING_MIN_LENGTH = CogSetting('minlength', int,
84
+		min_value=2,
85
+	)
86
+	SETTING_MIN_LENGTH = CogSetting(
87
+		'minlength',
88
+		int,
89
+		default_value=1,
80
 		brief='minimum message length',
90
 		brief='minimum message length',
81
 		description='The minimum number of characters in a message to be ' + \
91
 		description='The minimum number of characters in a message to be ' + \
82
 			'checked for duplicates. This can help ignore common short ' + \
92
 			'checked for duplicates. This can help ignore common short ' + \
83
 			'messages like "lol" or a single emoji.',
93
 			'messages like "lol" or a single emoji.',
84
-		usage='<character_count:int>',
85
-		min_value=1)
86
-	SETTING_TIMESPAN = CogSetting('timespan', float,
94
+		min_value=1,
95
+	)
96
+	SETTING_TIMESPAN = CogSetting(
97
+		'timespan',
98
+		timedelta,
99
+		default_value=timedelta(seconds=60),
87
 		brief='time window to look for dupe messages',
100
 		brief='time window to look for dupe messages',
88
-		description='The number of seconds of message history to look at ' + \
89
-			'when looking for duplicates. Shorter values are preferred, ' + \
101
+		description='The number of seconds of message history to look at '
102
+			'when looking for duplicates. Shorter values are preferred, '
90
 			'both to detect bots and avoid excessive memory usage.',
103
 			'both to detect bots and avoid excessive memory usage.',
91
-		usage='<seconds:int>',
92
-		min_value=1)
104
+		min_value=timedelta(seconds=1),
105
+	)
93
 
106
 
94
 	STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
107
 	STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
95
 	STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
108
 	STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
96
 
109
 
97
 	def __init__(self, bot):
110
 	def __init__(self, bot):
98
-		super().__init__(bot)
111
+		super().__init__(
112
+			bot,
113
+			config_prefix='crosspost',
114
+			short_description='Manages crosspost detection and handling.',
115
+			long_description='Detects a user posting in multiple channels in a short period of '
116
+							 'time: a common pattern for spammers.\n'
117
+							 '\n'
118
+							 "These used to be identical text, but more recent attacks have had "
119
+							 "small variations, such as different imgur URLs. It's reasonable to "
120
+							 "treat posting in many channels in a short period as suspicious on its "
121
+							 "own, regardless of whether they are identical.\n"
122
+							 "\n"
123
+							 "Repeated posts in the same channel aren't currently detected, as "
124
+							 "this can often be for a reason or due to trying a failed post when "
125
+							 "connectivity is poor. Minimum message length can be enforced for "
126
+							 "detection.",
127
+		)
99
 		self.add_setting(CrossPostCog.SETTING_ENABLED)
128
 		self.add_setting(CrossPostCog.SETTING_ENABLED)
100
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
129
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
101
 		self.add_setting(CrossPostCog.SETTING_DUPE_WARN_COUNT)
130
 		self.add_setting(CrossPostCog.SETTING_DUPE_WARN_COUNT)
328
 		# print(message)
357
 		# print(message)
329
 		pass
358
 		pass
330
 
359
 
331
-	@commands.Cog.listener()
360
+	@Cog.listener()
332
 	async def on_message(self, message: Message):
361
 	async def on_message(self, message: Message):
333
 		"""Event handler"""
362
 		"""Event handler"""
334
 		if message.author is None or \
363
 		if message.author is None or \
340
 			return
369
 			return
341
 		self.__trace("--ON MESSAGE--")
370
 		self.__trace("--ON MESSAGE--")
342
 		await self.__record_message(message)
371
 		await self.__record_message(message)
343
-
344
-	@commands.group(
345
-		brief='Manages crosspost detection and handling',
346
-	)
347
-	@commands.has_permissions(ban_members=True)
348
-	@commands.guild_only()
349
-	async def crosspost(self, context: commands.Context):
350
-		"""Detects members posting messages in multiple channels in a short period of time."""
351
-		if context.invoked_subcommand is None:
352
-			await context.send_help()

+ 158
- 0
rocketbot/cogs/gamescog.py ファイルの表示

1
+import math
2
+import random
3
+from time import perf_counter
4
+
5
+from discord import Interaction, UnfurledMediaItem
6
+from discord.app_commands import command, Range, guild_only
7
+from discord.ui import LayoutView, Section, TextDisplay, Thumbnail, Container
8
+
9
+from rocketbot.bot import Rocketbot
10
+from rocketbot.cogs.basecog import BaseCog
11
+
12
+
13
+class GamesCog(BaseCog, name="Games"):
14
+	"""Some really basic games or approximations thereof."""
15
+	def __init__(self, bot: Rocketbot):
16
+		super().__init__(
17
+			bot,
18
+			config_prefix='games',
19
+			short_description='Some stupid, low effort games.',
20
+		)
21
+
22
+	@command(
23
+		description='Rolls a die and gives the result. Provides seconds of amusement.'
24
+	)
25
+	@guild_only
26
+	async def roll(self, interaction: Interaction, sides: Range[int, 2, 100], share: bool = False):
27
+		"""
28
+		Rolls a die.
29
+
30
+		Parameters
31
+		----------
32
+		sides : Range[int, 2, 100]
33
+			how many sides on the die
34
+		share : bool
35
+			whether to show the result to everyone in chat, otherwise only shown to you
36
+		"""
37
+		result = random.randint(1, sides)
38
+		who = interaction.user.mention if share else 'You'
39
+		text = f'## :game_die: D{sides} rolled\n'\
40
+			   '\n'\
41
+			   f'    {who} rolled a **{result}**.'
42
+		if result == 69:
43
+			text += ' :smirk:'
44
+		if sides == 20 and result == 1:
45
+			text += ' :slight_frown:'
46
+		if sides == 20 and result == 20:
47
+			text += ' :tada:'
48
+
49
+		# This is a lot of work for a dumb joke
50
+		others = set()
51
+		for _ in range(min(sides - 1, 3)):
52
+			r = random.randint(1, sides)
53
+			while r in others or r == result:
54
+				r = random.randint(1, sides)
55
+			others.add(r)
56
+		text += f'\n\n-# Users who rolled {result} also liked {listed_items(list(others))}'
57
+
58
+		await interaction.response.send_message(text, ephemeral=not share)
59
+
60
+	@command(
61
+		description='Creates a really terrible Minesweeper puzzle.'
62
+	)
63
+	@guild_only
64
+	async def minesweeper(self, interaction: Interaction):
65
+		MINE = -1
66
+		BLANK = 0
67
+
68
+		# Generate grid
69
+		width, height = 8, 8  # about as big as you can get. 10x10 has too many spoiler blocks and Discord doesn't parse them.
70
+		mine_count = 10
71
+		grid = [[BLANK for _ in range(width)] for _ in range(height)]
72
+		all_positions = [(x, y) for y in range(height) for x in range(width)]
73
+
74
+		# Place mines randomly
75
+		random.shuffle(all_positions)
76
+		for x, y in all_positions[:mine_count]:
77
+			grid[x][y] = MINE
78
+
79
+		# Update free spaces with mine counts
80
+		for y in range(height):
81
+			for x in range(width):
82
+				if grid[x][y] == MINE:
83
+					continue
84
+				nearby_mine_count = 0
85
+				for y0 in range(y - 1, y + 2):
86
+					for x0 in range(x - 1, x + 2):
87
+						if 0 <= x0 < width and 0 <= y0 < height and grid[x0][y0] < 0:
88
+							nearby_mine_count += 1
89
+				grid[x][y] = nearby_mine_count
90
+
91
+		# Pick a non-mine starting tile to reveal (favoring 0s)
92
+		start_x, start_y = 0, 0
93
+		for n in range(8):
94
+			start_positions = [ pt for pt in all_positions if BLANK <= grid[pt[0]][pt[1]] <= n ]
95
+			if len(start_positions) > 0:
96
+				# sort by closeness to center
97
+				start_positions.sort(key=lambda pt: math.fabs(pt[0] - width / 2) + math.fabs(pt[1] - height / 2))
98
+				# pick a random one from the top 10
99
+				start_x, start_y = random.choice(start_positions[:10])
100
+				break
101
+
102
+		# Render
103
+		puzzle = ''
104
+		symbols = [
105
+			'\u274C',  # cross mark (red X)
106
+			'0\uFE0F\u20E3',  # 0 + variation selector 16 + combining enclosing keycap
107
+			'1\uFE0F\u20E3',
108
+			'2\uFE0F\u20E3',
109
+			'3\uFE0F\u20E3',
110
+			'4\uFE0F\u20E3',
111
+			'5\uFE0F\u20E3',
112
+			'6\uFE0F\u20E3',
113
+			'7\uFE0F\u20E3',
114
+			'8\uFE0F\u20E3',
115
+		]
116
+		for y in range(height):
117
+			puzzle += '    '
118
+			for x in range(width):
119
+				is_revealed = x == start_x and y == start_y
120
+				if not is_revealed:
121
+					puzzle += '||'
122
+				val = grid[x][y]
123
+				puzzle += symbols[val + 1]
124
+				if not is_revealed:
125
+					puzzle += '||'
126
+				puzzle += ' '
127
+			puzzle += '\n'
128
+
129
+		text = "## Minesweeper (kinda)\n"\
130
+			   f"Here's a really terrible randomized Minesweeper puzzle. Sorry. I did my best.¹\n"\
131
+			   "\n"\
132
+			   f"Uncover everything except the {mine_count} :x: mines. There's no game logic or anything. Just a markdown parlor trick. Police yourselves.\n"\
133
+			   "\n"\
134
+			   f"{puzzle}"\
135
+			   "\n"\
136
+			   "-# ¹ I didn't really do my best."
137
+		await interaction.response.send_message(
138
+			view=_MinesweeperLayout(text),
139
+			ephemeral=True,
140
+		)
141
+
142
+class _MinesweeperLayout(LayoutView):
143
+	def __init__(self, text_content: str):
144
+		super().__init__()
145
+		text = TextDisplay(text_content)
146
+		thumb = Thumbnail(UnfurledMediaItem('https://static.rksp.net/rocketbot/games/minesweeper/icon.png'), description='Minesweeper icon')
147
+		section = Section(text, accessory=thumb)
148
+		container = Container(section, accent_color=0xff0000)
149
+		self.add_item(container)
150
+
151
+def listed_items(items: list) -> str:
152
+	if len(items) == 0:
153
+		return 'nothing'
154
+	if len(items) == 1:
155
+		return f'{items[0]}'
156
+	if len(items) == 2:
157
+		return f'{items[0]} and {items[1]}'
158
+	return (', '.join([ str(i) for i in items[:-1]])) + f', and {items[-1]}'

+ 100
- 99
rocketbot/cogs/generalcog.py ファイルの表示

1
 """
1
 """
2
 Cog for handling most ungrouped commands and basic behaviors.
2
 Cog for handling most ungrouped commands and basic behaviors.
3
 """
3
 """
4
-import re
5
 from datetime import datetime, timedelta, timezone
4
 from datetime import datetime, timedelta, timezone
6
 from typing import Optional
5
 from typing import Optional
7
 
6
 
8
-from discord import Message
7
+from discord import Interaction, Message, User
8
+from discord.app_commands import command, default_permissions, guild_only, Transform
9
 from discord.errors import DiscordException
9
 from discord.errors import DiscordException
10
-from discord.ext import commands
10
+from discord.ext.commands import Cog
11
 
11
 
12
 from config import CONFIG
12
 from config import CONFIG
13
+from rocketbot.bot import Rocketbot
13
 from rocketbot.cogs.basecog import BaseCog, BotMessage
14
 from rocketbot.cogs.basecog import BaseCog, BotMessage
14
-from rocketbot.utils import timedelta_from_str, describe_timedelta, dump_stacktrace
15
+from rocketbot.utils import describe_timedelta, TimeDeltaTransformer
15
 from rocketbot.storage import ConfigKey, Storage
16
 from rocketbot.storage import ConfigKey, Storage
16
 
17
 
17
 class GeneralCog(BaseCog, name='General'):
18
 class GeneralCog(BaseCog, name='General'):
19
 	Cog for handling high-level bot functionality and commands. Should be the
20
 	Cog for handling high-level bot functionality and commands. Should be the
20
 	first cog added to the bot.
21
 	first cog added to the bot.
21
 	"""
22
 	"""
22
-	def __init__(self, bot: commands.Bot):
23
-		super().__init__(bot)
23
+
24
+	shared: Optional['GeneralCog'] = None
25
+
26
+	def __init__(self, bot: Rocketbot):
27
+		super().__init__(
28
+			bot,
29
+			config_prefix=None,
30
+			short_description='',
31
+		)
24
 		self.is_connected = False
32
 		self.is_connected = False
25
-		self.is_ready = False
26
-		self.is_first_ready = True
27
 		self.is_first_connect = True
33
 		self.is_first_connect = True
28
 		self.last_disconnect_time: Optional[datetime] = None
34
 		self.last_disconnect_time: Optional[datetime] = None
29
 		self.noteworthy_disconnect_duration = timedelta(seconds=5)
35
 		self.noteworthy_disconnect_duration = timedelta(seconds=5)
36
+		GeneralCog.shared = self
30
 
37
 
31
-	@commands.Cog.listener()
38
+	@Cog.listener()
32
 	async def on_connect(self):
39
 	async def on_connect(self):
33
 		"""Event handler"""
40
 		"""Event handler"""
34
 		if self.is_first_connect:
41
 		if self.is_first_connect:
41
 				self.log(None, f'Reconnected after {disconnect_duration.total_seconds()} seconds')
48
 				self.log(None, f'Reconnected after {disconnect_duration.total_seconds()} seconds')
42
 		self.is_connected = True
49
 		self.is_connected = True
43
 
50
 
44
-	@commands.Cog.listener()
51
+	@Cog.listener()
45
 	async def on_disconnect(self):
52
 	async def on_disconnect(self):
46
 		"""Event handler"""
53
 		"""Event handler"""
47
 		self.last_disconnect_time = datetime.now(timezone.utc)
54
 		self.last_disconnect_time = datetime.now(timezone.utc)
48
 		# self.log(None, 'Disconnected')
55
 		# self.log(None, 'Disconnected')
49
 
56
 
50
-	@commands.Cog.listener()
51
-	async def on_ready(self):
52
-		"""Event handler"""
53
-		self.log(None, 'Bot done initializing')
54
-		self.is_ready = True
55
-		try:
56
-			synced_commands = await self.bot.tree.sync()
57
-			for command in synced_commands:
58
-				self.log(None, f'Synced command: {command.name}')
59
-		except Exception as e:
60
-			dump_stacktrace(e)
61
-		if self.is_first_ready:
62
-			print('----------------------------------------------------------')
63
-			self.is_first_ready = False
64
-
65
-	@commands.Cog.listener()
57
+	@Cog.listener()
66
 	async def on_resumed(self):
58
 	async def on_resumed(self):
67
 		"""Event handler"""
59
 		"""Event handler"""
68
 		disconnect_duration = datetime.now(timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
60
 		disconnect_duration = datetime.now(timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
69
 		if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
61
 		if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
70
 			self.log(None, f'Session resumed after {disconnect_duration.total_seconds()} seconds')
62
 			self.log(None, f'Session resumed after {disconnect_duration.total_seconds()} seconds')
71
 
63
 
72
-	@commands.command(
73
-		brief='Posts a test warning',
74
-		description='Tests whether a warning channel is configured for this ' + \
75
-			'guild by posting a test warning. If a mod mention is ' + \
76
-			'configured, that user/role will be tagged in the test warning.',
64
+	@command(
65
+		description='Posts a test warning.',
66
+		extras={
67
+			'long_description': 'If a warning channel is configured, it will be posted '
68
+								'there. If a warning role/user is configured, they will be '
69
+								'tagged in the message.',
70
+		},
77
 	)
71
 	)
78
-	@commands.has_permissions(ban_members=True)
79
-	@commands.guild_only()
80
-	async def testwarn(self, context):
81
-		"""Command handler"""
82
-		if Storage.get_config_value(context.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
83
-			await context.message.reply(
72
+	@guild_only()
73
+	@default_permissions(ban_members=True)
74
+	async def test_warn(self, interaction: Interaction):
75
+		if Storage.get_config_value(interaction.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
76
+			await interaction.response.send_message(
84
 				f'{CONFIG["warning_emoji"]} No warning channel set!',
77
 				f'{CONFIG["warning_emoji"]} No warning channel set!',
85
-				mention_author=False)
78
+				ephemeral=True,
79
+			)
86
 		else:
80
 		else:
87
 			bm = BotMessage(
81
 			bm = BotMessage(
88
-				context.guild,
89
-				f'Test warning message (requested by {context.author.name})',
82
+				interaction.guild,
83
+				f'Test warning message (requested by {interaction.user.name})',
90
 				type=BotMessage.TYPE_MOD_WARNING)
84
 				type=BotMessage.TYPE_MOD_WARNING)
91
 			await self.post_message(bm)
85
 			await self.post_message(bm)
86
+			await interaction.response.send_message(
87
+				'Warning issued',
88
+				ephemeral=True,
89
+			)
92
 
90
 
93
-	@commands.command(
94
-		brief='Simple test reply',
95
-		description='Replies to the command message. Useful to ensure the ' + \
96
-			'bot is working properly.',
91
+	@command(
92
+		description='Responds to the user with a greeting.',
93
+		extras={
94
+			'long_description': 'Useful for checking bot responsiveness. Message '
95
+								'is only visible to the user.',
96
+		},
97
 	)
97
 	)
98
-	async def hello(self, context):
99
-		"""Command handler"""
100
-		await context.message.reply(
101
-			f'Hey, {context.author.name}!',
102
-		 	mention_author=False)
103
-
104
-	@commands.command(
105
-		brief='Shuts down the bot',
106
-		description='Causes the bot script to terminate. Only usable by a ' + \
107
-			'user with server admin permissions.',
98
+	async def hello(self, interaction: Interaction):
99
+		await interaction.response.send_message(
100
+			f'Hey, {interaction.user.name}!',
101
+		 	ephemeral=True,
102
+		)
103
+
104
+	@command(
105
+		description='Shuts down the bot.',
106
+		extras={
107
+			'long_description': 'For emergency use if the bot gains sentience. Only usable '
108
+								'by a server administrator.',
109
+		},
108
 	)
110
 	)
109
-	@commands.has_permissions(administrator=True)
110
-	@commands.guild_only()
111
-	async def shutdown(self, context: commands.Context):
111
+	@guild_only()
112
+	@default_permissions(administrator=True)
113
+	async def shutdown(self, interaction: Interaction):
112
 		"""Command handler"""
114
 		"""Command handler"""
113
-		await context.message.add_reaction('👋')
115
+		await interaction.response.send_message('👋', ephemeral=True)
114
 		await self.bot.close()
116
 		await self.bot.close()
115
 
117
 
116
-	@commands.command(
117
-		brief='Mass deletes messages',
118
-		description='Deletes recent messages by the given user. The user ' +
119
-			'can be either an @ mention or a numeric user ID. The age is ' +
120
-			'a duration, such as "30s", "5m", "1h30m". Only the most ' +
121
-			'recent 100 messages in each channel are searched.',
122
-		usage='<user:id|mention> <age:timespan>'
118
+	@command(
119
+		description='Mass deletes recent messages by a user.',
120
+		extras={
121
+			'long_description': 'The age is a duration, such as "30s", "5m", "1h30m", "7d". '
122
+								'Only the most recent 100 messages in each channel are searched.\n\n'
123
+								"The author can be a numeric ID if they aren't showing up in autocomplete.",
124
+			'usage': '<user:id|mention> <age:timespan>',
125
+		},
123
 	)
126
 	)
124
-	@commands.has_permissions(manage_messages=True)
125
-	@commands.guild_only()
126
-	async def deletemessages(self, context, user: str, age: str) -> None:
127
-		"""Command handler"""
128
-		member_id = self.__parse_member_id(user)
129
-		if member_id is None:
130
-			await context.message.reply(
131
-				f'{CONFIG["failure_emoji"]} user must be a mention or numeric user id',
132
-				mention_author=False)
133
-			return
134
-		try:
135
-			age_delta: timedelta = timedelta_from_str(age)
136
-		except ValueError:
137
-			await context.message.reply(
138
-				f'{CONFIG["failure_emoji"]} age must be a timespan, like "30s", "10m", "1h30m"',
139
-				mention_author=False)
140
-			return
141
-		cutoff: datetime = datetime.now(timezone.utc) - age_delta
127
+	@guild_only()
128
+	@default_permissions(manage_messages=True)
129
+	async def delete_messages(self, interaction: Interaction, author: User, age: Transform[timedelta, TimeDeltaTransformer]) -> None:
130
+		"""
131
+		Mass deletes messages.
132
+
133
+		Parameters
134
+		----------
135
+		interaction: :class:`Interaction`
136
+		author: :class:`User`
137
+			author of messages to delete
138
+		age: :class:`timedelta`
139
+			maximum age of messages to delete (e.g. 30s, 5m, 1h30s, 7d)
140
+		"""
141
+		member_id = author.id
142
+		cutoff: datetime = datetime.now(timezone.utc) - age
143
+
144
+		# Finding and deleting messages takes time but interaction needs a timely acknowledgement.
145
+		resp = await interaction.response.defer(ephemeral=True, thinking=True)
146
+
142
 		def predicate(message: Message) -> bool:
147
 		def predicate(message: Message) -> bool:
143
 			return str(message.author.id) == member_id and message.created_at >= cutoff
148
 			return str(message.author.id) == member_id and message.created_at >= cutoff
144
 		deleted_messages = []
149
 		deleted_messages = []
145
-		for channel in context.guild.text_channels:
150
+		for channel in interaction.guild.text_channels:
146
 			try:
151
 			try:
147
 				deleted_messages += await channel.purge(limit=100, check=predicate)
152
 				deleted_messages += await channel.purge(limit=100, check=predicate)
148
 			except DiscordException:
153
 			except DiscordException:
149
 				# XXX: Sloppily glossing over access errors instead of checking access
154
 				# XXX: Sloppily glossing over access errors instead of checking access
150
 				pass
155
 				pass
151
-		await context.message.reply(
152
-			f'{CONFIG["success_emoji"]} Deleted {len(deleted_messages)} ' + \
153
-			f'messages by <@!{member_id}> from the past {describe_timedelta(age_delta)}.',
154
-			mention_author=False)
155
-
156
-	def __parse_member_id(self, arg: str) -> Optional[str]:
157
-		p = re.compile('^<@!?([0-9]+)>$')
158
-		m = p.match(arg)
159
-		if m:
160
-			return m.group(1)
161
-		p = re.compile('^([0-9]+)$')
162
-		m = p.match(arg)
163
-		if m:
164
-			return m.group(1)
165
-		return None
156
+
157
+		if len(deleted_messages) > 0:
158
+			await resp.resource.edit(
159
+				content=f'{CONFIG["success_emoji"]} Deleted {len(deleted_messages)} '
160
+						f'messages by {author.mention} from the past {describe_timedelta(age)}.',
161
+			)
162
+		else:
163
+			await resp.resource.edit(
164
+				content=f'{CONFIG["success_emoji"]} No messages found for {author.mention} '
165
+						'from the past {describe_timedelta(age)}.',
166
+			)

+ 521
- 0
rocketbot/cogs/helpcog.py ファイルの表示

1
+"""Provides help commands for getting info on using other commands and configuration."""
2
+import re
3
+import time
4
+from typing import Union, Optional, TypedDict
5
+
6
+from discord import Interaction, Permissions, AppCommandType, ButtonStyle, InteractionResponse
7
+from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
8
+from discord.ui import ActionRow, Button, LayoutView, TextDisplay
9
+
10
+from config import CONFIG
11
+from rocketbot.bot import Rocketbot
12
+from rocketbot.cogs.basecog import BaseCog
13
+from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
14
+
15
+HelpTopic = Union[Command, Group, BaseCog]
16
+class HelpMeta(TypedDict):
17
+	id: str
18
+	text: str
19
+	topic: HelpTopic
20
+
21
+# Potential place to break text neatly in large help content
22
+PAGE_BREAK = '\f'
23
+
24
+def choice_from_topic(topic: HelpTopic, include_full_command: bool = False) -> Choice:
25
+	if isinstance(topic, BaseCog):
26
+		return Choice(name=f'⚙ {topic.qualified_name}', value=f'cog:{topic.qualified_name}')
27
+	if isinstance(topic, Group):
28
+		return Choice(name=f'/{topic.name}', value=f'cmd:{topic.name}')
29
+	if isinstance(topic, Command):
30
+		if topic.parent:
31
+			if include_full_command:
32
+				return Choice(name=f'/{topic.parent.name} {topic.name}', value=f'subcmd:{topic.parent.name}.{topic.name}')
33
+			return Choice(name=f'{topic.name}', value=f'subcmd:{topic.name}')
34
+		return Choice(name=f'/{topic.name}', value=f'cmd:{topic.name}')
35
+	return Choice(name='', value='')
36
+
37
+async def search_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
38
+	try:
39
+		if len(current) == 0:
40
+			return [
41
+				choice_from_topic(topic, include_full_command=True)
42
+				for topic in HelpCog.shared.all_accessible_topics(interaction.permissions)
43
+			]
44
+		return [
45
+			choice_from_topic(topic, include_full_command=True)
46
+			for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
47
+		][:25]
48
+	except BaseException as e:
49
+		dump_stacktrace(e)
50
+		return []
51
+
52
+class HelpCog(BaseCog, name='Help'):
53
+	shared: Optional['HelpCog'] = None
54
+
55
+	def __init__(self, bot: Rocketbot):
56
+		super().__init__(
57
+			bot,
58
+			config_prefix='help',
59
+			short_description='Provides help on using this bot.'
60
+		)
61
+		HelpCog.shared = self
62
+
63
+	def __create_help_index(self) -> None:
64
+		"""
65
+		Populates self.id_to_topic and self.keyword_index. Bails if already
66
+		populated. Intended to be run on demand so all cogs and commands have
67
+		had time to get set up and synced.
68
+		"""
69
+		if getattr(self, 'id_to_topic', None) is not None:
70
+			return
71
+		self.id_to_topic: dict[str, HelpTopic] = {}
72
+		self.topics: list[HelpMeta] = []
73
+
74
+		def process_text(t: str) -> str:
75
+			return ' '.join([
76
+				word
77
+				for word in re.split(r"[^a-z']+", t.lower())
78
+				if word not in trivial_words
79
+			]).strip()
80
+
81
+		cmds = self.all_commands()
82
+		for cmd in cmds:
83
+			key = f'cmd:{cmd.name}'
84
+			self.id_to_topic[key] = cmd
85
+			self.id_to_topic[f'/{cmd.name}'] = cmd
86
+			text = cmd.name
87
+			if cmd.description:
88
+				text += f' {cmd.description}'
89
+			if cmd.extras.get('long_description', None):
90
+				text += f' {cmd.extras["long_description"]}'
91
+			self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cmd })
92
+			if isinstance(cmd, Group):
93
+				for subcmd in cmd.commands:
94
+					key = f'subcmd:{cmd.name}.{subcmd.name}'
95
+					self.id_to_topic[key] = subcmd
96
+					self.id_to_topic[f'/{cmd.name} {subcmd.name}'] = subcmd
97
+					text = cmd.name
98
+					text += f' {subcmd.name}'
99
+					if subcmd.description:
100
+						text += f' {subcmd.description}'
101
+					if subcmd.extras.get('long_description', None):
102
+						text += f' {subcmd.extras["long_description"]}'
103
+					self.topics.append({ 'id': key, 'text': process_text(text), 'topic': subcmd })
104
+		for cog_qname, cog in self.bot.cogs.items():
105
+			if not isinstance(cog, BaseCog):
106
+				continue
107
+			key = f'cog:{cog_qname}'
108
+			self.id_to_topic[key] = cog
109
+			text = cog.qualified_name
110
+			if cog.short_description:
111
+				text += f' {cog.short_description}'
112
+			if cog.long_description:
113
+				text += f' {cog.long_description}'
114
+			self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cog })
115
+
116
+	def topic_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]:
117
+		self.__create_help_index()
118
+		return self.id_to_topic.get(symbol, None)
119
+
120
+	def all_commands(self) -> list[Union[Command, Group]]:
121
+		# PyCharm not interpreting conditional return type correctly.
122
+		# noinspection PyTypeChecker
123
+		cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
124
+		return sorted(cmds, key=lambda cmd: cmd.name)
125
+
126
+	def all_accessible_commands(self, permissions: Optional[Permissions]) -> list[Union[Command, Group]]:
127
+		return [
128
+			cmd
129
+			for cmd in self.all_commands()
130
+			if can_use_command(cmd, permissions)
131
+		]
132
+
133
+	def all_accessible_subcommands(self, permissions: Optional[Permissions]) -> list[Command]:
134
+		cmds = self.all_accessible_commands(permissions)
135
+		subcmds: list[Command] = []
136
+		for cmd in cmds:
137
+			if isinstance(cmd, Group):
138
+				for subcmd in sorted(cmd.commands, key=lambda cmd: cmd.name):
139
+					if can_use_command(subcmd, permissions):
140
+						subcmds.append(subcmd)
141
+		return subcmds
142
+
143
+	def all_accessible_cogs(self, permissions: Optional[Permissions]) -> list[BaseCog]:
144
+		return [
145
+			cog
146
+			for cog in self.basecogs
147
+			if can_use_cog(cog, permissions)
148
+		]
149
+
150
+	def all_accessible_topics(self, permissions: Optional[Permissions], *,
151
+							  include_cogs: bool = True,
152
+							  include_commands: bool = True,
153
+							  include_subcommands: bool = True) -> list[HelpTopic]:
154
+		topics = []
155
+		if include_cogs:
156
+			topics += self.all_accessible_cogs(permissions)
157
+		if include_commands:
158
+			topics += self.all_accessible_commands(permissions)
159
+		if include_subcommands:
160
+			topics += self.all_accessible_subcommands(permissions)
161
+		return topics
162
+
163
+	def topics_for_keywords(self, search: str, permissions: Optional[Permissions]) -> list[HelpTopic]:
164
+		start_time = time.perf_counter()
165
+		self.__create_help_index()
166
+
167
+		# Break into words (or word fragments)
168
+		words: list[str] = [
169
+			word
170
+			for word in re.split(r"[^a-z']+", search.lower())
171
+		]
172
+
173
+		# Find matches
174
+		def topic_matches(meta: HelpMeta) -> bool:
175
+			for word in words:
176
+				if word not in meta['text']:
177
+					return False
178
+			return True
179
+		matching_topics: list[HelpTopic] = [ topic['topic'] for topic in self.topics if topic_matches(topic) ]
180
+
181
+		# Filter by accessibility
182
+		accessible_topics = [
183
+			topic
184
+			for topic in matching_topics
185
+			if ((isinstance(topic, Command) or isinstance(topic, Group)) and can_use_command(topic, permissions)) or \
186
+			   (isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
187
+		]
188
+
189
+		# Sort and return
190
+		result = sorted(accessible_topics, key=lambda topic: (
191
+			isinstance(topic, Command),
192
+			isinstance(topic, BaseCog),
193
+			topic.qualified_name if isinstance(topic, BaseCog) else topic.name
194
+		))
195
+		duration = time.perf_counter() - start_time
196
+		if duration > 0.01:
197
+			self.log(None, f'search "{search}" took {duration} seconds')
198
+		return result
199
+
200
+	@command(
201
+		name='help',
202
+		description='Shows help for using commands and module configuration.',
203
+		extras={
204
+			'long_description': '`/help` will show a list of top-level topics.\n'
205
+								'\n'
206
+								"`/help /<command_name>` will show help about a specific command or list a command's subcommands.\n"
207
+								'\n'
208
+								'`/help /<command_name> <subcommand_name>` will show help about a specific subcommand.\n'
209
+								'\n'
210
+								'`/help <module_name>` will show help about configuring a module.\n'
211
+								'\n'
212
+								'`/help <keywords>` will do a text search for topics.',
213
+		}
214
+	)
215
+	@guild_only()
216
+	@autocomplete(search=search_autocomplete)
217
+	async def help_command(self, interaction: Interaction, search: Optional[str]) -> None:
218
+		"""
219
+		Shows help for using commands and subcommands and configuring modules.
220
+
221
+		Parameters
222
+		----------
223
+		interaction: Interaction
224
+		search: Optional[str]
225
+			search terms
226
+		"""
227
+		if search is None:
228
+			await self.__send_general_help(interaction)
229
+			return
230
+		topic = self.topic_for_help_symbol(search)
231
+		if topic:
232
+			await self.__send_topic_help(interaction, topic)
233
+			return
234
+		matches = self.topics_for_keywords(search, interaction.permissions)
235
+		await self.__send_keyword_help(interaction, matches)
236
+
237
+	async def __send_topic_help(self, interaction: Interaction, topic: HelpTopic) -> None:
238
+		if isinstance(topic, Command):
239
+			await self.__send_command_help(interaction, topic)
240
+			return
241
+		if isinstance(topic, Group):
242
+			await self.__send_command_help(interaction, topic)
243
+			return
244
+		if isinstance(topic, BaseCog):
245
+			await self.__send_cog_help(interaction, topic)
246
+			return
247
+		self.log(interaction.guild, f'No help for topic object {topic}')
248
+		await interaction.response.send_message(
249
+			f'{CONFIG["failure_emoji"]} Failed to get help info.',
250
+			ephemeral=True,
251
+			delete_after=10,
252
+		)
253
+
254
+	def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
255
+		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
256
+
257
+	def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
258
+		return {
259
+			subcmd.name: subcmd
260
+			for subcmd in cmd.commands
261
+			if can_use_command(subcmd, permissions)
262
+		} if can_use_command(cmd, permissions) else {}
263
+
264
+	async def __send_general_help(self, interaction: Interaction) -> None:
265
+		user_permissions: Permissions = interaction.permissions
266
+		all_commands = sorted(self.get_command_list(user_permissions).items())
267
+		all_cog_tuples: list[tuple[str, BaseCog]] = [
268
+			cog_tuple
269
+			for cog_tuple in sorted(self.basecog_map.items())
270
+			if can_use_cog(cog_tuple[1], user_permissions) and \
271
+			   (len(cog_tuple[1].settings) > 0)
272
+		]
273
+
274
+		text = f'## :information_source: Help'
275
+		if len(all_commands) + len(all_cog_tuples) == 0:
276
+			text = 'Nothing available for your permissions!'
277
+
278
+		if len(all_commands) > 0:
279
+			text += '\n### Commands'
280
+			text += '\nType `/help /commandname` for more information.'
281
+			for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
282
+				text += f'\n- `/{cmd_name}`: {cmd.description}'
283
+				if isinstance(cmd, Group):
284
+					subcommand_count = len(cmd.commands)
285
+					text += f' ({subcommand_count} subcommands)'
286
+			text += PAGE_BREAK
287
+
288
+		if len(all_cog_tuples) > 0:
289
+			text += '\n### Module Configuration'
290
+			for cog_name, cog in all_cog_tuples:
291
+				has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
292
+				text += f'\n- **{cog_name}**: {cog.short_description}'
293
+				if has_enabled:
294
+					text += f'\n   - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
295
+				for setting in cog.settings:
296
+					if setting.name == 'enabled':
297
+						continue
298
+					text += f'\n   - `/get` or `/set {cog.config_prefix}_{setting.name}`'
299
+				text += PAGE_BREAK
300
+
301
+		await self.__send_paged_help(interaction, text)
302
+
303
+	async def __send_keyword_help(self, interaction: Interaction, matching_topics: Optional[list[HelpTopic]]) -> None:
304
+		matching_commands = [
305
+			cmd
306
+			for cmd in matching_topics or []
307
+			if isinstance(cmd, Command) or isinstance(cmd, Group)
308
+		]
309
+		matching_cogs = [
310
+			cog
311
+			for cog in matching_topics or []
312
+			if isinstance(cog, BaseCog)
313
+		]
314
+		if len(matching_commands) + len(matching_cogs) == 0:
315
+			await interaction.response.send_message(
316
+				f'{CONFIG["failure_emoji"]} No available help topics found.',
317
+				ephemeral=True,
318
+				delete_after=10,
319
+			)
320
+			return
321
+		if len(matching_topics) == 1:
322
+			topic = matching_topics[0]
323
+			await self.__send_topic_help(interaction, topic)
324
+			return
325
+
326
+		text = '## :information_source: Matching Help Topics'
327
+		if len(matching_commands) > 0:
328
+			text += '\n### Commands'
329
+			for cmd in matching_commands:
330
+				if cmd.parent:
331
+					text += f'\n- `/{cmd.parent.name} {cmd.name}`'
332
+				else:
333
+					text += f'\n- `/{cmd.name}`'
334
+		if len(matching_cogs) > 0:
335
+			text += '\n### Modules'
336
+			for cog in matching_cogs:
337
+				text += f'\n- {cog.qualified_name}'
338
+
339
+		await self.__send_paged_help(interaction, text)
340
+
341
+	async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
342
+		text = ''
343
+		if addendum is not None:
344
+			text += addendum + '\n\n'
345
+		if command_or_group.parent:
346
+			text += f'## :information_source: Subcommand Help'
347
+			text += f'\n`/{command_or_group.parent.name} {command_or_group.name}`'
348
+		else:
349
+			text += f'## :information_source: Command Help'
350
+			if isinstance(command_or_group, Group):
351
+				text += f'\n`/{command_or_group.name} subcommand_name`'
352
+			else:
353
+				text += f'\n`/{command_or_group.name}`'
354
+		if isinstance(command_or_group, Command):
355
+			optional_nesting = 0
356
+			for param in command_or_group.parameters:
357
+				text += '  '
358
+				if not param.required:
359
+					text += '['
360
+					optional_nesting += 1
361
+				text += f'_{param.name}_'
362
+			if optional_nesting > 0:
363
+				text += ']' * optional_nesting
364
+		text += f'\n\n{command_or_group.description}'
365
+		if command_or_group.extras.get('long_description'):
366
+			text += f'\n\n{command_or_group.extras["long_description"]}'
367
+		if isinstance(command_or_group, Group):
368
+			subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
369
+			if len(subcmds) > 0:
370
+				text += '\n### Subcommands:'
371
+				for subcmd_name, subcmd in sorted(subcmds.items()):
372
+					text += f'\n- `{subcmd_name}`: {subcmd.description}'
373
+				text += f'\n-# To use a subcommand, type it after the command. e.g. `/{command_or_group.name} subcommand_name`'
374
+				text += f'\n-# Get help on a subcommand by typing `/help /{command_or_group.name} subcommand_name`'
375
+		else:
376
+			params = command_or_group.parameters
377
+			if len(params) > 0:
378
+				text += '\n### Parameters:'
379
+				for param in params:
380
+					text += f'\n- `{param.name}`: {param.description}'
381
+					if not param.required:
382
+						text += ' (optional)'
383
+
384
+		await self.__send_paged_help(interaction, text)
385
+
386
+	async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
387
+		text = f'## :information_source: Module Help'
388
+		text += f'\n**{cog.qualified_name}** module'
389
+		if cog.short_description is not None:
390
+			text += f'\n\n{cog.short_description}'
391
+		if cog.long_description is not None:
392
+			text += f'\n\n{cog.long_description}'
393
+
394
+		cmds = [
395
+			cmd
396
+			for cmd in sorted(cog.get_app_commands(), key=lambda c: c.name)
397
+			if can_use_command(cmd, interaction.permissions)
398
+		]
399
+		if len(cmds) > 0:
400
+			text += '\n### Commands:'
401
+			for cmd in cmds:
402
+				text += f'\n- `/{cmd.name}` - {cmd.description}'
403
+				if isinstance(cmd, Group):
404
+					subcmds = [ subcmd for subcmd in cmd.commands if can_use_command(subcmd, interaction.permissions) ]
405
+					if len(subcmds) > 0:
406
+						text += f' ({len(subcmds)} subcommands)'
407
+
408
+		settings = cog.settings
409
+		if len(settings) > 0:
410
+			text += '\n### Configuration'
411
+			enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
412
+			if enabled_setting is not None:
413
+				text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
414
+			for setting in sorted(settings, key=lambda s: s.name):
415
+				if setting.name == 'enabled':
416
+					continue
417
+				text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.brief}'
418
+
419
+		await self.__send_paged_help(interaction, text)
420
+
421
+	async def __send_paged_help(self, interaction: Interaction, text: str) -> None:
422
+		pages = _paginate(text)
423
+		if len(pages) == 1:
424
+			await interaction.response.send_message(pages[0], ephemeral=True)
425
+		else:
426
+			await _update_paged_help(interaction, None, 0, pages)
427
+
428
+def _paginate(text: str) -> list[str]:
429
+	max_page_size = 2000
430
+	chunks = text.split(PAGE_BREAK)
431
+	pages = [ '' ]
432
+	for chunk in chunks:
433
+		if len(chunk) > max_page_size:
434
+			raise ValueError('Help content needs more page breaks! One chunk is too big for message.')
435
+		if len(pages[-1] + chunk) < max_page_size:
436
+			pages[-1] += chunk
437
+		else:
438
+			pages.append(chunk)
439
+	page_count = len(pages)
440
+	if page_count == 1:
441
+		return pages
442
+
443
+	# Do another pass and try to even out the page lengths
444
+	indices = [ i * len(chunks) // page_count for i in range(page_count + 1) ]
445
+	even_pages = [
446
+		''.join(chunks[indices[i]:indices[i + 1]])
447
+		for i in range(page_count)
448
+	]
449
+	for page in even_pages:
450
+		if len(page) > max_page_size:
451
+			# We made a page too big. Give up.
452
+			return pages
453
+	return even_pages
454
+
455
+async def _update_paged_help(interaction: Interaction, original_interaction: Optional[Interaction], current_page: int, pages: list[str]) -> None:
456
+	try:
457
+		view = _PagingLayoutView(current_page, pages, original_interaction or interaction)
458
+		resolved = interaction
459
+		if original_interaction is not None:
460
+			# We have an original interaction from the initial command and a
461
+			# new one from the button press. Use the original to swap in the
462
+			# new page in place, then acknowledge the new one to satisfy the
463
+			# API that we didn't fail.
464
+			await original_interaction.edit_original_response(
465
+				view=view,
466
+			)
467
+			if interaction is not original_interaction:
468
+				await interaction.response.defer(ephemeral=True, thinking=False)
469
+		else:
470
+			# Initial send
471
+			await resolved.response.send_message(
472
+				view=view,
473
+				ephemeral=True,
474
+				delete_after=60,
475
+			)
476
+	except BaseException as e:
477
+		dump_stacktrace(e)
478
+
479
+class _PagingLayoutView(LayoutView):
480
+	def __init__(self, current_page: int, pages: list[str], original_interaction: Optional[Interaction]):
481
+		super().__init__()
482
+		self.current_page: int = current_page
483
+		self.pages: list[str] = pages
484
+		self.text.content = self.pages[self.current_page]
485
+		self.original_interaction = original_interaction
486
+		if current_page <= 0:
487
+			self.handle_prev_button.disabled = True
488
+		if current_page >= len(self.pages) - 1:
489
+			self.handle_next_button.disabled = True
490
+
491
+	text = TextDisplay('')
492
+
493
+	row = ActionRow()
494
+
495
+	@row.button(label='< Prev')
496
+	async def handle_prev_button(self, interaction: Interaction, button: Button) -> None:
497
+		new_page = max(0, self.current_page - 1)
498
+		await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
499
+
500
+	@row.button(label='Next >')
501
+	async def handle_next_button(self, interaction: Interaction, button: Button) -> None:
502
+		new_page = min(len(self.pages) - 1, self.current_page + 1)
503
+		await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
504
+
505
+# Exclusions from keyword indexing
506
+trivial_words = {
507
+	'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
508
+	'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
509
+	'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
510
+}
511
+
512
+def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
513
+	if user_permissions is None:
514
+		return False
515
+	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
516
+		return False
517
+	return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
518
+
519
+def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
520
+	# "Using" a cog for now means configuring it, and only mods can configure cogs.
521
+	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)

+ 0
- 150
rocketbot/cogs/joinagecog.py ファイルの表示

1
-import weakref
2
-
3
-from datetime import datetime, timedelta, timezone
4
-from discord import Guild, Member
5
-from discord.ext import commands
6
-
7
-from config import CONFIG
8
-from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
9
-from rocketbot.collections import AgeBoundList
10
-from rocketbot.storage import Storage
11
-from rocketbot.utils import timedelta_from_str
12
-
13
-class JoinAgeQueryContext:
14
-	"""
15
-	Data about a join age query
16
-	"""
17
-	def __init__(self, join_members: list, timespan: str):
18
-		self.join_members = list(join_members)
19
-		self.timespan = timespan
20
-		self.kicked_members = set()
21
-		self.banned_members = set()
22
-		self.results_message_ref = None
23
-
24
-class JoinAgeCog(BaseCog, name='Join Age'):
25
-	"""
26
-	Cog for finding users by when they joined.
27
-	"""
28
-	SETTING_ENABLED = CogSetting('enabled', bool,
29
-		brief='join age',
30
-		description='Whether this cog is enabled for a guild.')
31
-	SETTING_JOIN_TIME = CogSetting('jointime', float,
32
-		brief='maximum length of time to track new joins',
33
-		description='The number of seconds of join history to maintain.',
34
-		usage='<seconds:float>',
35
-		min_value=1.0)
36
-
37
-	STATE_KEY_RECENT_JOINS = "JoinAgeCog.recent_joins"
38
-
39
-	def __init__(self, bot):
40
-		super().__init__(bot)
41
-		self.add_setting(JoinAgeCog.SETTING_ENABLED)
42
-		self.add_setting(JoinAgeCog.SETTING_JOIN_TIME)
43
-
44
-	@commands.group(
45
-		brief='Tracks recently joined users with options to mass kick or ban',
46
-	)
47
-	@commands.has_permissions(ban_members=True)
48
-	@commands.guild_only()
49
-	async def joinage(self, context: commands.Context):
50
-		"""Join age tracking"""
51
-		if context.invoked_subcommand is None:
52
-			await context.send_help()
53
-
54
-	@joinage.command(
55
-		brief='Queries for users who joined in the past span of time',
56
-		description='Searches for users who joined the server recently. ' + \
57
-			'Can use time spans like 30s, 5m, 1h, 7d, etc.',
58
-		usage='<time_period>'
59
-	)
60
-	async def search(self, context: commands.Context, timespan: str):
61
-		"""Command handler"""
62
-		guild: Guild = context.guild
63
-		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
64
-		if recent_joins is None:
65
-			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
66
-			recent_joins = AgeBoundList(max_age, lambda i, member0 : member0.joined_at)
67
-			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
68
-		results: list = []
69
-		ts: timedelta = timedelta_from_str(timespan)
70
-		cutoff: datetime = datetime.now(timezone.utc) - ts
71
-		for member in recent_joins:
72
-			if member.joined_at > cutoff:
73
-				results.append(member)
74
-		ctx = JoinAgeQueryContext(results, timespan)
75
-		msg = BotMessage(guild,
76
-						text='',
77
-						type=BotMessage.TYPE_INFO,
78
-						context=ctx)
79
-		ctx.results_message_ref = weakref.ref(msg)
80
-		await self.__update_results_message(ctx)
81
-		await self.post_message(msg)
82
-
83
-	async def on_mod_react(self,
84
-			bot_message: BotMessage,
85
-			reaction: BotMessageReaction,
86
-			reacted_by: Member) -> None:
87
-		guild: Guild = bot_message.guild
88
-		ctx: JoinAgeQueryContext = bot_message.context
89
-		if reaction.emoji == CONFIG['kick_emoji']:
90
-			to_kick = set(ctx.join_members) - ctx.kicked_members
91
-			for member in to_kick:
92
-				await member.kick(
93
-					reason=f'Rocketbot: Mass kick based on join age, by {reacted_by.name}.')
94
-			ctx.kicked_members |= to_kick
95
-			await self.__update_results_message(ctx)
96
-			self.log(guild, f'Users kicked by {reacted_by.name}.')
97
-		elif reaction.emoji == CONFIG['ban_emoji']:
98
-			to_ban = set(ctx.join_members) - ctx.banned_members
99
-			for member in to_ban:
100
-				await member.ban(
101
-					reason=f'Rocketbot: Mass ban based on join age, by {reacted_by.name}.',
102
-					delete_message_days=0)
103
-			ctx.banned_members |= to_ban
104
-			await self.__update_results_message(ctx)
105
-			self.log(guild, f'Users banned by {reacted_by.name}')
106
-
107
-	@commands.Cog.listener()
108
-	async def on_member_join(self, member: Member) -> None:
109
-		"""Event handler"""
110
-		guild: Guild = member.guild
111
-		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
112
-			return
113
-		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
114
-		if recent_joins is None:
115
-			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
116
-			recent_joins = AgeBoundList(max_age, lambda i, member : member.joined_at)
117
-			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
118
-		recent_joins.append(member)
119
-
120
-	async def __update_results_message(self, context: JoinAgeQueryContext) -> None:
121
-		if context.results_message_ref is None:
122
-			return
123
-		bot_message = context.results_message_ref()
124
-		if bot_message is None:
125
-			return
126
-		text = f'The following members joined in the last {context.timespan}\n\n'
127
-		if len(context.join_members) > 0:
128
-			max_members = CONFIG['max_members_per_message']
129
-			for member in context.join_members[:max_members]:
130
-				text += '\n• '
131
-				if member in context.banned_members:
132
-					text += f'~~{member.mention} ({member.id})~~ - banned'
133
-				elif member in context.kicked_members:
134
-					text += f'~~{member.mention} ({member.id})~~ - kicked'
135
-				else:
136
-					text += f'{member.mention} ({member.id})'
137
-			if len(context.join_members) > max_members:
138
-				text += f'\n• {len(context.join_members) - max_members} more'
139
-		else:
140
-			text += 'No members found. If the bot was recently restarted ' + \
141
-				'or JoinAgeCog just enabled, only new joins will be tracked.'
142
-		await bot_message.set_text(text)
143
-		if len(context.join_members) > 0:
144
-			member_count = len(context.join_members)
145
-			kick_count = len(context.kicked_members)
146
-			ban_count = len(context.banned_members)
147
-			await bot_message.set_reactions(BotMessageReaction.standard_set(
148
-				did_kick=kick_count >= member_count,
149
-				did_ban=ban_count >= member_count,
150
-				user_count=member_count))

+ 37
- 30
rocketbot/cogs/joinraidcog.py ファイルの表示

4
 import weakref
4
 import weakref
5
 from datetime import datetime, timedelta
5
 from datetime import datetime, timedelta
6
 from discord import Guild, Member
6
 from discord import Guild, Member
7
-from discord.ext import commands
7
+from discord.ext.commands import Cog
8
 
8
 
9
 from config import CONFIG
9
 from config import CONFIG
10
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
10
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
29
 	"""
29
 	"""
30
 	Cog for monitoring member joins and detecting potential bot raids.
30
 	Cog for monitoring member joins and detecting potential bot raids.
31
 	"""
31
 	"""
32
-	SETTING_ENABLED = CogSetting('enabled', bool,
33
-			brief='join raid detection',
34
-			description='Whether this cog is enabled for a guild.')
35
-	SETTING_JOIN_COUNT = CogSetting('joincount', int,
36
-			brief='number of joins to trigger a warning',
37
-			description='The number of joins occuring within the time ' + \
38
-				'window to trigger a mod warning.',
39
-			usage='<count:int>',
40
-			min_value=2)
41
-	SETTING_JOIN_TIME = CogSetting('jointime', float,
42
-			brief='time window length to look for joins',
43
-			description='The number of seconds of join history to look ' + \
44
-				'at when counting recent joins. If joincount or more ' + \
45
-				'joins occur within jointime seconds a mod warning is issued.',
46
-			usage='<seconds:float>',
47
-			min_value=1.0,
48
-			max_value=900.0)
32
+	SETTING_ENABLED = CogSetting(
33
+		'enabled',
34
+		bool,
35
+		default_value=False,
36
+		brief='join raid detection',
37
+		description='Whether this module is enabled for a guild.',
38
+	)
39
+	SETTING_JOIN_COUNT = CogSetting(
40
+		'joincount',
41
+		int,
42
+		default_value=5,
43
+		brief='number of joins to trigger a warning',
44
+		description='The number of joins occurring within the time '
45
+					'window to trigger a mod warning.',
46
+		min_value=2,
47
+	)
48
+	SETTING_JOIN_TIME = CogSetting(
49
+		'jointime',
50
+		timedelta,
51
+		default_value=timedelta(seconds=5),
52
+		brief='time window length to look for joins',
53
+		description='The number of seconds of join history to look '
54
+					'at when counting recent joins. If joincount or more '
55
+				    'joins occur within jointime seconds a mod warning is issued.',
56
+		min_value=timedelta(seconds=1.0),
57
+		max_value=timedelta(seconds=900.0),
58
+	)
49
 
59
 
50
 	STATE_KEY_RECENT_JOINS = "JoinRaidCog.recent_joins"
60
 	STATE_KEY_RECENT_JOINS = "JoinRaidCog.recent_joins"
51
 	STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
61
 	STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
52
 
62
 
53
 	def __init__(self, bot):
63
 	def __init__(self, bot):
54
-		super().__init__(bot)
64
+		super().__init__(
65
+			bot,
66
+			config_prefix='joinraid',
67
+			short_description='Manages join raid detection and handling.',
68
+			long_description='Join raids consist of an unusual number of users joining in '
69
+							 'a short period of time and have shown a pattern of DMing '
70
+							 'members with spam or scams.'
71
+		)
55
 		self.add_setting(JoinRaidCog.SETTING_ENABLED)
72
 		self.add_setting(JoinRaidCog.SETTING_ENABLED)
56
 		self.add_setting(JoinRaidCog.SETTING_JOIN_COUNT)
73
 		self.add_setting(JoinRaidCog.SETTING_JOIN_COUNT)
57
 		self.add_setting(JoinRaidCog.SETTING_JOIN_TIME)
74
 		self.add_setting(JoinRaidCog.SETTING_JOIN_TIME)
58
 
75
 
59
-	@commands.group(
60
-		brief='Manages join raid detection and handling',
61
-	)
62
-	@commands.has_permissions(ban_members=True)
63
-	@commands.guild_only()
64
-	async def joinraid(self, context: commands.Context):
65
-		"""Join raid detection command group"""
66
-		if context.invoked_subcommand is None:
67
-			await context.send_help()
68
-
69
 	async def on_mod_react(self,
76
 	async def on_mod_react(self,
70
 			bot_message: BotMessage,
77
 			bot_message: BotMessage,
71
 			reaction: BotMessageReaction,
78
 			reaction: BotMessageReaction,
90
 			await self.__update_warning_message(raid)
97
 			await self.__update_warning_message(raid)
91
 			self.log(guild, f'Join raid users banned by {reacted_by.name}')
98
 			self.log(guild, f'Join raid users banned by {reacted_by.name}')
92
 
99
 
93
-	@commands.Cog.listener()
100
+	@Cog.listener()
94
 	async def on_member_join(self, member: Member) -> None:
101
 	async def on_member_join(self, member: Member) -> None:
95
 		"""Event handler"""
102
 		"""Event handler"""
96
 		guild: Guild = member.guild
103
 		guild: Guild = member.guild

+ 42
- 43
rocketbot/cogs/logcog.py ファイルの表示

2
 Cog for detecting large numbers of guild joins in a short period of time.
2
 Cog for detecting large numbers of guild joins in a short period of time.
3
 """
3
 """
4
 from collections.abc import Sequence
4
 from collections.abc import Sequence
5
-from datetime import datetime, timezone, timedelta
5
+from datetime import datetime, timedelta, timezone
6
 
6
 
7
 from discord import AuditLogAction, AuditLogEntry, Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
7
 from discord import AuditLogAction, AuditLogEntry, Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
8
 from discord.abc import GuildChannel
8
 from discord.abc import GuildChannel
9
-from discord.ext import commands, tasks
9
+from discord.ext import tasks
10
+from discord.ext.commands import Cog
10
 from discord.utils import escape_markdown
11
 from discord.utils import escape_markdown
11
 from typing import Optional, Tuple, Union, Callable
12
 from typing import Optional, Tuple, Union, Callable
12
 import difflib
13
 import difflib
37
 	"""
38
 	"""
38
 	Cog for logging notable events to a designated logging channel.
39
 	Cog for logging notable events to a designated logging channel.
39
 	"""
40
 	"""
40
-	SETTING_ENABLED = CogSetting('enabled', bool,
41
-			brief='logging',
42
-			description='Whether this cog is enabled for a guild.')
41
+	SETTING_ENABLED = CogSetting(
42
+		'enabled',
43
+		bool,
44
+		default_value=False,
45
+		brief='logging',
46
+		description='Whether this module is enabled for a guild.',
47
+	)
43
 
48
 
44
 	STATE_EVENT_BUFFER = 'LoggingCog.eventBuffer'
49
 	STATE_EVENT_BUFFER = 'LoggingCog.eventBuffer'
45
 
50
 
46
 	def __init__(self, bot):
51
 	def __init__(self, bot):
47
-		super().__init__(bot)
52
+		super().__init__(
53
+			bot,
54
+			config_prefix='logging',
55
+			short_description='Manages event logging.',
56
+		)
48
 		self.add_setting(LoggingCog.SETTING_ENABLED)
57
 		self.add_setting(LoggingCog.SETTING_ENABLED)
49
 		self.flush_buffers.start()
58
 		self.flush_buffers.start()
50
 		self.buffered_guilds: set[Guild] = set()
59
 		self.buffered_guilds: set[Guild] = set()
52
 	def cog_unload(self) -> None:
61
 	def cog_unload(self) -> None:
53
 		self.flush_buffers.cancel()
62
 		self.flush_buffers.cancel()
54
 
63
 
55
-	@commands.group(
56
-		brief='Manages event logging',
57
-	)
58
-	@commands.has_permissions(ban_members=True)
59
-	@commands.guild_only()
60
-	async def logging(self, context: commands.Context):
61
-		"""Logging command group"""
62
-		if context.invoked_subcommand is None:
63
-			await context.send_help()
64
-
65
 	# Events - Channels
64
 	# Events - Channels
66
 
65
 
67
-	@commands.Cog.listener()
66
+	@Cog.listener()
68
 	async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
67
 	async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
69
 		"""
68
 		"""
70
 		Called whenever a guild channel is deleted or created.
69
 		Called whenever a guild channel is deleted or created.
80
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
79
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
81
 		await bot_message.update()
80
 		await bot_message.update()
82
 
81
 
83
-	@commands.Cog.listener()
82
+	@Cog.listener()
84
 	async def on_guild_channel_create(self, channel: GuildChannel) -> None:
83
 	async def on_guild_channel_create(self, channel: GuildChannel) -> None:
85
 		"""
84
 		"""
86
 		Called whenever a guild channel is deleted or created.
85
 		Called whenever a guild channel is deleted or created.
96
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
95
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
97
 		await bot_message.update()
96
 		await bot_message.update()
98
 
97
 
99
-	@commands.Cog.listener()
98
+	@Cog.listener()
100
 	async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
99
 	async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
101
 		"""
100
 		"""
102
 		Called whenever a guild channel is updated. e.g. changed name, topic,
101
 		Called whenever a guild channel is updated. e.g. changed name, topic,
129
 
128
 
130
 	# Events - Guilds
129
 	# Events - Guilds
131
 
130
 
132
-	@commands.Cog.listener()
131
+	@Cog.listener()
133
 	async def on_guild_available(self, guild: Guild) -> None:
132
 	async def on_guild_available(self, guild: Guild) -> None:
134
 		pass
133
 		pass
135
 
134
 
136
-	@commands.Cog.listener()
135
+	@Cog.listener()
137
 	async def on_guild_unavailable(self, guild: Guild) -> None:
136
 	async def on_guild_unavailable(self, guild: Guild) -> None:
138
 		pass
137
 		pass
139
 
138
 
140
-	@commands.Cog.listener()
139
+	@Cog.listener()
141
 	async def on_guild_update(self, before: Guild, after: Guild) -> None:
140
 	async def on_guild_update(self, before: Guild, after: Guild) -> None:
142
 		pass
141
 		pass
143
 
142
 
144
-	@commands.Cog.listener()
143
+	@Cog.listener()
145
 	async def on_guild_emojis_update(self, guild: Guild, before: Sequence[Emoji], after: Sequence[Emoji]) -> None:
144
 	async def on_guild_emojis_update(self, guild: Guild, before: Sequence[Emoji], after: Sequence[Emoji]) -> None:
146
 		pass
145
 		pass
147
 
146
 
148
-	@commands.Cog.listener()
147
+	@Cog.listener()
149
 	async def on_guild_stickers_update(self, guild: Guild, before: Sequence[GuildSticker], after: Sequence[GuildSticker]) -> None:
148
 	async def on_guild_stickers_update(self, guild: Guild, before: Sequence[GuildSticker], after: Sequence[GuildSticker]) -> None:
150
 		pass
149
 		pass
151
 
150
 
152
-	@commands.Cog.listener()
151
+	@Cog.listener()
153
 	async def on_invite_create(self, invite: Invite) -> None:
152
 	async def on_invite_create(self, invite: Invite) -> None:
154
 		"""
153
 		"""
155
 		Called when an `Invite` is created. You must have manage_channels to receive this.
154
 		Called when an `Invite` is created. You must have manage_channels to receive this.
167
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
166
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
168
 		await bot_message.update()
167
 		await bot_message.update()
169
 
168
 
170
-	@commands.Cog.listener()
169
+	@Cog.listener()
171
 	async def on_invite_delete(self, invite: Invite) -> None:
170
 	async def on_invite_delete(self, invite: Invite) -> None:
172
 		"""
171
 		"""
173
 		Called when an `Invite` is deleted. You must have manage_channels to receive this.
172
 		Called when an `Invite` is deleted. You must have manage_channels to receive this.
186
 
185
 
187
 	# Events - Members
186
 	# Events - Members
188
 
187
 
189
-	@commands.Cog.listener()
188
+	@Cog.listener()
190
 	async def on_member_join(self, member: Member) -> None:
189
 	async def on_member_join(self, member: Member) -> None:
191
 		"""
190
 		"""
192
 		Called when a Member joins a Guild.
191
 		Called when a Member joins a Guild.
250
 			bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
249
 			bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
251
 		await bot_message.update()
250
 		await bot_message.update()
252
 
251
 
253
-	@commands.Cog.listener()
252
+	@Cog.listener()
254
 	async def on_member_remove(self, member: Member) -> None:
253
 	async def on_member_remove(self, member: Member) -> None:
255
 		"""
254
 		"""
256
 		Called when a Member leaves a Guild.
255
 		Called when a Member leaves a Guild.
284
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
283
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
285
 		await bot_message.update()
284
 		await bot_message.update()
286
 
285
 
287
-	@commands.Cog.listener()
286
+	@Cog.listener()
288
 	async def on_member_update(self, before: Member, after: Member) -> None:
287
 	async def on_member_update(self, before: Member, after: Member) -> None:
289
 		"""
288
 		"""
290
 		Called when a Member updates their profile.
289
 		Called when a Member updates their profile.
346
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
345
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
347
 		await bot_message.update()
346
 		await bot_message.update()
348
 
347
 
349
-	@commands.Cog.listener()
348
+	@Cog.listener()
350
 	async def on_user_update(self, before: User, after: User) -> None:
349
 	async def on_user_update(self, before: User, after: User) -> None:
351
 		"""
350
 		"""
352
 		Called when a User updates their profile.
351
 		Called when a User updates their profile.
378
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
377
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
379
 		await bot_message.update()
378
 		await bot_message.update()
380
 
379
 
381
-	@commands.Cog.listener()
380
+	@Cog.listener()
382
 	async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
381
 	async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
383
 		"""
382
 		"""
384
 		Called when user gets banned from a Guild.
383
 		Called when user gets banned from a Guild.
419
 				return entry
418
 				return entry
420
 		return None
419
 		return None
421
 
420
 
422
-	@commands.Cog.listener()
421
+	@Cog.listener()
423
 	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
422
 	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
424
 		"""
423
 		"""
425
 		Called when a User gets unbanned from a Guild.
424
 		Called when a User gets unbanned from a Guild.
472
 	async def before_flush_buffers_start(self) -> None:
471
 	async def before_flush_buffers_start(self) -> None:
473
 		await self.bot.wait_until_ready()
472
 		await self.bot.wait_until_ready()
474
 
473
 
475
-	@commands.Cog.listener()
474
+	@Cog.listener()
476
 	async def on_message(self, message: Message) -> None:
475
 	async def on_message(self, message: Message) -> None:
477
 		"""
476
 		"""
478
 		Called when a Message is created and sent.
477
 		Called when a Message is created and sent.
481
 		"""
480
 		"""
482
 		# print(f"Saw message {message.id} \"{message.content}\"")
481
 		# print(f"Saw message {message.id} \"{message.content}\"")
483
 
482
 
484
-	@commands.Cog.listener()
483
+	@Cog.listener()
485
 	async def on_message_edit(self, before: Message, after: Message) -> None:
484
 	async def on_message_edit(self, before: Message, after: Message) -> None:
486
 		"""
485
 		"""
487
 		Called when a Message receives an update event. If the message is not
486
 		Called when a Message receives an update event. If the message is not
512
 
511
 
513
 		self.__buffer_event(guild, 'edit', BufferedMessageEditEvent(guild, channel, before, after))
512
 		self.__buffer_event(guild, 'edit', BufferedMessageEditEvent(guild, channel, before, after))
514
 
513
 
515
-	@commands.Cog.listener()
514
+	@Cog.listener()
516
 	async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
515
 	async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
517
 		"""
516
 		"""
518
 		Called when a message is edited. Unlike on_message_edit(), this is called
517
 		Called when a message is edited. Unlike on_message_edit(), this is called
632
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
631
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
633
 		await bot_message.update()
632
 		await bot_message.update()
634
 
633
 
635
-	@commands.Cog.listener()
634
+	@Cog.listener()
636
 	async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
635
 	async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
637
 		"""
636
 		"""
638
 		Called when a message is deleted. Unlike on_message_delete(), this is
637
 		Called when a message is deleted. Unlike on_message_delete(), this is
660
 			return
659
 			return
661
 		self.__buffer_event(guild, 'delete', BufferedMessageDeleteEvent(guild, channel, payload.message_id, message))
660
 		self.__buffer_event(guild, 'delete', BufferedMessageDeleteEvent(guild, channel, payload.message_id, message))
662
 
661
 
663
-	@commands.Cog.listener()
662
+	@Cog.listener()
664
 	async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
663
 	async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
665
 		"""
664
 		"""
666
 		Called when a bulk delete is triggered. Unlike on_bulk_message_delete(),
665
 		Called when a bulk delete is triggered. Unlike on_bulk_message_delete(),
740
 
739
 
741
 	# Events - Roles
740
 	# Events - Roles
742
 
741
 
743
-	@commands.Cog.listener()
742
+	@Cog.listener()
744
 	async def on_guild_role_create(self, role: Role) -> None:
743
 	async def on_guild_role_create(self, role: Role) -> None:
745
 		"""
744
 		"""
746
 		Called when a Guild creates or deletes a new Role.
745
 		Called when a Guild creates or deletes a new Role.
756
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
755
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
757
 		await bot_message.update()
756
 		await bot_message.update()
758
 
757
 
759
-	@commands.Cog.listener()
758
+	@Cog.listener()
760
 	async def on_guild_role_delete(self, role: Role) -> None:
759
 	async def on_guild_role_delete(self, role: Role) -> None:
761
 		"""
760
 		"""
762
 		Called when a Guild creates or deletes a new Role.
761
 		Called when a Guild creates or deletes a new Role.
772
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
771
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
773
 		await bot_message.update()
772
 		await bot_message.update()
774
 
773
 
775
-	@commands.Cog.listener()
774
+	@Cog.listener()
776
 	async def on_guild_role_update(self, before: Role, after: Role) -> None:
775
 	async def on_guild_role_update(self, before: Role, after: Role) -> None:
777
 		"""
776
 		"""
778
 		Called when a Role is changed guild-wide.
777
 		Called when a Role is changed guild-wide.
815
 
814
 
816
 	# Events - Threads
815
 	# Events - Threads
817
 
816
 
818
-	@commands.Cog.listener()
817
+	@Cog.listener()
819
 	async def on_thread_create(self, thread: Thread) -> None:
818
 	async def on_thread_create(self, thread: Thread) -> None:
820
 		pass
819
 		pass
821
 
820
 
822
-	@commands.Cog.listener()
821
+	@Cog.listener()
823
 	async def on_thread_update(self, before: Thread, after: Thread) -> None:
822
 	async def on_thread_update(self, before: Thread, after: Thread) -> None:
824
 		pass
823
 		pass
825
 
824
 
826
-	@commands.Cog.listener()
825
+	@Cog.listener()
827
 	async def on_thread_delete(self, thread: Thread) -> None:
826
 	async def on_thread_delete(self, thread: Thread) -> None:
828
 		pass
827
 		pass
829
 
828
 

+ 211
- 60
rocketbot/cogs/patterncog.py ファイルの表示

2
 Cog for matching messages against guild-configurable criteria and taking
2
 Cog for matching messages against guild-configurable criteria and taking
3
 automated actions on them.
3
 automated actions on them.
4
 """
4
 """
5
+import re
5
 from datetime import datetime
6
 from datetime import datetime
6
 from typing import Optional
7
 from typing import Optional
7
 
8
 
8
-from discord import Guild, Member, Message, utils as discordutils
9
-from discord.ext import commands
9
+from discord import Guild, Member, Message, utils as discordutils, Interaction
10
+from discord.app_commands import Choice, Group, autocomplete
11
+from discord.ext.commands import Cog
10
 
12
 
11
 from config import CONFIG
13
 from config import CONFIG
14
+from rocketbot.bot import Rocketbot
12
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction
15
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction
13
 from rocketbot.cogsetting import CogSetting
16
 from rocketbot.cogsetting import CogSetting
14
 from rocketbot.pattern import PatternCompiler, PatternDeprecationError, \
17
 from rocketbot.pattern import PatternCompiler, PatternDeprecationError, \
15
 	PatternError, PatternStatement
18
 	PatternError, PatternStatement
16
 from rocketbot.storage import Storage
19
 from rocketbot.storage import Storage
20
+from rocketbot.utils import dump_stacktrace, MOD_PERMISSIONS
21
+
17
 
22
 
18
 class PatternContext:
23
 class PatternContext:
19
 	"""
24
 	"""
27
 		self.is_kicked = False
32
 		self.is_kicked = False
28
 		self.is_banned = False
33
 		self.is_banned = False
29
 
34
 
35
+async def pattern_name_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
36
+	choices: list[Choice[str]] = []
37
+	try:
38
+		if interaction.guild is None:
39
+			return []
40
+		patterns: dict[str, PatternStatement] = PatternCog.shared.get_patterns(interaction.guild)
41
+		current_normal = current.lower().strip()
42
+		for name in sorted(patterns.keys()):
43
+			if len(current_normal) == 0 or current_normal.startswith(name.lower()):
44
+				choices.append(Choice(name=name, value=name))
45
+	except BaseException as e:
46
+		dump_stacktrace(e)
47
+	return choices
48
+
49
+async def action_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
50
+	# FIXME: WORK IN PROGRESS
51
+	print(f'autocomplete action - current = "{current}"')
52
+	regex = re.compile('^(.*?)([a-zA-Z]+)$')
53
+	match: Optional[re.Match[str]] = regex.match(current)
54
+	initial: str = ''
55
+	stub: str = current
56
+	if match:
57
+		initial = match.group(1).strip()
58
+		stub = match.group(2)
59
+	if PatternCompiler.ACTION_TO_ARGS.get(stub, None) is not None:
60
+		# Matches perfectly. Suggest another instead of completing the current.
61
+		initial = current.strip() + ', '
62
+		stub = ''
63
+	print(f'initial = "{initial}", stub = "{stub}"')
64
+
65
+	options: list[Choice[str]] = []
66
+	for action in sorted(PatternCompiler.ACTION_TO_ARGS.keys()):
67
+		if len(stub) == 0 or action.startswith(stub.lower()):
68
+			arg_types = PatternCompiler.ACTION_TO_ARGS[action]
69
+			arg_type_strs = []
70
+			for arg_type in arg_types:
71
+				if arg_type == PatternCompiler.TYPE_TEXT:
72
+					arg_type_strs.append('"message"')
73
+				else:
74
+					raise ValueError(f'Argument type {arg_type} not yet supported')
75
+			suffix = '' if len(arg_type_strs) == 0 else ' ' + (' '.join(arg_type_strs))
76
+			options.append(Choice(name=action, value=f'{initial.strip()} {action}{suffix}'))
77
+	return options
78
+
79
+async def priority_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
80
+	return [
81
+		Choice(name='very low (50)', value=50),
82
+		Choice(name='low (75)', value=75),
83
+		Choice(name='normal (100)', value=100),
84
+		Choice(name='high (125)', value=125),
85
+		Choice(name='very high (150)', value=150),
86
+	]
87
+
88
+_long_help = \
89
+"""Patterns are a powerful but complex topic. See <https://git.rixafrix.com/ialbert/python-app-rocketbot/src/branch/main/docs/patterns.md> for full documentation.
90
+
91
+### Quick cheat sheet
92
+
93
+> `/pattern add` _pattern\\_name_ _action\\_list_ `if` _expression_
94
+
95
+- _pattern\\_name_ is a brief name for identifying the pattern later (not shown to user)
96
+- _action\\_list_ is a comma-delimited list of actions to take on matching messages and is any of:
97
+   - `ban`
98
+   - `delete`
99
+   - `kick`
100
+   - `modinfo` - logs a message but doesn't tag mods
101
+   - `modwarn` - tags mods
102
+   - `reply` "message text"
103
+- _expression_ determines which messages match, of the form _field_ _op_ _value_.
104
+   - Fields:
105
+      - `content.markdown`: string
106
+      - `content.plain`: string
107
+      - `author`: user
108
+      - `author.id`: id
109
+      - `author.joinage`: timespan
110
+      - `author.name`: string
111
+      - `lastmatched`: timespan
112
+   - Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`, `contains`, `!contains`, `matches`, `!matches`, `containsword`, `!containsword`
113
+   - Can combine multiple expressions with `!`, `and`, `or`, and parentheses."""
114
+
30
 class PatternCog(BaseCog, name='Pattern Matching'):
115
 class PatternCog(BaseCog, name='Pattern Matching'):
31
 	"""
116
 	"""
32
 	Highly flexible cog for performing various actions on messages that match
117
 	Highly flexible cog for performing various actions on messages that match
33
 	various criteria. Patterns can be defined by mods for each guild.
118
 	various criteria. Patterns can be defined by mods for each guild.
34
 	"""
119
 	"""
35
 
120
 
36
-	SETTING_PATTERNS = CogSetting('patterns', None)
121
+	SETTING_PATTERNS = CogSetting('patterns', None, default_value=None)
122
+
123
+	shared: Optional['PatternCog'] = None
124
+
125
+	def __init__(self, bot: Rocketbot):
126
+		super().__init__(
127
+			bot,
128
+			config_prefix='patterns',
129
+			short_description='Manages message pattern matching.',
130
+			long_description=_long_help
131
+		)
132
+		PatternCog.shared = self
37
 
133
 
38
-	def __get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
134
+	def get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
39
 		"""
135
 		"""
40
 		Returns a name -> PatternStatement lookup for the guild.
136
 		Returns a name -> PatternStatement lookup for the guild.
41
 		"""
137
 		"""
80
 			Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
176
 			Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
81
 		last_matched[name] = time
177
 		last_matched[name] = time
82
 
178
 
83
-	@commands.Cog.listener()
179
+	@Cog.listener()
84
 	async def on_message(self, message: Message) -> None:
180
 	async def on_message(self, message: Message) -> None:
85
 		"""Event listener"""
181
 		"""Event listener"""
86
 		if message.author is None or \
182
 		if message.author is None or \
94
 			# Ignore mods
190
 			# Ignore mods
95
 			return
191
 			return
96
 
192
 
97
-		patterns = self.__get_patterns(message.guild)
193
+		patterns = self.get_patterns(message.guild)
98
 		for statement in sorted(patterns.values(), key=lambda s : s.priority, reverse=True):
194
 		for statement in sorted(patterns.values(), key=lambda s : s.priority, reverse=True):
99
 			other_fields = {
195
 			other_fields = {
100
 				'last_matched': self.__get_last_matched(message.guild, statement.name),
196
 				'last_matched': self.__get_last_matched(message.guild, statement.name),
189
 			did_kick=context.is_kicked,
285
 			did_kick=context.is_kicked,
190
 			did_ban=context.is_banned))
286
 			did_ban=context.is_banned))
191
 
287
 
192
-	@commands.group(
193
-		brief='Manages message pattern matching',
288
+	pattern = Group(
289
+		name='pattern',
290
+		description='Manages message pattern matching.',
291
+		guild_only=True,
292
+		default_permissions=MOD_PERMISSIONS,
293
+		extras={
294
+			'long_description': _long_help,
295
+		},
194
 	)
296
 	)
195
-	@commands.has_permissions(ban_members=True)
196
-	@commands.guild_only()
197
-	async def pattern(self, context: commands.Context):
198
-		"""Message pattern matching command group"""
199
-		if context.invoked_subcommand is None:
200
-			await context.send_help()
201
 
297
 
202
 	@pattern.command(
298
 	@pattern.command(
203
-		brief='Adds a custom pattern',
204
-		description='Adds a custom pattern. Patterns use a simplified ' + \
205
-			'expression language. Full documentation found here: ' + \
206
-			'https://git.rixafrix.com/ialbert/python-app-rocketbot/src/' + \
207
-			'branch/main/docs/patterns.md',
208
-		usage='<pattern_name> <expression...>',
209
-		ignore_extra=True
299
+		description='Adds or updates a custom pattern.',
300
+		extras={
301
+			'long_description': _long_help,
302
+		},
210
 	)
303
 	)
211
-	async def add(self, context: commands.Context, name: str):
212
-		"""Command handler"""
213
-		pattern_str = PatternCompiler.expression_str_from_context(context, name)
304
+	@autocomplete(
305
+		name=pattern_name_autocomplete,
306
+		# actions=action_autocomplete
307
+	)
308
+	async def add(
309
+			self,
310
+			interaction: Interaction,
311
+			name: str,
312
+			actions: str,
313
+			expression: str
314
+	) -> None:
315
+		"""
316
+		Adds a custom pattern.
317
+
318
+		Parameters
319
+		----------
320
+		interaction : Interaction
321
+		name : str
322
+			a name for the new or existing pattern
323
+		actions : str
324
+			actions to take when a message matches
325
+		expression : str
326
+			criteria for matching chat messages
327
+		"""
328
+		pattern_str = f'{actions} if {expression}'
329
+		guild = interaction.guild
214
 		try:
330
 		try:
215
 			statement = PatternCompiler.parse_statement(name, pattern_str)
331
 			statement = PatternCompiler.parse_statement(name, pattern_str)
216
 			statement.check_deprecations()
332
 			statement.check_deprecations()
217
-			patterns = self.__get_patterns(context.guild)
333
+			patterns = self.get_patterns(guild)
218
 			patterns[name] = statement
334
 			patterns[name] = statement
219
-			self.__save_patterns(context.guild, patterns)
220
-			await context.message.reply(
335
+			self.__save_patterns(guild, patterns)
336
+			await interaction.response.send_message(
221
 				f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
337
 				f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
222
-				mention_author=False)
338
+				ephemeral=True,
339
+			)
223
 		except PatternError as e:
340
 		except PatternError as e:
224
-			await context.message.reply(
341
+			await interaction.response.send_message(
225
 				f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
342
 				f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
226
-				mention_author=False)
343
+				ephemeral=True,
344
+			)
227
 
345
 
228
 	@pattern.command(
346
 	@pattern.command(
229
-		brief='Removes a custom pattern',
230
-		usage='<pattern_name>'
347
+		description='Removes a custom pattern.',
348
+		extras={
349
+			'usage': '<pattern_name>',
350
+		},
231
 	)
351
 	)
232
-	async def remove(self, context: commands.Context, name: str):
233
-		"""Command handler"""
234
-		patterns = self.__get_patterns(context.guild)
352
+	@autocomplete(name=pattern_name_autocomplete)
353
+	async def remove(self, interaction: Interaction, name: str):
354
+		"""
355
+		Removes a custom pattern.
356
+
357
+		Parameters
358
+		----------
359
+		interaction: Interaction
360
+		name: str
361
+			name of the pattern to remove
362
+		"""
363
+		guild = interaction.guild
364
+		patterns = self.get_patterns(guild)
235
 		if patterns.get(name) is not None:
365
 		if patterns.get(name) is not None:
236
 			del patterns[name]
366
 			del patterns[name]
237
-			self.__save_patterns(context.guild, patterns)
238
-			await context.message.reply(
367
+			self.__save_patterns(guild, patterns)
368
+			await interaction.response.send_message(
239
 				f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
369
 				f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
240
-				mention_author=False)
370
+				ephemeral=True,
371
+			)
241
 		else:
372
 		else:
242
-			await context.message.reply(
373
+			await interaction.response.send_message(
243
 				f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
374
 				f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
244
-				mention_author=False)
375
+				ephemeral=True,
376
+			)
245
 
377
 
246
 	@pattern.command(
378
 	@pattern.command(
247
-		brief='Lists all patterns'
379
+		description='Lists all patterns.',
248
 	)
380
 	)
249
-	async def list(self, context: commands.Context) -> None:
250
-		"""Command handler"""
251
-		patterns = self.__get_patterns(context.guild)
381
+	async def list(self, interaction: Interaction) -> None:
382
+		guild = interaction.guild
383
+		patterns = self.get_patterns(guild)
252
 		if len(patterns) == 0:
384
 		if len(patterns) == 0:
253
-			await context.message.reply('No patterns defined.', mention_author=False)
385
+			await interaction.response.send_message(
386
+				'No patterns defined.',
387
+				ephemeral=True,
388
+			)
254
 			return
389
 			return
255
 		msg = ''
390
 		msg = ''
256
 		for name, statement in sorted(patterns.items()):
391
 		for name, statement in sorted(patterns.items()):
257
 			msg += f'Pattern `{name}` (priority={statement.priority}):\n```\n{statement.original}\n```\n'
392
 			msg += f'Pattern `{name}` (priority={statement.priority}):\n```\n{statement.original}\n```\n'
258
-		await context.message.reply(msg, mention_author=False)
393
+		await interaction.response.send_message(msg, ephemeral=True)
259
 
394
 
260
 	@pattern.command(
395
 	@pattern.command(
261
-		brief='Sets a pattern\'s priority level',
262
-		description='Sets the priority for a pattern. Messages are checked ' +
263
-			'against patterns with the highest priority first. Patterns with ' +
264
-			'the same priority may be checked in arbitrary order. Default ' +
265
-			'priority is 100.',
396
+		description="Sets a pattern's priority level.",
397
+		extras={
398
+			'long_description': 'Messages are checked against patterns with the '
399
+								'highest priority first. Patterns with the same '
400
+								'priority may be checked in arbitrary order. Default '
401
+								'priority is 100.',
402
+		},
266
 	)
403
 	)
267
-	async def setpriority(self, context: commands.Context, name: str, priority: int) -> None:
268
-		"""Command handler"""
269
-		patterns = self.__get_patterns(context.guild)
404
+	@autocomplete(name=pattern_name_autocomplete, priority=priority_autocomplete)
405
+	async def setpriority(self, interaction: Interaction, name: str, priority: int) -> None:
406
+		"""
407
+		Sets a pattern's priority level.
408
+
409
+		Parameters
410
+		----------
411
+		interaction: Interaction
412
+		name: str
413
+			the name of the pattern
414
+		priority: int
415
+			evaluation priority
416
+		"""
417
+		guild = interaction.guild
418
+		patterns = self.get_patterns(guild)
270
 		statement = patterns.get(name)
419
 		statement = patterns.get(name)
271
 		if statement is None:
420
 		if statement is None:
272
-			await context.message.reply(
421
+			await interaction.response.send_message(
273
 				f'{CONFIG["failure_emoji"]} No such pattern `{name}`',
422
 				f'{CONFIG["failure_emoji"]} No such pattern `{name}`',
274
-				mention_author=False)
423
+				ephemeral=True,
424
+			)
275
 			return
425
 			return
276
 		statement.priority = priority
426
 		statement.priority = priority
277
-		self.__save_patterns(context.guild, patterns)
278
-		await context.message.reply(
427
+		self.__save_patterns(guild, patterns)
428
+		await interaction.response.send_message(
279
 			f'{CONFIG["success_emoji"]} Priority for pattern `{name}` ' + \
429
 			f'{CONFIG["success_emoji"]} Priority for pattern `{name}` ' + \
280
-			f'updated to `{priority}`.',
281
-			mention_author=False)
430
+				f'updated to `{priority}`.',
431
+			ephemeral=True,
432
+		)

+ 49
- 38
rocketbot/cogs/urlspamcog.py ファイルの表示

3
 """
3
 """
4
 import re
4
 import re
5
 from datetime import timedelta
5
 from datetime import timedelta
6
+from typing import Literal
7
+
6
 from discord import Member, Message, utils as discordutils
8
 from discord import Member, Message, utils as discordutils
7
-from discord.ext import commands
9
+from discord.ext.commands import Cog
8
 from discord.utils import escape_markdown
10
 from discord.utils import escape_markdown
9
 
11
 
10
 from config import CONFIG
12
 from config import CONFIG
27
 	Can be configured to take immediate action or just warn the mods.
29
 	Can be configured to take immediate action or just warn the mods.
28
 	"""
30
 	"""
29
 
31
 
30
-	SETTING_ENABLED = CogSetting('enabled', bool,
31
-			brief='URL spam detection',
32
-			description='Whether URLs posted soon after joining are flagged.')
33
-	SETTING_ACTION = CogSetting('action', str,
34
-			brief='action to take on spam',
35
-			description='The action to take on detected URL spam.',
36
-			enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'})
37
-	SETTING_JOIN_AGE = CogSetting('joinage', float,
38
-			brief='seconds since member joined',
39
-			description='The minimum seconds since the user joined the ' + \
40
-				'server before they can post URLs. URLs posted by users ' + \
41
-				'who joined too recently will be flagged. Keep in mind ' + \
42
-				'many servers have a minimum 10 minute cooldown before ' + \
43
-				'new members can say anything. Setting to 0 effectively ' + \
44
-				'disables URL spam detection.',
45
-			usage='<seconds:int>',
46
-			min_value=0)
47
-	SETTING_DECEPTIVE_ACTION = CogSetting('deceptiveaction', str,
48
-			brief='action to take on deceptive link markdown',
49
-			description='The action to take on chat messages with links ' + \
50
-				'where the text looks like a different URL than the actual link.',
51
-			enum_values={'nothing', 'modwarn', 'modwarndelete',
52
-				'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'})
32
+	SETTING_ENABLED = CogSetting(
33
+		'enabled',
34
+		bool,
35
+		default_value=False,
36
+		brief='URL spam detection',
37
+		description='Whether URLs posted soon after joining are flagged.',
38
+	)
39
+	SETTING_ACTION = CogSetting(
40
+		'action',
41
+		Literal['nothing', 'modwarn', 'delete', 'kick', 'ban'],
42
+		default_value='nothing',
43
+		brief='action to take on spam',
44
+		description='The action to take on detected URL spam.',
45
+		enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'},
46
+	)
47
+	SETTING_JOIN_AGE = CogSetting(
48
+		'joinage',
49
+		timedelta,
50
+		default_value=timedelta(minutes=15),
51
+		brief='seconds since member joined',
52
+		description='The minimum seconds since the user joined the '
53
+					'server before they can post URLs. URLs posted by users '
54
+				    'who joined too recently will be flagged. Keep in mind '
55
+				    'many servers have a minimum 10 minute cooldown before '
56
+				    'new members can say anything. Setting to 0 effectively '
57
+				    'disables URL spam detection.',
58
+		min_value=timedelta(seconds=0),
59
+	)
60
+	SETTING_DECEPTIVE_ACTION = CogSetting(
61
+		'deceptiveaction',
62
+		Literal['nothing', 'modwarn', 'modwarndelete', 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'],
63
+		default_value='nothing',
64
+		brief='action to take on deceptive link markdown',
65
+		description='The action to take on chat messages with links '
66
+					'where the text looks like a different URL than the actual link.',
67
+		enum_values={'nothing', 'modwarn', 'modwarndelete',
68
+					 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'},
69
+	)
53
 
70
 
54
 	def __init__(self, bot):
71
 	def __init__(self, bot):
55
-		super().__init__(bot)
72
+		super().__init__(
73
+			bot,
74
+			config_prefix='urlspam',
75
+			short_description='Manages URL spam detection.',
76
+		)
56
 		self.add_setting(URLSpamCog.SETTING_ENABLED)
77
 		self.add_setting(URLSpamCog.SETTING_ENABLED)
57
 		self.add_setting(URLSpamCog.SETTING_ACTION)
78
 		self.add_setting(URLSpamCog.SETTING_ACTION)
58
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
79
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
59
 		self.add_setting(URLSpamCog.SETTING_DECEPTIVE_ACTION)
80
 		self.add_setting(URLSpamCog.SETTING_DECEPTIVE_ACTION)
60
 
81
 
61
-	@commands.group(
62
-		brief='Manages URL spam detection',
63
-	)
64
-	@commands.has_permissions(ban_members=True)
65
-	@commands.guild_only()
66
-	async def urlspam(self, context: commands.Context):
67
-		"""URL spam command group"""
68
-		if context.invoked_subcommand is None:
69
-			await context.send_help()
70
-
71
-	@commands.Cog.listener()
82
+	@Cog.listener()
72
 	async def on_message(self, message: Message):
83
 	async def on_message(self, message: Message):
73
 		"""Event listener"""
84
 		"""Event listener"""
74
 		if message.author is None or \
85
 		if message.author is None or \
226
 					return True
237
 					return True
227
 		return False
238
 		return False
228
 
239
 
229
-	def is_url(self, s: str):
240
+	def is_url(self, s: str) -> bool:
230
 		"""Tests if a string is strictly a URL"""
241
 		"""Tests if a string is strictly a URL"""
231
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
242
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
232
 		ipv4_host_pattern = r'[0-9\.]+'
243
 		ipv4_host_pattern = r'[0-9\.]+'
237
 		pattern = r'^http[s]?://' + host_pattern + port_pattern + path_pattern + '$'
248
 		pattern = r'^http[s]?://' + host_pattern + port_pattern + path_pattern + '$'
238
 		return re.match(pattern, s, re.IGNORECASE) is not None
249
 		return re.match(pattern, s, re.IGNORECASE) is not None
239
 
250
 
240
-	def is_casual_url(self, s: str):
251
+	def is_casual_url(self, s: str) -> bool:
241
 		"""Tests if a string is a "casual URL" with no scheme included"""
252
 		"""Tests if a string is a "casual URL" with no scheme included"""
242
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
253
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
243
 		ipv4_host_pattern = r'[0-9\.]+'
254
 		ipv4_host_pattern = r'[0-9\.]+'

+ 91
- 38
rocketbot/cogs/usernamecog.py ファイルの表示

3
 """
3
 """
4
 from typing import Optional
4
 from typing import Optional
5
 
5
 
6
-from discord import Guild, Member
7
-from discord.ext import commands
6
+from discord import Guild, Member, Permissions, Interaction
7
+from discord.app_commands import Group
8
+from discord.ext.commands import Cog
8
 
9
 
9
 from config import CONFIG
10
 from config import CONFIG
10
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
11
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
11
 from rocketbot.storage import Storage
12
 from rocketbot.storage import Storage
13
+from rocketbot.utils import MOD_PERMISSIONS
14
+
12
 
15
 
13
 class UsernamePatternContext:
16
 class UsernamePatternContext:
14
 	"""
17
 	"""
44
 	message on a match.
47
 	message on a match.
45
 	"""
48
 	"""
46
 
49
 
47
-	SETTING_ENABLED = CogSetting('enabled', bool,
48
-			brief='username pattern detection',
49
-			description='Whether new users are checked for common patterns.')
50
+	SETTING_ENABLED = CogSetting(
51
+		'enabled',
52
+		bool,
53
+		default_value=False,
54
+		brief='username pattern detection',
55
+		description='Whether new users are checked for username patterns.',
56
+	)
50
 
57
 
51
-	SETTING_PATTERNS = CogSetting('patterns', None)
58
+	SETTING_PATTERNS = CogSetting('patterns', None, default_value=None)
52
 
59
 
53
 	def __init__(self, bot):
60
 	def __init__(self, bot):
54
-		super().__init__(bot)
61
+		super().__init__(
62
+			bot,
63
+			config_prefix='username',
64
+			short_description='Manages username pattern detection.',
65
+			long_description='When new users join, if their username matches '
66
+							 'a configured pattern the mods will be alerted.'
67
+		)
55
 		self.add_setting(UsernamePatternCog.SETTING_ENABLED)
68
 		self.add_setting(UsernamePatternCog.SETTING_ENABLED)
56
 
69
 
57
 	def __get_patterns(self, guild: Guild) -> list[str]:
70
 	def __get_patterns(self, guild: Guild) -> list[str]:
73
 		"""
86
 		"""
74
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, patterns)
87
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, patterns)
75
 
88
 
76
-	@commands.group(
77
-		brief='Manages username pattern detection'
89
+	username = Group(
90
+		name='username',
91
+		description='Manages username pattern detection.',
92
+		guild_only=True,
93
+		default_permissions=MOD_PERMISSIONS,
94
+		extras={
95
+			'long_description': 'When new users join, if their username matches '
96
+								'a configured pattern the mods will be alerted.',
97
+		},
78
 	)
98
 	)
79
-	@commands.has_permissions(ban_members=True)
80
-	@commands.guild_only()
81
-	async def username(self, context: commands.Context):
82
-		"""Username pattern command group"""
83
-		if context.invoked_subcommand is None:
84
-			await context.send_help()
85
 
99
 
86
 	@username.command(
100
 	@username.command(
87
-		brief='Adds a username pattern',
88
 		description='Adds a username pattern.',
101
 		description='Adds a username pattern.',
89
-		usage='<pattern>'
102
+		extras={
103
+			'long_description': 'When a user joins the server, if their username '
104
+								'matches a configured pattern the mods will be alerted. '
105
+								'Matching is currently a simple substring test.',
106
+			'usage': '<pattern>',
107
+		},
90
 	)
108
 	)
91
-	async def add(self, context: commands.Context, pattern: str) -> None:
92
-		"""Command handler"""
109
+	async def add(self, interaction: Interaction, pattern: str) -> None:
110
+		"""
111
+		Adds a username pattern to match against new members.
112
+
113
+		Parameters
114
+		----------
115
+		interaction : Interaction
116
+		pattern : str
117
+			a substring to look for in usernames
118
+		"""
93
 		norm_pattern = pattern.lower()
119
 		norm_pattern = pattern.lower()
94
-		patterns: list[str] = self.__get_patterns(context.guild)
120
+		patterns: list[str] = self.__get_patterns(interaction.guild)
95
 		if norm_pattern in patterns:
121
 		if norm_pattern in patterns:
96
-			await context.reply(f'Pattern `{norm_pattern}` already added.', mention_author=False)
122
+			await interaction.response.send_message(
123
+				f'{CONFIG["warning_emoji"]} Pattern `{norm_pattern}` already added.',
124
+				ephemeral=True
125
+			)
97
 			return
126
 			return
98
 		patterns.append(norm_pattern)
127
 		patterns.append(norm_pattern)
99
-		self.__save_patterns(context.guild, patterns)
100
-		await context.reply(f'Pattern `{norm_pattern}` added.', mention_author=False)
128
+		self.__save_patterns(interaction.guild, patterns)
129
+		await interaction.response.send_message(
130
+			f'{CONFIG["success_emoji"]} Pattern `{norm_pattern}` added.',
131
+			ephemeral=True
132
+		)
101
 
133
 
102
 	@username.command(
134
 	@username.command(
103
-		brief='Removes a username pattern',
104
-		description='Removes an existing username pattern',
105
-		usage='<pattern>'
135
+		description='Removes a username pattern.',
136
+		extras={
137
+			'usage': '<pattern>',
138
+		},
106
 	)
139
 	)
107
-	async def remove(self, context: commands.Context, pattern: str) -> None:
108
-		"""Command handler"""
140
+	async def remove(self, interaction: Interaction, pattern: str) -> None:
141
+		"""
142
+		Removes a username pattern.
143
+
144
+		Parameters
145
+		----------
146
+		interaction : Interaction
147
+		pattern : str
148
+		    the existing username pattern to remove
149
+		"""
109
 		norm_pattern = pattern.lower()
150
 		norm_pattern = pattern.lower()
110
-		guild: Guild = context.guild
151
+		guild: Guild = interaction.guild
111
 		patterns: list[str] = self.__get_patterns(guild)
152
 		patterns: list[str] = self.__get_patterns(guild)
112
 		len_before = len(patterns)
153
 		len_before = len(patterns)
113
 		patterns = list(filter(lambda p: p != norm_pattern, patterns))
154
 		patterns = list(filter(lambda p: p != norm_pattern, patterns))
114
 		if len(patterns) == len_before:
155
 		if len(patterns) == len_before:
115
-			await context.reply(f'Pattern `{norm_pattern}` not found.', mention_author=False)
156
+			await interaction.response.send_message(
157
+				f'{CONFIG["warning_emoji"]} Pattern `{norm_pattern}` not found.',
158
+				ephemeral=True,
159
+			)
116
 			return
160
 			return
117
 		self.__save_patterns(guild, patterns)
161
 		self.__save_patterns(guild, patterns)
118
-		await context.reply(f'Pattern `{norm_pattern}` removed.', mention_author=False)
162
+		await interaction.response.send_message(
163
+			f'{CONFIG["success_emoji"]} Pattern `{norm_pattern}` removed.',
164
+			ephemeral=True,
165
+		)
119
 
166
 
120
 	@username.command(
167
 	@username.command(
121
-		brief='Lists username patterns'
168
+		description='Lists existing username patterns.'
122
 	)
169
 	)
123
-	async def list(self, context: commands.Context) -> None:
170
+	async def list(self, interaction: Interaction) -> None:
124
 		"""Command handler"""
171
 		"""Command handler"""
125
-		guild: Guild = context.guild
172
+		guild: Guild = interaction.guild
126
 		patterns: list[str] = self.__get_patterns(guild)
173
 		patterns: list[str] = self.__get_patterns(guild)
127
 		if len(patterns) == 0:
174
 		if len(patterns) == 0:
128
-			await context.reply('No patterns defined', mention_author=False)
175
+			await interaction.response.send_message(
176
+				f'{CONFIG["success_emoji"]} No patterns defined.',
177
+				ephemeral=True,
178
+			)
129
 		else:
179
 		else:
130
-			msg = 'Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
131
-			await context.reply(msg, mention_author=False)
180
+			msg = f'{CONFIG["info_emoji"]} Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
181
+			await interaction.response.send_message(
182
+				msg,
183
+				ephemeral=True,
184
+			)
132
 
185
 
133
-	@commands.Cog.listener()
186
+	@Cog.listener()
134
 	async def on_member_join(self, member: Member) -> None:
187
 	async def on_member_join(self, member: Member) -> None:
135
 		"""Event handler"""
188
 		"""Event handler"""
136
 		for pattern in self.__get_patterns(member.guild):
189
 		for pattern in self.__get_patterns(member.guild):

+ 344
- 158
rocketbot/cogsetting.py ファイルの表示

1
 """
1
 """
2
 A guild configuration setting available for editing via bot commands.
2
 A guild configuration setting available for editing via bot commands.
3
 """
3
 """
4
-from typing import Any, Callable, Coroutine, Optional, Type
4
+from datetime import timedelta
5
+from typing import Any, Optional, Type, Literal, Union
5
 
6
 
6
-from discord.ext import commands
7
-from discord.ext.commands import Bot, Command, Context, Group, Cog
7
+from discord import Interaction, Permissions
8
+from discord.app_commands import Range, Transform, describe
9
+from discord.app_commands.commands import Command, Group, CommandCallback, rename
10
+from discord.ext.commands import Bot
8
 
11
 
9
 from config import CONFIG
12
 from config import CONFIG
10
 from rocketbot.storage import Storage
13
 from rocketbot.storage import Storage
11
-from rocketbot.utils import first_command_group
14
+from rocketbot.utils import bot_log, TimeDeltaTransformer, MOD_PERMISSIONS, dump_stacktrace, str_from_timedelta
12
 
15
 
13
-def _fix_command(command: Command) -> None:
14
-	"""
15
-	HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to
16
-	supply the context argument. This removes that argument from the list.
17
-	"""
18
-	params = command.params
19
-	del params['context']
20
-	command.params = params
16
+
17
+def describe_type(datatype: Type) -> str:
18
+	if datatype is int:
19
+		return 'integer'
20
+	if datatype is float:
21
+		return 'float'
22
+	if datatype is str:
23
+		return 'string'
24
+	if datatype is bool:
25
+		return 'boolean'
26
+	if datatype is timedelta:
27
+		return 'timespan'
28
+	if getattr(datatype, '__origin__', None) is Union:
29
+		return '|'.join([ describe_type(a) for a in datatype.__args__ ])
30
+	if getattr(datatype, '__origin__', None) is Literal:
31
+		return '"' + ('"|"'.join(datatype.__args__)) + '"'
32
+	return datatype.__class__.__name__
21
 
33
 
22
 class CogSetting:
34
 class CogSetting:
23
 	"""
35
 	"""
24
 	Describes a configuration setting for a guild that can be edited by the
36
 	Describes a configuration setting for a guild that can be edited by the
25
-	mods of those guilds. BaseCog can generate "get" and "set" commands (or
26
-	"enable" and "disable" commands for boolean values) automatically, reducing
37
+	mods of those guilds. BaseCog can generate "/get" and "/set" commands (or
38
+	"/enable" and "/disable" commands for boolean values) automatically, reducing
27
 	the boilerplate of generating commands manually. Offers simple validation rules.
39
 	the boilerplate of generating commands manually. Offers simple validation rules.
28
 	"""
40
 	"""
41
+
42
+	permissions: Permissions = Permissions(Permissions.manage_messages.flag)
43
+
29
 	def __init__(self,
44
 	def __init__(self,
30
 			name: str,
45
 			name: str,
31
 			datatype: Optional[Type],
46
 			datatype: Optional[Type],
47
+			default_value: Any,
32
 			brief: Optional[str] = None,
48
 			brief: Optional[str] = None,
33
 			description: Optional[str] = None,
49
 			description: Optional[str] = None,
34
-			usage: Optional[str] = None,
35
 			min_value: Optional[Any] = None,
50
 			min_value: Optional[Any] = None,
36
 			max_value: Optional[Any] = None,
51
 			max_value: Optional[Any] = None,
37
 			enum_values: Optional[set[Any]] = None):
52
 			enum_values: Optional[set[Any]] = None):
38
 		"""
53
 		"""
39
-		Params:
40
-		- name         Setting identifier. Must follow variable naming
41
-		               conventions.
42
-		- datatype     Datatype of the setting. E.g. int, float, str
43
-		- brief        Description of the setting, starting with lower case.
44
-		               Will be inserted into phrases like "Sets <brief>" and
45
-					   "Gets <brief".
46
-		- description  Long-form description. Min, max, and enum values will be
47
-		               appended to the end, so does not need to include these.
48
-		- usage        Description of the value argument in a set command, e.g.
49
-		               "<maxcount:int>"
50
-		- min_value    Smallest allowable value. Must be of the same datatype as
51
-		               the value. None for no minimum.
52
-		- max_value    Largest allowable value. None for no maximum.
53
-		- enum_values  Set of allowed values. None if unconstrained.
54
+		Parameters
55
+		----------
56
+		name: str
57
+			Setting identifier. Must follow variable naming conventions.
58
+		datatype: Optional[Type]
59
+		    Datatype of the setting. E.g. int, float, str
60
+		default_value: Any
61
+			Value to use if a guild has not yet configured one.
62
+		brief: Optional[str]
63
+			Description of the setting, starting with lower case.
64
+			Will be inserted into phrases like "Sets <brief>" and
65
+			"Gets <brief>".
66
+		description: Optional[str]
67
+		  	Long-form description. Min, max, and enum values will be
68
+		    appended to the end, so does not need to include these.
69
+		min_value: Optional[Any]
70
+		    Smallest allowable value. Must be of the same datatype as
71
+		    the value. None for no minimum.
72
+		max_value: Optional[Any]
73
+		    Largest allowable value. None for no maximum.
74
+		enum_values: Optional[set[Any]]
75
+		  	Set of allowed values. None if unconstrained.
54
 		"""
76
 		"""
55
 		self.name: str = name
77
 		self.name: str = name
56
 		self.datatype: Type = datatype
78
 		self.datatype: Type = datatype
79
+		self.default_value = default_value
57
 		self.brief: Optional[str] = brief
80
 		self.brief: Optional[str] = brief
58
 		self.description: str = description or ''  # Can't be None
81
 		self.description: str = description or ''  # Can't be None
59
-		self.usage: Optional[str] = usage
60
 		self.min_value: Optional[Any] = min_value
82
 		self.min_value: Optional[Any] = min_value
61
 		self.max_value: Optional[Any] = max_value
83
 		self.max_value: Optional[Any] = max_value
62
 		self.enum_values: Optional[set[Any]] = enum_values
84
 		self.enum_values: Optional[set[Any]] = enum_values
63
-		if self.enum_values or self.min_value is not None or self.max_value is not None:
64
-			self.description += '\n'
65
 		if self.enum_values:
85
 		if self.enum_values:
66
-			allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
67
-			self.description += f'\nAllowed values: {allowed_values}'
68
-		if self.min_value is not None:
69
-			self.description += f'\nMin value: {self.min_value}'
70
-		if self.max_value is not None:
71
-			self.description += f'\nMax value: {self.max_value}'
72
-		if self.usage is None:
73
-			self.usage = f'<{self.name}>'
74
-
75
-	def validate_value(self, new_value) -> None:
86
+			value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
87
+			self.description += f' (Permitted values: {value_list})'
88
+		elif self.min_value is not None and self.max_value is not None:
89
+			self.description += f' (Value must be between `{self.min_value}` and `{self.max_value}`)'
90
+		elif self.min_value is not None:
91
+			self.description += f' (Minimum value: {self.min_value})'
92
+		elif self.max_value is not None:
93
+			self.description += f' (Maximum value: {self.max_value})'
94
+
95
+	def validate_value(self, new_value: Any) -> None:
76
 		"""
96
 		"""
77
 		Checks if a value is legal for this setting. Raises a ValueError if not.
97
 		Checks if a value is legal for this setting. Raises a ValueError if not.
78
 		"""
98
 		"""
84
 			allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`'
104
 			allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`'
85
 			raise ValueError(f'`{self.name}` must be one of {allowed_values}')
105
 			raise ValueError(f'`{self.name}` must be one of {allowed_values}')
86
 
106
 
87
-	def set_up(self, cog: Cog, bot: Bot, group: Group) -> None:
107
+	def set_up(self, cog: 'BaseCog') -> None:
88
 		"""
108
 		"""
89
 		Sets up getter and setter commands for this setting. This should
109
 		Sets up getter and setter commands for this setting. This should
90
 		usually only be called by BaseCog.
110
 		usually only be called by BaseCog.
91
 		"""
111
 		"""
92
 		if self.name in ('enabled', 'is_enabled'):
112
 		if self.name in ('enabled', 'is_enabled'):
93
-			(group or bot).add_command(self.__make_enable_command(cog))
94
-			(group or bot).add_command(self.__make_disable_command(cog))
113
+			self.__enable_group.add_command(self.__make_enable_command(cog))
114
+			self.__disable_group.add_command(self.__make_disable_command(cog))
95
 		else:
115
 		else:
96
-			(group or bot).add_command(self.__make_getter_command(cog))
97
-			(group or bot).add_command(self.__make_setter_command(cog))
116
+			self.__get_group.add_command(self.__make_getter_command(cog))
117
+			self.__set_group.add_command(self.__make_setter_command(cog))
98
 
118
 
99
-	def __make_getter_command(self, cog: Cog) -> Command:
119
+	def to_stored_value(self, native_value: Any) -> Any:
120
+		"""Converts a configuration value to a JSON-compatible datatype."""
121
+		if self.datatype is timedelta:
122
+			return native_value.total_seconds()
123
+		return native_value
124
+
125
+	def to_native_value(self, stored_value: Any) -> Any:
126
+		"""Converts the stored JSON-compatible datatype to its actual value."""
127
+		if self.datatype is timedelta and isinstance(stored_value, (int, float)):
128
+			return timedelta(seconds=stored_value)
129
+		return stored_value
130
+
131
+	@staticmethod
132
+	def native_value_to_str(native_value: Any) -> str:
133
+		"""Formats a native configuration value to a user-presentable string."""
134
+		if native_value is None:
135
+			return '<no value>'
136
+		if isinstance(native_value, timedelta):
137
+			return str_from_timedelta(native_value)
138
+		if isinstance(native_value, bool):
139
+			return 'true' if native_value else 'false'
140
+		return f'{native_value}'
141
+
142
+	def __make_getter_command(self, cog: 'BaseCog') -> Command:
100
 		setting: CogSetting = self
143
 		setting: CogSetting = self
101
-		async def getter(cog0: Cog, context: Context) -> None:
102
-			setting_name = setting.name
103
-			if isinstance(context.command.parent, Group):
104
-				setting_name = f'{context.command.parent.name}.{setting_name}'
105
-			key = f'{cog0.__class__.__name__}.{setting.name}'
106
-			value = Storage.get_config_value(context.guild, key)
144
+		setting_name = setting.name
145
+		if cog.config_prefix is not None:
146
+			setting_name = f'{cog.config_prefix}_{setting_name}'
147
+		async def getter(interaction: Interaction) -> None:
148
+			key = f'{cog.__class__.__name__}.{setting.name}'
149
+			value = setting.to_native_value(Storage.get_config_value(interaction.guild, key))
107
 			if value is None:
150
 			if value is None:
108
-				value = cog0.get_cog_default(setting.name)
109
-				await context.message.reply(
110
-					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
111
-					mention_author=False)
151
+				value = setting.to_native_value(cog.get_cog_default(setting.name))
152
+				await interaction.response.send_message(
153
+					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{CogSetting.native_value_to_str(value)}`',
154
+					ephemeral=True
155
+				)
112
 			else:
156
 			else:
113
-				await context.message.reply(
114
-					f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
115
-					mention_author=False)
157
+				await interaction.response.send_message(
158
+					f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{CogSetting.native_value_to_str(value)}`',
159
+					ephemeral=True
160
+				)
161
+
162
+		bot_log(None, cog.__class__, f"Creating command: /get {setting_name}")
116
 		command = Command(
163
 		command = Command(
117
-			getter,
118
-			name=f'get{setting.name}',
119
-			brief=f'Shows {setting.brief}',
120
-			description=setting.description,
121
-			checks=[
122
-				commands.has_permissions(ban_members=True),
123
-				commands.guild_only(),
124
-			])
125
-		command.cog = cog
126
-		_fix_command(command)
164
+			name=setting_name,
165
+			description=f'Shows {self.brief}.',
166
+			callback=getter,
167
+			parent=CogSetting.__get_group,
168
+			extras={
169
+				'cog': cog,
170
+				'setting': setting,
171
+				'long_description': setting.description,
172
+			},
173
+		)
127
 		return command
174
 		return command
128
 
175
 
129
-	def __make_setter_command(self, cog: Cog) -> Command:
176
+	def __make_setter_command(self, cog: 'BaseCog') -> Command:
130
 		setting: CogSetting = self
177
 		setting: CogSetting = self
131
-		async def setter_common(cog0: Cog, context: Context, new_value) -> None:
178
+		setting_name = setting.name
179
+		if cog.config_prefix is not None:
180
+			setting_name = f'{cog.config_prefix}_{setting_name}'
181
+		async def setter_general(interaction: Interaction, new_value) -> None:
132
 			try:
182
 			try:
133
 				setting.validate_value(new_value)
183
 				setting.validate_value(new_value)
134
 			except ValueError as ve:
184
 			except ValueError as ve:
135
-				await context.message.reply(
185
+				await interaction.response.send_message(
136
 					f'{CONFIG["failure_emoji"]} {ve}',
186
 					f'{CONFIG["failure_emoji"]} {ve}',
137
-					mention_author=False)
187
+					ephemeral=True
188
+				)
138
 				return
189
 				return
139
-			setting_name = setting.name
140
-			if isinstance(context.command.parent, Group):
141
-				setting_name = f'{context.command.parent.name}.{setting_name}'
142
-			key = f'{cog0.__class__.__name__}.{setting.name}'
143
-			Storage.set_config_value(context.guild, key, new_value)
144
-			await context.message.reply(
145
-				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
146
-				mention_author=False)
147
-			await cog0.on_setting_updated(context.guild, setting)
148
-			cog0.log(context.guild, f'{context.author.name} set {key} to {new_value}')
149
-
150
-		async def setter_int(cog1, context, new_value: int):
151
-			await setter_common(cog1, context, new_value)
152
-		async def setter_float(cog2, context, new_value: float):
153
-			await setter_common(cog2, context, new_value)
154
-		async def setter_str(cog3, context, new_value: str):
155
-			await setter_common(cog3, context, new_value)
156
-		async def setter_bool(cog4, context, new_value: bool):
157
-			await setter_common(cog4, context, new_value)
158
-
159
-		setter: Callable[[Cog, Context, Any], Coroutine]
160
-		if setting.datatype == int:
161
-			setter = setter_int
162
-		elif setting.datatype == float:
190
+			key = f'{cog.__class__.__name__}.{setting.name}'
191
+			Storage.set_config_value(interaction.guild, key, setting.to_stored_value(new_value))
192
+			await interaction.response.send_message(
193
+				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{setting.to_native_value(new_value)}`',
194
+				ephemeral=True
195
+			)
196
+			await cog.on_setting_updated(interaction.guild, setting)
197
+			cog.log(interaction.guild, f'{interaction.user.name} set {key} to {new_value}')
198
+
199
+		setter: CommandCallback = setter_general
200
+		if self.datatype is int:
201
+			if self.min_value is not None or self.max_value is not None:
202
+				r_min = self.min_value
203
+				r_max = self.max_value
204
+				@rename(new_value=self.name)
205
+				@describe(new_value=self.brief)
206
+				async def setter_range(interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
207
+					await setter_general(interaction, new_value)
208
+				setter = setter_range
209
+			else:
210
+				@rename(new_value=self.name)
211
+				@describe(new_value=self.brief)
212
+				async def setter_int(interaction: Interaction, new_value: int) -> None:
213
+					await setter_general(interaction, new_value)
214
+				setter = setter_int
215
+		elif self.datatype is float:
216
+			@rename(new_value=self.name)
217
+			@describe(new_value=self.brief)
218
+			async def setter_float(interaction: Interaction, new_value: float) -> None:
219
+				await setter_general(interaction, new_value)
163
 			setter = setter_float
220
 			setter = setter_float
164
-		elif setting.datatype == str:
165
-			setter = setter_str
166
-		elif setting.datatype == bool:
221
+		elif self.datatype is timedelta:
222
+			@rename(new_value=self.name)
223
+			@describe(new_value=f'{self.brief} (e.g. 30s, 5m, 1h30s, 7d)')
224
+			async def setter_timedelta(interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None:
225
+				await setter_general(interaction, new_value)
226
+			setter = setter_timedelta
227
+		elif getattr(self.datatype, '__origin__', None) == Literal:
228
+			dt = self.datatype
229
+			@rename(new_value=self.name)
230
+			@describe(new_value=self.brief)
231
+			async def setter_enum(interaction: Interaction, new_value: dt) -> None:
232
+				await setter_general(interaction, new_value)
233
+			setter = setter_enum
234
+		elif self.datatype is str:
235
+			if self.enum_values is not None:
236
+				raise ValueError('Type for a setting with enum values should be typing.Literal')
237
+			else:
238
+				@rename(new_value=self.name)
239
+				@describe(new_value=self.brief)
240
+				async def setter_str(interaction: Interaction, new_value: str) -> None:
241
+					await setter_general(interaction, new_value)
242
+				setter = setter_str
243
+		elif self.datatype is bool:
244
+			@rename(new_value=self.name)
245
+			@describe(new_value=self.brief)
246
+			async def setter_bool(interaction: Interaction, new_value: bool) -> None:
247
+				await setter_general(interaction, new_value)
167
 			setter = setter_bool
248
 			setter = setter_bool
168
-		else:
169
-			raise ValueError(f'Datatype {setting.datatype} unsupported')
249
+		elif self.datatype is not None:
250
+			raise ValueError(f'Invalid type {self.datatype}')
170
 
251
 
252
+		bot_log(None, cog.__class__, f"Creating command: /set {setting_name} <{describe_type(self.datatype)}>")
171
 		command = Command(
253
 		command = Command(
172
-			setter,
173
-			name=f'set{setting.name}',
174
-			brief=f'Sets {setting.brief}',
175
-			description=setting.description,
176
-			usage=setting.usage,
177
-			checks=[
178
-				commands.has_permissions(ban_members=True),
179
-				commands.guild_only(),
180
-			])
181
-		# HACK: Passing `cog` in init gets ignored and set to `None` so set after.
182
-		# This ensures the callback is passed the cog as `self` argument.
183
-		command.cog = cog
184
-		_fix_command(command)
254
+			name=setting_name,
255
+			description=f'Sets {self.brief}.',
256
+			callback=setter,
257
+			parent=CogSetting.__set_group,
258
+			extras={
259
+				'cog': cog,
260
+				'setting': setting,
261
+				'long_description': setting.description,
262
+			},
263
+		)
185
 		return command
264
 		return command
186
 
265
 
187
-	def __make_enable_command(self, cog: Cog) -> Command:
266
+	def __make_enable_command(self, cog: 'BaseCog') -> Command:
188
 		setting: CogSetting = self
267
 		setting: CogSetting = self
189
-		async def enabler(cog0: Cog, context: Context) -> None:
190
-			key = f'{cog0.__class__.__name__}.{setting.name}'
191
-			Storage.set_config_value(context.guild, key, True)
192
-			await context.message.reply(
268
+		async def enabler(interaction: Interaction) -> None:
269
+			key = f'{cog.__class__.__name__}.{setting.name}'
270
+			Storage.set_config_value(interaction.guild, key, True)
271
+			await interaction.response.send_message(
193
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
272
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
194
-				mention_author=False)
195
-			await cog0.on_setting_updated(context.guild, setting)
196
-			cog0.log(context.guild, f'{context.author.name} enabled {cog0.__class__.__name__}')
273
+				ephemeral=True
274
+			)
275
+			await cog.on_setting_updated(interaction.guild, setting)
276
+			cog.log(interaction.guild, f'{interaction.user.name} enabled {cog.__class__.__name__}')
197
 
277
 
278
+		bot_log(None, cog.__class__, f"Creating command: /enable {cog.config_prefix}")
198
 		command = Command(
279
 		command = Command(
199
-			enabler,
200
-			name='enable',
201
-			brief=f'Enables {setting.brief}',
202
-			description=setting.description,
203
-			checks=[
204
-				commands.has_permissions(ban_members=True),
205
-				commands.guild_only(),
206
-			])
207
-		command.cog = cog
208
-		_fix_command(command)
280
+			name=cog.config_prefix,
281
+			description=f'Enables {cog.qualified_name} functionality.',
282
+			callback=enabler,
283
+			parent=CogSetting.__enable_group,
284
+			extras={
285
+				'cog': cog,
286
+				'setting': setting,
287
+				'long_description': setting.description,
288
+			},
289
+		)
209
 		return command
290
 		return command
210
 
291
 
211
-	def __make_disable_command(self, cog: Cog) -> Command:
292
+	def __make_disable_command(self, cog: 'BaseCog') -> Command:
212
 		setting: CogSetting = self
293
 		setting: CogSetting = self
213
-		async def disabler(cog0: Cog, context: Context) -> None:
214
-			key = f'{cog0.__class__.__name__}.{setting.name}'
215
-			Storage.set_config_value(context.guild, key, False)
216
-			await context.message.reply(
294
+		async def disabler(interaction: Interaction) -> None:
295
+			key = f'{cog.__class__.__name__}.{setting.name}'
296
+			Storage.set_config_value(interaction.guild, key, False)
297
+			await interaction.response.send_message(
217
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
298
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
218
-				mention_author=False)
219
-			await cog0.on_setting_updated(context.guild, setting)
220
-			cog0.log(context.guild, f'{context.author.name} disabled {cog0.__class__.__name__}')
299
+				ephemeral=True
300
+			)
301
+			await cog.on_setting_updated(interaction.guild, setting)
302
+			cog.log(interaction.guild, f'{interaction.user.name} disabled {cog.__class__.__name__}')
221
 
303
 
304
+		bot_log(None, cog.__class__, f"Creating command: /disable {cog.config_prefix}")
222
 		command = Command(
305
 		command = Command(
223
-			disabler,
224
-			name='disable',
225
-			brief=f'Disables {setting.brief}',
226
-			description=setting.description,
227
-			checks=[
228
-				commands.has_permissions(ban_members=True),
229
-				commands.guild_only(),
230
-			])
231
-		command.cog = cog
232
-		_fix_command(command)
306
+			name=cog.config_prefix,
307
+			description=f'Disables {cog.config_prefix} functionality',
308
+			callback=disabler,
309
+			parent=CogSetting.__disable_group,
310
+			extras={
311
+				'cog': cog,
312
+				'setting': setting,
313
+				'long_description': setting.description,
314
+			},
315
+		)
233
 		return command
316
 		return command
234
 
317
 
318
+	__set_group: Group
319
+	__get_group: Group
320
+	__enable_group: Group
321
+	__disable_group: Group
322
+
235
 	@classmethod
323
 	@classmethod
236
-	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
324
+	def set_up_all(cls, cog: 'BaseCog', bot: Bot, settings: list['CogSetting']) -> None:
237
 		"""
325
 		"""
238
 		Sets up editing commands for a list of CogSettings and adds them to a
326
 		Sets up editing commands for a list of CogSettings and adds them to a
239
 		cog. If the cog has a command Group, commands will be added to it.
327
 		cog. If the cog has a command Group, commands will be added to it.
240
-		Otherwise they will be added at the top level.
328
+		Otherwise, they will be added at the top level.
241
 		"""
329
 		"""
242
-		group: Group = first_command_group(cog)
330
+		cls.__set_up_base_commands(bot)
331
+		if len(settings) == 0:
332
+			return
243
 		for setting in settings:
333
 		for setting in settings:
244
-			setting.set_up(cog, bot, group)
334
+			setting.set_up(cog)
335
+
336
+	@classmethod
337
+	def __set_up_base_commands(cls, bot: Bot) -> None:
338
+		if getattr(cls, f'_CogSetting__set_group', None) is not None:
339
+			return
340
+		cls.__set_group = Group(
341
+			name='set',
342
+			description='Sets a configuration value for this guild.',
343
+			guild_only=True,
344
+			default_permissions=MOD_PERMISSIONS,
345
+			extras={
346
+				'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/get` to '
347
+									'see the current value for this guild.',
348
+			},
349
+		)
350
+		cls.__get_group = Group(
351
+			name='get',
352
+			description='Shows a configuration value for this guild.',
353
+			guild_only=True,
354
+			default_permissions=MOD_PERMISSIONS,
355
+			extras={
356
+				'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/set` to '
357
+									'change the value.',
358
+			},
359
+		)
360
+		cls.__enable_group = Group(
361
+			name='enable',
362
+			description='Enables a module for this guild.',
363
+			guild_only=True,
364
+			default_permissions=MOD_PERMISSIONS,
365
+			extras={
366
+				'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/disable` '
367
+									'to disable an enabled module.',
368
+			},
369
+		)
370
+		cls.__disable_group = Group(
371
+			name='disable',
372
+			description='Disables a module for this guild.',
373
+			guild_only=True,
374
+			default_permissions=MOD_PERMISSIONS,
375
+			extras={
376
+				'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/enable` '
377
+					                're-enable a disabled module.',
378
+			},
379
+		)
380
+		bot.tree.add_command(cls.__set_group)
381
+		bot.tree.add_command(cls.__get_group)
382
+		bot.tree.add_command(cls.__enable_group)
383
+		bot.tree.add_command(cls.__disable_group)
384
+
385
+		from rocketbot.cogs.basecog import BaseCog
386
+		async def show_all(interaction: Interaction) -> None:
387
+			try:
388
+				guild = interaction.guild
389
+				if guild is None:
390
+					await interaction.response.send_message(
391
+						f'{CONFIG["failure_emoji"]} No guild.',
392
+						ephemeral=True,
393
+						delete_after=10,
394
+					)
395
+					return
396
+				text = '## :information_source: Configuration'
397
+				for cog_name, cog in sorted(bot.cogs.items()):
398
+					if not isinstance(cog, BaseCog):
399
+						continue
400
+					bcog: BaseCog = cog
401
+					if len(bcog.settings) == 0:
402
+						continue
403
+					text += f'\n### {bcog.qualified_name} Module'
404
+					for setting in sorted(bcog.settings, key=lambda s: (s.name != 'enabled', s.name)):
405
+						key = f'{bcog.__class__.__name__}.{setting.name}'
406
+						value = setting.to_native_value(Storage.get_config_value(guild, key))
407
+						deflt = setting.to_native_value(bcog.get_cog_default(setting.name))
408
+						if setting.name == 'enabled':
409
+							text += f'\n- Module is '
410
+							if value is not None:
411
+								text += '**' + ('enabled' if value else 'disabled') + '**'
412
+							else:
413
+								text += ('enabled' if deflt else 'disabled') + ' _(default)_'
414
+						else:
415
+							if value is not None:
416
+								text += f'\n- `{bcog.config_prefix}_{setting.name}` = **{CogSetting.native_value_to_str(value)}**'
417
+							else:
418
+								text += f'\n- `{bcog.config_prefix}_{setting.name}` = {CogSetting.native_value_to_str(deflt)} _(using default)_'
419
+				await interaction.response.send_message(
420
+					text,
421
+					ephemeral=True,
422
+				)
423
+			except BaseException as e:
424
+				dump_stacktrace(e)
425
+		show_all_command = Command(
426
+			name='all',
427
+			description='Shows all configuration for this guild.',
428
+			callback=show_all,
429
+		)
430
+		cls.__get_group.add_command(show_all_command)

+ 134
- 81
rocketbot/pattern.py ファイルの表示

5
 import re
5
 import re
6
 from abc import ABCMeta, abstractmethod
6
 from abc import ABCMeta, abstractmethod
7
 from datetime import datetime, timezone
7
 from datetime import datetime, timezone
8
-from typing import Any, Union
8
+from typing import Any, Union, Literal
9
 
9
 
10
 from discord import Message, utils as discordutils
10
 from discord import Message, utils as discordutils
11
 from discord.ext.commands import Context
11
 from discord.ext.commands import Context
13
 from rocketbot.utils import is_user_id, str_from_quoted_str, timedelta_from_str, \
13
 from rocketbot.utils import is_user_id, str_from_quoted_str, timedelta_from_str, \
14
 	user_id_from_mention
14
 	user_id_from_mention
15
 
15
 
16
+PatternField = Literal['content.markdown', 'content', 'content.plain', 'author', 'author.id', 'author.joinage', 'author.name', 'lastmatched']
17
+PatternComparisonOperator = Literal['==', '!=', '<', '>', '<=', '>=', 'contains', '!contains', 'matches', '!matches', 'containsword', '!containsword']
18
+PatternBooleanOperator = Literal['!', 'and', 'or']
19
+PatternActionType = Literal['ban', 'delete', 'kick', 'modinfo', 'modwarn', 'reply']
20
+
16
 class PatternError(RuntimeError):
21
 class PatternError(RuntimeError):
17
 	"""
22
 	"""
18
 	Error thrown when parsing a pattern statement.
23
 	Error thrown when parsing a pattern statement.
27
 	"""
32
 	"""
28
 	Describes one action to take on a matched message or its author.
33
 	Describes one action to take on a matched message or its author.
29
 	"""
34
 	"""
35
+
36
+	TYPE_BAN: PatternActionType = 'ban'
37
+	TYPE_DELETE: PatternActionType = 'delete'
38
+	TYPE_KICK: PatternActionType = 'kick'
39
+	TYPE_INFORM_MODS: PatternActionType = 'modinfo'
40
+	TYPE_WARN_MODS: PatternActionType = 'modwarn'
41
+	TYPE_REPLY: PatternActionType = 'reply'
42
+
30
 	def __init__(self, action: str, args: list[Any]):
43
 	def __init__(self, action: str, args: list[Any]):
31
 		self.action = action
44
 		self.action = action
32
 		self.arguments = list(args)
45
 		self.arguments = list(args)
55
 	Message matching expression with a simple "<field> <operator> <value>"
68
 	Message matching expression with a simple "<field> <operator> <value>"
56
 	structure.
69
 	structure.
57
 	"""
70
 	"""
58
-	def __init__(self, field: str, operator: str, value: Any):
71
+
72
+	FIELD_CONTENT_MARKDOWN: PatternField = 'content.markdown'
73
+	FIELD_CONTENT_PLAIN: PatternField = 'content.plain'
74
+	FIELD_AUTHOR_ID: PatternField = 'author.id'
75
+	FIELD_AUTHOR_JOINAGE: PatternField = 'author.joinage'
76
+	FIELD_AUTHOR_NAME: PatternField = 'author.name'
77
+	FIELD_LAST_MATCHED: PatternField = 'lastmatched'
78
+
79
+	# Less preferred but recognized field aliases
80
+	ALIAS_FIELD_CONTENT_MARKDOWN: PatternField = 'content'
81
+	ALIAS_FIELD_AUTHOR_ID: PatternField = 'author'
82
+
83
+	OP_EQUALS: PatternComparisonOperator = '=='
84
+	OP_NOT_EQUALS: PatternComparisonOperator = '!='
85
+	OP_LESS_THAN: PatternComparisonOperator = '<'
86
+	OP_GREATER_THAN: PatternComparisonOperator = '>'
87
+	OP_LESS_THAN_OR_EQUALS: PatternComparisonOperator = '<='
88
+	OP_GREATER_THAN_OR_EQUALS: PatternComparisonOperator = '>='
89
+	OP_CONTAINS: PatternComparisonOperator = 'contains'
90
+	OP_NOT_CONTAINS: PatternComparisonOperator = '!contains'
91
+	OP_MATCHES: PatternComparisonOperator = 'matches'
92
+	OP_NOT_MATCHES: PatternComparisonOperator = '!matches'
93
+	OP_CONTAINS_WORD: PatternComparisonOperator = 'containsword'
94
+	OP_NOT_CONTAINS_WORD: PatternComparisonOperator = '!containsword'
95
+
96
+	def __init__(self, field: PatternField, operator: PatternComparisonOperator, value: Any):
59
 		super().__init__()
97
 		super().__init__()
60
-		self.field: str = field
61
-		self.operator: str = operator
98
+		self.field: PatternField = field
99
+		self.operator: PatternComparisonOperator = operator
62
 		self.value: Any = value
100
 		self.value: Any = value
63
 
101
 
64
 	def __field_value(self, message: Message, other_fields: dict[str, Any]) -> Any:
102
 	def __field_value(self, message: Message, other_fields: dict[str, Any]) -> Any:
65
-		if self.field in ('content.markdown', 'content'):
103
+		cls = PatternSimpleExpression
104
+		if self.field in (cls.FIELD_CONTENT_MARKDOWN, cls.ALIAS_FIELD_CONTENT_MARKDOWN):
66
 			return message.content
105
 			return message.content
67
-		if self.field == 'content.plain':
106
+		if self.field == cls.FIELD_CONTENT_PLAIN:
68
 			return discordutils.remove_markdown(message.clean_content)
107
 			return discordutils.remove_markdown(message.clean_content)
69
-		if self.field == 'author':
108
+		if self.field in (cls.FIELD_AUTHOR_ID, cls.ALIAS_FIELD_AUTHOR_ID):
70
 			return str(message.author.id)
109
 			return str(message.author.id)
71
-		if self.field == 'author.id':
72
-			return str(message.author.id)
73
-		if self.field == 'author.joinage':
110
+		if self.field == cls.FIELD_AUTHOR_JOINAGE:
74
 			return message.created_at - message.author.joined_at
111
 			return message.created_at - message.author.joined_at
75
-		if self.field == 'author.name':
112
+		if self.field == cls.FIELD_AUTHOR_NAME:
76
 			return message.author.name
113
 			return message.author.name
77
-		if self.field == 'lastmatched':
114
+		if self.field == cls.FIELD_LAST_MATCHED:
78
 			long_ago = datetime(year=1900, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc)
115
 			long_ago = datetime(year=1900, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc)
79
 			last_matched = other_fields.get('last_matched') or long_ago
116
 			last_matched = other_fields.get('last_matched') or long_ago
80
 			return message.created_at - last_matched
117
 			return message.created_at - last_matched
81
-		raise ValueError(f'Bad field name {self.field}')
118
+		raise ValueError(f'Bad field name "{self.field}"')
82
 
119
 
83
 	def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
120
 	def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
121
+		cls = PatternSimpleExpression
84
 		field_value = self.__field_value(message, other_fields)
122
 		field_value = self.__field_value(message, other_fields)
85
-		if self.operator == '==':
123
+		if self.operator == cls.OP_EQUALS:
86
 			if isinstance(field_value, str) and isinstance(self.value, str):
124
 			if isinstance(field_value, str) and isinstance(self.value, str):
87
 				return field_value.lower() == self.value.lower()
125
 				return field_value.lower() == self.value.lower()
88
 			return field_value == self.value
126
 			return field_value == self.value
89
-		if self.operator == '!=':
127
+		if self.operator == cls.OP_NOT_EQUALS:
90
 			if isinstance(field_value, str) and isinstance(self.value, str):
128
 			if isinstance(field_value, str) and isinstance(self.value, str):
91
 				return field_value.lower() != self.value.lower()
129
 				return field_value.lower() != self.value.lower()
92
 			return field_value != self.value
130
 			return field_value != self.value
93
-		if self.operator == '<':
131
+		if self.operator == cls.OP_LESS_THAN:
94
 			return field_value < self.value
132
 			return field_value < self.value
95
-		if self.operator == '>':
133
+		if self.operator == cls.OP_GREATER_THAN:
96
 			return field_value > self.value
134
 			return field_value > self.value
97
-		if self.operator == '<=':
135
+		if self.operator == cls.OP_LESS_THAN_OR_EQUALS:
98
 			return field_value <= self.value
136
 			return field_value <= self.value
99
-		if self.operator == '>=':
137
+		if self.operator == cls.OP_GREATER_THAN_OR_EQUALS:
100
 			return field_value >= self.value
138
 			return field_value >= self.value
101
-		if self.operator == 'contains':
139
+		if self.operator == cls.OP_CONTAINS:
102
 			return self.value.lower() in field_value.lower()
140
 			return self.value.lower() in field_value.lower()
103
-		if self.operator == '!contains':
141
+		if self.operator == cls.OP_NOT_CONTAINS:
104
 			return self.value.lower() not in field_value.lower()
142
 			return self.value.lower() not in field_value.lower()
105
-		if self.operator in ('matches', 'containsword'):
143
+		if self.operator in (cls.OP_MATCHES, cls.OP_CONTAINS_WORD):
106
 			return self.value.search(field_value.lower()) is not None
144
 			return self.value.search(field_value.lower()) is not None
107
-		if self.operator in ('!matches', '!containsword'):
145
+		if self.operator in (cls.OP_NOT_MATCHES, cls.OP_NOT_CONTAINS_WORD):
108
 			return self.value.search(field_value.lower()) is None
146
 			return self.value.search(field_value.lower()) is None
109
 		raise ValueError(f'Bad operator {self.operator}')
147
 		raise ValueError(f'Bad operator {self.operator}')
110
 
148
 
116
 	Message matching expression that combines several child expressions with
154
 	Message matching expression that combines several child expressions with
117
 	a boolean operator.
155
 	a boolean operator.
118
 	"""
156
 	"""
119
-	def __init__(self, operator: str, operands: list[PatternExpression]):
157
+	OP_NOT = '!'
158
+	OP_AND = 'and'
159
+	OP_OR = 'or'
160
+
161
+	def __init__(self, operator: PatternBooleanOperator, operands: list[PatternExpression]):
120
 		super().__init__()
162
 		super().__init__()
121
-		self.operator = operator
163
+		self.operator: PatternBooleanOperator = operator
122
 		self.operands = list(operands)
164
 		self.operands = list(operands)
123
 
165
 
124
 	def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
166
 	def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
125
-		if self.operator == '!':
167
+		if self.operator == PatternCompoundExpression.OP_NOT:
126
 			return not self.operands[0].matches(message, other_fields)
168
 			return not self.operands[0].matches(message, other_fields)
127
-		if self.operator == 'and':
169
+		if self.operator == PatternCompoundExpression.OP_AND:
128
 			for op in self.operands:
170
 			for op in self.operands:
129
 				if not op.matches(message, other_fields):
171
 				if not op.matches(message, other_fields):
130
 					return False
172
 					return False
131
 			return True
173
 			return True
132
-		if self.operator == 'or':
174
+		if self.operator == PatternCompoundExpression.OP_OR:
133
 			for op in self.operands:
175
 			for op in self.operands:
134
 				if op.matches(message, other_fields):
176
 				if op.matches(message, other_fields):
135
 					return True
177
 					return True
137
 		raise ValueError(f'Bad operator "{self.operator}"')
179
 		raise ValueError(f'Bad operator "{self.operator}"')
138
 
180
 
139
 	def __str__(self) -> str:
181
 	def __str__(self) -> str:
140
-		if self.operator == '!':
182
+		if self.operator == PatternCompoundExpression.OP_NOT:
141
 			return f'(!( {self.operands[0]} ))'
183
 			return f'(!( {self.operands[0]} ))'
142
 		strs = map(str, self.operands)
184
 		strs = map(str, self.operands)
143
 		joined = f' {self.operator} '.join(strs)
185
 		joined = f' {self.operator} '.join(strs)
204
 	"""
246
 	"""
205
 	Parses a user-provided message filter statement into a PatternStatement.
247
 	Parses a user-provided message filter statement into a PatternStatement.
206
 	"""
248
 	"""
207
-	TYPE_FLOAT: str = 'float'
208
-	TYPE_ID: str = 'id'
209
-	TYPE_INT: str = 'int'
210
-	TYPE_MEMBER: str = 'Member'
211
-	TYPE_REGEX: str = 'regex'
212
-	TYPE_TEXT: str = 'text'
213
-	TYPE_TIMESPAN: str = 'timespan'
214
-
215
-	FIELD_TO_TYPE: dict[str, str] = {
216
-		'author': TYPE_MEMBER,
217
-		'author.id': TYPE_ID,
218
-		'author.joinage': TYPE_TIMESPAN,
219
-		'author.name': TYPE_TEXT,
220
-		'content': TYPE_TEXT, # deprecated, use content.markdown or content.plain
221
-		'content.markdown': TYPE_TEXT,
222
-		'content.plain': TYPE_TEXT,
223
-		'lastmatched': TYPE_TIMESPAN,
249
+	DATATYPE_FLOAT: str = 'float'
250
+	DATATYPE_ID: str = 'id'
251
+	DATATYPE_INT: str = 'int'
252
+	DATATYPE_MEMBER: str = 'Member'
253
+	DATATYPE_REGEX: str = 'regex'
254
+	DATATYPE_TEXT: str = 'text'
255
+	DATATYPE_TIMESPAN: str = 'timespan'
256
+
257
+	FIELD_TO_DATATYPE: dict[PatternField, str] = {
258
+		PatternSimpleExpression.ALIAS_FIELD_AUTHOR_ID: DATATYPE_MEMBER,
259
+		PatternSimpleExpression.FIELD_AUTHOR_ID: DATATYPE_ID,
260
+		PatternSimpleExpression.FIELD_AUTHOR_JOINAGE: DATATYPE_TIMESPAN,
261
+		PatternSimpleExpression.FIELD_AUTHOR_NAME: DATATYPE_TEXT,
262
+		PatternSimpleExpression.ALIAS_FIELD_CONTENT_MARKDOWN: DATATYPE_TEXT, # deprecated, use content.markdown or content.plain
263
+		PatternSimpleExpression.FIELD_CONTENT_MARKDOWN: DATATYPE_TEXT,
264
+		PatternSimpleExpression.FIELD_CONTENT_PLAIN: DATATYPE_TEXT,
265
+		PatternSimpleExpression.FIELD_LAST_MATCHED: DATATYPE_TIMESPAN,
224
 	}
266
 	}
225
-	DEPRECATED_FIELDS: set[str] = { 'content' }
226
-
227
-	ACTION_TO_ARGS: dict[str, list[str]] = {
228
-		'ban': [],
229
-		'delete': [],
230
-		'kick': [],
231
-		'modinfo': [],
232
-		'modwarn': [],
233
-		'reply': [ TYPE_TEXT ],
267
+	DEPRECATED_FIELDS: set[PatternField] = { 'content' }
268
+
269
+	ACTION_TO_ARGS: dict[PatternActionType, list[str]] = {
270
+		PatternAction.TYPE_BAN: [],
271
+		PatternAction.TYPE_DELETE: [],
272
+		PatternAction.TYPE_KICK: [],
273
+		PatternAction.TYPE_INFORM_MODS: [],
274
+		PatternAction.TYPE_WARN_MODS: [],
275
+		PatternAction.TYPE_REPLY: [ DATATYPE_TEXT ],
234
 	}
276
 	}
235
 
277
 
236
-	OPERATORS_IDENTITY: set[str] = { '==', '!=' }
237
-	OPERATORS_COMPARISON: set[str] = { '<', '>', '<=', '>=' }
238
-	OPERATORS_NUMERIC: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
239
-	OPERATORS_TEXT: set[str] = OPERATORS_IDENTITY | {
240
-		'contains', '!contains',
241
-		'containsword', '!containsword',
242
-		'matches', '!matches',
278
+	OPERATORS_IDENTITY: set[PatternComparisonOperator] = {
279
+		PatternSimpleExpression.OP_EQUALS,
280
+		PatternSimpleExpression.OP_NOT_EQUALS,
281
+	}
282
+	OPERATORS_COMPARISON: set[PatternComparisonOperator] = {
283
+		PatternSimpleExpression.OP_LESS_THAN,
284
+		PatternSimpleExpression.OP_GREATER_THAN,
285
+		PatternSimpleExpression.OP_LESS_THAN_OR_EQUALS,
286
+		PatternSimpleExpression.OP_GREATER_THAN_OR_EQUALS,
287
+	}
288
+	OPERATORS_NUMERIC: set[PatternComparisonOperator] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
289
+	OPERATORS_TEXT: set[PatternComparisonOperator] = OPERATORS_IDENTITY | {
290
+		PatternSimpleExpression.OP_CONTAINS,
291
+		PatternSimpleExpression.OP_NOT_CONTAINS,
292
+		PatternSimpleExpression.OP_CONTAINS_WORD,
293
+		PatternSimpleExpression.OP_NOT_CONTAINS_WORD,
294
+		PatternSimpleExpression.OP_MATCHES,
295
+		PatternSimpleExpression.OP_NOT_MATCHES,
243
 	}
296
 	}
244
 	OPERATORS_ALL: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
297
 	OPERATORS_ALL: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
245
 
298
 
246
-	TYPE_TO_OPERATORS: dict[str, set[str]] = {
247
-		TYPE_ID: OPERATORS_IDENTITY,
248
-		TYPE_MEMBER: OPERATORS_IDENTITY,
249
-		TYPE_TEXT: OPERATORS_TEXT,
250
-		TYPE_INT: OPERATORS_NUMERIC,
251
-		TYPE_FLOAT: OPERATORS_NUMERIC,
252
-		TYPE_TIMESPAN: OPERATORS_NUMERIC,
299
+	DATATYPE_TO_OPERATORS: dict[str, set[PatternComparisonOperator]] = {
300
+		DATATYPE_ID: OPERATORS_IDENTITY,
301
+		DATATYPE_MEMBER: OPERATORS_IDENTITY,
302
+		DATATYPE_TEXT: OPERATORS_TEXT,
303
+		DATATYPE_INT: OPERATORS_NUMERIC,
304
+		DATATYPE_FLOAT: OPERATORS_NUMERIC,
305
+		DATATYPE_TIMESPAN: OPERATORS_NUMERIC,
253
 	}
306
 	}
254
 
307
 
255
 	WHITESPACE_CHARS: str = ' \t\n\r'
308
 	WHITESPACE_CHARS: str = ' \t\n\r'
458
 					return subexpressions[0], token_index
511
 					return subexpressions[0], token_index
459
 				return (PatternCompoundExpression(last_compound_operator,
512
 				return (PatternCompoundExpression(last_compound_operator,
460
 					subexpressions), token_index)
513
 					subexpressions), token_index)
461
-			if tokens[token_index] in { "and", "or" }:
514
+			if tokens[token_index] in { PatternCompoundExpression.OP_AND, PatternCompoundExpression.OP_OR }:
462
 				compound_operator = tokens[token_index]
515
 				compound_operator = tokens[token_index]
463
 				if last_compound_operator and \
516
 				if last_compound_operator and \
464
 						compound_operator != last_compound_operator:
517
 						compound_operator != last_compound_operator:
468
 					]
521
 					]
469
 				last_compound_operator = compound_operator
522
 				last_compound_operator = compound_operator
470
 				token_index += 1
523
 				token_index += 1
471
-			if tokens[token_index] == '!':
524
+			if tokens[token_index] == PatternCompoundExpression.OP_NOT:
472
 				(exp, next_index) = cls.__read_expression(tokens,
525
 				(exp, next_index) = cls.__read_expression(tokens,
473
 						token_index + 1, depth + 1, one_subexpression=True)
526
 						token_index + 1, depth + 1, one_subexpression=True)
474
 				subexpressions.append(PatternCompoundExpression('!', [exp]))
527
 				subexpressions.append(PatternCompoundExpression('!', [exp]))
507
 			raise PatternError('Expression nests too deeply')
560
 			raise PatternError('Expression nests too deeply')
508
 		if token_index >= len(tokens):
561
 		if token_index >= len(tokens):
509
 			raise PatternError('Expected field name, found EOL')
562
 			raise PatternError('Expected field name, found EOL')
510
-		field = tokens[token_index]
563
+		field: PatternField = tokens[token_index]
511
 		token_index += 1
564
 		token_index += 1
512
 
565
 
513
-		datatype = cls.FIELD_TO_TYPE.get(field)
566
+		datatype = cls.FIELD_TO_DATATYPE.get(field, None)
514
 		if datatype is None:
567
 		if datatype is None:
515
 			raise PatternError(f'No such field "{field}"')
568
 			raise PatternError(f'No such field "{field}"')
516
 
569
 
519
 		op = tokens[token_index]
572
 		op = tokens[token_index]
520
 		token_index += 1
573
 		token_index += 1
521
 
574
 
522
-		if op == '!':
575
+		if op == PatternCompoundExpression.OP_NOT:
523
 			if token_index >= len(tokens):
576
 			if token_index >= len(tokens):
524
 				raise PatternError('Expected operator, found EOL')
577
 				raise PatternError('Expected operator, found EOL')
525
 			op = '!' + tokens[token_index]
578
 			op = '!' + tokens[token_index]
526
 			token_index += 1
579
 			token_index += 1
527
 
580
 
528
-		allowed_ops = cls.TYPE_TO_OPERATORS[datatype]
581
+		allowed_ops = cls.DATATYPE_TO_OPERATORS[datatype]
529
 		if op not in allowed_ops:
582
 		if op not in allowed_ops:
530
 			if op in cls.OPERATORS_ALL:
583
 			if op in cls.OPERATORS_ALL:
531
 				raise PatternError(f'Operator {op} cannot be used with ' + \
584
 				raise PatternError(f'Operator {op} cannot be used with ' + \
551
 		"""
604
 		"""
552
 		Converts a value token to its Python value. Raises ValueError on failure.
605
 		Converts a value token to its Python value. Raises ValueError on failure.
553
 		"""
606
 		"""
554
-		if datatype == cls.TYPE_ID:
607
+		if datatype == cls.DATATYPE_ID:
555
 			if not is_user_id(value):
608
 			if not is_user_id(value):
556
 				raise ValueError(f'Illegal user id value: {value}')
609
 				raise ValueError(f'Illegal user id value: {value}')
557
 			return value
610
 			return value
558
-		if datatype == cls.TYPE_MEMBER:
611
+		if datatype == cls.DATATYPE_MEMBER:
559
 			return user_id_from_mention(value)
612
 			return user_id_from_mention(value)
560
-		if datatype == cls.TYPE_TEXT:
613
+		if datatype == cls.DATATYPE_TEXT:
561
 			s = str_from_quoted_str(value)
614
 			s = str_from_quoted_str(value)
562
 			if op in ('matches', '!matches'):
615
 			if op in ('matches', '!matches'):
563
 				try:
616
 				try:
570
 				except re.error as e:
623
 				except re.error as e:
571
 					raise ValueError(f'Invalid regex: {e}') from e
624
 					raise ValueError(f'Invalid regex: {e}') from e
572
 			return s
625
 			return s
573
-		if datatype == cls.TYPE_INT:
626
+		if datatype == cls.DATATYPE_INT:
574
 			return int(value)
627
 			return int(value)
575
-		if datatype == cls.TYPE_FLOAT:
628
+		if datatype == cls.DATATYPE_FLOAT:
576
 			return float(value)
629
 			return float(value)
577
-		if datatype == cls.TYPE_TIMESPAN:
630
+		if datatype == cls.DATATYPE_TIMESPAN:
578
 			return timedelta_from_str(value)
631
 			return timedelta_from_str(value)
579
 		raise ValueError(f'Unhandled datatype {datatype}')
632
 		raise ValueError(f'Unhandled datatype {datatype}')

+ 54
- 9
rocketbot/utils.py ファイルの表示

7
 from datetime import datetime, timedelta
7
 from datetime import datetime, timedelta
8
 from typing import Any, Optional, Type, Union
8
 from typing import Any, Optional, Type, Union
9
 
9
 
10
-from discord import Guild
11
-from discord.ext.commands import Cog, Group
10
+import discord
11
+from discord import Guild, Permissions
12
+from discord.ext.commands import Cog
12
 
13
 
13
-def dump_stacktrace(e: Exception) -> None:
14
+def dump_stacktrace(e: BaseException) -> None:
14
 	print(e, file=sys.stderr)
15
 	print(e, file=sys.stderr)
15
 	traceback.print_exception(type(e), e, e.__traceback__)
16
 	traceback.print_exception(type(e), e, e.__traceback__)
16
 
17
 
17
 def timedelta_from_str(s: str) -> timedelta:
18
 def timedelta_from_str(s: str) -> timedelta:
18
 	"""
19
 	"""
19
-	Parses a timespan. Format examples:
20
+	Parses a timespan.
21
+
22
+	Format examples:
20
 	"30m"
23
 	"30m"
21
 	"10s"
24
 	"10s"
22
 	"90d"
25
 	"90d"
23
 	"1h30m"
26
 	"1h30m"
24
 	"73d18h22m52s"
27
 	"73d18h22m52s"
28
+
29
+	Parameters
30
+	----------
31
+	s : str
32
+		string to parse
33
+
34
+	Returns
35
+	-------
36
+	timedelta
37
+
38
+	Raises
39
+	------
40
+	ValueError
41
+		if parsing fails
25
 	"""
42
 	"""
26
-	p: re.Pattern = re.compile('^(?:[0-9]+[dhms])+$')
43
+	p: re.Pattern = re.compile('^(?:[0-9]+[a-zA-Z])+$')
27
 	if p.match(s) is None:
44
 	if p.match(s) is None:
28
-		raise ValueError("Illegal timespan value '{s}'.")
45
+		raise ValueError(f'Illegal timespan value "{s}". Examples: 30s, 5m, 1h30m, 30d')
29
 	p = re.compile('([0-9]+)([dhms])')
46
 	p = re.compile('([0-9]+)([dhms])')
30
 	days: int = 0
47
 	days: int = 0
31
 	hours: int = 0
48
 	hours: int = 0
33
 	seconds: int = 0
50
 	seconds: int = 0
34
 	for m in p.finditer(s):
51
 	for m in p.finditer(s):
35
 		scalar = int(m.group(1))
52
 		scalar = int(m.group(1))
36
-		unit = m.group(2)
53
+		unit = m.group(2).lower()
37
 		if unit == 'd':
54
 		if unit == 'd':
38
 			days = scalar
55
 			days = scalar
39
 		elif unit == 'h':
56
 		elif unit == 'h':
42
 			minutes = scalar
59
 			minutes = scalar
43
 		elif unit == 's':
60
 		elif unit == 's':
44
 			seconds = scalar
61
 			seconds = scalar
62
+		else:
63
+			raise ValueError(f'Invalid unit "{unit}". Valid units: "s"=seconds, "m"=minutes, "h"=hours, "d"=days')
45
 	return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
64
 	return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
46
 
65
 
47
 def str_from_timedelta(td: timedelta) -> str:
66
 def str_from_timedelta(td: timedelta) -> str:
84
 		components = components[0:max_components]
103
 		components = components[0:max_components]
85
 	return ' '.join(components)
104
 	return ' '.join(components)
86
 
105
 
87
-def first_command_group(cog: Cog) -> Optional[Group]:
106
+def _old_first_command_group(cog: Cog) -> Optional[discord.ext.commands.Group]:
88
 	"""Returns the first command Group found in a cog."""
107
 	"""Returns the first command Group found in a cog."""
89
 	for member_name in dir(cog):
108
 	for member_name in dir(cog):
90
 		member = getattr(cog, member_name)
109
 		member = getattr(cog, member_name)
91
-		if isinstance(member, Group):
110
+		if isinstance(member, discord.ext.commands.Group):
111
+			return member
112
+	return None
113
+
114
+def first_command_group(cog: Cog) -> Optional[discord.app_commands.Group]:
115
+	"""Returns the first slash command Group found in a cog."""
116
+	for member_name in dir(cog):
117
+		member = getattr(cog, member_name)
118
+		if isinstance(member, discord.app_commands.Group):
92
 			return member
119
 			return member
93
 	return None
120
 	return None
94
 
121
 
143
 	if len(val) < 2 or val[0:1] not in __QUOTE_CHARS or val[-1:] not in __QUOTE_CHARS:
170
 	if len(val) < 2 or val[0:1] not in __QUOTE_CHARS or val[-1:] not in __QUOTE_CHARS:
144
 		raise ValueError(f'Not a quoted string: {val}')
171
 		raise ValueError(f'Not a quoted string: {val}')
145
 	return val[1:-1]
172
 	return val[1:-1]
173
+
174
+MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
175
+
176
+from discord import Interaction
177
+from discord.app_commands import Transformer
178
+from discord.ext.commands import BadArgument
179
+
180
+class TimeDeltaTransformer(Transformer):
181
+	async def transform(self, interaction: Interaction, value: Any) -> timedelta:
182
+		try:
183
+			return timedelta_from_str(str(value))
184
+		except ValueError as e:
185
+			print("Invalid time delta:", e)
186
+			raise BadArgument(str(e))
187
+
188
+	@property
189
+	def _error_display_name(self) -> str:
190
+		return 'timedelta'

読み込み中…
キャンセル
保存