Explorar el Código

Going with username-based Kickstarter linking. Ruff linter cleanup.

pull/35/head
Rocketsoup hace 1 semana
padre
commit
776c000110

+ 4
- 4
main.py Ver fichero

@@ -13,11 +13,11 @@ if sys.version_info < MIN_PYTHON_VERSION:
13 13
 	raise RuntimeError(f'rocketbot requires Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}.{MIN_PYTHON_VERSION[2]} '
14 14
 		f'or greater. Detected {ver[0]}.{ver[1]}.{ver[2]}.')
15 15
 
16
-import asyncio  # noqa: E402
16
+import asyncio
17 17
 
18
-from config import CONFIG  # noqa: E402
19
-from rocketbot.bot import start_bot  # noqa: E402
20
-from rocketbot.utils import bot_log  # noqa: E402
18
+from config import CONFIG
19
+from rocketbot.bot import start_bot
20
+from rocketbot.utils import bot_log
21 21
 
22 22
 CURRENT_CONFIG_VERSION = 5
23 23
 if (CONFIG.get('__config_version') or 0) < CURRENT_CONFIG_VERSION:

+ 4
- 4
rocketbot/bot.py Ver fichero

@@ -1,7 +1,7 @@
1 1
 import traceback
2
-from typing import Optional
3 2
 
4 3
 from discord import Intents
4
+from discord.errors import DiscordException
5 5
 from discord.ext import commands
6 6
 
7 7
 from config import CONFIG
@@ -69,13 +69,13 @@ class Rocketbot(commands.Bot):
69 69
 					CogSetting.set_up_all(bcog, self, bcog.settings)
70 70
 		try:
71 71
 			synced_commands = await self.tree.sync()
72
-			for command in synced_commands:
72
+			for command in sorted(synced_commands, key=lambda c: c.name):
73 73
 				bot_log(None, None, f'Synced command: /{command.name}')
74
-		except Exception as e:
74
+		except DiscordException as e:
75 75
 			dump_stacktrace(e)
76 76
 
77 77
 # Current active bot instance
78
-rocketbot: Optional[Rocketbot] = None
78
+rocketbot: Rocketbot | None = None
79 79
 
80 80
 def __create_bot():
81 81
 	global rocketbot

+ 14
- 14
rocketbot/botmessage.py Ver fichero

@@ -3,7 +3,7 @@ Classes for crafting messages from the bot. Content can change as information
3 3
 changes, and mods can perform actions on the message via emoji reactions.
4 4
 """
5 5
 from datetime import datetime
6
-from typing import Any, Optional, Union
6
+from typing import Any
7 7
 
8 8
 from discord import Guild, Message, PartialEmoji, TextChannel
9 9
 
@@ -42,11 +42,11 @@ class BotMessageReaction:
42 42
 
43 43
 	@classmethod
44 44
 	def standard_set(cls,
45
-			did_delete: bool = None,
45
+			did_delete: bool | None = None,
46 46
 			message_count: int = 1,
47
-			did_kick: bool = None,
48
-			did_ban: bool = None,
49
-			user_count: int = 1) -> list: # list[BotMessageReaction]:
47
+			did_kick: bool | None = None,
48
+			did_ban: bool | None = None,
49
+			user_count: int = 1) -> list['BotMessageReaction']:
50 50
 		"""
51 51
 		Convenience factory for generating any of the three most common
52 52
 		commands: delete message(s), kick user(s), and ban user(s). All
@@ -125,8 +125,8 @@ class BotMessage:
125 125
 			guild: Guild,
126 126
 			text: str,
127 127
 			type: int = TYPE_DEFAULT, # pylint: disable=redefined-builtin
128
-			context: Optional[Any] = None,
129
-			reply_to: Optional[Message] = None,
128
+			context: Any | None = None,
129
+			reply_to: Message | None = None,
130 130
 			suppress_embeds: bool = False):
131 131
 		"""
132 132
 		Creates a bot message.
@@ -140,13 +140,13 @@ class BotMessage:
140 140
 		self.guild: Guild = guild
141 141
 		self.text: str = text
142 142
 		self.type: int = type
143
-		self.context: Optional[Any] = context
144
-		self.quote: Optional[str] = None
143
+		self.context: Any | None = context
144
+		self.quote: str | None = None
145 145
 		self.source_cog = None  # Set by `BaseCog.post_message()`
146 146
 		self.__posted_text: list[str] = []  # last text posted, to test for changes
147 147
 		self.__posted_emoji: set[str] = set()  # last emoji list posted
148 148
 		self.__messages: list[Message] = []  # set once the message has been posted
149
-		self.__reply_to: Optional[Message] = reply_to
149
+		self.__reply_to: Message | None = reply_to
150 150
 		self.__suppress_embeds = suppress_embeds
151 151
 		self.__reactions: list[BotMessageReaction] = []
152 152
 
@@ -163,7 +163,7 @@ class BotMessage:
163 163
 		broken into multiple Discord messages."""
164 164
 		return [ m.id for m in self.__messages ]
165 165
 
166
-	def message_sent_at(self) -> Optional[datetime]:
166
+	def message_sent_at(self) -> datetime | None:
167 167
 		"""Returns when the message was sent or None if not sent."""
168 168
 		return norm_datetime(self.__messages[0].created_at) if len(self.__messages) > 0 else None
169 169
 
@@ -217,7 +217,7 @@ class BotMessage:
217 217
 			self.__reactions.append(reaction)
218 218
 		await self.update_if_sent()
219 219
 
220
-	async def remove_reaction(self, reaction_or_emoji: Union[BotMessageReaction, str]) -> None:
220
+	async def remove_reaction(self, reaction_or_emoji: BotMessageReaction | str) -> None:
221 221
 		"""
222 222
 		Removes a reaction. Can pass either a BotMessageReaction or just the
223 223
 		emoji string. If the message has been sent, it will be updated.
@@ -230,7 +230,7 @@ class BotMessage:
230 230
 				await self.update_if_sent()
231 231
 				return
232 232
 
233
-	def reaction_for_emoji(self, emoji) -> Optional[BotMessageReaction]:
233
+	def reaction_for_emoji(self, emoji) -> BotMessageReaction | None:
234 234
 		"""
235 235
 		Finds the BotMessageReaction for the given emoji or None if not found.
236 236
 		Accepts either a PartialEmoji or str.
@@ -272,7 +272,7 @@ class BotMessage:
272 272
 					self.__messages.append(message)
273 273
 				self.__posted_text = message_bodies
274 274
 		else: # No messages posted yet
275
-			channel: Optional[TextChannel] = None
275
+			channel: TextChannel | None = None
276 276
 			for index, body in enumerate(message_bodies):
277 277
 				if index == 0 and self.__reply_to:
278 278
 					message = await self.__reply_to.reply(content=body, mention_author=False)

+ 15
- 14
rocketbot/cogs/bangcommandcog.py Ver fichero

@@ -11,6 +11,7 @@ from discord import (
11 11
 	TextStyle,
12 12
 )
13 13
 from discord.app_commands import Choice, Group, autocomplete
14
+from discord.errors import DiscordException
14 15
 from discord.ext.commands import Cog
15 16
 from discord.ui import Label, Modal, Select, TextInput
16 17
 
@@ -67,7 +68,7 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
67 68
 	def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
68 69
 		return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
69 70
 
70
-	def get_saved_command(self, guild: Guild, name: str) -> Optional[BangCommand]:
71
+	def get_saved_command(self, guild: Guild, name: str) -> BangCommand | None:
71 72
 		cmds = self.get_saved_commands(guild)
72 73
 		name = BangCommandCog._normalize_name(name)
73 74
 		return cmds.get(name, None)
@@ -91,7 +92,7 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
91 92
 		}
92 93
 	)
93 94
 	@autocomplete(name=command_autocomplete)
