#35 Kickstarter member linking feature

Sloučený
ialbert sloučil 10 revizí z větve kickstarter do větve main před před 1 týdnem

+ 7
- 1
config.sample.py Zobrazit soubor

1
 # Copy this file to config.py and fill in "<REQUIRED>" values
1
 # Copy this file to config.py and fill in "<REQUIRED>" values
2
 CONFIG = {
2
 CONFIG = {
3
-	'__config_version': 4,
3
+	'__config_version': 5,
4
 
4
 
5
     # -----------------------------------------------------------------------
5
     # -----------------------------------------------------------------------
6
     # General
6
     # General
14
 	'max_members_per_message': 20,
14
 	'max_members_per_message': 20,
15
 	# Minimum seconds between warnings about the same member.
15
 	# Minimum seconds between warnings about the same member.
16
 	'squelch_warning_seconds': 300,
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
     # Emojis used for performing actions on flagged activities. The bot will
25
     # Emojis used for performing actions on flagged activities. The bot will

+ 5
- 5
main.py Zobrazit soubor

13
 	raise RuntimeError(f'rocketbot requires Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}.{MIN_PYTHON_VERSION[2]} '
13
 	raise RuntimeError(f'rocketbot requires Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}.{MIN_PYTHON_VERSION[2]} '
14
 		f'or greater. Detected {ver[0]}.{ver[1]}.{ver[2]}.')
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
 if (CONFIG.get('__config_version') or 0) < CURRENT_CONFIG_VERSION:
23
 if (CONFIG.get('__config_version') or 0) < CURRENT_CONFIG_VERSION:
24
 	# If you're getting this error, it means something changed in config.py's
24
 	# If you're getting this error, it means something changed in config.py's
25
 	# format. Consult config.sample.py and compare it to your own config.py.
25
 	# format. Consult config.sample.py and compare it to your own config.py.

+ 1
- 1
requirements.txt Zobrazit soubor

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

+ 21
- 6
rocketbot/bot.py Zobrazit soubor

1
 import traceback
1
 import traceback
2
-from typing import Optional
3
 
2
 
4
 from discord import Intents
3
 from discord import Intents
4
+from discord.errors import DiscordException
5
 from discord.ext import commands
5
 from discord.ext import commands
6
 
6
 
7
 from config import CONFIG
7
 from config import CONFIG
69
 					CogSetting.set_up_all(bcog, self, bcog.settings)
69
 					CogSetting.set_up_all(bcog, self, bcog.settings)
70
 		try:
70
 		try:
71
 			synced_commands = await self.tree.sync()
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
 				bot_log(None, None, f'Synced command: /{command.name}')
73
 				bot_log(None, None, f'Synced command: /{command.name}')
74
-		except Exception as e:
74
+		except DiscordException as e:
75
 			dump_stacktrace(e)
75
 			dump_stacktrace(e)
76
 
76
 
77
 # Current active bot instance
77
 # Current active bot instance
78
-rocketbot: Optional[Rocketbot] = None
78
+rocketbot: Rocketbot | None = None
79
 
79
 
80
 def __create_bot():
80
 def __create_bot():
81
 	global rocketbot
81
 	global rocketbot
83
 		return
83
 		return
84
 	bot_log(None, None, 'Creating bot...')
84
 	bot_log(None, None, 'Creating bot...')
85
 	intents = Intents.default()
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
 	intents.presences = False
89
 	intents.presences = False
89
 	rocketbot = Rocketbot(intents=intents)
90
 	rocketbot = Rocketbot(intents=intents)
90
 __create_bot()
91
 __create_bot()
98
 	from rocketbot.cogs.generalcog import GeneralCog
99
 	from rocketbot.cogs.generalcog import GeneralCog
99
 	from rocketbot.cogs.helpcog import HelpCog
100
 	from rocketbot.cogs.helpcog import HelpCog
100
 	from rocketbot.cogs.joinraidcog import JoinRaidCog
101
 	from rocketbot.cogs.joinraidcog import JoinRaidCog
102
+	from rocketbot.cogs.kickstartercog import KickstarterCog
101
 	from rocketbot.cogs.logcog import LoggingCog
103
 	from rocketbot.cogs.logcog import LoggingCog
102
 	from rocketbot.cogs.patterncog import PatternCog
104
 	from rocketbot.cogs.patterncog import PatternCog
103
 	from rocketbot.cogs.urlspamcog import URLSpamCog
105
 	from rocketbot.cogs.urlspamcog import URLSpamCog
111
 	await rocketbot.add_cog(ConfigCog(rocketbot))
113
 	await rocketbot.add_cog(ConfigCog(rocketbot))
112
 
114
 
113
 	# Optional
115
 	# Optional
116
+	if AutoKickCog.supports_intents(rocketbot.intents):
114
 		await rocketbot.add_cog(AutoKickCog(rocketbot))
117
 		await rocketbot.add_cog(AutoKickCog(rocketbot))
118
+	if BangCommandCog.supports_intents(rocketbot.intents):
115
 		await rocketbot.add_cog(BangCommandCog(rocketbot))
119
 		await rocketbot.add_cog(BangCommandCog(rocketbot))
120
+	if CrossPostCog.supports_intents(rocketbot.intents):
116
 		await rocketbot.add_cog(CrossPostCog(rocketbot))
121
 		await rocketbot.add_cog(CrossPostCog(rocketbot))
122
+	if GamesCog.supports_intents(rocketbot.intents):
117
 		await rocketbot.add_cog(GamesCog(rocketbot))
123
 		await rocketbot.add_cog(GamesCog(rocketbot))
124
+	if HelpCog.supports_intents(rocketbot.intents):
118
 		await rocketbot.add_cog(HelpCog(rocketbot))
125
 		await rocketbot.add_cog(HelpCog(rocketbot))
126
+	if JoinRaidCog.supports_intents(rocketbot.intents):
119
 		await rocketbot.add_cog(JoinRaidCog(rocketbot))
127
 		await rocketbot.add_cog(JoinRaidCog(rocketbot))
128
+	if LoggingCog.supports_intents(rocketbot.intents):
120
 		await rocketbot.add_cog(LoggingCog(rocketbot))
129
 		await rocketbot.add_cog(LoggingCog(rocketbot))
130
+	if PatternCog.supports_intents(rocketbot.intents):
121
 		await rocketbot.add_cog(PatternCog(rocketbot))
131
 		await rocketbot.add_cog(PatternCog(rocketbot))
132
+	if URLSpamCog.supports_intents(rocketbot.intents):
122
 		await rocketbot.add_cog(URLSpamCog(rocketbot))
133
 		await rocketbot.add_cog(URLSpamCog(rocketbot))
134
+	if UsernamePatternCog.supports_intents(rocketbot.intents):
123
 		await rocketbot.add_cog(UsernamePatternCog(rocketbot))
135
 		await rocketbot.add_cog(UsernamePatternCog(rocketbot))
136
+	if VideoPreviewCog.supports_intents(rocketbot.intents):
124
 		await rocketbot.add_cog(VideoPreviewCog(rocketbot))
137
 		await rocketbot.add_cog(VideoPreviewCog(rocketbot))
138
+	if KickstarterCog.supports_intents(rocketbot.intents):
139
+		await rocketbot.add_cog(KickstarterCog(rocketbot))
125
 
140
 
126
 	await rocketbot.start(CONFIG['client_token'], reconnect=True)
141
 	await rocketbot.start(CONFIG['client_token'], reconnect=True)
127
 	print('\nBot aborted')
142
 	print('\nBot aborted')

+ 14
- 14
rocketbot/botmessage.py Zobrazit soubor

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

+ 5
- 1
rocketbot/cogs/autokickcog.py Zobrazit soubor

1
 from datetime import datetime, timedelta, timezone
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
 from discord.ext import tasks
4
 from discord.ext import tasks
5
 from discord.ext.commands import Cog
5
 from discord.ext.commands import Cog
6
 from discord.ext.tasks import Loop
6
 from discord.ext.tasks import Loop
158
 			if val % 10 == 3:
158
 			if val % 10 == 3:
159
 				return f'{val}rd'
159
 				return f'{val}rd'
160
 		return f'{val}th'
160
 		return f'{val}th'
161
+
162
+	@classmethod
163
+	def supports_intents(cls, intents: Intents) -> bool:
164
+		return intents.members

+ 27
- 14
rocketbot/cogs/bangcommandcog.py Zobrazit soubor

1
 import re
1
 import re
2
 from typing import Optional, TypedDict
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
 from discord.app_commands import Choice, Group, autocomplete
13
 from discord.app_commands import Choice, Group, autocomplete
14
+from discord.errors import DiscordException
6
 from discord.ext.commands import Cog
15
 from discord.ext.commands import Cog
7
 from discord.ui import Label, Modal, Select, TextInput
16
 from discord.ui import Label, Modal, Select, TextInput
8
 
17
 
59
 	def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
68
 	def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
60
 		return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
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
 		cmds = self.get_saved_commands(guild)
72
 		cmds = self.get_saved_commands(guild)
64
 		name = BangCommandCog._normalize_name(name)
73
 		name = BangCommandCog._normalize_name(name)
65
 		return cmds.get(name, None)
74
 		return cmds.get(name, None)
83
 		}
92
 		}
84
 	)
