Bladeren bron

Weird merge conflict

main
Rocketsoup 1 week geleden
bovenliggende
commit
a73378a4d5

+ 4
- 4
main.py Bestand weergeven

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 = 5
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:

+ 1
- 1
requirements.txt Bestand weergeven

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

+ 7
- 4
rocketbot/bot.py Bestand weergeven

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
99
 	from rocketbot.cogs.generalcog import GeneralCog
99
 	from rocketbot.cogs.generalcog import GeneralCog
100
 	from rocketbot.cogs.helpcog import HelpCog
100
 	from rocketbot.cogs.helpcog import HelpCog
101
 	from rocketbot.cogs.joinraidcog import JoinRaidCog
101
 	from rocketbot.cogs.joinraidcog import JoinRaidCog
102
+	from rocketbot.cogs.kickstartercog import KickstarterCog
102
 	from rocketbot.cogs.logcog import LoggingCog
103
 	from rocketbot.cogs.logcog import LoggingCog
103
 	from rocketbot.cogs.patterncog import PatternCog
104
 	from rocketbot.cogs.patterncog import PatternCog
104
 	from rocketbot.cogs.urlspamcog import URLSpamCog
105
 	from rocketbot.cogs.urlspamcog import URLSpamCog
134
 		await rocketbot.add_cog(UsernamePatternCog(rocketbot))
135
 		await rocketbot.add_cog(UsernamePatternCog(rocketbot))
135
 	if VideoPreviewCog.supports_intents(rocketbot.intents):
136
 	if VideoPreviewCog.supports_intents(rocketbot.intents):
136
 		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))
137
 
140
 
138
 	await rocketbot.start(CONFIG['client_token'], reconnect=True)
141
 	await rocketbot.start(CONFIG['client_token'], reconnect=True)
139
 	print('\nBot aborted')
142
 	print('\nBot aborted')

+ 14
- 14
rocketbot/botmessage.py Bestand weergeven

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)

+ 15
- 14
rocketbot/cogs/bangcommandcog.py Bestand weergeven

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

+ 11
- 7
rocketbot/cogs/basecog.py Bestand weergeven

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

+ 1
- 2
rocketbot/cogs/configcog.py Bestand weergeven

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
 

+ 3
- 4
rocketbot/cogs/crosspostcog.py Bestand weergeven

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
252
 		await self.__update_message_from_context(context)
251
 		await self.__update_message_from_context(context)
253
 
252
 
254
 	async def __update_message_from_context(self, context: SpamContext) -> None:
253
 	async def __update_message_from_context(self, context: SpamContext) -> None:
255
-		first_spam_message: Message = sorted(list(context.spam_messages), key=lambda m: m.created_at)[0]
254
+		first_spam_message: Message = min(context.spam_messages, key=lambda m: m.created_at)
256
 		spam_count = len(context.spam_messages)
255
 		spam_count = len(context.spam_messages)
257
 		channel_count = len(context.unique_channels)
256
 		channel_count = len(context.unique_channels)
258
 		deleted_count = len(context.deleted_messages)
257
 		deleted_count = len(context.deleted_messages)
284
 				body += f'messages in {channel_count} channels within {max_age_str} ' + \
283
 				body += f'messages in {channel_count} channels within {max_age_str} ' + \
285
 						f'({duplicate_count} are identical, showing first one).'
284
 						f'({duplicate_count} are identical, showing first one).'
286
 			max_links = 10
285
 			max_links = 10
287
-			for msg in sorted(list(context.spam_messages), key=lambda m: m.created_at)[:max_links]:
286
+			for msg in sorted(context.spam_messages, key=lambda m: m.created_at)[:max_links]:
288
 				body += f'\n- {msg.jump_url}'
287
 				body += f'\n- {msg.jump_url}'
289
 			if len(context.spam_messages) > max_links:
288
 			if len(context.spam_messages) > max_links:
290
 				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 Bestand weergeven

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 Bestand weergeven

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)

+ 900
- 0
rocketbot/cogs/kickstartercog.py Bestand weergeven

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)

+ 11
- 10
rocketbot/cogs/logcog.py Bestand weergeven

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
401
 		await bot_message.update()
402
 		await bot_message.update()
402
 
403
 
403
 	@Cog.listener()
404
 	@Cog.listener()
404
-	async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
405
+	async def on_member_ban(self, guild: Guild, user: User | Member) -> None:
405
 		"""
406
 		"""
406
 		Called when user gets banned from a Guild.
407
 		Called when user gets banned from a Guild.
407
 
408
 
424
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
425
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
425
 		await bot_message.update()
426
 		await bot_message.update()
426
 
427
 
427
-	async def __find_audit_entry(self, user: Union[User, Member], action: AuditLogAction, max_age: int = 10) -> Optional[AuditLogEntry]:
428
+	async def __find_audit_entry(self, user: User | Member, action: AuditLogAction, max_age: int = 10) -> AuditLogEntry | None:
428
 		"""
429
 		"""
429
 		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
430
 		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
442
 		return None
443
 		return None
443
 
444
 
444
 	@Cog.listener()
445
 	@Cog.listener()