94
-	async def define_command(self, interaction: Interaction, name: str, definition: Optional[str] = None, mod_only: bool = False) -> None:
95
+	async def define_command(self, interaction: Interaction, name: str, definition: str | None = None, mod_only: bool = False) -> None:
95 96
 		"""
96 97
 		Defines or redefines a bang command.
97 98
 
@@ -223,9 +224,8 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
223 224
 		if len(content) < 1 or len(content) > 2000:
224 225
 			raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
225 226
 		cmds = self.get_saved_commands(guild)
226
-		if check_exists:
227
-			if cmds.get(name, None) is not None:
228
-				raise ValueError(f'Command with name "{name}" already exists.')
227
+		if check_exists and cmds.get(name, None) is not None:
228
+			raise ValueError(f'Command with name "{name}" already exists.')
229 229
 		cmds[name] = {
230 230
 			'content': content,
231 231
 			'mod_only': mod_only,
@@ -262,17 +262,14 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
262 262
 
263 263
 	@staticmethod
264 264
 	def _normalize_name(name: str) -> str:
265
-		name = name.lower().strip()
266
-		if name.startswith('!'):
267
-			name = name[1:]
268
-		return name
265
+		return name.lower().strip().removeprefix('!')
269 266
 
270 267
 	@staticmethod
271
-	def _is_valid_name(name: Optional[str]) -> bool:
268
+	def _is_valid_name(name: str | None) -> bool:
272 269
 		return name is not None and re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
273 270
 
274 271
 	@staticmethod
275
-	def _name_from_command_message(name: Optional[str]) -> Optional[str]:
272
+	def _name_from_command_message(name: str | None) -> str | None:
276 273
 		if name is None:
277 274
 			return None
278 275
 		match = re.match(r'^!((?:[a-z]+)(?:[_-][a-z]+)*)\b.*$', name)
@@ -316,7 +313,11 @@ class _EditModal(Modal, title='Edit Command'):
316 313
 		)
317 314
 	)
318 315
 
319
-	def __init__(self, name: Optional[str] = None, content: Optional[str] = None, mod_only: Optional[bool] = None, exists: bool = False):
316
+	def __init__(self,
317
+			name: str | None = None,
318
+			content: str | None = None,
319
+			mod_only: bool | None = None,
320
+			exists: bool = False):
320 321
 		super().__init__()
321 322
 		self.exists = exists
322 323
 		# noinspection PyTypeChecker
@@ -360,5 +361,5 @@ class _EditModal(Modal, title='Edit Command'):
360 361
 				f'{CONFIG["failure_emoji"]} Save failed',
361 362
 				ephemeral=True,
362 363
 			)
363
-		except BaseException:
364
-			pass
364
+		except DiscordException as e:
365
+			dump_stacktrace(e)

+ 7
- 8
rocketbot/cogs/basecog.py Ver fichero

@@ -2,7 +2,6 @@
2 2
 Base cog class and helper classes.
3 3
 """
4 4
 from datetime import datetime, timedelta, timezone
5
-from typing import Optional
6 5
 
7 6
 import discord
8 7
 from discord import (
@@ -17,6 +16,7 @@ from discord import (
17 16
 from discord.abc import GuildChannel
18 17
 from discord.app_commands import AppCommandError
19 18
 from discord.app_commands.errors import CommandInvokeError
19
+from discord.errors import DiscordException
20 20
 from discord.ext.commands import Cog
21 21
 
22 22
 from config import CONFIG
@@ -43,9 +43,9 @@ class BaseCog(Cog):
43 43
 	def __init__(
44 44
 			self,
45 45
 			bot: Rocketbot,
46
-			config_prefix: Optional[str],
46
+			config_prefix: str | None,
47 47
 			short_description: str,
48
-			long_description: Optional[str] = None,
48
+			long_description: str | None = None,
49 49
 	):
50 50
 		"""
51 51
 		Parameters
@@ -60,7 +60,7 @@ class BaseCog(Cog):
60 60
 		self.bot: Rocketbot = bot
61 61
 		self.are_settings_setup: bool = False
62 62
 		self.settings: list[CogSetting] = []
63
-		self.config_prefix: Optional[str] = config_prefix
63
+		self.config_prefix: str | None = config_prefix
64 64
 		self.short_description: str = short_description
65 65
 		self.long_description: str = long_description
66 66
 
@@ -78,8 +78,7 @@ class BaseCog(Cog):
78 78
 		except discord.InteractionResponded:
79 79
 			try:
80 80
 				await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
81
-			except BaseException:
82
-				interaction.channel.send
81
+			except DiscordException:
83 82
 				bot_log(interaction.guild, None, message)
84 83
 
85 84
 	@property
@@ -124,7 +123,7 @@ class BaseCog(Cog):
124 123
 
125 124
 	@classmethod
126 125
 	def get_guild_setting(cls,
127
-			guild: Optional[Guild],
126
+			guild: Guild | None,
128 127
 			setting: CogSetting,
129 128
 			use_cog_default_if_not_set: bool = True):
130 129
 		"""
@@ -303,7 +302,7 @@ class BaseCog(Cog):
303 302
 	# Helpers
304 303
 
305 304
 	@classmethod
306
-	def log(cls, guild: Optional[Guild], message) -> None:
305
+	def log(cls, guild: Guild | None, message) -> None:
307 306
 		"""
308 307
 		Writes a message to the console. Intended for significant events only.
309 308
 		"""

+ 1
- 2
rocketbot/cogs/configcog.py Ver fichero

@@ -1,7 +1,6 @@
1 1
 """
2 2
 Cog handling general configuration for a guild.
3 3
 """
4
-from typing import Optional, Union
5 4
 
6 5
 from discord import Guild, Interaction, Role, TextChannel, User
7 6
 from discord.app_commands import Group
@@ -85,7 +84,7 @@ class ConfigCog(BaseCog, name='Configuration'):
85 84
 	)
86 85
 	async def set_warning_mention(self,
87 86
 			interaction: Interaction,
88
-			mention: Optional[Union[User, Role]] = None) -> None:
87
+			mention: User | Role | None = None) -> None:
89 88
 		"""
90 89
 		Sets a user/role to mention in warning messages.
91 90
 

+ 3
- 4
rocketbot/cogs/crosspostcog.py Ver fichero

@@ -3,7 +3,6 @@ Cog for detecting spam messages posted in multiple channels.
3 3
 """
4 4
 import re
5 5
 from datetime import datetime, timedelta, timezone
6
-from typing import Optional
7 6
 
8 7
 from discord import Member, Message, TextChannel
9 8
 from discord import utils as discordutils
@@ -23,7 +22,7 @@ class SpamContext:
23 22
 	def __init__(self, member: Member) -> None:
24 23
 		self.member: Member = member
25 24
 		self.age: datetime = datetime.now(timezone.utc)
26
-		self.bot_message: Optional[BotMessage] = None
25
+		self.bot_message: BotMessage | None = None
27 26
 		self.is_kicked: bool = False
28 27
 		self.is_banned: bool = False
29 28
 		self.is_autobanned: bool = False
@@ -252,7 +251,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
252 251
 		await self.__update_message_from_context(context)
253 252
 
254 253
 	async def __update_message_from_context(self, context: SpamContext) -> None:
255
-		first_spam_message: Message = sorted(list(context.spam_messages), key=lambda m: m.created_at)[0]
254
+		first_spam_message: Message = min(context.spam_messages, key=lambda m: m.created_at)
256 255
 		spam_count = len(context.spam_messages)
257 256
 		channel_count = len(context.unique_channels)
258 257
 		deleted_count = len(context.deleted_messages)
@@ -284,7 +283,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
284 283
 				body += f'messages in {channel_count} channels within {max_age_str} ' + \
285 284
 						f'({duplicate_count} are identical, showing first one).'
286 285
 			max_links = 10
287
-			for msg in sorted(list(context.spam_messages), key=lambda m: m.created_at)[:max_links]:
286
+			for msg in sorted(context.spam_messages, key=lambda m: m.created_at)[:max_links]:
288 287
 				body += f'\n- {msg.jump_url}'
289 288
 			if len(context.spam_messages) > max_links:
