#35 Kickstarter member linking feature

Злито
ialbert злито 10 комітів з kickstarter до main 1 тиждень тому

+ 7
- 1
config.sample.py Переглянути файл

@@ -1,6 +1,6 @@
1 1
 # Copy this file to config.py and fill in "<REQUIRED>" values
2 2
 CONFIG = {
3
-	'__config_version': 4,
3
+	'__config_version': 5,
4 4
 
5 5
     # -----------------------------------------------------------------------
6 6
     # General
@@ -14,6 +14,12 @@ CONFIG = {
14 14
 	'max_members_per_message': 20,
15 15
 	# Minimum seconds between warnings about the same member.
16 16
 	'squelch_warning_seconds': 300,
17
+	# Whether the Discord app has Message Content intent enabled. Enables more
18
+	# features but requires applying for access for bots serving 10k or more users.
19
+	'has_message_content_intent': False,
20
+	# Whether the Discord app has Server Members intent enabled. Enables more
21
+	# features but requires applying for access for bots serving 10k or more users.
22
+	'has_members_intent': False,
17 23
 
18 24
     # -----------------------------------------------------------------------
19 25
     # Emojis used for performing actions on flagged activities. The bot will

+ 5
- 5
main.py Переглянути файл

@@ -13,13 +13,13 @@ 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
-CURRENT_CONFIG_VERSION = 4
22
+CURRENT_CONFIG_VERSION = 5
23 23
 if (CONFIG.get('__config_version') or 0) < CURRENT_CONFIG_VERSION:
24 24
 	# If you're getting this error, it means something changed in config.py's
25 25
 	# format. Consult config.sample.py and compare it to your own config.py.

+ 1
- 1
requirements.txt Переглянути файл

@@ -1,2 +1,2 @@
1
-discord.py == 2.6.4
1
+discord.py == 2.7.1
2 2
 yt-dlp == 2026.07.04

+ 32
- 17
rocketbot/bot.py Переглянути файл

@@ -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
@@ -83,8 +83,9 @@ def __create_bot():
83 83
 		return
84 84
 	bot_log(None, None, 'Creating bot...')
85 85
 	intents = Intents.default()
86
-	intents.message_content = True
87
-	intents.members = True
86
+	# Set privileged intents. Must agree with configuration in Discord app.
87
+	intents.message_content = CONFIG.get('has_message_content_intent', False)
88
+	intents.members = CONFIG.get('has_members_intent', False)
88 89
 	intents.presences = False
89 90
 	rocketbot = Rocketbot(intents=intents)
90 91
 __create_bot()
@@ -98,6 +99,7 @@ async def start_bot():
98 99
 	from rocketbot.cogs.generalcog import GeneralCog
99 100
 	from rocketbot.cogs.helpcog import HelpCog
100 101
 	from rocketbot.cogs.joinraidcog import JoinRaidCog
102
+	from rocketbot.cogs.kickstartercog import KickstarterCog
101 103
 	from rocketbot.cogs.logcog import LoggingCog
102 104
 	from rocketbot.cogs.patterncog import PatternCog
103 105
 	from rocketbot.cogs.urlspamcog import URLSpamCog
@@ -111,17 +113,30 @@ async def start_bot():
111 113
 	await rocketbot.add_cog(ConfigCog(rocketbot))
112 114
 
113 115
 	# Optional
114
-	await rocketbot.add_cog(AutoKickCog(rocketbot))
115
-	await rocketbot.add_cog(BangCommandCog(rocketbot))
116
-	await rocketbot.add_cog(CrossPostCog(rocketbot))
117
-	await rocketbot.add_cog(GamesCog(rocketbot))
118
-	await rocketbot.add_cog(HelpCog(rocketbot))
119
-	await rocketbot.add_cog(JoinRaidCog(rocketbot))
120
-	await rocketbot.add_cog(LoggingCog(rocketbot))
121
-	await rocketbot.add_cog(PatternCog(rocketbot))
122
-	await rocketbot.add_cog(URLSpamCog(rocketbot))
123
-	await rocketbot.add_cog(UsernamePatternCog(rocketbot))
124
-	await rocketbot.add_cog(VideoPreviewCog(rocketbot))
116
+	if AutoKickCog.supports_intents(rocketbot.intents):
117
+		await rocketbot.add_cog(AutoKickCog(rocketbot))
118
+	if BangCommandCog.supports_intents(rocketbot.intents):
119
+		await rocketbot.add_cog(BangCommandCog(rocketbot))
120
+	if CrossPostCog.supports_intents(rocketbot.intents):
121
+		await rocketbot.add_cog(CrossPostCog(rocketbot))
122
+	if GamesCog.supports_intents(rocketbot.intents):
123
+		await rocketbot.add_cog(GamesCog(rocketbot))
124
+	if HelpCog.supports_intents(rocketbot.intents):
125
+		await rocketbot.add_cog(HelpCog(rocketbot))
126
+	if JoinRaidCog.supports_intents(rocketbot.intents):
127
+		await rocketbot.add_cog(JoinRaidCog(rocketbot))
128
+	if LoggingCog.supports_intents(rocketbot.intents):
129
+		await rocketbot.add_cog(LoggingCog(rocketbot))
130
+	if PatternCog.supports_intents(rocketbot.intents):
131
+		await rocketbot.add_cog(PatternCog(rocketbot))
132
+	if URLSpamCog.supports_intents(rocketbot.intents):
133
+		await rocketbot.add_cog(URLSpamCog(rocketbot))
134
+	if UsernamePatternCog.supports_intents(rocketbot.intents):
135
+		await rocketbot.add_cog(UsernamePatternCog(rocketbot))
136
+	if VideoPreviewCog.supports_intents(rocketbot.intents):
137
+		await rocketbot.add_cog(VideoPreviewCog(rocketbot))
138
+	if KickstarterCog.supports_intents(rocketbot.intents):
139
+		await rocketbot.add_cog(KickstarterCog(rocketbot))
125 140
 
126 141
 	await rocketbot.start(CONFIG['client_token'], reconnect=True)
127 142
 	print('\nBot aborted')

+ 14
- 14
rocketbot/botmessage.py Переглянути файл

@@ -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)

+ 5
- 1
rocketbot/cogs/autokickcog.py Переглянути файл

@@ -1,6 +1,6 @@
1 1
 from datetime import datetime, timedelta, timezone
2 2
 
3
-from discord import Guild, Member, Status
3
+from discord import Guild, Intents, Member, Status
4 4
 from discord.ext import tasks
5 5
 from discord.ext.commands import Cog
6 6
 from discord.ext.tasks import Loop
@@ -158,3 +158,7 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
158 158
 			if val % 10 == 3:
159 159
 				return f'{val}rd'
160 160
 		return f'{val}th'
161
+
162
+	@classmethod
163
+	def supports_intents(cls, intents: Intents) -> bool:
164
+		return intents.members

+ 28
- 15
rocketbot/cogs/bangcommandcog.py Переглянути файл

@@ -1,8 +1,17 @@
1 1
 import re
2 2
 from typing import Optional, TypedDict
3 3
 
4
-from discord import Guild, Interaction, Message, SelectOption, TextChannel, TextStyle
4
+from discord import (
5
+	Guild,
6
+	Intents,
7
+	Interaction,
8
+	Message,
9
+	SelectOption,
10
+	TextChannel,
11
+	TextStyle,
12
+)
5 13
 from discord.app_commands import Choice, Group, autocomplete
14
+from discord.errors import DiscordException
6 15
 from discord.ext.commands import Cog
7 16
 from discord.ui import Label, Modal, Select, TextInput
8 17
 
@@ -59,7 +68,7 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
59 68
 	def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
60 69
 		return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
61 70
 
62
-	def get_saved_command(self, guild: Guild, name: str) -> Optional[BangCommand]:
71
+	def get_saved_command(self, guild: Guild, name: str) -> BangCommand | None:
63 72
 		cmds = self.get_saved_commands(guild)
64 73
 		name = BangCommandCog._normalize_name(name)
65 74
 		return cmds.get(name, None)
@@ -83,7 +92,7 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
83 92
 		}
84 93
 	)
85 94
 	@autocomplete(name=command_autocomplete)