93
 	)
85
 	@autocomplete(name=command_autocomplete)
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
 		Defines or redefines a bang command.
97
 		Defines or redefines a bang command.
89
 
98
 
215
 		if len(content) < 1 or len(content) > 2000:
224
 		if len(content) < 1 or len(content) > 2000:
216
 			raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
225
 			raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
217
 		cmds = self.get_saved_commands(guild)
226
 		cmds = self.get_saved_commands(guild)
218
-		if check_exists:
219
-			if cmds.get(name, None) is not None:
227
+		if check_exists and cmds.get(name, None) is not None:
220
 			raise ValueError(f'Command with name "{name}" already exists.')
228
 			raise ValueError(f'Command with name "{name}" already exists.')
221
 		cmds[name] = {
229
 		cmds[name] = {
222
 			'content': content,
230
 			'content': content,
254
 
262
 
255
 	@staticmethod
263
 	@staticmethod
256
 	def _normalize_name(name: str) -> str:
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
 	@staticmethod
267
 	@staticmethod
263
-	def _is_valid_name(name: Optional[str]) -> bool:
268
+	def _is_valid_name(name: str | None) -> bool:
264
 		return name is not None and re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
269
 		return name is not None and re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
265
 
270
 
266
 	@staticmethod
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
 		if name is None:
273
 		if name is None:
269
 			return None
274
 			return None
270
 		match = re.match(r'^!((?:[a-z]+)(?:[_-][a-z]+)*)\b.*$', name)
275
 		match = re.match(r'^!((?:[a-z]+)(?:[_-][a-z]+)*)\b.*$', name)
271
 		return BangCommandCog._normalize_name(match.group(1)) if match else None
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
 class _EditModal(Modal, title='Edit Command'):
282
 class _EditModal(Modal, title='Edit Command'):
274
 	name_label = Label(
283
 	name_label = Label(
275
 		text='Command name',
284
 		text='Command name',
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
 		super().__init__()
321
 		super().__init__()
309
 		self.exists = exists
322
 		self.exists = exists
310
 		# noinspection PyTypeChecker
323
 		# noinspection PyTypeChecker
348
 				f'{CONFIG["failure_emoji"]} Save failed',
361
 				f'{CONFIG["failure_emoji"]} Save failed',
349
 				ephemeral=True,
362
 				ephemeral=True,
350
 			)
363
 			)
351
-		except BaseException:
352
-			pass
364
+		except DiscordException as e:
365
+			dump_stacktrace(e)

+ 17
- 6
rocketbot/cogs/basecog.py Zobrazit soubor

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

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

+ 7
- 4
rocketbot/cogs/crosspostcog.py Zobrazit soubor

3
 """
3
 """
4
 import re
4
 import re
5
 from datetime import datetime, timedelta, timezone
5
 from datetime import datetime, timedelta, timezone
6
-from typing import Optional
7
 
6
 
8
 from discord import Member, Message, TextChannel
7
 from discord import Member, Message, TextChannel
9
 from discord import utils as discordutils
8
 from discord import utils as discordutils
23
 	def __init__(self, member: Member) -> None:
22
 	def __init__(self, member: Member) -> None:
24
 		self.member: Member = member
23
 		self.member: Member = member
25
 		self.age: datetime = datetime.now(timezone.utc)
24
 		self.age: datetime = datetime.now(timezone.utc)
26
-		self.bot_message: Optional[BotMessage] = None
25
+		self.bot_message: BotMessage | None = None
27
 		self.is_kicked: bool = False
26
 		self.is_kicked: bool = False
28
 		self.is_banned: bool = False
27
 		self.is_banned: bool = False
29
 		self.is_autobanned: bool = False
28
 		self.is_autobanned: bool = False
141
 			self.__trace(f"User {message.author.name} exempt from crosspost checks")
140
 			self.__trace(f"User {message.author.name} exempt from crosspost checks")
142
 			return
141
 			return
143
 		def compute_message_hash(m: Message) -> int:
142
 		def compute_message_hash(m: Message) -> int:
143
+			if self.bot.intents.message_content:
144
 				to_hash = m.content
144
 				to_hash = m.content
145
 				# URLs sometimes differ per spam message, so simplify them
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]))+'
146
 				url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