290 289
 				body += f'\n- ...{len(context.spam_messages) - max_links} more...'

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

@@ -32,7 +32,7 @@ class GeneralCog(BaseCog, name='General'):
32 32
 		)
33 33
 		self.is_connected = False
34 34
 		self.is_first_connect = True
35
-		self.last_disconnect_time: Optional[datetime] = None
35
+		self.last_disconnect_time: datetime | None = None
36 36
 		self.noteworthy_disconnect_duration = timedelta(seconds=5)
37 37
 		GeneralCog.shared = self
38 38
 

+ 21
- 20
rocketbot/cogs/helpcog.py Ver fichero

@@ -1,7 +1,7 @@
1 1
 """Provides help commands for getting info on using other commands and configuration."""
2 2
 import re
3 3
 import time
4
-from typing import Optional, TypedDict, Union
4
+from typing import Optional, TypedDict
5 5
 
6 6
 from discord import AppCommandType, Interaction, Permissions
7 7
 from discord.app_commands import (
@@ -12,6 +12,7 @@ from discord.app_commands import (
12 12
 	command,
13 13
 	guild_only,
14 14
 )
15
+from discord.errors import DiscordException
15 16
 
16 17
 from config import CONFIG
17 18
 from rocketbot.bot import Rocketbot
@@ -19,7 +20,7 @@ from rocketbot.cogs.basecog import BaseCog
19 20
 from rocketbot.ui.pagedcontent import paginate, update_paged_content
20 21
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
21 22
 
22
-HelpTopic = Union[Command, Group, BaseCog]
23
+HelpTopic = Command | Group | BaseCog
23 24
 class HelpMeta(TypedDict):
24 25
 	id: str
25 26
 	text: str
@@ -52,7 +53,7 @@ async def search_autocomplete(interaction: Interaction, current: str) -> list[Ch
52 53
 			choice_from_topic(topic, include_full_command=True)
53 54
 			for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
54 55
 		][:25]
55
-	except BaseException as e:
56
+	except DiscordException as e:
56 57
 		dump_stacktrace(e)
57 58
 		return []
58 59
 
@@ -120,24 +121,24 @@ class HelpCog(BaseCog, name='Help'):
120 121
 				text += f' {cog.long_description}'
121 122
 			self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cog })
122 123
 
123
-	def topic_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]:
124
+	def topic_for_help_symbol(self, symbol: str) -> HelpTopic | None:
124 125
 		self.__create_help_index()
125 126
 		return self.id_to_topic.get(symbol, None)
126 127
 
127
-	def all_commands(self) -> list[Union[Command, Group]]:
128
+	def all_commands(self) -> list[Command | Group]:
128 129
 		# PyCharm not interpreting conditional return type correctly.
129 130
 		# noinspection PyTypeChecker
130
-		cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
131
+		cmds: list[Command | Group] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
131 132
 		return sorted(cmds, key=lambda cmd: cmd.name)
132 133
 
133
-	def all_accessible_commands(self, permissions: Optional[Permissions]) -> list[Union[Command, Group]]:
134
+	def all_accessible_commands(self, permissions: Permissions | None) -> list[Command | Group]:
134 135
 		return [
135 136
 			cmd
136 137
 			for cmd in self.all_commands()
137 138
 			if can_use_command(cmd, permissions)
138 139
 		]
139 140
 
140
-	def all_accessible_subcommands(self, permissions: Optional[Permissions]) -> list[Command]:
141
+	def all_accessible_subcommands(self, permissions: Permissions | None) -> list[Command]:
141 142
 		cmds = self.all_accessible_commands(permissions)
142 143
 		subcmds: list[Command] = []
143 144
 		for cmd in cmds:
@@ -147,14 +148,14 @@ class HelpCog(BaseCog, name='Help'):
147 148
 						subcmds.append(subcmd)
148 149
 		return subcmds
149 150
 
150
-	def all_accessible_cogs(self, permissions: Optional[Permissions]) -> list[BaseCog]:
151
+	def all_accessible_cogs(self, permissions: Permissions | None) -> list[BaseCog]:
151 152
 		return [
152 153
 			cog
153 154
 			for cog in self.basecogs
154 155
 			if can_use_cog(cog, permissions)
155 156
 		]
156 157
 