86
-	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:
87 96
 		"""
88 97
 		Defines or redefines a bang command.
89 98
 
@@ -215,9 +224,8 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
215 224
 		if len(content) < 1 or len(content) > 2000:
216 225
 			raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
217 226
 		cmds = self.get_saved_commands(guild)
218
-		if check_exists:
219
-			if cmds.get(name, None) is not None:
220
-				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.')
221 229
 		cmds[name] = {
222 230
 			'content': content,
223 231
 			'mod_only': mod_only,
@@ -254,22 +262,23 @@ class BangCommandCog(BaseCog, name='Bang Commands'):
254 262
 
255 263
 	@staticmethod
256 264
 	def _normalize_name(name: str) -> str:
257
-		name = name.lower().strip()
258
-		if name.startswith('!'):
259
-			name = name[1:]
260
-		return name
265
+		return name.lower().strip().removeprefix('!')
261 266
 
262 267
 	@staticmethod
263
-	def _is_valid_name(name: Optional[str]) -> bool:
268
+	def _is_valid_name(name: str | None) -> bool:
264 269
 		return name is not None and re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
265 270
 
266 271
 	@staticmethod
267
-	def _name_from_command_message(name: Optional[str]) -> Optional[str]:
272
+	def _name_from_command_message(name: str | None) -> str | None:
268 273
 		if name is None:
269 274
 			return None
270 275
 		match = re.match(r'^!((?:[a-z]+)(?:[_-][a-z]+)*)\b.*$', name)
271 276
 		return BangCommandCog._normalize_name(match.group(1)) if match else None
272 277
 
278
+	@classmethod
279
+	def supports_intents(cls, intents: Intents) -> bool:
280
+		return intents.message_content
281
+
273 282
 class _EditModal(Modal, title='Edit Command'):
274 283
 	name_label = Label(
275 284
 		text='Command name',
@@ -304,7 +313,11 @@ class _EditModal(Modal, title='Edit Command'):
304 313
 		)
305 314
 	)
306 315
 
307
-	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):
308 321
 		super().__init__()
309 322
 		self.exists = exists
310 323
 		# noinspection PyTypeChecker
@@ -348,5 +361,5 @@ class _EditModal(Modal, title='Edit Command'):
348 361
 				f'{CONFIG["failure_emoji"]} Save failed',
349 362
 				ephemeral=True,
350 363
 			)
351
-		except BaseException:
352
-			pass
364
+		except DiscordException as e:
365
+			dump_stacktrace(e)

+ 18
- 7
rocketbot/cogs/basecog.py Переглянути файл

@@ -2,11 +2,11 @@
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 (
9 8
 	Guild,
9
+	Intents,
10 10
 	Interaction,
11 11
 	Member,
12 12
 	Message,
@@ -16,6 +16,7 @@ from discord import (
16 16
 from discord.abc import GuildChannel
17 17
 from discord.app_commands import AppCommandError
18 18
 from discord.app_commands.errors import CommandInvokeError
19
+from discord.errors import DiscordException
19 20
 from discord.ext.commands import Cog
20 21
 
21 22
 from config import CONFIG
@@ -42,9 +43,9 @@ class BaseCog(Cog):
42 43
 	def __init__(
43 44
 			self,
44 45
 			bot: Rocketbot,
45
-			config_prefix: Optional[str],
46
+			config_prefix: str | None,
46 47
 			short_description: str,
47
-			long_description: Optional[str] = None,
48
+			long_description: str | None = None,
48 49
 	):
49 50
 		"""
50 51
 		Parameters
@@ -59,13 +60,14 @@ class BaseCog(Cog):
59 60
 		self.bot: Rocketbot = bot
60 61
 		self.are_settings_setup: bool = False
61 62
 		self.settings: list[CogSetting] = []
62
-		self.config_prefix: Optional[str] = config_prefix
63
+		self.config_prefix: str | None = config_prefix
63 64
 		self.short_description: str = short_description
64 65
 		self.long_description: str = long_description
65 66
 
66 67
 	async def cog_app_command_error(self, interaction: Interaction, error: AppCommandError) -> None:
67 68
 		if isinstance(error, CommandInvokeError):
68 69
 			error = error.original
70
+		bot_log(interaction.guild, type(self), f"Error in {interaction.command.qualified_name if interaction.command else 'unknown command'}")
69 71
 		dump_stacktrace(error)
70 72
 		message = f"\nException: {error.__class__.__name__}, "\
71 73
 				  f"Command: {interaction.command.qualified_name if interaction.command else None}, "\
@@ -74,7 +76,10 @@ class BaseCog(Cog):
74 76
 		try:
75 77
 			await interaction.response.send_message(f"An error occurred: {message}", ephemeral=True)
76 78
 		except discord.InteractionResponded:
77
-			await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
79
+			try:
80
+				await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
81
+			except DiscordException:
82
+				bot_log(interaction.guild, None, message)
78 83
 
79 84
 	@property
80 85
 	def basecogs(self) -> list['BaseCog']:
@@ -118,7 +123,7 @@ class BaseCog(Cog):
118 123
 
119 124
 	@classmethod
120 125
 	def get_guild_setting(cls,
121
-			guild: Optional[Guild],
126
+			guild: Guild | None,
122 127
 			setting: CogSetting,
123 128
 			use_cog_default_if_not_set: bool = True):
124 129
 		"""
@@ -297,8 +302,14 @@ class BaseCog(Cog):
297 302
 	# Helpers
298 303
 
299 304
 	@classmethod
300
-	def log(cls, guild: Optional[Guild], message) -> None:
305
+	def log(cls, guild: Guild | None, message) -> None:
301 306
 		"""
302 307
 		Writes a message to the console. Intended for significant events only.