148
 				# Add attachment metadata
148
 				# Add attachment metadata
149
 				for attachment in m.attachments:
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}]]'
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
 			h = hash(to_hash)
154
 			h = hash(to_hash)
152
 			self.__trace(f"Hash for message #{m.id} by {m.author.name} is {h}\n\thash content: \"{to_hash}\"")
155
 			self.__trace(f"Hash for message #{m.id} by {m.author.name} is {h}\n\thash content: \"{to_hash}\"")
153
 			return h
156
 			return h
248
 		await self.__update_message_from_context(context)
251
 		await self.__update_message_from_context(context)
249
 
252
 
250
 	async def __update_message_from_context(self, context: SpamContext) -> None:
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
 		spam_count = len(context.spam_messages)
255
 		spam_count = len(context.spam_messages)
253
 		channel_count = len(context.unique_channels)
256
 		channel_count = len(context.unique_channels)
254
 		deleted_count = len(context.deleted_messages)
257
 		deleted_count = len(context.deleted_messages)
280
 				body += f'messages in {channel_count} channels within {max_age_str} ' + \
283
 				body += f'messages in {channel_count} channels within {max_age_str} ' + \
281
 						f'({duplicate_count} are identical, showing first one).'
284
 						f'({duplicate_count} are identical, showing first one).'
282
 			max_links = 10
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
 				body += f'\n- {msg.jump_url}'
287
 				body += f'\n- {msg.jump_url}'
285
 			if len(context.spam_messages) > max_links:
288
 			if len(context.spam_messages) > max_links:
286
 				body += f'\n- ...{len(context.spam_messages) - max_links} more...'
289
 				body += f'\n- ...{len(context.spam_messages) - max_links} more...'

+ 1
- 1
rocketbot/cogs/generalcog.py Zobrazit soubor

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

+ 21
- 20
rocketbot/cogs/helpcog.py Zobrazit soubor

1
 """Provides help commands for getting info on using other commands and configuration."""
1
 """Provides help commands for getting info on using other commands and configuration."""
2
 import re
2
 import re
3
 import time
3
 import time
4
-from typing import Optional, TypedDict, Union
4
+from typing import Optional, TypedDict
5
 
5
 
6
 from discord import AppCommandType, Interaction, Permissions
6
 from discord import AppCommandType, Interaction, Permissions
7
 from discord.app_commands import (
7
 from discord.app_commands import (
12
 	command,
12
 	command,
13
 	guild_only,
13
 	guild_only,
14
 )
14
 )
15
+from discord.errors import DiscordException
15
 
16
 
16
 from config import CONFIG
17
 from config import CONFIG
17
 from rocketbot.bot import Rocketbot
18
 from rocketbot.bot import Rocketbot
19
 from rocketbot.ui.pagedcontent import paginate, update_paged_content
20
 from rocketbot.ui.pagedcontent import paginate, update_paged_content
20
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
21
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
21
 
22
 
22
-HelpTopic = Union[Command, Group, BaseCog]
23
+HelpTopic = Command | Group | BaseCog
23
 class HelpMeta(TypedDict):
24
 class HelpMeta(TypedDict):
24
 	id: str
25
 	id: str
25
 	text: str
26
 	text: str
52
 			choice_from_topic(topic, include_full_command=True)
53
 			choice_from_topic(topic, include_full_command=True)
53
 			for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
54
 			for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
54
 		][:25]
55
 		][:25]
55
-	except BaseException as e:
56
+	except DiscordException as e:
56
 		dump_stacktrace(e)
57
 		dump_stacktrace(e)
57
 		return []
58
 		return []
58
 
59
 
120
 				text += f' {cog.long_description}'
121
 				text += f' {cog.long_description}'
121
 			self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cog })
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
 		self.__create_help_index()
125
 		self.__create_help_index()
125
 		return self.id_to_topic.get(symbol, None)
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
 		# PyCharm not interpreting conditional return type correctly.
129
 		# PyCharm not interpreting conditional return type correctly.