157
-	def all_accessible_topics(self, permissions: Optional[Permissions], *,
158
+	def all_accessible_topics(self, permissions: Permissions | None, *,
158 159
 							  include_cogs: bool = True,
159 160
 							  include_commands: bool = True,
160 161
 							  include_subcommands: bool = True) -> list[HelpTopic]:
@@ -167,7 +168,7 @@ class HelpCog(BaseCog, name='Help'):
167 168
 			topics += self.all_accessible_subcommands(permissions)
168 169
 		return topics
169 170
 
170
-	def topics_for_keywords(self, search: str, permissions: Optional[Permissions]) -> list[HelpTopic]:
171
+	def topics_for_keywords(self, search: str, permissions: Permissions | None) -> list[HelpTopic]:
171 172
 		start_time = time.perf_counter()
172 173
 		self.__create_help_index()
173 174
 
@@ -189,7 +190,7 @@ class HelpCog(BaseCog, name='Help'):
189 190
 		accessible_topics = [
190 191
 			topic
191 192
 			for topic in matching_topics
192
-			if ((isinstance(topic, Command) or isinstance(topic, Group)) and can_use_command(topic, permissions)) or \
193
+			if ((isinstance(topic, (Command, Group))) and can_use_command(topic, permissions)) or \
193 194
 			   (isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
194 195
 		]
195 196
 
@@ -221,7 +222,7 @@ class HelpCog(BaseCog, name='Help'):
221 222
 	)
222 223
 	@guild_only()
223 224
 	@autocomplete(search=search_autocomplete)
224
-	async def help_command(self, interaction: Interaction, search: Optional[str]) -> None:
225
+	async def help_command(self, interaction: Interaction, search: str | None) -> None:
225 226
 		"""
226 227
 		Shows help for using commands and subcommands and configuring modules.
227 228
 
@@ -259,10 +260,10 @@ class HelpCog(BaseCog, name='Help'):
259 260
 			delete_after=10,
260 261
 		)
261 262
 
262
-	def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
263
+	def get_command_list(self, permissions: Permissions | None = None) -> dict[str, Command | Group]:
263 264
 		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
264 265
 
265
-	def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
266
+	def get_subcommand_list(self, cmd: Group, permissions: Permissions | None = None) -> dict[str, Command]:
266 267
 		return {
267 268
 			subcmd.name: subcmd
268 269
 			for subcmd in cmd.commands
@@ -308,11 +309,11 @@ class HelpCog(BaseCog, name='Help'):
308 309
 
309 310
 		await self.__send_paged_help(interaction, text)
310 311
 
311
-	async def __send_keyword_help(self, interaction: Interaction, matching_topics: Optional[list[HelpTopic]]) -> None:
312
+	async def __send_keyword_help(self, interaction: Interaction, matching_topics: list[HelpTopic] | None) -> None:
312 313
 		matching_commands = [
313 314
 			cmd
314 315
 			for cmd in matching_topics or []
315
-			if isinstance(cmd, Command) or isinstance(cmd, Group)
316
+			if isinstance(cmd, (Command, Group))
316 317
 		]
317 318
 		matching_cogs = [
318 319
 			cog
@@ -346,7 +347,7 @@ class HelpCog(BaseCog, name='Help'):
346 347
 
347 348
 		await self.__send_paged_help(interaction, text)
348 349
 
349
-	async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
350
+	async def __send_command_help(self, interaction: Interaction, command_or_group: Command | Group, addendum: str | None = None) -> None:
350 351
 		text = ''
351 352
 		if addendum is not None:
352 353
 			text += addendum + '\n\n'
@@ -437,13 +438,13 @@ trivial_words = {
437 438
 	'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
438 439
 }
439 440
 
440
-def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
441
+def can_use_command(cmd: Group | Command, user_permissions: Permissions | None) -> bool:
441 442
 	if user_permissions is None:
442 443
 		return False
443 444
 	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
444 445
 		return False
445 446
 	return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
446 447
 
447
-def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
448
+def can_use_cog(cog: BaseCog, user_permissions: Permissions | None) -> bool:
448 449
 	# "Using" a cog for now means configuring it, and only mods can configure cogs.
449 450
 	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)

+ 279
- 830
rocketbot/cogs/kickstartercog.py
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 11
- 10
rocketbot/cogs/logcog.py Ver fichero

@@ -3,9 +3,9 @@ Cog for detecting large numbers of guild joins in a short period of time.
3 3
 """
4 4
 import difflib
5 5
 import re
6
-from collections.abc import Sequence
6
+from collections.abc import Callable, Sequence
7 7
 from datetime import datetime, timedelta, timezone
8
-from typing import Any, Callable, Optional, Union
8
+from typing import Any
9 9
 
10 10
 from discord import (
11 11
 	AuditLogAction,
@@ -24,6 +24,7 @@ from discord import (
24 24
 	User,
25 25
 )
26 26
 from discord.abc import GuildChannel
27
+from discord.errors import DiscordException
27 28
 from discord.ext import tasks
28 29
 from discord.ext.commands import Cog
29 30
 from discord.utils import escape_markdown
@@ -34,7 +35,7 @@ from rocketbot.utils import dump_stacktrace, norm_datetime
34 35
 
35 36
 
36 37
 class BufferedMessageEditEvent:
37
-	def __init__(self, guild: Guild, channel: GuildChannel, before: Optional[Message], after: Message, data = None) -> None:
38
+	def __init__(self, guild: Guild, channel: GuildChannel, before: Message | None, after: Message, data = None) -> None:
38 39
 		self.guild = guild
39 40
 		self.channel = channel
40 41
 		self.before = before
@@ -42,7 +43,7 @@ class BufferedMessageEditEvent:
42 43
 		self.data = data
43 44
 
44 45
 class BufferedMessageDeleteEvent:
45
-	def __init__(self, guild: Guild, channel: GuildChannel, message_id: int, message: Optional[Message] = None) -> None:
46
+	def __init__(self, guild: Guild, channel: GuildChannel, message_id: int, message: Message | None = None) -> None:
46 47
 		self.guild = guild
47 48
 		self.channel = channel
48 49
 		self.message_id = message_id
@@ -401,7 +402,7 @@ class LoggingCog(BaseCog, name='Logging'):
401 402
 		await bot_message.update()
402 403
 
403 404
 	@Cog.listener()
404
-	async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
405
+	async def on_member_ban(self, guild: Guild, user: User | Member) -> None:
405 406
 		"""
406 407
 		Called when user gets banned from a Guild.
407 408
 
@@ -424,7 +425,7 @@ class LoggingCog(BaseCog, name='Logging'):
424 425
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
425 426
 		await bot_message.update()
426 427
 
427
-	async def __find_audit_entry(self, user: Union[User, Member], action: AuditLogAction, max_age: int = 10) -> Optional[AuditLogEntry]:
428
+	async def __find_audit_entry(self, user: User | Member, action: AuditLogAction, max_age: int = 10) -> AuditLogEntry | None:
428 429
 		"""
429 430
 		Searches the audit log for the most recent entry of a given type for a
430 431
 		given user. Intended for finding the relevant entry for a ban/kick that
@@ -442,7 +443,7 @@ class LoggingCog(BaseCog, name='Logging'):
442 443
 		return None
443 444
 
444 445
 	@Cog.listener()
445
-	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
446
+	async def on_member_unban(self, guild: Guild, user: User | Member) -> None:
446 447
 		"""
447 448
 		Called when a User gets unbanned from a Guild.
448 449
 
@@ -476,7 +477,7 @@ class LoggingCog(BaseCog, name='Logging'):
476 477
 			self.buffered_guilds.clear()
477 478
 			for guild in guilds:
478 479
 				await self.__flush_buffers_for_guild(guild)
479
-		except Exception as e:
480
+		except DiscordException as e:
480 481
 			dump_stacktrace(e)
481 482
 
482 483
 	async def __flush_buffers_for_guild(self, guild: Guild) -> None:
@@ -739,7 +740,7 @@ class LoggingCog(BaseCog, name='Logging'):
739 740
 		else:
740 741
 			complex_deletes = events
741 742
 		if len(complex_deletes) > 0:
742
-			messages_per_author: dict[Optional[User], list[BufferedMessageDeleteEvent]] = self.__groupby(complex_deletes, lambda e: e.author)
743
+			messages_per_author: dict[User | None, list[BufferedMessageDeleteEvent]] = self.__groupby(complex_deletes, lambda e: e.author)
743 744
 			text = 'Multiple messages deleted' if len(complex_deletes) > 1 else 'Message deleted'
744 745
 			row_count = 0
745 746
 			for author, messages in messages_per_author.items():
@@ -873,7 +874,7 @@ class LoggingCog(BaseCog, name='Logging'):
873 874
 			return '> _<no content>_'
874 875
 		return '> ' + escape_markdown(s).replace('\n', '\n> ')
875 876
 
876
-	def __describe_user(self, user: Union[User, Member]) -> str:
877
+	def __describe_user(self, user: User | Member) -> str:
877 878
 		"""
878 879
 		Standardized Markdown describing a user or member.
879 880
 		"""

+ 5
- 4
rocketbot/cogs/patterncog.py Ver fichero

@@ -9,6 +9,7 @@ from typing import Optional
9 9
 from discord import Guild, Intents, Interaction, Member, Message
10 10
 from discord import utils as discordutils
11 11
 from discord.app_commands import Choice, Group, autocomplete
12
+from discord.errors import DiscordException
12 13
 from discord.ext.commands import Cog
13 14
 
14 15
 from config import CONFIG
@@ -47,7 +48,7 @@ async def pattern_name_autocomplete(interaction: Interaction, current: str) -> l
47 48
 		for name in sorted(patterns.keys()):
48 49
 			if len(current_normal) == 0 or current_normal.startswith(name.lower()):
49 50
 				choices.append(Choice(name=name, value=name))
50
-	except BaseException as e:
51
+	except DiscordException as e:
51 52
 		dump_stacktrace(e)
52 53
 	return choices
53 54
 
@@ -55,7 +56,7 @@ async def action_autocomplete(interaction: Interaction, current: str) -> list[Ch
55 56
 	# FIXME: WORK IN PROGRESS
56 57
 	print(f'autocomplete action - current = "{current}"')
57 58
 	regex = re.compile('^(.*?)([a-zA-Z]+)$')
58
-	match: Optional[re.Match[str]] = regex.match(current)
59
+	match: re.Match[str] | None = regex.match(current)
59 60
 	initial: str = ''
60 61
 	stub: str = current
61 62
 	if match:
@@ -163,11 +164,11 @@ class PatternCog(BaseCog, name='Pattern Matching'):
163 164
 	def __save_patterns(cls,
164 165
 			guild: Guild,
165 166
 			patterns: dict[str, PatternStatement]) -> None:
166
-		to_save: list[dict] = list(map(lambda ps: ps.to_json(), patterns.values()))
167
+		to_save: list[dict] = [ps.to_json() for ps in patterns.values()]
167 168
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
168 169
 
169 170
 	@classmethod
170
-	def __get_last_matched(cls, guild: Guild, name: str) -> Optional[datetime]:
171
+	def __get_last_matched(cls, guild: Guild, name: str) -> datetime | None:
171 172
 		last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
172 173
 		if last_matched:
173 174
 			return last_matched.get(name)

+ 5
- 9
rocketbot/cogs/urlspamcog.py Ver fichero

@@ -270,15 +270,11 @@ class URLSpamCog(BaseCog, name='URL Spam'):
270 270
 					link = 'https://' + link[12:]
271 271
 				if link.startswith('http://www.'):
272 272
 					link = 'http://' + link[11:]
273
-				if link.endswith('/'):
274
-					link = link[:-1]
275
-				if label.startswith('www.'):
276
-					label = label[4:]
277
-				if label.endswith('/'):
278
-					label = label[:-1]
279
-				if link.startswith('https://') and 'https://' + label != link:
280
-					return True
281
-				elif link.startswith('http://') and 'http://' + label != link:
273
+				link = link.removesuffix('/')
274
+				label = label.removeprefix('www.')
275
+				label = label.removesuffix('/')
276
+				if (link.startswith('https://') and 'https://' + label != link) or \
277
+					(link.startswith('http://') and 'http://' + label != link):
282 278
 					return True
283 279
 		return False
284 280
 

+ 5
- 8
rocketbot/cogs/usernamecog.py Ver fichero

@@ -1,7 +1,6 @@
1 1
 """
2 2
 Cog for detecting username patterns.
3 3
 """
4
-from typing import Optional
5 4
 
6 5
 from discord import Guild, Intents, Interaction, Member
7 6
 from discord.app_commands import Group
@@ -19,9 +18,9 @@ class UsernamePatternContext:
19 18
 	"""
20 19
 	def __init__(self, member: Member) -> None:
21 20
 		self.member: Member = member
22
-		self.kicked_by: Optional[Member] = None
23
-		self.banned_by: Optional[Member] = None
24
-		self.ignored_by: Optional[Member] = None
21
+		self.kicked_by: Member | None = None
22
+		self.banned_by: Member | None = None
23
+		self.ignored_by: Member | None = None
25 24
 
26 25
 	def reactions(self) -> list[BotMessageReaction]:
27 26
 		"""
@@ -187,9 +186,7 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
187 186
 	async def on_member_join(self, member: Member) -> None:
188 187
 		"""Event handler"""
189 188
 		for pattern in self.__get_patterns(member.guild):
190
-			if self.matches(pattern, member.name):
191
-				await self.handle_match(member, pattern)
192
-			elif self.matches(pattern, member.display_name):
189
+			if self.matches(pattern, member.name) or self.matches(pattern, member.display_name):
193 190
 				await self.handle_match(member, pattern)
194 191
 
195 192
 	def matches(self, pattern: str, subject: str) -> bool:
@@ -209,7 +206,7 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
209 206
 		context = UsernamePatternContext(member)
210 207
 		bm = BotMessage(
211 208
 			member.guild,
212
-			f'User {member.mention} ({str(member.id)}, {member.display_name}) has ' +
209
+			f'User {member.mention} ({member.id}, {member.display_name}) has ' +
213 210
 			f'username matching pattern `{pattern}`.',
214 211
 			BotMessage.TYPE_INFO if self.was_warned_recently(member) else BotMessage.TYPE_MOD_WARNING,
215 212
 			context)

+ 30
- 31
rocketbot/cogs/videopreviewcog.py Ver fichero

@@ -1,10 +1,10 @@
1 1
 import asyncio
2 2
 import json
3 3
 import re
4
-import subprocess
5 4
 from datetime import timedelta
5
+from typing import Any
6 6
 
7
-from discord import Intents, Message
7
+from discord import Guild, Intents, Message
8 8
 from discord.ext.commands import Cog
9 9
 
10 10
 from rocketbot.cogs.basecog import BaseCog
@@ -19,15 +19,11 @@ from rocketbot.utils import (
19 19
 def filter_video_format(format: dict) -> bool:
20 20
 	if format.get('resolution') == 'audio only':
21 21
 		return False
22
-	if format.get('format_note') == 'DASH audio':
23
-		return False
24
-	return True
22
+	return format.get('format_note') != 'DASH audio'
25 23
 
26 24
 def rank_video_format(format: dict) -> tuple:
27 25
 	content = 0
28
-	if format.get('resolution') == 'audio only':
29
-		content = 1
30
-	elif format.get('format_note') == 'DASH audio':
26
+	if format.get('resolution') == 'audio only' or format.get('format_note') == 'DASH audio':
31 27
 		content = 1
32 28
 	elif format.get('format_note') == 'DASH video':
33 29
 		content = 2
@@ -87,7 +83,7 @@ class VideoPreviewCog(BaseCog, name='Video Link Previews'):
87 83
 	REGEX_FACEBOOK_POST = r'(https?:\/\/(?:www\.)?)\w*(facebook\.com(?:\/\w+)+\/\w+\/?)'
88 84
 	REGEX_TWITTER_POST = r'(https?:\/\/(?:www\.)?)\w*((?:twitter|x)\.com\/\w+\/status\/[0-9]+)'
89 85
 
90
-	REGEX_SPOILERS = '\|\|.+\|\|'
86
+	REGEX_SPOILERS = r'\|\|.+\|\|'
91 87
 
92 88
 	# Best video and best audio, mp4 format with m4a audio
93 89
 	FORMATS = 'bv*[ext=mp4]+ba[ext=m4a]/' \
@@ -154,11 +150,11 @@ class VideoPreviewCog(BaseCog, name='Video Link Previews'):
154 150
 		delay: timedelta = self.get_guild_setting(message.guild, Self.SETTING_DELAY)
155 151
 		await asyncio.sleep(delay.total_seconds())
156 152
 		# Look for embeds already showing the video
157
-		# self.log(message.guild, "Checking message for embeds")
153
+		self.__trace(message.guild, "Checking message for embeds")
158 154
 		for embed in message.embeds:
159 155
 			if embed.video.url:
160 156
 				# If there's any video, skip downloading any previews
161
-				# self.log(message.guild, "Message already has a video. Skipping this message.")
157
+				self.__trace(message.guild, "Message already has a video. Skipping this message.")
162 158
 				return
163 159
 		await self._fetch_previews(message, links)
164 160
 
@@ -169,37 +165,36 @@ class VideoPreviewCog(BaseCog, name='Video Link Previews'):
169 165
 		await asyncio.gather(*promises)
170 166
 
171 167
 	async def _fetch_preview(self, message: Message, link: MessageLink):
172
-		result = subprocess.run(
173
-			[
174
-				'yt-dlp',
175
-				'--skip-download',
176
-				'--dump-single-json',
177
-				link.url,
178
-			],
179
-			stdout=subprocess.PIPE,
180
-			stderr=subprocess.PIPE,
181
-			universal_newlines=True
168
+		process = await asyncio.create_subprocess_exec(
169
+			'yt-dlp',
170
+			'--skip-download',
171
+			'--dump-single-json',
172
+			link.url,
173
+			stdout=asyncio.subprocess.PIPE,
174
+			stderr=asyncio.subprocess.PIPE
182 175
 		)
183
-		if result.returncode != 0:
184
-			# self.log(message.guild, "Fetching link info JSON failed. Skipping preview.")
185
-			# self.log(message.guild, result.stderr)
176
+		stdout, stderr = await process.communicate()
177
+		await process.wait()
178
+		if process.returncode != 0:
179
+			self.__trace(message.guild, "Fetching link info JSON failed. Skipping preview.")
180
+			self.__trace(message.guild, stderr.decode())
186 181
 			return
187 182
 		try:
188
-			info: dict = json.loads(result.stdout)
189
-		except Exception as e:
190
-			# self.log(message.guild, f"Error parsing info.json. Skipping preview. {e}")
183
+			info: dict = json.loads(stdout)
184
+		except json.JSONDecodeError as e:
185
+			self.__trace(message.guild, f"Error parsing info.json. Skipping preview. {e}")
191 186
 			return
192 187
 		description = info.get('description') or ''
193 188
 		formats: list[dict] = info.get('formats') or []
194
-		# self.log(message.guild, f"Found {len(formats)} formats")
189
+		self.__trace(message.guild, f"Found {len(formats)} formats")
195 190
 		formats = list(filter(filter_video_format, formats))
196
-		# self.log(message.guild, f"Filtered to {len(formats)} formats")
191
+		self.__trace(message.guild, f"Filtered to {len(formats)} formats")
197 192
 		sorted_formats: list[dict] = sorted(formats, key=rank_video_format, reverse=True)
198 193
 		if len(sorted_formats) == 0:
199
-			# self.log(message.guild, f"No eligible formats for URL {link.url}")
194
+			self.__trace(message.guild, f"No eligible formats for URL {link.url}")
200 195
 			return
201 196
 		best_format: dict = sorted_formats[0]
202
-		# self.log(message.guild, f"Best format is id {best_format.get('format_id')}")
197
+		self.__trace(message.guild, f"Best format is id {best_format.get('format_id')}")
203 198
 		video_url: str = best_format.get('url')
204 199
 		link_description: str = "video"
205 200
 		if (best_format.get('width') or 0) > 0 and (best_format.get('height') or 0) > 0:
@@ -220,6 +215,10 @@ class VideoPreviewCog(BaseCog, name='Video Link Previews'):
220 215
 			mention_author=False
221 216
 		)
222 217
 
218
+	def __trace(self, guild: Guild, message: Any):
219
+		# self.log(guild, message)
220
+		pass
221
+
223 222
 	@classmethod
224 223
 	def supports_intents(cls, intents: Intents) -> bool:
225 224
 		return intents.message_content

+ 13
- 12
rocketbot/cogsetting.py Ver fichero

@@ -3,11 +3,12 @@ A guild configuration setting available for editing via bot commands.
3 3
 """
4 4
 
5 5
 from datetime import timedelta
6
-from typing import TYPE_CHECKING, Any, Literal, Optional, Union
6
+from typing import TYPE_CHECKING, Any, Literal, Union
7 7
 
8 8
 from discord import Interaction, Permissions
9 9
 from discord.app_commands import Range, Transform, describe
10 10
 from discord.app_commands.commands import Command, CommandCallback, Group, rename
11
+from discord.errors import DiscordException
11 12
 from discord.ext.commands import Bot
12 13
 
13 14
 from config import CONFIG
@@ -54,13 +55,13 @@ class CogSetting:
54 55
 
55 56
 	def __init__(self,
56 57
 			name: str,
57
-			datatype: Optional[type],
58
+			datatype: type | None,
58 59
 			default_value: Any,
59
-			brief: Optional[str] = None,
60
-			description: Optional[str] = None,
61
-			min_value: Optional[Any] = None,
62
-			max_value: Optional[Any] = None,
63
-			enum_values: Optional[set[Any]] = None):
60
+			brief: str | None = None,
61
+			description: str | None = None,
62
+			min_value: Any | None = None,
63
+			max_value: Any | None = None,
64
+			enum_values: set[Any] | None = None):
64 65
 		"""
65 66
 		Parameters
66 67
 		----------
@@ -88,11 +89,11 @@ class CogSetting:
88 89
 		self.name: str = name
89 90
 		self.datatype: type = datatype
90 91
 		self.default_value = default_value
91
-		self.brief: Optional[str] = brief
92
+		self.brief: str | None = brief
92 93
 		self.description: str = description or ''  # Can't be None
93
-		self.min_value: Optional[Any] = min_value
94
-		self.max_value: Optional[Any] = max_value
95
-		self.enum_values: Optional[set[Any]] = enum_values
94
+		self.min_value: Any | None = min_value
95
+		self.max_value: Any | None = max_value
96
+		self.enum_values: set[Any] | None = enum_values
96 97
 		if self.enum_values:
97 98
 			value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
98 99
 			self.description += f' (Permitted values: {value_list})'
@@ -426,7 +427,7 @@ class CogSetting:
426 427
 					text,
427 428
 					ephemeral=True,
428 429
 				)
429
-			except BaseException as e:
430
+			except DiscordException as e:
430 431
 				dump_stacktrace(e)
431 432
 		show_all_command = Command(
432 433
 			name='all',

+ 2
- 2
rocketbot/collections.py Ver fichero

@@ -451,8 +451,8 @@ class AgeBoundList(AbstractMutableList[V], Generic[V, A, D]):
451 451
 		if self.is_culling or len(self) <= 1:
452 452
 			return
453 453
 		self.is_culling = True
454
-		min_age: Optional[A] = None
455
-		max_age: Optional[A] = None
454
+		min_age: A | None = None
455
+		max_age: A | None = None
456 456
 		ages: dict[int, A] = {}
457 457
 		for i, elem in enumerate(self):
458 458
 			age: A = self.element_age(i, elem)

+ 17
- 17
rocketbot/pattern.py Ver fichero

@@ -5,7 +5,7 @@ to take on them.
5 5
 import re
6 6
 from abc import ABCMeta, abstractmethod
7 7
 from datetime import datetime, timezone
8
-from typing import Any, Literal, Union
8
+from typing import Any, ClassVar, Literal
9 9
 
10 10
 from discord import Message
11 11
 from discord import utils as discordutils
@@ -260,7 +260,7 @@ class PatternCompiler:
260 260
 	DATATYPE_TEXT: str = 'text'
261 261
 	DATATYPE_TIMESPAN: str = 'timespan'
262 262
 
263
-	FIELD_TO_DATATYPE: dict[PatternField, str] = {
263
+	FIELD_TO_DATATYPE: ClassVar[dict[PatternField, str]] = {
264 264
 		PatternSimpleExpression.ALIAS_FIELD_AUTHOR_ID: DATATYPE_MEMBER,
265 265
 		PatternSimpleExpression.FIELD_AUTHOR_ID: DATATYPE_ID,
266 266
 		PatternSimpleExpression.FIELD_AUTHOR_JOINAGE: DATATYPE_TIMESPAN,
@@ -270,9 +270,9 @@ class PatternCompiler:
270 270
 		PatternSimpleExpression.FIELD_CONTENT_PLAIN: DATATYPE_TEXT,
271 271
 		PatternSimpleExpression.FIELD_LAST_MATCHED: DATATYPE_TIMESPAN,
272 272
 	}
273
-	DEPRECATED_FIELDS: set[PatternField] = { 'content' }
273
+	DEPRECATED_FIELDS: ClassVar[set[PatternField]] = { 'content' }
274 274
 
275
-	ACTION_TO_ARGS: dict[PatternActionType, list[str]] = {
275
+	ACTION_TO_ARGS: ClassVar[dict[PatternActionType, list[str]]] = {
276 276
 		PatternAction.TYPE_BAN: [],
277 277
 		PatternAction.TYPE_DELETE: [],
278 278
 		PatternAction.TYPE_KICK: [],
@@ -281,18 +281,18 @@ class PatternCompiler:
281 281
 		PatternAction.TYPE_REPLY: [ DATATYPE_TEXT ],
282 282
 	}
283 283
 
284
-	OPERATORS_IDENTITY: set[PatternComparisonOperator] = {
284
+	OPERATORS_IDENTITY: ClassVar[set[PatternComparisonOperator]] = {
285 285
 		PatternSimpleExpression.OP_EQUALS,
286 286
 		PatternSimpleExpression.OP_NOT_EQUALS,
287 287
 	}
288
-	OPERATORS_COMPARISON: set[PatternComparisonOperator] = {
288
+	OPERATORS_COMPARISON: ClassVar[set[PatternComparisonOperator]] = {
289 289
 		PatternSimpleExpression.OP_LESS_THAN,
290 290
 		PatternSimpleExpression.OP_GREATER_THAN,
291 291
 		PatternSimpleExpression.OP_LESS_THAN_OR_EQUALS,
292 292
 		PatternSimpleExpression.OP_GREATER_THAN_OR_EQUALS,
293 293
 	}
294
-	OPERATORS_NUMERIC: set[PatternComparisonOperator] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
295
-	OPERATORS_TEXT: set[PatternComparisonOperator] = OPERATORS_IDENTITY | {
294
+	OPERATORS_NUMERIC: ClassVar[set[PatternComparisonOperator]] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
295
+	OPERATORS_TEXT: ClassVar[set[PatternComparisonOperator]] = OPERATORS_IDENTITY | {
296 296
 		PatternSimpleExpression.OP_CONTAINS,
297 297
 		PatternSimpleExpression.OP_NOT_CONTAINS,
298 298
 		PatternSimpleExpression.OP_CONTAINS_WORD,
@@ -302,7 +302,7 @@ class PatternCompiler:
302 302
 	}
303 303
 	OPERATORS_ALL: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
304 304
 
305
-	DATATYPE_TO_OPERATORS: dict[str, set[PatternComparisonOperator]] = {
305
+	DATATYPE_TO_OPERATORS: ClassVar[dict[str, set[PatternComparisonOperator]]] = {
306 306
 		DATATYPE_ID: OPERATORS_IDENTITY,
307 307
 		DATATYPE_MEMBER: OPERATORS_IDENTITY,
308 308
 		DATATYPE_TEXT: OPERATORS_TEXT,
@@ -356,7 +356,7 @@ class PatternCompiler:
356 356
 		Converts a message filter statement into a list of tokens.
357 357
 		"""
358 358
 		tokens: list[str] = []
359
-		in_quote: Union[bool, str] = False
359
+		in_quote: bool | str = False
360 360
 		in_escape: bool = False
361 361
 		all_token_types: set[str] = { 'sym', 'op', 'val' }
362 362
 		possible_token_types: set[str] = set(all_token_types)
@@ -404,11 +404,11 @@ class PatternCompiler:
404 404
 					if ch in cls.OP_CHARS:
405 405
 						possible_ch_types.add('op')
406 406
 					if len(current_token) > 0 and \
407
-							possible_ch_types.isdisjoint(possible_token_types):
408
-						if len(current_token) > 0:
409
-							tokens.append(current_token)
410
-							current_token = ''
411
-							possible_token_types |= all_token_types
407
+							possible_ch_types.isdisjoint(possible_token_types) and \
408
+							len(current_token) > 0:
409
+						tokens.append(current_token)
410
+						current_token = ''
411
+						possible_token_types |= all_token_types
412 412
 					possible_token_types &= possible_ch_types
413 413
 					current_token += ch
414 414
 		if len(current_token) > 0:
@@ -590,7 +590,7 @@ class PatternCompiler:
590 590
 				raise PatternError(f'Operator {op} cannot be used with ' + \
591 591
 					f'field "{field}"')
592 592
 			raise PatternError(f'Unrecognized operator "{op}" - allowed: ' + \
593
-				f'{sorted(list(allowed_ops))}')
593
+				f'{sorted(allowed_ops)}')
594 594
 