303 308
 		"""
304 309
 		bot_log(guild, cls, message)
310
+
311
+	# Meta
312
+
313
+	@classmethod
314
+	def supports_intents(cls, intents: Intents) -> bool:
315
+		return True

+ 1
- 2
rocketbot/cogs/configcog.py Переглянути файл

@@ -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
 

+ 14
- 11
rocketbot/cogs/crosspostcog.py Переглянути файл

@@ -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
@@ -141,13 +140,17 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
141 140
 			self.__trace(f"User {message.author.name} exempt from crosspost checks")
142 141
 			return
143 142
 		def compute_message_hash(m: Message) -> int:
144
-			to_hash = m.content
145
-			# URLs sometimes differ per spam message, so simplify them
146
-			url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
147
-			to_hash = re.sub(url_regex, '<url>', to_hash)
148
-			# Add attachment metadata
149
-			for attachment in m.attachments:
150
-				to_hash += f'\n[[ATT: ct={attachment.content_type} s={attachment.size} w={attachment.width} h={attachment.height}]]'
143
+			if self.bot.intents.message_content:
144
+				to_hash = m.content
145
+				# URLs sometimes differ per spam message, so simplify them
146
+				url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
147
+				to_hash = re.sub(url_regex, '<url>', to_hash)
148
+				# Add attachment metadata
149
+				for attachment in m.attachments:
150
+					to_hash += f'\n[[ATT: ct={attachment.content_type} s={attachment.size} w={attachment.width} h={attachment.height}]]'
151
+			else:
152
+				# Without content, treat every message as unique
153
+				to_hash = str(m.id)
151 154
 			h = hash(to_hash)
152 155
 			self.__trace(f"Hash for message #{m.id} by {m.author.name} is {h}\n\thash content: \"{to_hash}\"")
153 156
 			return h
@@ -248,7 +251,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
248 251
 		await self.__update_message_from_context(context)
249 252
 
250 253
 	async def __update_message_from_context(self, context: SpamContext) -> None:
251
-		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)
252 255
 		spam_count = len(context.spam_messages)
253 256
 		channel_count = len(context.unique_channels)
254 257
 		deleted_count = len(context.deleted_messages)
@@ -280,7 +283,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
280 283
 				body += f'messages in {channel_count} channels within {max_age_str} ' + \
281 284
 						f'({duplicate_count} are identical, showing first one).'
282 285
 			max_links = 10
283
-			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]:
284 287
 				body += f'\n- {msg.jump_url}'
285 288
 			if len(context.spam_messages) > max_links:
286 289
 				body += f'\n- ...{len(context.spam_messages) - max_links} more...'

+ 1
- 1
rocketbot/cogs/generalcog.py Переглянути файл

@@ -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 Переглянути файл

@@ -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)

+ 5
- 1
rocketbot/cogs/joinraidcog.py Переглянути файл

@@ -5,7 +5,7 @@ import weakref
5 5
 from datetime import datetime, timedelta
6 6
 from typing import Optional
7 7
 
8
-from discord import Guild, Member
8
+from discord import Guild, Intents, Member
9 9
 from discord.ext.commands import Cog
10 10
 
11 11
 from config import CONFIG
@@ -189,3 +189,7 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
189 189
 			did_kick=kick_count >= member_count,
190 190
 			did_ban=ban_count >= member_count,
191 191
 			user_count=member_count))
192
+
193
+	@classmethod
194
+	def supports_intents(cls, intents: Intents) -> bool:
195
+		return intents.members

+ 900
- 0
rocketbot/cogs/kickstartercog.py Переглянути файл

@@ -0,0 +1,900 @@
1
+import heapq
2
+from asyncio import sleep
3
+from collections.abc import Awaitable, Callable, Iterable
4
+from enum import IntEnum
5
+from sqlite3 import Connection, connect
6
+from time import time as now_timestamp
7
+from typing import Optional, TypeVar
8
+
9
+from discord import Attachment, Guild, Interaction, Member, Role
10
+from discord.app_commands import Group, default_permissions
11
+from discord.errors import DiscordException, HTTPException
12
+from discord.ext.commands import Cog
13
+from discord.ui import FileUpload, Label, Modal
14
+
15
+from config import CONFIG
16
+from rocketbot.bot import Rocketbot
17
+from rocketbot.cogs.basecog import BaseCog
18
+from rocketbot.cogsetting import CogSetting
19
+from rocketbot.utils import (
20
+	ADMIN_PERMISSIONS,
21
+	MOD_PERMISSIONS,
22
+	dump_stacktrace,
23
+	is_discord_username,
24
+	levenshtein,
25
+)
26
+
27
+_T = TypeVar('_T')
28
+class KickstarterCog(BaseCog):
29
+	"""
30
+	Assigns Discord users a role if their username appears in an imported list,
31
+	generally the results of a Kickstarter survey.
32
+
33
+	Discord does not currently have a native Kickstarter integration, and doing
34
+	a full API integration is a bit ambitious, so this is a stopgap solution.
35
+	"""
36
+
37
+	shared: Optional['KickstarterCog'] = None
38
+
39
+	SETTING_ENABLED = CogSetting(
40
+		name='enabled',
41
+		datatype=bool,
42
+		default_value=False,
43
+		brief='Kickstarter user linking',
44
+		description='Whether this module is enabled for a guild.',
45
+	)
46
+	SETTING_ROLE = CogSetting(
47
+		name='backer_role',
48
+		datatype=int,
49
+		default_value=0,
50
+		brief='role to assign to Kickstarter backers',
51
+		description=''
52
+	)
53
+
54
+	def __init__(self, bot: Rocketbot):
55
+		super().__init__(
56
+			bot,
57
+			config_prefix='kickstarter',
58
+			short_description='For linking Kickstarter backers to their Discord handles.',
59
+		)
60
+		Self = KickstarterCog
61
+		self.add_setting(Self.SETTING_ENABLED)
62
+		# SETTING_ROLE managed manually
63
+		self.con: Connection = connect('kickstarter.sqlite3')
64
+		Self.shared = self
65
+
66
+	def __is_enabled(self, guild: Guild) -> bool:
67
+		Self = KickstarterCog
68
+		return self.get_guild_setting(guild, Self.SETTING_ENABLED)
69
+
70
+	def __get_backer_role_id(self, guild: Guild) -> int | None:
71
+		Self = KickstarterCog
72
+		role_id = self.get_guild_setting(guild, Self.SETTING_ROLE)
73
+		return role_id if role_id != 0 else None
74
+
75
+	async def __fetch_backer_role(self, guild: Guild) -> Role:
76
+		role_id = self.__get_backer_role_id(guild)
77
+		if role_id is None:
78
+			raise _NoBackerRoleException()
79
+		ret_val = guild.get_role(role_id) or await guild.fetch_role(role_id)
80
+		if ret_val is None:
81
+			self.log(guild, f"Backer role with id {role_id} could not be retrieved. Role removed?")
82
+			raise _NoBackerRoleException()
83
+		return ret_val
84
+
85
+	def __get_cached_backer_role(self, guild: Guild) -> Role | None:
86
+		"""Synchronously gets the configured backer role from the cache if possible."""
87
+		role_id = self.__get_backer_role_id(guild)
88
+		return guild.get_role(role_id) if role_id is not None else None
89
+
90
+	def __set_backer_role(self, role: Role | None):
91
+		Self = KickstarterCog
92
+		self.set_guild_setting(role.guild, Self.SETTING_ROLE, role.id if role else None)
93
+
94
+	async def __check_disabled(self, interaction: Interaction) -> bool:
95
+		"""Checks if this feature is enabled, and if not, sends an error
96
+		response. Return value is whether caller should bail out."""
97
+		if not self.__is_enabled(interaction.guild):
98
+			text = f"{CONFIG['failure_emoji']} Kickstarter feature not enabled"
99
+			await interaction.response.send_message(text, ephemeral=True)
100
+			return True
101
+		return False
102
+
103
+	async def __check_configured(self, interaction: Interaction) -> bool:
104
+		"""Checks if the current guild is configured properly for this feature,
105
+		and if not, sends an error response. Return value is whether caller
106
+		should bail out."""
107
+		if self.__get_backer_role_id(interaction.guild) is None:
108
+			text = f"{CONFIG['failure_emoji']} Backer role is not configured"
109
+			await interaction.response.send_message(text, ephemeral=True)
110
+			return True
111
+		return False
112
+
113
+	# -- Admin commands -----
114
+
115
+	kickstarter = Group(
116
+		name='kickstarter',
117
+		description='Manages roles for users identified as Kickstarter backers.',
118
+		guild_only=True,
119
+		default_permissions=MOD_PERMISSIONS
120
+	)
121
+
122
+	@kickstarter.command(
123
+		description='Configures which role to give to Kickstarter backers.'
124
+	)
125
+	@default_permissions(ADMIN_PERMISSIONS)
126
+	async def set_role(self, interaction: Interaction, role: Role):
127
+		self.__set_backer_role(role)
128
+		text = f"{CONFIG['info_emoji']} Backer role set to {role.name}"
129
+		await interaction.response.send_message(text, ephemeral=True)
130
+
131
+	@kickstarter.command(
132
+		description='Assigns backer role to imported backer usernames.'
133
+	)
134
+	async def sync(self, interaction: Interaction):
135
+		if await self.__check_disabled(interaction): return
136
+		if await self.__check_configured(interaction): return
137
+
138
+		guild = interaction.guild
139
+		await interaction.response.defer(ephemeral=True, thinking=True)
140
+		sync_result: _SyncUsernamesResult = await self.__sync_by_username(guild)
141
+		text = f"{CONFIG['success_emoji']} Sync complete.\n" + \
142
+			sync_result.summary_markdown()
143
+		await interaction.followup.send(text, ephemeral=True)
144
+
145
+	@kickstarter.command(
146
+		description='Shows info about Kickstarter linked Discord members.'
147
+	)
148
+	async def info(self, interaction: Interaction):
149
+		if await self.__check_disabled(interaction): return
150
+		if await self.__check_configured(interaction): return
151
+
152
+		await interaction.response.defer(ephemeral=True, thinking=True)
153
+		guild = interaction.guild
154
+		backer_role = await self.__fetch_backer_role(guild)
155
+		stats: _Stats = self.__fetch_stats(guild.id)
156
+
157
+		lines: list[str] = []
158
+		if backer_role is None:
159
+			lines.append("- No backer role configured yet (use " \
160
+				f"`/{KickstarterCog.kickstarter.name} {KickstarterCog.set_role.name}`)")
161
+		else:
162
+			lines.append(f"- Backer role configured as `{backer_role.name}`")
163
+		if stats.username_count > 0:
164
+			lines.append(f"- **{stats.username_count:,}** Discord usernames imported")
165
+			lines.append("- Last import with new records on " \
166
+				f"<t:{stats.username_last_imported_at}:f>")
167
+			lines.append(f"- **{stats.username_found_count:,}** members linked successfully")
168
+			if stats.username_not_found_count > 0:
169
+				lines.append(f"- **{stats.username_not_found_count:,}** imported " \
170
+					"usernames not yet linked to Discord members")
171
+			if stats.username_unprocessed_count > 0:
172
+				lines.append(f"- **{stats.username_unprocessed_count:,}** " \
173
+					"usernames added since last sync")
174
+		else:
175
+			lines.append("- No Discord usernames imported yet. Use " \
176
+				f"`/{KickstarterCog.kickstarter.name} {KickstarterCog.upload.name}`")
177
+
178
+		text = f"{CONFIG['info_emoji']} Kickstarter import stats\n\n"
179
+		text += "\n".join(lines)
180
+		await interaction.followup.send(text, ephemeral=True)
181
+
182
+	@kickstarter.command(
183
+		description='Shows Kickstarter link details about a specific Discord member.'
184
+	)
185
+	async def find_member(self, interaction: Interaction, member: Member):
186
+		if await self.__check_disabled(interaction): return
187
+		if await self.__check_configured(interaction): return
188
+
189
+		await interaction.response.defer(ephemeral=True, thinking=True)
190
+		guild = interaction.guild
191
+		try:
192
+			backer_role = await self.__fetch_backer_role(guild)
193
+		except _NoBackerRoleException:
194
+			text = f"{CONFIG['failure_emoji']} Backer role not yet configured!"
195
+			await interaction.followup.send(text, ephemeral=True)
196
+			return
197
+
198
+		if backer_role in member.roles:
199
+			text = f"{CONFIG['info_emoji']} Member `@{member.name}` has " \
200
+				f"`{backer_role.name}` role."
201
+			await interaction.followup.send(text, ephemeral=True)
202
+			return
203
+
204
+		if member.bot:
205
+			text = f"{CONFIG['info_emoji']} Member `@{member.name}` is a robut! " \
206
+				"Robuts cannot back Kickstarters!"
207
+			await interaction.followup.send(text, ephemeral=True)
208
+			return
209
+
210
+		username_record = self.__fetch_kickstarter_username(guild.id, username=member.name)
211
+		if username_record is not None:
212
+			await member.add_roles(backer_role)
213
+			username_record.lookup_status = _LookupStatus.member_found
214
+			username_record.discord_member_id = member.id
215
+			self.__update_kickstarter_username(username_record)
216
+			text = f"{CONFIG['success_emoji']} Member's username found in " \
217
+				f"Kickstarter export. The `{backer_role.name}` role has now " \
218
+				"been assigned to them."
219
+			await interaction.followup.send(text, ephemeral=True)
220
+			return
221
+
222
+		closest_matches = self.__find_nearest_usernames(guild.id, member.name,
223
+			limit=8, max_distance=3)
224
+		stats = self.__fetch_stats(guild.id)
225
+		text = f"{CONFIG['info_emoji']} The username `@{member.name}` is not in " \
226
+			"the latest Kickstarter import." \
227
+			"\n" \
228
+			"\n- Did they complete their survey yet?" \
229
+			"\n- Did they fill in their Discord username in the survey?" \
230
+			"\n- Did they complete their survey after the last import " \
231
+				f"(<t:{stats.username_last_imported_at}:f>)? They might be in the next one."
232
+		if len(closest_matches) > 0:
233
+			text += "\n- Did they misspell their username in the survey? " \
234
+				"Here are some similar ones."
235
+			for similar in closest_matches:
236
+				text += f"\n   - `{similar.discord_username}`"
237
+				if similar.lookup_status == _LookupStatus.misspelled_username:
238
+					text += f" (manually linked to <@{similar.discord_member_id}> by mod)"
239
+			text += f"\n-# Hint: Use `/{KickstarterCog.kickstarter.name} {KickstarterCog.link.name} " \
240
+				f"@{member.name} surveyusername` to link the member to their " \
241
+				"misspelled survey username"
242
+		await interaction.followup.send(text, ephemeral=True)
243
+
244
+	@kickstarter.command(
245
+		description='Manually links a member to a misspelled survey username.'
246
+	)
247
+	async def link(self, interaction: Interaction, member: Member, username: str):
248
+		if await self.__check_disabled(interaction): return
249
+		if await self.__check_configured(interaction): return
250
+
251
+		await interaction.response.defer(ephemeral=True, thinking=True)
252
+		guild = interaction.guild
253
+		try:
254
+			backer_role = await self.__fetch_backer_role(guild)
255
+		except _NoBackerRoleException:
256
+			text = f"{CONFIG['failure_emoji']} Backer role not configured!"
257
+			await interaction.followup.send(text, ephemeral=True)
258
+			return
259
+
260
+		normal_username = KickstarterCog.__normalize_discord_username(username)
261
+		username = self.__fetch_kickstarter_username(guild.id, username=normal_username)
262
+		if username is None:
263
+			closest_matches = self.__find_nearest_usernames(guild.id, normal_username, 1)
264
+			text = f"{CONFIG['failure_emoji']} No survey username found for `{username}`."
265
+			if len(closest_matches) > 0:
266
+				text += f" Did you mean `/{KickstarterCog.kickstarter.name} " \
267
+					f"{KickstarterCog.link.name} @{member.name} " \
268
+					f"{closest_matches[0].discord_username}`?"
269
+			await interaction.followup.send(text, ephemeral=True)
270
+			return
271
+
272
+		if username.lookup_status == _LookupStatus.member_found:
273
+			text = f"{CONFIG['failure_emoji']} That survey username is already " \
274
+				f"linked to <@{username.discord_member_id}>. Cannot be linked to " \
275
+				"another member.\n" \
276
+				f"- `/{KickstarterCog.kickstarter.name} {KickstarterCog.reset.name} " \
277
+				f"{username}` will unlink that Discord record\n" \
278
+				"- If all else fails, you can just manually give them the backer role."
279
+			await interaction.followup.send(text, ephemeral=True)
280
+			return
281
+		elif username.lookup_status == _LookupStatus.misspelled_username:
282
+			text = f"{CONFIG['failure_emoji']} That survey username was already " \
283
+				f"assigned to <@{username.discord_member_id}> by a mod using this " \
284
+				"command.\n" \
285
+				f"- `/{KickstarterCog.kickstarter.name} {KickstarterCog.reset.name} " \
286
+				f"{username}` will unlink that Discord record\n" \
287
+				"- If all else fails, you can just manually give them the backer role."
288
+			await interaction.followup.send(text, ephemeral=True)
289
+			return
290
+
291
+		if backer_role not in member.roles:
292
+			await member.add_roles(backer_role)
293
+		username.discord_member_id = member.id
294
+		username.lookup_status = _LookupStatus.misspelled_username
295
+		self.__update_kickstarter_username(username)
296
+
297
+		text = f"{CONFIG['success_emoji']} Member @{member.name} linked to " \
298
+			f"survey username @{username.discord_username} and given " \
299
+			f"{backer_role.name} role!"
300
+		await interaction.followup.send(text, ephemeral=True)
301
+
302
+	@kickstarter.command(
303
+		description='Resets an imported Kickstarter record.'
304
+	)
305
+	async def reset(self, interaction: Interaction, username: str):
306
+		if await self.__check_disabled(interaction): return
307
+
308
+		guild = interaction.guild
309
+		normal_username = KickstarterCog.__normalize_discord_username(username)
310
+		record = self.__fetch_kickstarter_username(guild.id, username=normal_username)
311
+		if record is None:
312
+			text = f"{CONFIG['failure_emoji']} No record found for `@{normal_username}`."
313
+			similar = self.__find_nearest_usernames(guild.id, normal_username, limit=5, max_distance=3)
314
+			if len(similar) > 0:
315
+				text += " Did you mean one of these?"
316
+				for s in similar:
317
+					text += f"\n- `@{s.discord_username}`"
318
+			await interaction.response.send_message(text, ephemeral=True)
319
+			return
320
+		old_member_id = record.discord_member_id
321
+		old_lookup_status = record.lookup_status
322
+		record.discord_member_id = None
323
+		record.lookup_status = _LookupStatus.unprocessed
324
+		self.__update_kickstarter_username(record)
325
+		text = f"{CONFIG['success_emoji']} Record for `@{normal_username}` reset. " \
326
+			"(No roles removed from member.)"
327
+		if old_member_id is not None:
328
+			text += f"\n- `discord_member_id` changed from `{old_member_id}` to `{record.discord_member_id}`"
329
+		if old_lookup_status is not None:
330
+			text += f"\n- `lookup_status` changed from `{_describe_lookup_status(old_lookup_status)}`" \
331
+				f" to `{_describe_lookup_status(record.lookup_status)}`"
332
+		await interaction.response.send_message(text, ephemeral=True)
333
+
334
+	@kickstarter.command(
335
+		description='Uploads an export of Kickstarter users.'
336
+	)
337
+	async def upload(self, interaction: Interaction):
338
+		if await self.__check_disabled(interaction): return
339
+		if await self.__check_configured(interaction): return
340
+
341
+		await interaction.response.send_modal(_UploadUsernamesModal())
342
+
343
+	# @override
344
+	async def interaction_check(self, interaction: Interaction) -> bool:
345
+		if interaction.command is not None:
346
+			self.__trace(interaction.guild, f"@{interaction.user.name} used " \
347
+				f"/{interaction.command.qualified_name}")
348
+		return True
349
+
350
+
351
+	# -- Events --
352
+
353
+	@Cog.listener()
354
+	async def on_member_join(self, member: Member) -> None:
355
+		guild = member.guild
356
+		if not self.__is_enabled(guild): return
357
+		if self.__get_backer_role_id(guild) is None: return
358
+
359
+		try:
360
+			backer_role = await self.__fetch_backer_role(guild)
361
+		except _NoBackerRoleException:
362
+			self.log(guild, "Backer role configured but can't be retrieved")
363
+			return  # Bad id? Role removed?
364
+		if backer_role in member.roles:
365
+			return  # Already has role
366
+		username = self.__fetch_kickstarter_username(guild.id, username=member.name)
367
+		if username is None:
368
+			return  # Not on list
369
+		if username.lookup_status == _LookupStatus.misspelled_username:
370
+			self.log(guild, f"\u0007Member @{member.name} joined but their " \
371
+				"username was already manually attached to user id " \
372
+				f"{username.discord_member_id}")
373
+			return
374
+
375
+		self.__trace(guild, f"Member @{member.name} joined and is a backer. " \
376
+			"Granting backer role.")
377
+		await member.add_roles(backer_role)
378
+		username.lookup_status = _LookupStatus.member_found
379
+		username.discord_member_id = member.id
380
+		self.__update_kickstarter_username(username)
381
+
382
+	# -- UI callbacks -----
383
+
384
+	async def on_username_upload_submit(self,
385
+		interaction: Interaction,
386
+		attachment: Attachment
387
+	):
388
+		"""Callback for username upload modal."""
389
+		await interaction.response.defer(ephemeral=True, thinking=True)
390
+		guild = interaction.guild
391
+		import_result: _ImportResult = await self.__import_usernames(guild, attachment)
392
+		try:
393
+			sync_result: _SyncUsernamesResult = await self.__sync_by_username(guild)
394
+			text = f"{CONFIG['success_emoji']} Username import complete.\n" + \
395
+				import_result.summary_markdown() + "\n" + \
396
+				sync_result.summary_markdown()
397
+		except _NoBackerRoleException:
398
+			text = f"{CONFIG['failure_emoji']} Backer role must be configured " \
399
+				"before importing."
400
+		await interaction.followup.send(text, ephemeral=True)
401
+
402
+
403
+	# -- Operations -----
404
+
405
+	async def __import_usernames(self,
406
+		guild: Guild,
407
+		attachment: Attachment
408
+	) -> '_ImportResult':
409
+		"""Imports Discord usernames from an upload attachment."""
410
+		self.__trace(guild, f"Download - start - {attachment.filename} " \
411
+			f"({attachment.size:,} bytes, {attachment.content_type})")
412
+		file_bytes = await attachment.read()
413
+		file_str = file_bytes.decode('utf-8')
414
+		self.__trace(guild, "Download - complete")
415
+
416
+		self.__trace(guild, "Parse - start")
417
+		lines = file_str.splitlines(keepends=False)
418
+		usernames = []
419
+		malformed_count = 0
420
+		malformed_usernames = []
421
+		for line in lines:
422
+			username = self.__normalize_discord_username(line)
423
+			if (username.startswith('"') and username.endswith('"')) or \
424
+				(username.startswith("'") and username.endswith("'")):
425
+				# Remove quotes
426
+				username = username[1:-1].strip()
427
+			if username == '':
428
+				continue
429
+			if is_discord_username(username):
430
+				usernames.append(username)
431
+			else:
432
+				self.__trace(guild, f"Not a Discord username: \"{line}\"")
433
+				malformed_usernames.append(line)
434
+				malformed_count += 1
435
+		self.__trace(guild, f"Parse - complete - {len(lines):,} lines, {len(usernames):,} " \
436
+			f"valid usernames, {malformed_count:,} malformed usernames")
437
+
438
+		self.__trace(guild, f"Storing - start - {len(usernames):,} usernames")
439
+		new_username_count = self.__store_kickstarter_usernames(guild.id, usernames)
440
+		self.__trace(guild, f"Storing - complete - {new_username_count:,} unique " \
441
+			"usernames stored")
442
+
443
+		return _ImportResult(
444
+			attachment.filename,
445
+			attachment.size,
446
+			len(lines),
447
+			len(usernames),
448
+			malformed_count,
449
+			new_username_count,
450
+			malformed_usernames
451
+		)
452
+
453
+	async def __sync_by_username(self, guild: Guild) -> '_SyncUsernamesResult':
454
+		backer_role = await self.__fetch_backer_role(guild)
455
+		complete_count = 0
456
+		not_found_count = 0
457
+		self.__trace(guild, "Fetch usernames - start")
458
+		usernames: list[_KickstarterDiscordUser] = \
459
+			self.__fetch_incomplete_kickstarter_usernames(guild.id,
460
+				{
461
+					_LookupStatus.unprocessed,
462
+					_LookupStatus.username_not_found
463
+				})
464
+		self.__trace(guild, f"Fetch usernames - complete - found {len(usernames):,}")
465
+		username_to_member_id: dict[str, int] = {}
466
+		if self.bot.intents.members:
467
+			self.__trace(guild, "Fetching guild members from API - start")
468
+			async for member in guild.fetch_members(limit=None):
469
+				username_to_member_id[member.name] = member.id
470
+			self.__trace(guild, "Fetching guild members from API - complete - " \
471
+				f"got {len(username_to_member_id):,}")
472
+		async def username_loop_handler(username: _KickstarterDiscordUser):
473
+			nonlocal complete_count
474
+			nonlocal not_found_count
475
+			member_id = username_to_member_id.get(username.discord_username)
476
+			if member_id is not None:
477
+				member = guild.get_member(member_id) or \
478
+					await guild.fetch_member(member_id)
479
+			else:
480
+				member = guild.get_member_named(username.discord_username)
481
+			if member is None:
482
+				not_found_count += 1
483
+				if username.lookup_status != _LookupStatus.username_not_found:
484
+					username.lookup_status = _LookupStatus.username_not_found
485
+					self.__update_kickstarter_username(username)
486
+				return
487
+			username.lookup_status = _LookupStatus.member_found
488
+			username.discord_member_id = member.id
489
+			self.__update_kickstarter_username(username)
490
+			if backer_role not in member.roles:
491
+				await member.add_roles(backer_role)
492
+				complete_count += 1
493
+		self.__trace(guild, "Sync loop - start")
494
+		failure_count = await self.__throttled_loop(guild, usernames,
495
+			username_loop_handler, update_seconds=10.0)
496
+		self.__trace(guild, f"Sync loop - complete - {complete_count:,} completed, " \
497
+			f"{not_found_count:,} not found")
498
+		return _SyncUsernamesResult(complete_count, not_found_count, failure_count)
499
+
500
+	async def __throttled_loop(self,
501
+		guild: Guild,
502
+		iter: Iterable[_T],
503
+		callback: Callable[[_T], Awaitable[None]],
504
+		update_seconds: float | None = None
505
+	) -> int:
506
+		"""Iterates a loop with automatically adjusting sleeps based on
507
+		throttling exceptions."""
508
+		failure_count = 0
509
+		sleep_length = 0.0
510
+		start_time = now_timestamp()
511
+		last_update_time = start_time
512
+		for iter_count, elem in enumerate(iter):
513
+			complete = False
514
+			for _ in range(5):
515
+				try:
516
+					await sleep(sleep_length)
517
+					await callback(elem)
518
+					complete = True
519
+					break
520
+				except HTTPException as ex:
521
+					if ex.status == 429:  # rate limited
522
+						retry_header_value = ex.response.headers.get('retry_after')
523
+						retry_after_millis = float(retry_header_value or '1000')
524
+						self.__trace(guild, "Rate limited while processing. " \
525
+							f"retry_after={retry_header_value}")
526
+						await sleep(retry_after_millis / 1000.0)
527
+						sleep_length = 1.0 if sleep_length == 0.0 else sleep_length * 2.0
528
+						self.__trace(guild, f"Sleep increased to {sleep_length:,}s " \
529
+							"due to rate limiting")
530
+					else:
531
+						dump_stacktrace(ex)
532
+				except DiscordException as ex:
533
+					dump_stacktrace(ex)
534
+			if not complete:
535
+				failure_count += 1
536
+			if update_seconds is not None and now_timestamp() - last_update_time >= update_seconds:
537
+				self.__trace(guild, f"Completed {iter_count + 1:,} iterations")
538
+				last_update_time = now_timestamp()
539
+		return failure_count
540
+
541
+
542
+	# -- Database functions -----
543
+
544
+	def __store_kickstarter_username(self, record: '_KickstarterDiscordUser'):
545
+		cur = self.con.cursor()
546
+		cur.execute("""
547
+			INSERT OR IGNORE INTO kickstarter_discord_users (
548
+				guild_id,
549
+				discord_username,
550
+				discord_member_id,
551
+				lookup_status,
552
+				imported_at
553
+			) VALUES (
554
+				:guild_id,
555
+				:discord_username,
556
+				:discord_member_id,
557
+				:lookup_status,
558
+				:imported_at
559
+			)
560
+			""", {
561
+				'guild_id': record.guild_id,
562
+				'discord_username': self.__normalize_discord_username(record.discord_username),
563
+				'discord_member_id': record.discord_member_id,
564
+				'lookup_status': record.lookup_status,
565
+				'imported_at': record.imported_at
566
+			})
567
+		row_id = cur.lastrowid
568
+		self.con.commit()
569
+		cur.close()
570
+		if row_id is not None and row_id != 0:
571
+			record.pk = row_id
572
+
573
+	def __store_kickstarter_usernames(self, guild_id: int, usernames: list[str]) -> int:
574
+		imported_at: int = int(now_timestamp())
575
+		cur = self.con.cursor()
576
+		for username in usernames:
577
+			cur.execute("""
578
+				INSERT OR IGNORE INTO kickstarter_discord_users (
579
+					guild_id,
580
+					discord_username,
581
+					imported_at
582
+				) VALUES (
583
+					:guild_id,
584
+					:discord_username,
585
+					:imported_at
586
+				)
587
+				""", {
588
+					'guild_id': guild_id,
589
+					'discord_username': self.__normalize_discord_username(username),
590
+					'imported_at': imported_at
591
+				})
592
+		cur.execute("""
593
+			SELECT COUNT(1)
594
+			FROM kickstarter_discord_users
595
+			WHERE imported_at = ?
596
+			""", (imported_at, ))
597
+		imported_count = cur.fetchone()[0]
598
+		self.con.commit()
599
+		cur.close()
600
+		return imported_count
601
+
602
+	def __fetch_kickstarter_username(self,
603
+		guild_id: int,
604
+		*,
605
+		member_id: int | None = None,
606
+		username: str | None = None
607
+	) -> Optional['_KickstarterDiscordUser']:
608
+		"""Fetches an imported Discord username by EITHER member id or username
609
+		(must provide exactly one)"""
610
+		cur = self.con.cursor()
611
+		cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
612
+		if member_id is not None:
613
+			cur.execute("""
614
+				SELECT *
615
+				FROM kickstarter_discord_users
616
+				WHERE
617
+					guild_id = :guild_id
618
+					AND discord_member_id = :member_id
619
+				""", { 'guild_id': guild_id, 'member_id': member_id })
620
+		elif username is not None:
621
+			cur.execute("""
622
+				SELECT *
623
+				FROM kickstarter_discord_users
624
+				WHERE
625
+					guild_id = :guild_id
626
+					AND discord_username = :username
627
+				""", { 'guild_id': guild_id, 'username': username })
628
+		ret_val = cur.fetchone()
629
+		cur.close()
630
+		return ret_val
631
+
632
+	def __fetch_incomplete_kickstarter_usernames(self,
633
+		guild_id: int,
634
+		statuses: set['_LookupStatus']
635
+	) -> list['_KickstarterDiscordUser']:
636
+		cur = self.con.cursor()
637
+		cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
638
+		params = [ guild_id ] + list(statuses)
639
+		status_placeholders = ', '.join('?' * len(statuses))
640
+		sql = f"""
641
+			SELECT *
642
+			FROM kickstarter_discord_users
643
+			WHERE
644
+				guild_id = ?
645
+				AND lookup_status IN ({status_placeholders})
646
+			"""
647
+		cur.execute(sql, params)
648
+		ret_val = cur.fetchall()
649
+		cur.close()
650
+		return ret_val
651
+
652
+	def __update_kickstarter_username(self, user: '_KickstarterDiscordUser'):
653
+		cur = self.con.cursor()
654
+		cur.execute("""
655
+			UPDATE kickstarter_discord_users
656
+			SET discord_member_id = :discord_member_id,
657
+				lookup_status = :lookup_status
658
+			WHERE pk = :pk
659
+			""", {
660
+				'discord_member_id': user.discord_member_id,
661
+				'lookup_status': user.lookup_status,
662
+				'pk': user.pk
663
+			})
664
+		self.con.commit()
665
+		cur.close()
666
+
667
+	def __fetch_stats(self, guild_id: int) -> '_Stats':
668
+		"""Returns member link stats."""
669
+		cur = self.con.cursor()
670
+
671
+		cur.execute("""
672
+			SELECT
673
+				COUNT(1) AS total,
674
+				SUM(IIF(lookup_status = 0, 1, 0)) AS unprocessed_count,
675
+				SUM(IIF(lookup_status = 1, 1, 0)) AS not_found_count,
676
+				SUM(IIF(lookup_status = 2, 1, 0)) AS found_count,
677
+				MAX(imported_at) AS last_import
678
+			FROM kickstarter_discord_users
679
+			""")
680
+		(
681
+			username_count,
682
+			username_unprocessed_count,
683
+			username_not_found_count,
684
+			username_found_count,
685
+			username_last_imported_at
686
+		) = cur.fetchone()
687
+
688
+		cur.close()
689
+		return _Stats(
690
+			username_count,
691
+			username_unprocessed_count,
692
+			username_not_found_count,
693
+			username_found_count,
694
+			username_last_imported_at
695
+		)
696
+
697
+	def __find_nearest_usernames(self,
698
+		guild_id: int,
699
+		username: str,
700
+		limit: int = 10,
701
+		max_distance: int = 999
702
+	) -> list['_KickstarterDiscordUser']:
703
+		cur = self.con.cursor()
704
+		cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
705
+		cur.execute("""
706
+			SELECT *
707
+			FROM kickstarter_discord_users
708
+			WHERE guild_id = ?
709
+				AND lookup_status IN (?, ?, ?)
710
+		""", (
711
+			guild_id,
712
+			_LookupStatus.unprocessed,
713
+			_LookupStatus.username_not_found,
714
+			_LookupStatus.misspelled_username,
715
+		))
716
+		records: list[_KickstarterDiscordUser] = cur.fetchall()
717
+		cur.close()
718
+
719
+		normal_username = KickstarterCog.__normalize_discord_username(username)
720
+		in_bounds_count = 0
721
+		def compute(r: _KickstarterDiscordUser) -> int:
722
+			nonlocal in_bounds_count
723
+			score = levenshtein(normal_username, r.discord_username)
724
+			if score <= max_distance:
725
+				in_bounds_count += 1
726
+			return score
727
+		closest = heapq.nsmallest(limit, records, key=compute)
728
+		if len(closest) > in_bounds_count:
729
+			closest = closest[:in_bounds_count]
730
+		return closest
731
+
732
+
733
+	# -- Utils -----
734
+
735
+	def __trace(self, guild: Guild, message: str):
736
+		self.log(guild, message)
737
+
738
+	@staticmethod
739
+	def __normalize_discord_username(username: str) -> str:
740
+		norm = username.lower().strip()
741
+		if norm.startswith('@'):
742
+			norm = norm[1:].strip()
743
+		return norm
744
+
745
+class _LookupStatus(IntEnum):
746
+	# Username imported but has not been looked up yet
747
+	unprocessed = 0
748
+	# Username was searched for in guild but not found (user might not have joined yet)
749
+	username_not_found = 1
750
+	# Username found in guild and member id stored
751
+	member_found = 2
752
+	# Mod manually linked this imported username to a Discord member to correct a username typo
753
+	misspelled_username = 3
754
+
755
+def _describe_lookup_status(status: _LookupStatus) -> str:
756
+	if status == _LookupStatus.unprocessed:
757
+		return 'unprocessed'
758
+	if status == _LookupStatus.username_not_found:
759
+		return 'member not in server'
760
+	if status == _LookupStatus.member_found:
761
+		return 'member found'
762
+	if status == _LookupStatus.misspelled_username:
763
+		return 'manually linked by mod'
764
+	return '-unknown-'
765
+
766
+class _KickstarterDiscordUser:
767
+	"""kickstarter_discord_users table row"""
768
+	def __init__(self,
769
+		pk: int,
770
+		guild_id: int,
771
+		discord_username: str,
772
+		discord_member_id: int | None,
773
+		lookup_status: _LookupStatus,
774
+		imported_at: int
775
+	):
776
+		self.pk: int = pk
777
+		self.guild_id: int = guild_id
778
+		self.discord_username: str = discord_username
779
+		"""Discord username provided in Kickstarter survey."""
780
+		self.discord_member_id: int | None = discord_member_id
781
+		"""Discord member ID when synced successfully or None when not yet linked."""
782
+		self.lookup_status: _LookupStatus = lookup_status
783
+		self.imported_at: int = imported_at
784
+		"""Unix timestamp when this record was first imported."""
785
+
786
+class _Stats:
787
+	def __init__(self,
788
+		username_count: int,
789
+		username_unprocessed_count: int,
790
+		username_not_found_count: int,
791
+		username_found_count: int,
792
+		username_last_imported_at: int
793
+	):
794
+		self.username_count: int = username_count
795
+		self.username_unprocessed_count: int = username_unprocessed_count
796
+		self.username_not_found_count: int = username_not_found_count
797
+		self.username_found_count: int = username_found_count
798
+		self.username_last_imported_at: int = username_last_imported_at
799
+
800
+class _NoBackerRoleException(BaseException):
801
+	pass
802
+
803
+class _ImportResult:
804
+	def __init__(self,
805
+		filename: str,
806
+		file_bytes: int,
807
+		line_count: int,
808
+		valid_record_count: int,
809
+		malformed_record_count: int,
810
+		new_record_count: int,
811
+		invalid_records: list[str]
812
+	):
813
+		self.filename: str = filename
814
+		"""Filename of the uploaded file."""
815
+		self.file_bytes: int = file_bytes
816
+		"""Size of uploaded file in bytes."""
817
+		self.line_count: int = line_count
818
+		"""Number of lines in the uploaded text file."""
819
+		self.valid_record_count: int = valid_record_count
820
+		"""How many records were valid."""
821
+		self.malformed_record_count: int = malformed_record_count
822
+		"""How many records were skipped because they were malformed."""
823
+		self.new_record_count: int = new_record_count
824
+		"""How many records were imported. May be less than valid_record_count
825
+		if some were already imported."""
826
+		self.invalid_records: list[str] = invalid_records
827
+		"""List of records that could not be imported. (May be partial if lots of failures.)"""
828
+
829
+	def summary_markdown(self) -> str:
830
+		lines: list[str] = []
831
+		if self.valid_record_count > 0:
832
+			lines.append(f"- Read {self.valid_record_count:,} valid records")
833
+		else:
834
+			lines.append("- Upload contained **no valid records**")
835
+		if self.malformed_record_count > 0:
836
+			lines.append(f"- Read **{self.malformed_record_count:,} malformed records**")
837
+		if self.new_record_count > 0:
838
+			lines.append(f"- Imported {self.new_record_count:,} new unique records")
839
+		else:
840
+			lines.append("- No new unique records (all previously imported)")
841
+		return "\n".join(lines)
842
+
843
+class _SyncUsernamesResult:
844
+	def __init__(self,
845
+		complete_count: int,
846
+		not_found_count: int,
847
+		failure_count: int
848
+	):
849
+		self.complete_count: int = complete_count
850
+		self.not_found_count: int = not_found_count
851
+		self.failure_count: int = failure_count
852
+
853
+
854
+	def summary_markdown(self) -> str:
855
+		lines: list[str] = []
856
+		if self.complete_count > 0:
857
+			lines.append(f"- Gave backer role to {self.complete_count:,} new members")
858
+		else:
859
+			lines.append("- No new members found")
860
+		if self.not_found_count > 0:
861
+			lines.append(f"- {self.not_found_count:,} members could not be found " \
862
+				"by the provided username")
863
+		if self.failure_count > 0:
864
+			lines.append(f"- Failed to link {self.failure_count:,} members")
865
+		return "\n".join(lines)
866
+
867
+class _UploadUsernamesModal(Modal):
868
+	upload_label = Label(
869
+		text='Discord user export',
870
+		description='Upload a plain text file containing one backer Discord username per line.',
871
+		component=FileUpload(
872
+			required=True,
873
+			min_values=1, max_values=1
874
+		)
875
+	)
876
+
877
+	def __init__(self):
878
+		super().__init__(title='Upload Discord Usernames', timeout=None)
879
+
880
+	# @override
881
+	async def on_submit(self, interaction: Interaction) -> None:
882
+		# noinspection PyTypeChecker
883
+		upload_input: FileUpload = self.upload_label.component
884
+		if len(upload_input.values) < 1:
885
+			text = f"{CONFIG['failure_emoji']} No export file included"
886
+			await interaction.response.send(text, ephemeral=True)
887
+			return
888
+		attachment = upload_input.values[0]
889
+		await KickstarterCog.shared.on_username_upload_submit(interaction, attachment)
890
+
891
+	# @override
892
+	async def on_error(self, interaction: Interaction, error: Exception) -> None:
893
+		dump_stacktrace(error)
894
+		try:
895
+			await interaction.response.send_message(
896
+				f'{CONFIG["failure_emoji"]} Upload failed :(',
897
+				ephemeral=True,
898
+			)
899
+		except DiscordException as e:
900
+			dump_stacktrace(e)

+ 29
- 10
rocketbot/cogs/logcog.py Переглянути файл

@@ -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
@@ -206,6 +207,8 @@ class LoggingCog(BaseCog, name='Logging'):
206 207
 		Called when a Member joins a Guild.
207 208
 
208 209
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_join
210
+
211
+		Requires Members privileged intent.
209 212
 		"""