129
 		# noinspection PyTypeChecker
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
 		return sorted(cmds, key=lambda cmd: cmd.name)
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
 		return [
135
 		return [
135
 			cmd
136
 			cmd
136
 			for cmd in self.all_commands()
137
 			for cmd in self.all_commands()
137
 			if can_use_command(cmd, permissions)
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
 		cmds = self.all_accessible_commands(permissions)
142
 		cmds = self.all_accessible_commands(permissions)
142
 		subcmds: list[Command] = []
143
 		subcmds: list[Command] = []
143
 		for cmd in cmds:
144
 		for cmd in cmds:
147
 						subcmds.append(subcmd)
148
 						subcmds.append(subcmd)
148
 		return subcmds
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
 		return [
152
 		return [
152
 			cog
153
 			cog
153
 			for cog in self.basecogs
154
 			for cog in self.basecogs
154
 			if can_use_cog(cog, permissions)
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
 							  include_cogs: bool = True,
159
 							  include_cogs: bool = True,
159
 							  include_commands: bool = True,
160
 							  include_commands: bool = True,
160
 							  include_subcommands: bool = True) -> list[HelpTopic]:
161
 							  include_subcommands: bool = True) -> list[HelpTopic]:
167
 			topics += self.all_accessible_subcommands(permissions)
168
 			topics += self.all_accessible_subcommands(permissions)
168
 		return topics
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
 		start_time = time.perf_counter()
172
 		start_time = time.perf_counter()
172
 		self.__create_help_index()
173
 		self.__create_help_index()
173
 
174
 
189
 		accessible_topics = [
190
 		accessible_topics = [
190
 			topic
191
 			topic
191
 			for topic in matching_topics
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
 			   (isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
194
 			   (isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
194
 		]
195
 		]
195
 
196
 
221
 	)
222
 	)
222
 	@guild_only()
223
 	@guild_only()
223
 	@autocomplete(search=search_autocomplete)
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
 		Shows help for using commands and subcommands and configuring modules.
227
 		Shows help for using commands and subcommands and configuring modules.
227
 
228
 
259
 			delete_after=10,
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
 		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
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
 		return {
267
 		return {
267
 			subcmd.name: subcmd
268
 			subcmd.name: subcmd
268
 			for subcmd in cmd.commands
269
 			for subcmd in cmd.commands
308
 
309
 
309
 		await self.__send_paged_help(interaction, text)
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
 		matching_commands = [
313
 		matching_commands = [
313
 			cmd
314
 			cmd
314
 			for cmd in matching_topics or []
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
 		matching_cogs = [
318
 		matching_cogs = [
318
 			cog
319
 			cog
346
 
347
 
347
 		await self.__send_paged_help(interaction, text)
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
 		text = ''
351
 		text = ''
351
 		if addendum is not None:
352
 		if addendum is not None:
352
 			text += addendum + '\n\n'
353
 			text += addendum + '\n\n'
437
 	'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
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
 	if user_permissions is None:
442
 	if user_permissions is None:
442
 		return False
443
 		return False
443
 	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
444
 	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
444
 		return False
445
 		return False
445
 	return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
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
 	# "Using" a cog for now means configuring it, and only mods can configure cogs.
449
 	# "Using" a cog for now means configuring it, and only mods can configure cogs.
449
 	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)
450
 	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)

+ 5
- 1
rocketbot/cogs/joinraidcog.py Zobrazit soubor

5
 from datetime import datetime, timedelta
5
 from datetime import datetime, timedelta
6
 from typing import Optional
6
 from typing import Optional
7
 
7
 
8
-from discord import Guild, Member
8
+from discord import Guild, Intents, Member
9
 from discord.ext.commands import Cog
9
 from discord.ext.commands import Cog
10
 
10
 
11
 from config import CONFIG
11
 from config import CONFIG
189
 			did_kick=kick_count >= member_count,
189
 			did_kick=kick_count >= member_count,
190
 			did_ban=ban_count >= member_count,
190
 			did_ban=ban_count >= member_count,
191
 			user_count=member_count))
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 Zobrazit soubor

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 Zobrazit soubor

3
 """
3
 """
4
 import difflib
4
 import difflib
5
 import re
5
 import re
6
-from collections.abc import Sequence
6
+from collections.abc import Callable, Sequence
7
 from datetime import datetime, timedelta, timezone
7
 from datetime import datetime, timedelta, timezone
8
-from typing import Any, Callable, Optional, Union
8
+from typing import Any
9
 
9
 
10
 from discord import (
10
 from discord import (
11
 	AuditLogAction,
11
 	AuditLogAction,
24
 	User,
24
 	User,
25
 )
25
 )
26
 from discord.abc import GuildChannel
26
 from discord.abc import GuildChannel
27
+from discord.errors import DiscordException
27
 from discord.ext import tasks
28
 from discord.ext import tasks
28
 from discord.ext.commands import Cog
29
 from discord.ext.commands import Cog
29
 from discord.utils import escape_markdown
30
 from discord.utils import escape_markdown
34
 
35
 
35
 
36
 
36
 class BufferedMessageEditEvent:
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
 		self.guild = guild
39
 		self.guild = guild
39
 		self.channel = channel
40
 		self.channel = channel
40
 		self.before = before
41
 		self.before = before
42
 		self.data = data
43
 		self.data = data
43
 
44
 
44
 class BufferedMessageDeleteEvent:
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
 		self.guild = guild
47
 		self.guild = guild
47
 		self.channel = channel
48
 		self.channel = channel
48
 		self.message_id = message_id
49
 		self.message_id = message_id
206
 		Called when a Member joins a Guild.
207
 		Called when a Member joins a Guild.
207
 
208
 
208
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_join
209
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_join
210
+
211
+		Requires Members privileged intent.
209
 		"""
212
 		"""
210
 		guild = member.guild
213
 		guild = member.guild
211
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
214
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
273
 		will not be called, you may use on_raw_member_remove() instead.
276
 		will not be called, you may use on_raw_member_remove() instead.
274
 
277
 
275
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_remove
278
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_remove
279
+
280
+		Requires Members privileged intent.
276
 		"""
281
 		"""
277
 		guild = member.guild
282
 		guild = member.guild
278
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
283
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
312
 		* flags
317
 		* flags
313
 
318
 
314
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_update
319
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_update
320
+
321
+		Requires Members privileged intent.
315
 		"""
322
 		"""
316
 		guild = after.guild
323
 		guild = after.guild
317
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
324
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
371
 		* discriminator
378
 		* discriminator
372
 
379
 
373
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_user_update
380
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_user_update
381
+
382
+		Requires Members privileged intent.
374
 		"""
383
 		"""
375
 		if hasattr(after, 'guild'):
384
 		if hasattr(after, 'guild'):
376
 			guild = after.guild
385
 			guild = after.guild
393
 		await bot_message.update()
402
 		await bot_message.update()
394
 
403
 
395
 	@Cog.listener()
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
 		Called when user gets banned from a Guild.
407
 		Called when user gets banned from a Guild.
399
 
408
 
416
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
425
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
417
 		await bot_message.update()
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
 		Searches the audit log for the most recent entry of a given type for a
430
 		Searches the audit log for the most recent entry of a given type for a
422
 		given user. Intended for finding the relevant entry for a ban/kick that
431
 		given user. Intended for finding the relevant entry for a ban/kick that
434
 		return None
443
 		return None
435
 
444
 
436
 	@Cog.listener()
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
 		Called when a User gets unbanned from a Guild.
448
 		Called when a User gets unbanned from a Guild.
440
 
449
 
468
 			self.buffered_guilds.clear()
477
 			self.buffered_guilds.clear()
469
 			for guild in guilds:
478
 			for guild in guilds:
470
 				await self.__flush_buffers_for_guild(guild)
479
 				await self.__flush_buffers_for_guild(guild)
471
-		except Exception as e:
480
+		except DiscordException as e:
472
 			dump_stacktrace(e)
481
 			dump_stacktrace(e)
473
 
482
 
474
 	async def __flush_buffers_for_guild(self, guild: Guild) -> None:
483
 	async def __flush_buffers_for_guild(self, guild: Guild) -> None:
492
 		Called when a Message is created and sent.
501
 		Called when a Message is created and sent.
493
 
502
 
494
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message
503
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message
504
+
505
+		Content fields blank without MessageContent privileged intent.
495
 		"""
506
 		"""
496
 		# print(f"on_message:"
507
 		# print(f"on_message:"
497
 		# 	f"\n\tid: {message.id}"
508
 		# 	f"\n\tid: {message.id}"
523
 		* A call message has received an update to its participants or ending time.
534
 		* A call message has received an update to its participants or ending time.
524
 
535
 
525
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message_edit
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
 		guild = after.guild
540
 		guild = after.guild
528
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
541
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
556
 		Discord embed server.
569
 		Discord embed server.
557
 
570
 
558
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_edit
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
 		if payload.cached_message:
575
 		if payload.cached_message:
561
 			return  # already handled by on_message_edit
576
 			return  # already handled by on_message_edit
663
 		RawMessageDeleteEvent.cached_message
678
 		RawMessageDeleteEvent.cached_message
664
 
679
 
665
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_delete
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
 		message = payload.cached_message
684
 		message = payload.cached_message
668
 		if message and message.author.id == self.bot.user.id:
685
 		if message and message.author.id == self.bot.user.id:
692
 		RawBulkMessageDeleteEvent.cached_messages
709
 		RawBulkMessageDeleteEvent.cached_messages
693
 
710
 
694
 		https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_bulk_message_delete
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
 		guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
715
 		guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
697
 		if not guild:
716
 		if not guild:
721
 		else:
740
 		else:
722
 			complex_deletes = events
741
 			complex_deletes = events
723
 		if len(complex_deletes) > 0:
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
 			text = 'Multiple messages deleted' if len(complex_deletes) > 1 else 'Message deleted'
744
 			text = 'Multiple messages deleted' if len(complex_deletes) > 1 else 'Message deleted'
726
 			row_count = 0
745
 			row_count = 0
727
 			for author, messages in messages_per_author.items():
746
 			for author, messages in messages_per_author.items():
855
 			return '> _<no content>_'
874
 			return '> _<no content>_'
856
 		return '> ' + escape_markdown(s).replace('\n', '\n> ')
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
 		Standardized Markdown describing a user or member.
879
 		Standardized Markdown describing a user or member.
861
 		"""
880
 		"""

+ 10
- 5
rocketbot/cogs/patterncog.py Zobrazit soubor

6
 from datetime import datetime
6
 from datetime import datetime
7
 from typing import Optional
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
 from discord import utils as discordutils
10
 from discord import utils as discordutils
11
 from discord.app_commands import Choice, Group, autocomplete
11
 from discord.app_commands import Choice, Group, autocomplete
12
+from discord.errors import DiscordException
12
 from discord.ext.commands import Cog
13
 from discord.ext.commands import Cog
13
 
14
 
14
 from config import CONFIG
15
 from config import CONFIG
47
 		for name in sorted(patterns.keys()):
48
 		for name in sorted(patterns.keys()):
48
 			if len(current_normal) == 0 or current_normal.startswith(name.lower()):
49
 			if len(current_normal) == 0 or current_normal.startswith(name.lower()):
49
 				choices.append(Choice(name=name, value=name))
50
 				choices.append(Choice(name=name, value=name))
50
-	except BaseException as e:
51
+	except DiscordException as e:
51
 		dump_stacktrace(e)
52
 		dump_stacktrace(e)
52
 	return choices
53
 	return choices
53
 
54
 
55
 	# FIXME: WORK IN PROGRESS
56
 	# FIXME: WORK IN PROGRESS
56
 	print(f'autocomplete action - current = "{current}"')
57
 	print(f'autocomplete action - current = "{current}"')
57
 	regex = re.compile('^(.*?)([a-zA-Z]+)$')
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
 	initial: str = ''
60
 	initial: str = ''
60
 	stub: str = current
61
 	stub: str = current
61
 	if match:
62
 	if match:
163
 	def __save_patterns(cls,
164
 	def __save_patterns(cls,
164
 			guild: Guild,
165
 			guild: Guild,
165
 			patterns: dict[str, PatternStatement]) -> None:
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
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
168
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
168
 
169
 
169
 	@classmethod
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
 		last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
172
 		last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
172
 		if last_matched:
173
 		if last_matched:
173
 			return last_matched.get(name)
174
 			return last_matched.get(name)
435
 				f'updated to `{priority}`.',
436
 				f'updated to `{priority}`.',
436
 			ephemeral=True,
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 Zobrazit soubor

5
 from datetime import timedelta
5
 from datetime import timedelta
6
 from typing import Literal
6
 from typing import Literal
7
 
7
 
8
-from discord import Guild, Member, Message
8
+from discord import Guild, Intents, Member, Message
9
 from discord import utils as discordutils
9
 from discord import utils as discordutils
10
 from discord.ext.commands import Cog
10
 from discord.ext.commands import Cog
11
 from discord.utils import escape_markdown
11
 from discord.utils import escape_markdown
270
 					link = 'https://' + link[12:]
270
 					link = 'https://' + link[12:]
271
 				if link.startswith('http://www.'):
271
 				if link.startswith('http://www.'):
272
 					link = 'http://' + link[11:]
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
 					return True
278
 					return True
283
 		return False
279
 		return False
284
 
280
 
358
 	def __find_urls(cls, text: str) -> list[str]:
354
 	def __find_urls(cls, text: str) -> list[str]:
359
 		p = re.compile(r'https?://\S+')
355
 		p = re.compile(r'https?://\S+')
360
 		return re.findall(p, text)
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 Zobrazit soubor

1
 """
1
 """
2
 Cog for detecting username patterns.
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
 from discord.app_commands import Group
6
 from discord.app_commands import Group
8
 from discord.ext.commands import Cog
7
 from discord.ext.commands import Cog
9
 
8
 
19
 	"""
18
 	"""
20
 	def __init__(self, member: Member) -> None:
19
 	def __init__(self, member: Member) -> None:
21
 		self.member: Member = member
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
 	def reactions(self) -> list[BotMessageReaction]:
25
 	def reactions(self) -> list[BotMessageReaction]:
27
 		"""
26
 		"""
187
 	async def on_member_join(self, member: Member) -> None:
186
 	async def on_member_join(self, member: Member) -> None:
188
 		"""Event handler"""
187
 		"""Event handler"""
189
 		for pattern in self.__get_patterns(member.guild):
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
 				await self.handle_match(member, pattern)
190
 				await self.handle_match(member, pattern)
194
 
191
 
195
 	def matches(self, pattern: str, subject: str) -> bool:
192
 	def matches(self, pattern: str, subject: str) -> bool:
209
 		context = UsernamePatternContext(member)
206
 		context = UsernamePatternContext(member)
210
 		bm = BotMessage(
207
 		bm = BotMessage(
211
 			member.guild,
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
 			f'username matching pattern `{pattern}`.',
210
 			f'username matching pattern `{pattern}`.',
214
 			BotMessage.TYPE_INFO if self.was_warned_recently(member) else BotMessage.TYPE_MOD_WARNING,
211
 			BotMessage.TYPE_INFO if self.was_warned_recently(member) else BotMessage.TYPE_MOD_WARNING,
215
 			context)
212
 			context)
239
 			context.ignored_by = reacted_by
236
 			context.ignored_by = reacted_by
240
 			self.log(context.member.guild, f'Warning ignored by {reacted_by.name}')
237
 			self.log(context.member.guild, f'Warning ignored by {reacted_by.name}')
241
 			await bot_message.set_reactions(context.reactions())
238
 			await bot_message.set_reactions(context.reactions())
239
+
240
+	@classmethod
241
+	def supports_intents(cls, intents: Intents) -> bool:
242
+		return intents.members

+ 30
- 27
rocketbot/cogs/videopreviewcog.py Zobrazit soubor

1
 import asyncio
1
 import asyncio
2
 import json
2
 import json
3
 import re
3
 import re
4
-import subprocess
5
 from datetime import timedelta
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
 from discord.ext.commands import Cog
8
 from discord.ext.commands import Cog
9
 
9
 
10
 from rocketbot.cogs.basecog import BaseCog
10
 from rocketbot.cogs.basecog import BaseCog
19
 def filter_video_format(format: dict) -> bool:
19
 def filter_video_format(format: dict) -> bool:
20
 	if format.get('resolution') == 'audio only':
20
 	if format.get('resolution') == 'audio only':
21
 		return False
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
 def rank_video_format(format: dict) -> tuple:
24
 def rank_video_format(format: dict) -> tuple:
27
 	content = 0
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
 		content = 1
27
 		content = 1
32
 	elif format.get('format_note') == 'DASH video':
28
 	elif format.get('format_note') == 'DASH video':
33
 		content = 2
29
 		content = 2
87
 	REGEX_FACEBOOK_POST = r'(https?:\/\/(?:www\.)?)\w*(facebook\.com(?:\/\w+)+\/\w+\/?)'
83
 	REGEX_FACEBOOK_POST = r'(https?:\/\/(?:www\.)?)\w*(facebook\.com(?:\/\w+)+\/\w+\/?)'
88
 	REGEX_TWITTER_POST = r'(https?:\/\/(?:www\.)?)\w*((?:twitter|x)\.com\/\w+\/status\/[0-9]+)'
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
 	# Best video and best audio, mp4 format with m4a audio
88
 	# Best video and best audio, mp4 format with m4a audio
93
 	FORMATS = 'bv*[ext=mp4]+ba[ext=m4a]/' \
89
 	FORMATS = 'bv*[ext=mp4]+ba[ext=m4a]/' \
154
 		delay: timedelta = self.get_guild_setting(message.guild, Self.SETTING_DELAY)
150
 		delay: timedelta = self.get_guild_setting(message.guild, Self.SETTING_DELAY)
155
 		await asyncio.sleep(delay.total_seconds())
151
 		await asyncio.sleep(delay.total_seconds())
156
 		# Look for embeds already showing the video
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
 		for embed in message.embeds:
154
 		for embed in message.embeds:
159
 			if embed.video.url:
155
 			if embed.video.url:
160
 				# If there's any video, skip downloading any previews
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
 				return
158
 				return
163
 		await self._fetch_previews(message, links)
159
 		await self._fetch_previews(message, links)
164
 
160
 
169
 		await asyncio.gather(*promises)
165
 		await asyncio.gather(*promises)
170
 
166
 
171
 	async def _fetch_preview(self, message: Message, link: MessageLink):
167
 	async def _fetch_preview(self, message: Message, link: MessageLink):
172
-		result = subprocess.run(
173
-			[
168
+		process = await asyncio.create_subprocess_exec(
174
 			'yt-dlp',
169
 			'yt-dlp',
175
 			'--skip-download',
170
 			'--skip-download',
176
 			'--dump-single-json',
171
 			'--dump-single-json',
177
 			link.url,
172
 			link.url,
178
-			],
179
-			stdout=subprocess.PIPE,
180
-			stderr=subprocess.PIPE,
181
-			universal_newlines=True
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
 			return
181
 			return
187
 		try:
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
 			return
186
 			return
192
 		description = info.get('description') or ''
187
 		description = info.get('description') or ''
193
 		formats: list[dict] = info.get('formats') or []
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
 		formats = list(filter(filter_video_format, formats))
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
 		sorted_formats: list[dict] = sorted(formats, key=rank_video_format, reverse=True)
192
 		sorted_formats: list[dict] = sorted(formats, key=rank_video_format, reverse=True)
198
 		if len(sorted_formats) == 0:
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
 			return
195
 			return
201
 		best_format: dict = sorted_formats[0]
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
 		video_url: str = best_format.get('url')
198
 		video_url: str = best_format.get('url')
204
 		link_description: str = "video"
199
 		link_description: str = "video"
205
 		if (best_format.get('width') or 0) > 0 and (best_format.get('height') or 0) > 0:
200
 		if (best_format.get('width') or 0) > 0 and (best_format.get('height') or 0) > 0:
219
 			content,
214
 			content,
220
 			mention_author=False
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 Zobrazit soubor

3
 """
3
 """
4
 
4
 
5
 from datetime import timedelta
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
 from discord import Interaction, Permissions
8
 from discord import Interaction, Permissions
9
 from discord.app_commands import Range, Transform, describe
9
 from discord.app_commands import Range, Transform, describe
10
 from discord.app_commands.commands import Command, CommandCallback, Group, rename
10
 from discord.app_commands.commands import Command, CommandCallback, Group, rename
11
+from discord.errors import DiscordException
11
 from discord.ext.commands import Bot
12
 from discord.ext.commands import Bot
12
 
13
 
13
 from config import CONFIG
14
 from config import CONFIG
54
 
55
 
55
 	def __init__(self,
56
 	def __init__(self,
56
 			name: str,
57
 			name: str,
57
-			datatype: Optional[type],
58
+			datatype: type | None,
58
 			default_value: Any,
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
 		Parameters
66
 		Parameters
66
 		----------
67
 		----------
88
 		self.name: str = name
89
 		self.name: str = name
89
 		self.datatype: type = datatype
90
 		self.datatype: type = datatype
90
 		self.default_value = default_value
91
 		self.default_value = default_value
91
-		self.brief: Optional[str] = brief
92
+		self.brief: str | None = brief
92
 		self.description: str = description or ''  # Can't be None
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
 		if self.enum_values:
97
 		if self.enum_values:
97
 			value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
98
 			value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
98
 			self.description += f' (Permitted values: {value_list})'
99
 			self.description += f' (Permitted values: {value_list})'
426
 					text,
427
 					text,
427
 					ephemeral=True,
428
 					ephemeral=True,
428
 				)
429
 				)
429
-			except BaseException as e:
430
+			except DiscordException as e:
430
 				dump_stacktrace(e)
431
 				dump_stacktrace(e)
431
 		show_all_command = Command(
432
 		show_all_command = Command(
432
 			name='all',
433
 			name='all',

+ 2
- 2
rocketbot/collections.py Zobrazit soubor

451
 		if self.is_culling or len(self) <= 1:
451
 		if self.is_culling or len(self) <= 1:
452
 			return
452
 			return
453
 		self.is_culling = True
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
 		ages: dict[int, A] = {}
456
 		ages: dict[int, A] = {}
457
 		for i, elem in enumerate(self):
457
 		for i, elem in enumerate(self):
458
 			age: A = self.element_age(i, elem)
458
 			age: A = self.element_age(i, elem)

+ 14
- 14
rocketbot/pattern.py Zobrazit soubor

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

+ 10
- 10
rocketbot/storage.py Zobrazit soubor

4
 import json
4
 import json
5
 from datetime import datetime, timedelta, timezone
5
 from datetime import datetime, timedelta, timezone
6
 from os.path import exists
6
 from os.path import exists
7
-from typing import Any, Optional
7
+from typing import Any
8
 
8
 
9
 from discord import Guild
9
 from discord import Guild
10
 
10
 
28
 
28
 
29
 	# -- Transient state management -----------------------------------------
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
 	@classmethod
33
 	@classmethod
34
 	def get_state(cls, guild: Guild) -> dict[str, Any]:
34
 	def get_state(cls, guild: Guild) -> dict[str, Any]:
43
 		return state
43
 		return state
44
 
44
 
45
 	@classmethod
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
 		Returns a state value for the given guild and key, or `None` if not set.
48
 		Returns a state value for the given guild and key, or `None` if not set.
49
 		"""
49
 		"""
50
 		return cls.get_state(guild).get(key)
50
 		return cls.get_state(guild).get(key)
51
 
51
 
52
 	@classmethod
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
 		Updates a transient value associated with the given guild and key name.
55
 		Updates a transient value associated with the given guild and key name.
56
 		A value of `None` removes any previous value for that key.
56
 		A value of `None` removes any previous value for that key.
58
 		cls.set_state_values(guild, { key: value })
58
 		cls.set_state_values(guild, { key: value })
59
 
59
 
60
 	@classmethod
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
 		Merges in a set of key-value pairs into the transient state for the
63
 		Merges in a set of key-value pairs into the transient state for the
64
 		given guild. Any pairs with a value of `None` will be removed from the
64
 		given guild. Any pairs with a value of `None` will be removed from the
78
 	# -- Persisted configuration management ---------------------------------
78
 	# -- Persisted configuration management ---------------------------------
79
 
79
 
80
 	# discord.Guild.id -> dict
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
 	@classmethod
83
 	@classmethod
84
 	def get_config(cls, guild: Guild) -> dict[str, Any]:
84
 	def get_config(cls, guild: Guild) -> dict[str, Any]:
99
 		return config
99
 		return config
100
 
100
 
101
 	@classmethod
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
 		Returns a persisted guild config value stored under the given key.
104
 		Returns a persisted guild config value stored under the given key.
105
 		Returns `None` if not present.
105
 		Returns `None` if not present.
107
 		return cls.get_config(guild).get(key)
107
 		return cls.get_config(guild).get(key)
108
 
108
 
109
 	@classmethod
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
 		Adds/updates the given key-value pair to the persisted config for the
112
 		Adds/updates the given key-value pair to the persisted config for the
113
 		given Guild. If `value` is `None` the key will be removed from persisted
113
 		given Guild. If `value` is `None` the key will be removed from persisted
116
 		cls.set_config_values(guild, { key: value })
116
 		cls.set_config_values(guild, { key: value })
117
 
117
 
118
 	@classmethod
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
 		Merges the given `values` dict with the saved config for the given guild
121
 		Merges the given `values` dict with the saved config for the given guild
122
 		and writes it to disk. `values` must be JSON-encodable or a `ValueError`
122
 		and writes it to disk. `values` must be JSON-encodable or a `ValueError`
164
 		cls.__trace('State saved')
164
 		cls.__trace('State saved')
165
 
165
 
166
 	@classmethod
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
 		Loads config for a guild from a JSON file on disk, or `None` if not
169
 		Loads config for a guild from a JSON file on disk, or `None` if not
170
 		found.
170
 		found.

+ 5
- 6
rocketbot/ui/pagedcontent.py Zobrazit soubor

4
 at fairly uniform intervals.
4
 at fairly uniform intervals.
5
 """
5
 """
6
 
6
 
7
-from typing import Optional
8
-
9
 from discord import Interaction
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
 from rocketbot.utils import dump_stacktrace
11
 from rocketbot.utils import dump_stacktrace
13
 
12
 
46
 
45
 
47
 async def update_paged_content(
46
 async def update_paged_content(
48
 		interaction: Interaction,
47
 		interaction: Interaction,
49
-		original_interaction: Optional[Interaction],
48
+		original_interaction: Interaction | None,
50
 		current_page: int,
49
 		current_page: int,
51
 		pages: list[str],
50
 		pages: list[str],
52
 		**send_args,
51
 		**send_args,
96
 				ephemeral=True,
95
 				ephemeral=True,
97
 				**send_args,
96
 				**send_args,
98
 			)
97
 			)
99
-	except BaseException as e:
98
+	except DiscordException as e:
100
 		dump_stacktrace(e)
99
 		dump_stacktrace(e)
101
 
100
 
102
 class _PagingLayoutView(LayoutView):
101
 class _PagingLayoutView(LayoutView):
104
 			self,
103
 			self,
105
 			current_page: int,
104
 			current_page: int,
106
 			pages: list[str],
105
 			pages: list[str],
107
-			original_interaction: Optional[Interaction],
106
+			original_interaction: Interaction | None,
108
 			**send_args,
107
 			**send_args,
109
 	) -> None:
108
 	) -> None:
110
 		super().__init__()
109
 		super().__init__()

+ 38
- 9
rocketbot/utils.py Zobrazit soubor

5
 import sys
5
 import sys
6
 import traceback
6
 import traceback
7
 from datetime import datetime, timedelta, timezone
7
 from datetime import datetime, timedelta, timezone
8
-from typing import Any, Optional, Union
8
+from typing import Any
9
 
9
 
10
 import discord
10
 import discord
11
 from discord import Guild, Interaction, Permissions
11
 from discord import Guild, Interaction, Permissions
105
 		components = components[0:max_components]
105
 		components = components[0:max_components]
106
 	return ' '.join(components)
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
 	"""Returns the first command Group found in a cog."""
109
 	"""Returns the first command Group found in a cog."""
110
 	for member_name in dir(cog):
110
 	for member_name in dir(cog):
111
 		member = getattr(cog, member_name)
111
 		member = getattr(cog, member_name)
113
 			return member
113
 			return member
114
 	return None
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
 	"""Returns the first slash command Group found in a cog."""
117
 	"""Returns the first slash command Group found in a cog."""
118
 	for member_name in dir(cog):
118
 	for member_name in dir(cog):
119
 		member = getattr(cog, member_name)
119
 		member = getattr(cog, member_name)
121
 			return member
121
 			return member
122
 	return None
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
 	"""Logs a message to stdout with time, cog, and guild info."""
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
 	s = f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|'
127
 	s = f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|'
128
 	s += f'{cog_class.__name__}|' if cog_class else '-|'
128
 	s += f'{cog_class.__name__}|' if cog_class else '-|'
129
 	s += f'{guild.name}] ' if guild else '-] '
129
 	s += f'{guild.name}] ' if guild else '-] '
135
 __MENTION_REGEX: re.Pattern = re.compile('^<@[!&]([0-9]{17,20})>$')
135
 __MENTION_REGEX: re.Pattern = re.compile('^<@[!&]([0-9]{17,20})>$')
136
 __USER_MENTION_REGEX: re.Pattern = re.compile('^<@!([0-9]{17,20})>$')
136
 __USER_MENTION_REGEX: re.Pattern = re.compile('^<@!([0-9]{17,20})>$')
137
 __ROLE_MENTION_REGEX: re.Pattern = re.compile('^<@&([0-9]{17,20})>$')
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
 def is_user_id(val: str) -> bool:
141
 def is_user_id(val: str) -> bool:
140
 	"""Tests if a string is in user/role ID format."""
142
 	"""Tests if a string is in user/role ID format."""
152
 	"""Tests if a string is a user mention."""
154
 	"""Tests if a string is a user mention."""
153
 	return __USER_MENTION_REGEX.match(val) is not None
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
 def user_id_from_mention(mention: str) -> str:
165
 def user_id_from_mention(mention: str) -> str:
156
 	"""Extracts the user ID from a mention. Raises a ValueError if malformed."""
166
 	"""Extracts the user ID from a mention. Raises a ValueError if malformed."""
157
 	m = __USER_MENTION_REGEX.match(mention)
167
 	m = __USER_MENTION_REGEX.match(mention)
159
 		return m.group(1)
169
 		return m.group(1)
160
 	raise ValueError(f'"{mention}" is not an @ user mention')
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
 	"""Returns a Markdown user mention from a user id."""
173
 	"""Returns a Markdown user mention from a user id."""
164
 	return f'<@!{user_id}>'
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
 	"""Returns a Markdown role mention from a role id."""
177
 	"""Returns a Markdown role mention from a role id."""
168
 	return f'<@&{role_id}>'
178
 	return f'<@&{role_id}>'
169
 
179
 
187
 
197
 
188
 def format_bytes(size: int) -> str:
198
 def format_bytes(size: int) -> str:
189
 	"""Formats s size in bytes to a human readable description (e.g. "3.2 KiB")"""
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
 	kib = 1024
201
 	kib = 1024
193
 	mib = kib * kib
202
 	mib = kib * kib
194
 	gib = mib * kib
203
 	gib = mib * kib
213
 		return dt
222
 		return dt
214
 	return datetime.fromtimestamp(dt.timestamp(), timezone.utc)
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
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
244
 MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
245
+ADMIN_PERMISSIONS: Permissions = Permissions(Permissions.administrator.flag)
217
 
246
 
218
 class TimeDeltaTransformer(Transformer):
247
 class TimeDeltaTransformer(Transformer):
219
 	async def transform(self, interaction: Interaction, value: Any) -> timedelta:
248
 	async def transform(self, interaction: Interaction, value: Any) -> timedelta:

+ 11
- 0
sql/kickstarter-create.sql Zobrazit soubor

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

Načítá se…
Zrušit
Uložit