595 595
 		if token_index >= len(tokens):
596 596
 			raise PatternError('Expected value, found EOL')
@@ -606,7 +606,7 @@ class PatternCompiler:
606 606
 		return exp, token_index
607 607
 
608 608
 	@classmethod
609
-	def __parse_value(cls, value: str, datatype: str, op: str = None) -> Any:
609
+	def __parse_value(cls, value: str, datatype: str, op: str | None = None) -> Any:
610 610
 		"""
611 611
 		Converts a value token to its Python value. Raises ValueError on failure.
612 612
 		"""

+ 10
- 10
rocketbot/storage.py Ver fichero

@@ -4,7 +4,7 @@ Handles storage of persisted and non-persisted data for the bot.
4 4
 import json
5 5
 from datetime import datetime, timedelta, timezone
6 6
 from os.path import exists
7
-from typing import Any, Optional
7
+from typing import Any
8 8
 
9 9
 from discord import Guild
10 10
 
@@ -28,7 +28,7 @@ class Storage:
28 28
 
29 29
 	# -- Transient state management -----------------------------------------
30 30
 
31
-	__guild_id_to_state: dict[int, dict[str, Any]] = {}
31
+	__guild_id_to_state: dict[int, dict[str, Any]] = {}  # noqa: RUF012
32 32
 