210 213
 		guild = member.guild
211 214
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
@@ -273,6 +276,8 @@ class LoggingCog(BaseCog, name='Logging'):
273 276
 		will not be called, you may use on_raw_member_remove() instead.
274 277
 
275 278
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_remove
279
+
280
+		Requires Members privileged intent.
276 281
 		"""
277 282
 		guild = member.guild
278 283
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
@@ -312,6 +317,8 @@ class LoggingCog(BaseCog, name='Logging'):
312 317
 		* flags
313 318
 
314 319
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_update
320
+
321
+		Requires Members privileged intent.
315 322
 		"""
316 323
 		guild = after.guild
317 324
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
@@ -371,6 +378,8 @@ class LoggingCog(BaseCog, name='Logging'):
371 378
 		* discriminator
372 379
 
373 380
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_user_update
381
+
382
+		Requires Members privileged intent.
374 383
 		"""
375 384
 		if hasattr(after, 'guild'):
376 385
 			guild = after.guild
@@ -393,7 +402,7 @@ class LoggingCog(BaseCog, name='Logging'):
393 402
 		await bot_message.update()
394 403
 
395 404
 	@Cog.listener()
396
-	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:
397 406
 		"""
398 407
 		Called when user gets banned from a Guild.
399 408
 
@@ -416,7 +425,7 @@ class LoggingCog(BaseCog, name='Logging'):
416 425
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
417 426
 		await bot_message.update()