445
-	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
446
+	async def on_member_unban(self, guild: Guild, user: User | Member) -> None:
446
 		"""
447
 		"""
447
 		Called when a User gets unbanned from a Guild.
448
 		Called when a User gets unbanned from a Guild.
448
 
449
 
476
 			self.buffered_guilds.clear()
477
 			self.buffered_guilds.clear()
477
 			for guild in guilds:
478
 			for guild in guilds:
478
 				await self.__flush_buffers_for_guild(guild)
479
 				await self.__flush_buffers_for_guild(guild)
479
-		except Exception as e:
480
+		except DiscordException as e:
480
 			dump_stacktrace(e)
481
 			dump_stacktrace(e)
481
 
482
 
482
 	async def __flush_buffers_for_guild(self, guild: Guild) -> None:
483
 	async def __flush_buffers_for_guild(self, guild: Guild) -> None:
739
 		else:
740
 		else:
740
 			complex_deletes = events
741
 			complex_deletes = events
741
 		if len(complex_deletes) > 0:
742
 		if len(complex_deletes) > 0:
742
-			messages_per_author: dict[Optional[User], list[BufferedMessageDeleteEvent]] = self.__groupby(complex_deletes, lambda e: e.author)
743
+			messages_per_author: dict[User | None, list[BufferedMessageDeleteEvent]] = self.__groupby(complex_deletes, lambda e: e.author)
743
 			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'
744
 			row_count = 0
745
 			row_count = 0
745
 			for author, messages in messages_per_author.items():
746
 			for author, messages in messages_per_author.items():
873
 			return '> _<no content>_'
874
 			return '> _<no content>_'
874
 		return '> ' + escape_markdown(s).replace('\n', '\n> ')
875
 		return '> ' + escape_markdown(s).replace('\n', '\n> ')
875
 
876
 
876
-	def __describe_user(self, user: Union[User, Member]) -> str:
877
+	def __describe_user(self, user: User | Member) -> str:
877
 		"""
878
 		"""
878
 		Standardized Markdown describing a user or member.
879
 		Standardized Markdown describing a user or member.
879
 		"""
880
 		"""

+ 5
- 4
rocketbot/cogs/patterncog.py Bestand weergeven

9
 from discord import Guild, Intents, 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)

+ 5
- 9
rocketbot/cogs/urlspamcog.py Bestand weergeven

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
 

+ 5
- 8
rocketbot/cogs/usernamecog.py Bestand weergeven

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, Intents, Interaction, Member
5
 from discord import Guild, Intents, Interaction, Member
7
 from discord.app_commands import Group
6
 from discord.app_commands import Group
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)

+ 30
- 31
rocketbot/cogs/videopreviewcog.py Bestand weergeven

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 Intents, 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
-			[
174
-				'yt-dlp',
175
-				'--skip-download',
176
-				'--dump-single-json',
177
-				link.url,
178
-			],
179
-			stdout=subprocess.PIPE,
180
-			stderr=subprocess.PIPE,
181
-			universal_newlines=True
168
+		process = await asyncio.create_subprocess_exec(
169
+			'yt-dlp',
170
+			'--skip-download',
171
+			'--dump-single-json',
172
+			link.url,
173
+			stdout=asyncio.subprocess.PIPE,
174
+			stderr=asyncio.subprocess.PIPE
182
 		)
175
 		)
183
-		if result.returncode != 0:
184
-			# self.log(message.guild, "Fetching link info JSON failed. Skipping preview.")
185
-			# self.log(message.guild, result.stderr)
176
+		stdout, stderr = await process.communicate()
177
+		await process.wait()
178
+		if process.returncode != 0:
179
+			self.__trace(message.guild, "Fetching link info JSON failed. Skipping preview.")
180
+			self.__trace(message.guild, stderr.decode())
186
 			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:
220
 			mention_author=False
215
 			mention_author=False
221
 		)
216
 		)
222
 
217
 
218
+	def __trace(self, guild: Guild, message: Any):
219
+		# self.log(guild, message)
220
+		pass
221
+
223
 	@classmethod
222
 	@classmethod
224
 	def supports_intents(cls, intents: Intents) -> bool:
223
 	def supports_intents(cls, intents: Intents) -> bool:
225
 		return intents.message_content
224
 		return intents.message_content

+ 13
- 12
rocketbot/cogsetting.py Bestand weergeven

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 Bestand weergeven

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)

+ 17
- 17
rocketbot/pattern.py Bestand weergeven

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:
409
-							tokens.append(current_token)
410
-							current_token = ''
411
-							possible_token_types |= all_token_types
407
+							possible_ch_types.isdisjoint(possible_token_types) and \
408
+							len(current_token) > 0:
409
+						tokens.append(current_token)
410
+						current_token = ''
411
+						possible_token_types |= all_token_types
412
 					possible_token_types &= possible_ch_types
412
 					possible_token_types &= possible_ch_types
413
 					current_token += ch
413
 					current_token += ch
414
 		if len(current_token) > 0:
414
 		if len(current_token) > 0:
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 Bestand weergeven

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 Bestand weergeven

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 Bestand weergeven

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 Bestand weergeven

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

Laden…
Annuleren
Opslaan