33 33
 	@classmethod
34 34
 	def get_state(cls, guild: Guild) -> dict[str, Any]:
@@ -43,14 +43,14 @@ class Storage:
43 43
 		return state
44 44
 
45 45
 	@classmethod
46
-	def get_state_value(cls, guild: Guild, key: str) -> Optional[Any]:
46
+	def get_state_value(cls, guild: Guild, key: str) -> Any | None:
47 47
 		"""
48 48
 		Returns a state value for the given guild and key, or `None` if not set.
49 49
 		"""
50 50
 		return cls.get_state(guild).get(key)
51 51
 
52 52
 	@classmethod
53
-	def set_state_value(cls, guild: Guild, key: str, value: Optional[Any]) -> None:
53
+	def set_state_value(cls, guild: Guild, key: str, value: Any | None) -> None:
54 54
 		"""
55 55
 		Updates a transient value associated with the given guild and key name.
56 56
 		A value of `None` removes any previous value for that key.
@@ -58,7 +58,7 @@ class Storage:
58 58
 		cls.set_state_values(guild, { key: value })
59 59
 
60 60
 	@classmethod
61
-	def set_state_values(cls, guild: Guild, values: Optional[dict[str, Optional[Any]]]) -> None:
61
+	def set_state_values(cls, guild: Guild, values: dict[str, Any | None] | None) -> None:
62 62
 		"""