418 427
 
419
-	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:
420 429
 		"""
421 430
 		Searches the audit log for the most recent entry of a given type for a
422 431
 		given user. Intended for finding the relevant entry for a ban/kick that
@@ -434,7 +443,7 @@ class LoggingCog(BaseCog, name='Logging'):
434 443
 		return None
435 444
 
436 445
 	@Cog.listener()
437
-	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:
438 447
 		"""
439 448
 		Called when a User gets unbanned from a Guild.
440 449
 
@@ -468,7 +477,7 @@ class LoggingCog(BaseCog, name='Logging'):
468 477
 			self.buffered_guilds.clear()
469 478
 			for guild in guilds:
470 479
 				await self.__flush_buffers_for_guild(guild)
471
-		except Exception as e:
480
+		except DiscordException as e:
472 481
 			dump_stacktrace(e)
473 482
 
474 483
 	async def __flush_buffers_for_guild(self, guild: Guild) -> None:
@@ -492,6 +501,8 @@ class LoggingCog(BaseCog, name='Logging'):
492 501
 		Called when a Message is created and sent.
493 502
 
494 503
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message
504
+
505
+		Content fields blank without MessageContent privileged intent.
495 506
 		"""
496 507
 		# print(f"on_message:"
497 508
 		# 	f"\n\tid: {message.id}"
@@ -523,6 +534,8 @@ class LoggingCog(BaseCog, name='Logging'):
523 534
 		* A call message has received an update to its participants or ending time.
524 535
 
525 536
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message_edit
537
+
538
+		Content fields blank without MessageContent privileged intent.
526 539
 		"""