63 63
 		Merges in a set of key-value pairs into the transient state for the
64 64
 		given guild. Any pairs with a value of `None` will be removed from the
@@ -78,7 +78,7 @@ class Storage:
78 78
 	# -- Persisted configuration management ---------------------------------
79 79
 
80 80
 	# discord.Guild.id -> dict
81
-	__guild_id_to_config: dict[int, dict[str, Any]] = {}
81
+	__guild_id_to_config: dict[int, dict[str, Any]] = {}  # noqa: RUF012
82 82
 
83 83
 	@classmethod
84 84
 	def get_config(cls, guild: Guild) -> dict[str, Any]:
@@ -99,7 +99,7 @@ class Storage:
99 99
 		return config
100 100
 
101 101
 	@classmethod
102
-	def get_config_value(cls, guild: Guild, key: str) -> Optional[Any]:
102
+	def get_config_value(cls, guild: Guild, key: str) -> Any | None:
103 103
 		"""
104 104
 		Returns a persisted guild config value stored under the given key.
105 105
 		Returns `None` if not present.
@@ -107,7 +107,7 @@ class Storage:
107 107
 		return cls.get_config(guild).get(key)
108 108
 
109 109
 	@classmethod
110
-	def set_config_value(cls, guild: Guild, key: str, value: Optional[Any]) -> None:
110
+	def set_config_value(cls, guild: Guild, key: str, value: Any | None) -> None:
111 111
 		"""
112 112
 		Adds/updates the given key-value pair to the persisted config for the
113 113
 		given Guild. If `value` is `None` the key will be removed from persisted
@@ -116,7 +116,7 @@ class Storage:
116 116
 		cls.set_config_values(guild, { key: value })
117 117
 
118 118
 	@classmethod
119
-	def set_config_values(cls, guild: Guild, values: Optional[dict[str, Optional[Any]]]) -> None:
119
+	def set_config_values(cls, guild: Guild, values: dict[str, Any | None] | None) -> None:
120 120
 		"""
121 121
 		Merges the given `values` dict with the saved config for the given guild
122 122
 		and writes it to disk. `values` must be JSON-encodable or a `ValueError`
@@ -164,7 +164,7 @@ class Storage:
164 164
 		cls.__trace('State saved')
165 165
 
166 166
 	@classmethod
167
-	def __read_guild_config(cls, guild: Guild) -> Optional[dict[str, Any]]:
167
+	def __read_guild_config(cls, guild: Guild) -> dict[str, Any] | None:
168 168
 		"""
169 169
 		Loads config for a guild from a JSON file on disk, or `None` if not
170 170
 		found.

+ 5
- 6
rocketbot/ui/pagedcontent.py Ver fichero

@@ -4,10 +4,9 @@ source message to insert `PAGE_BREAK` characters at meaningful breaks, preferabl
4 4
 at fairly uniform intervals.