527 540
 		guild = after.guild
528 541
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
@@ -556,6 +569,8 @@ class LoggingCog(BaseCog, name='Logging'):
556 569
 		Discord embed server.
557 570
 
558 571
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_edit
572
+
573
+		Content fields blank without MessageContent privileged intent.
559 574
 		"""
560 575
 		if payload.cached_message:
561 576
 			return  # already handled by on_message_edit
@@ -663,6 +678,8 @@ class LoggingCog(BaseCog, name='Logging'):
663 678
 		RawMessageDeleteEvent.cached_message
664 679
 
665 680
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_delete
681
+
682
+		Content fields blank without MessageContent privileged intent.
666 683
 		"""
667 684
 		message = payload.cached_message
668 685
 		if message and message.author.id == self.bot.user.id:
@@ -692,6 +709,8 @@ class LoggingCog(BaseCog, name='Logging'):
692 709
 		RawBulkMessageDeleteEvent.cached_messages
693 710
 
694 711
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_bulk_message_delete
712
+
713
+		Content fields blank without MessageContent privileged intent.
695 714
 		"""
696 715
 		guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
697 716
 		if not guild:
@@ -721,7 +740,7 @@ class LoggingCog(BaseCog, name='Logging'):
721 740
 		else:
722 741
 			complex_deletes = events
723 742
 		if len(complex_deletes) > 0:
724
-			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)
725 744
 			text = 'Multiple messages deleted' if len(complex_deletes) > 1 else 'Message deleted'
726 745
 			row_count = 0
727 746
 			for author, messages in messages_per_author.items():
@@ -855,7 +874,7 @@ class LoggingCog(BaseCog, name='Logging'):
855 874
 			return '> _<no content>_'
856 875
 		return '> ' + escape_markdown(s).replace('\n', '\n> ')
857 876
 
858
-	def __describe_user(self, user: Union[User, Member]) -> str:
877
+	def __describe_user(self, user: User | Member) -> str:
859 878
 		"""
860 879
 		Standardized Markdown describing a user or member.
861 880
 		"""

+ 10
- 5
rocketbot/cogs/patterncog.py Переглянути файл

@@ -6,9 +6,10 @@ import re
6 6
 from datetime import datetime
7 7
 from typing import Optional
8 8
 
9
-from discord import Guild, Interaction, Member, Message
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)
@@ -435,3 +436,7 @@ class PatternCog(BaseCog, name='Pattern Matching'):
435 436
 				f'updated to `{priority}`.',
436 437
 			ephemeral=True,
437 438
 		)
439
+
440
+	@classmethod
441
+	def supports_intents(cls, intents: Intents) -> bool:
442
+		return intents.message_content

+ 10
- 10
rocketbot/cogs/urlspamcog.py Переглянути файл

@@ -5,7 +5,7 @@ import re
5 5
 from datetime import timedelta
6 6
 from typing import Literal
7 7
 
8
-from discord import Guild, Member, Message
8
+from discord import Guild, Intents, Member, Message
9 9
 from discord import utils as discordutils
10 10
 from discord.ext.commands import Cog
11 11
 from discord.utils import escape_markdown
@@ -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
 
@@ -358,3 +354,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
358 354
 	def __find_urls(cls, text: str) -> list[str]:
359 355
 		p = re.compile(r'https?://\S+')
360 356
 		return re.findall(p, text)
357
+
358
+	@classmethod
359
+	def supports_intents(cls, intents: Intents) -> bool:
360
+		return intents.message_content

+ 10
- 9
rocketbot/cogs/usernamecog.py Переглянути файл

@@ -1,9 +1,8 @@
1 1
 """