5 5
 """
6 6
 
7
-from typing import Optional
8
-
9 7
 from discord import Interaction
10
-from discord.ui import LayoutView, TextDisplay, ActionRow, Button
8
+from discord.errors import DiscordException
9
+from discord.ui import ActionRow, Button, LayoutView, TextDisplay
11 10
 
12 11
 from rocketbot.utils import dump_stacktrace
13 12
 
@@ -46,7 +45,7 @@ def paginate(text: str) -> list[str]:
46 45
 
47 46
 async def update_paged_content(
48 47
 		interaction: Interaction,
49
-		original_interaction: Optional[Interaction],
48
+		original_interaction: Interaction | None,
50 49
 		current_page: int,
51 50
 		pages: list[str],
52 51
 		**send_args,
@@ -96,7 +95,7 @@ async def update_paged_content(
96 95
 				ephemeral=True,
97 96
 				**send_args,
98 97
 			)
99
-	except BaseException as e:
98
+	except DiscordException as e:
100 99
 		dump_stacktrace(e)
101 100
 
102 101
 class _PagingLayoutView(LayoutView):
@@ -104,7 +103,7 @@ class _PagingLayoutView(LayoutView):
104 103
 			self,
105 104
 			current_page: int,
106 105
 			pages: list[str],
107
-			original_interaction: Optional[Interaction],
106
+			original_interaction: Interaction | None,
108 107
 			**send_args,
109 108
 	) -> None:
110 109
 		super().__init__()

+ 28
- 9
rocketbot/utils.py Ver fichero

@@ -5,7 +5,7 @@ import re
5 5
 import sys
6 6
 import traceback
7 7
 from datetime import datetime, timedelta, timezone
8
-from typing import Any, Optional, Union
8
+from typing import Any
9 9
 
10 10
 import discord
11 11
 from discord import Guild, Interaction, Permissions
@@ -105,7 +105,7 @@ def describe_timedelta(td: timedelta, max_components: int = 2) -> str:
105 105
 		components = components[0:max_components]
106 106
 	return ' '.join(components)
107 107
 
108
-def _old_first_command_group(cog: Cog) -> Optional[discord.ext.commands.Group]:
108
+def _old_first_command_group(cog: Cog) -> discord.ext.commands.Group | None:
109 109
 	"""Returns the first command Group found in a cog."""
110 110
 	for member_name in dir(cog):
111 111
 		member = getattr(cog, member_name)
@@ -113,7 +113,7 @@ def _old_first_command_group(cog: Cog) -> Optional[discord.ext.commands.Group]:
113 113
 			return member
114 114
 	return None
115 115
 
116
-def first_command_group(cog: Cog) -> Optional[discord.app_commands.Group]:
116
+def first_command_group(cog: Cog) -> discord.app_commands.Group | None:
117 117
 	"""Returns the first slash command Group found in a cog."""
118 118
 	for member_name in dir(cog):
119 119
 		member = getattr(cog, member_name)
@@ -121,9 +121,9 @@ def first_command_group(cog: Cog) -> Optional[discord.app_commands.Group]:
121 121
 			return member
122 122
 	return None
123 123
 
124
-def bot_log(guild: Optional[Guild], cog_class: Optional[type], message: Any) -> None:
124
+def bot_log(guild: Guild | None, cog_class: type | None, message: Any) -> None:
125 125
 	"""Logs a message to stdout with time, cog, and guild info."""
126
-	now: datetime = datetime.now() # local
126
+	now: datetime = datetime.now(tz=None)  # noqa: DTZ005
127 127
 	s = f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|'
128 128
 	s += f'{cog_class.__name__}|' if cog_class else '-|'
129 129
 	s += f'{guild.name}] ' if guild else '-] '
@@ -169,11 +169,11 @@ def user_id_from_mention(mention: str) -> str:
169 169
 		return m.group(1)
170 170
 	raise ValueError(f'"{mention}" is not an @ user mention')
171 171
 
172
-def mention_from_user_id(user_id: Union[str, int]) -> str:
172
+def mention_from_user_id(user_id: str | int) -> str:
173 173
 	"""Returns a Markdown user mention from a user id."""
174 174
 	return f'<@!{user_id}>'
175 175
 
176
-def mention_from_role_id(role_id: Union[str, int]) -> str:
176
+def mention_from_role_id(role_id: str | int) -> str:
177 177
 	"""Returns a Markdown role mention from a role id."""
178 178
 	return f'<@&{role_id}>'
179 179
 
@@ -197,8 +197,7 @@ def suppress_markdown_url_previews(markdown: str) -> str:
197 197
 
198 198
 def format_bytes(size: int) -> str:
199 199
 	"""Formats s size in bytes to a human readable description (e.g. "3.2 KiB")"""
200
-	if size < 0:
201
-		size = 0
200
+	size = max(size, 0)
202 201
 	kib = 1024
203 202
 	mib = kib * kib
204 203
 	gib = mib * kib
@@ -223,7 +222,27 @@ def norm_datetime(dt: datetime) -> datetime:
223 222
 		return dt
224 223
 	return datetime.fromtimestamp(dt.timestamp(), timezone.utc)
225 224
 
225
+def levenshtein(a: str, b: str) -> int:
226
+	"""Returns the Levenshtein distance between two strings."""
227
+	# Based on https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
228
+	m: int = len(a)
229
+	n: int = len(b)
230
+	v0: list[int] = [ i for i in range(n + 1) ]
231
+	v1: list[int] = [ 0 for i in range(n + 1) ]
232
+	for i in range(m):
233
+		v1[0] = i + 1
234
+		for j in range(n):
235
+			deletion_cost = v0[j + 1] + 1
236
+			insertion_cost = v1[j] + 1
237
+			substitution_cost = v0[j] + (0 if a[i] == b[j] else 1)
238
+			v1[j + 1] = min(deletion_cost, insertion_cost, substitution_cost)
239
+		h = v0
240
+		v0 = v1
241
+		v1 = h
242
+	return v0[n]
243
+
226 244
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
245
+ADMIN_PERMISSIONS: Permissions = Permissions(Permissions.administrator.flag)
227 246
 
228 247
 class TimeDeltaTransformer(Transformer):
229 248
 	async def transform(self, interaction: Interaction, value: Any) -> timedelta:

+ 0
- 18
sql/kickstarter-create.sql Ver fichero

@@ -1,11 +1,3 @@
1
-CREATE TABLE IF NOT EXISTS kickstarter_emails (
2
-	pk INTEGER PRIMARY KEY AUTOINCREMENT,
3
-	guild_id INTEGER NOT NULL,  -- Discord Guild.id
4
-	email_hash TEXT NOT NULL,  -- base64(sha256(email.lower.trimmed))
5
-	imported_at INTEGER NOT NULL,  -- unix timestamp
6
-	UNIQUE(guild_id, email_hash) ON CONFLICT FAIL
7
-);
8
-
9 1
 CREATE TABLE IF NOT EXISTS kickstarter_discord_users (
10 2
 	pk INTEGER PRIMARY KEY AUTOINCREMENT,
11 3
 	guild_id INTEGER NOT NULL,  -- Discord Guild.id
@@ -17,13 +9,3 @@ CREATE TABLE IF NOT EXISTS kickstarter_discord_users (
17 9
 );
18 10
 CREATE INDEX IF NOT EXISTS idx_kickstarter_discord_users_discord_member_id
19 11
 	ON kickstarter_discord_users (discord_member_id);
20
-
21
-CREATE TABLE IF NOT EXISTS member_links (
22
-	pk INTEGER PRIMARY KEY AUTOINCREMENT,
23
-	guild_id INTEGER NOT NULL,  -- Discord Guild.id
24
-	member_id INTEGER NOT NULL,  -- Discord Member.id
25
-	email_hash TEXT NOT NULL,  -- base64(sha256(email.lower.trimmed))
26
-	complete INTEGER NOT NULL DEFAULT 0,  -- link found, role assigned successfully
27
-	UNIQUE(guild_id, member_id) ON CONFLICT FAIL,
28
-	UNIQUE(guild_id, email_hash) ON CONFLICT FAIL
29
-);

Loading…
Cancelar
Guardar