2 2
 Cog for detecting username patterns.
3 3
 """
4
-from typing import Optional
5 4
 
6
-from discord import Guild, Interaction, Member
5
+from discord import Guild, Intents, Interaction, Member
7 6
 from discord.app_commands import Group
8 7
 from discord.ext.commands import Cog
9 8
 
@@ -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)
@@ -239,3 +236,7 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
239 236
 			context.ignored_by = reacted_by
240 237
 			self.log(context.member.guild, f'Warning ignored by {reacted_by.name}')
241 238
 			await bot_message.set_reactions(context.reactions())
239
+
240
+	@classmethod
241
+	def supports_intents(cls, intents: Intents) -> bool:
242
+		return intents.members

+ 34
- 31
rocketbot/cogs/videopreviewcog.py Переглянути файл

@@ -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 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:
@@ -219,3 +214,11 @@ class VideoPreviewCog(BaseCog, name='Video Link Previews'):
219 214
 			content,
220 215
 			mention_author=False
221 216
 		)
217
+
218
+	def __trace(self, guild: Guild, message: Any):
219
+		# self.log(guild, message)
220
+		pass
221
+
222
+	@classmethod
223
+	def supports_intents(cls, intents: Intents) -> bool:
224
+		return intents.message_content

+ 13
- 12
rocketbot/cogsetting.py Переглянути файл

@@ -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 Переглянути файл

@@ -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 Переглянути файл

@@ -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 Переглянути файл

@@ -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 Переглянути файл

@@ -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__()

+ 38
- 9
rocketbot/utils.py Переглянути файл

@@ -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 '-] '
@@ -135,6 +135,8 @@ __ID_REGEX: re.Pattern = re.compile('^[0-9]{17,20}$')
135 135
 __MENTION_REGEX: re.Pattern = re.compile('^<@[!&]([0-9]{17,20})>$')
136 136
 __USER_MENTION_REGEX: re.Pattern = re.compile('^<@!([0-9]{17,20})>$')
137 137
 __ROLE_MENTION_REGEX: re.Pattern = re.compile('^<@&([0-9]{17,20})>$')
138
+__EMAIL_REGEX: re.Pattern = re.compile(r'^(?:(?:[^<>()\[\]\\.,;:\s@"]+(?:\.[^<>()\[\]\\.,;:\s@"]+)*)|(?:".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(?:(?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$')
139
+__USERNAME_REGEX: re.Pattern = re.compile(r'[a-z0-9\._]{2,32}')
138 140
 
139 141
 def is_user_id(val: str) -> bool:
140 142
 	"""Tests if a string is in user/role ID format."""
@@ -152,6 +154,14 @@ def is_user_mention(val: str) -> bool:
152 154
 	"""Tests if a string is a user mention."""
153 155
 	return __USER_MENTION_REGEX.match(val) is not None
154 156
 
157
+def is_email_address(val: str) -> bool:
158
+	"""Tests if a string is a well-formed email address."""
159
+	return __EMAIL_REGEX.match(val) is not None
160
+
161
+def is_discord_username(val: str) -> bool:
162
+	"""Tests if a string is a properly formatted Discord username."""
163
+	return __USERNAME_REGEX.match(val.lower())
164
+
155 165
 def user_id_from_mention(mention: str) -> str:
156 166
 	"""Extracts the user ID from a mention. Raises a ValueError if malformed."""
157 167
 	m = __USER_MENTION_REGEX.match(mention)
@@ -159,11 +169,11 @@ def user_id_from_mention(mention: str) -> str:
159 169
 		return m.group(1)
160 170
 	raise ValueError(f'"{mention}" is not an @ user mention')
161 171
 
162
-def mention_from_user_id(user_id: Union[str, int]) -> str:
172
+def mention_from_user_id(user_id: str | int) -> str:
163 173
 	"""Returns a Markdown user mention from a user id."""
164 174
 	return f'<@!{user_id}>'
165 175
 
166
-def mention_from_role_id(role_id: Union[str, int]) -> str:
176
+def mention_from_role_id(role_id: str | int) -> str:
167 177
 	"""Returns a Markdown role mention from a role id."""
168 178
 	return f'<@&{role_id}>'
169 179
 
@@ -187,8 +197,7 @@ def suppress_markdown_url_previews(markdown: str) -> str:
187 197
 
188 198
 def format_bytes(size: int) -> str:
189 199
 	"""Formats s size in bytes to a human readable description (e.g. "3.2 KiB")"""
190
-	if size < 0:
191
-		size = 0
200
+	size = max(size, 0)
192 201
 	kib = 1024
193 202
 	mib = kib * kib
194 203
 	gib = mib * kib
@@ -213,7 +222,27 @@ def norm_datetime(dt: datetime) -> datetime:
213 222
 		return dt
214 223
 	return datetime.fromtimestamp(dt.timestamp(), timezone.utc)
215 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
+
216 244
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
245
+ADMIN_PERMISSIONS: Permissions = Permissions(Permissions.administrator.flag)
217 246
 
218 247
 class TimeDeltaTransformer(Transformer):
219 248
 	async def transform(self, interaction: Interaction, value: Any) -> timedelta:

+ 11
- 0
sql/kickstarter-create.sql Переглянути файл

@@ -0,0 +1,11 @@
1
+CREATE TABLE IF NOT EXISTS kickstarter_discord_users (
2
+	pk INTEGER PRIMARY KEY AUTOINCREMENT,
3
+	guild_id INTEGER NOT NULL,  -- Discord Guild.id
4
+	discord_username TEXT NOT NULL,  -- Discord Member.name
5
+	discord_member_id INTEGER DEFAULT NULL,  -- resolved member id
6
+	lookup_status INTEGER NOT NULL DEFAULT 0,  -- enum defined in code
7
+	imported_at INTEGER NOT NULL,  -- unix timestamp
8
+	UNIQUE(guild_id, discord_username COLLATE NOCASE) ON CONFLICT FAIL
9
+);
10
+CREATE INDEX IF NOT EXISTS idx_kickstarter_discord_users_discord_member_id
11
+	ON kickstarter_discord_users (discord_member_id);

Завантаження…
Відмінити
Зберегти