Quellcode durchsuchen

Adding /kickstarter reset command

pull/35/head
Rocketsoup vor 1 Woche
Ursprung
Commit
eab4a69c52
1 geänderte Dateien mit 95 neuen und 22 gelöschten Zeilen
  1. 95
    22
      rocketbot/cogs/kickstartercog.py

+ 95
- 22
rocketbot/cogs/kickstartercog.py Datei anzeigen

@@ -1,10 +1,10 @@
1 1
 import heapq
2 2
 from asyncio import sleep
3
-from collections.abc import Iterable
3
+from collections.abc import Awaitable, Callable, Iterable
4 4
 from enum import IntEnum
5 5
 from sqlite3 import Connection, connect
6 6
 from time import time as now_timestamp
7
-from typing import Optional
7
+from typing import Optional, TypeVar
8 8
 
9 9
 from discord import Attachment, Guild, Interaction, Member, Role
10 10
 from discord.app_commands import Group, default_permissions
@@ -24,10 +24,7 @@ from rocketbot.utils import (
24 24
 	levenshtein,
25 25
 )
26 26
 
27
-_PERMITTED_GUILD_IDS = [
28
-	405011810937339905,  # prod server
29
-	900805482825007104,  # test server
30
-]
27
+_T = TypeVar('_T')
31 28
 class KickstarterCog(BaseCog):
32 29
 	"""
33 30
 	Assigns Discord users a role if their username appears in an imported list,
@@ -119,7 +116,6 @@ class KickstarterCog(BaseCog):
119 116
 		name='kickstarter',
120 117
 		description='Manages roles for users identified as Kickstarter backers.',
121 118
 		guild_only=True,
122
-		# guild_ids=_PERMITTED_GUILD_IDS,
123 119
 		default_permissions=MOD_PERMISSIONS
124 120
 	)
125 121
 
@@ -222,7 +218,8 @@ class KickstarterCog(BaseCog):
222 218
 			await interaction.followup.send(text, ephemeral=True)
223 219
 			return
224 220
 
225
-		closest_matches = self.__find_nearest_usernames(guild.id, member.name, limit=8, max_distance=3)
221
+		closest_matches = self.__find_nearest_usernames(guild.id, member.name,
222
+			limit=8, max_distance=3)
226 223
 		stats = self.__fetch_stats(guild.id)
227 224
 		text = f"{CONFIG['info_emoji']} The username `@{member.name}` is not in " \
228 225
 			"the latest Kickstarter import." \
@@ -233,8 +230,7 @@ class KickstarterCog(BaseCog):
233 230
 				f"(<t:{stats.username_last_imported_at}:f>)? They might be in the next one."
234 231
 		if len(closest_matches) > 0:
235 232
 			text += "\n- Did they misspell their username in the survey? " \
236
-				"Here are some similar ones. If one looks likely, you can fix it " \
237
-				f"with `/kickstarter link @{member.name} <username>`"
233
+				"Here are some similar ones."
238 234
 			for similar in closest_matches:
239 235
 				text += f"\n   - `{similar.discord_username}`"
240 236
 				if similar.lookup_status == _LookupStatus.misspelled_username:
@@ -266,7 +262,9 @@ class KickstarterCog(BaseCog):
266 262
 			closest_matches = self.__find_nearest_usernames(guild.id, normal_username, 1)
267 263
 			text = f"{CONFIG['failure_emoji']} No survey username found for `{username}`."
268 264
 			if len(closest_matches) > 0:
269
-				text += f" Did you mean `/{KickstarterCog.kickstarter.name} {KickstarterCog.link.name} @{member.name} {closest_matches[0].discord_username}`?"
265
+				text += f" Did you mean `/{KickstarterCog.kickstarter.name} " \
266
+					f"{KickstarterCog.link.name} @{member.name} " \
267
+					f"{closest_matches[0].discord_username}`?"
270 268
 			await interaction.followup.send(text, ephemeral=True)
271 269
 			return
272 270
 
@@ -274,7 +272,9 @@ class KickstarterCog(BaseCog):
274 272
 			text = f"{CONFIG['failure_emoji']} That survey username is already " \
275 273
 				f"linked to <@{username.discord_member_id}>. Cannot be linked to " \
276 274
 				"another member.\n" \
277
-				"-# If all else fails, you can just manually give them the backer role."
275
+				f"- `/{KickstarterCog.kickstarter.name} {KickstarterCog.reset.name} " \
276
+				f"{username}` will unlink that Discord record" \
277
+				"- If all else fails, you can just manually give them the backer role."
278 278
 			await interaction.followup.send(text, ephemeral=True)
279 279
 			return
280 280
 		elif username.lookup_status == _LookupStatus.misspelled_username:
@@ -297,6 +297,38 @@ class KickstarterCog(BaseCog):
297 297
 		await interaction.followup.send(text, ephemeral=True)
298 298
 
299 299
 	@kickstarter.command(
300
+		description='Resets an imported Kickstarter record.'
301
+	)
302
+	async def reset(self, interaction: Interaction, username: str):
303
+		if await self.__check_disabled(interaction): return
304
+
305
+		guild = interaction.guild
306
+		normal_username = KickstarterCog.__normalize_discord_username(username)
307
+		record = self.__fetch_kickstarter_username(guild.id, username=normal_username)
308
+		if record is None:
309
+			text = f"{CONFIG['failure_emoji']} No record found for `@{normal_username}`."
310
+			similar = self.__find_nearest_usernames(guild.id, normal_username, limit=5, max_distance=3)
311
+			if len(similar) > 0:
312
+				text += " Did you mean one of these?"
313
+				for s in similar:
314
+					text += f"\n- `@{s.discord_username}`"
315
+			await interaction.response.send_message(text, ephemeral=True)
316
+			return
317
+		old_member_id = record.discord_member_id
318
+		old_lookup_status = record.lookup_status
319
+		record.discord_member_id = None
320
+		record.lookup_status = _LookupStatus.unprocessed
321
+		self.__update_kickstarter_username(record)
322
+		text = f"{CONFIG['success_emoji']} Record for `@{normal_username}` reset. " \
323
+			"(No roles removed from member.)"
324
+		if old_member_id is not None:
325
+			text += f"\n- member_id: `{old_member_id}` --> `{record.discord_member_id}`"
326
+		if old_lookup_status is not None:
327
+			text += f"\n- lookup_status: `{_describe_lookup_status(old_lookup_status)}`" \
328
+				f" --> `{_describe_lookup_status(record.lookup_status)}`"
329
+		await interaction.response.send_message(text, ephemeral=True)
330
+
331
+	@kickstarter.command(
300 332
 		description='Uploads an export of Kickstarter users.'
301 333
 	)
302 334
 	async def upload(self, interaction: Interaction):
@@ -307,7 +339,8 @@ class KickstarterCog(BaseCog):
307 339
 
308 340
 	async def interaction_check(self, interaction: Interaction) -> bool:
309 341
 		if interaction.command is not None:
310
-			self.__trace(interaction.guild, f"@{interaction.user.name} used /{interaction.command.qualified_name}")
342
+			self.__trace(interaction.guild, f"@{interaction.user.name} used " \
343
+				f"/{interaction.command.qualified_name}")
311 344
 		return True
312 345
 
313 346
 
@@ -329,6 +362,11 @@ class KickstarterCog(BaseCog):
329 362
 		username = self.__fetch_kickstarter_username(guild.id, username=member.name)
330 363
 		if username is None:
331 364
 			return  # Not on list
365
+		if username.lookup_status == _LookupStatus.misspelled_username:
366
+			self.log(guild, f"\u0007Member @{member.name} joined but their " \
367
+				"username was already manually attached to user id " \
368
+				f"{username.discord_member_id}")
369
+			return
332 370
 
333 371
 		self.__trace(guild, f"Member @{member.name} joined and is a backer. " \
334 372
 			"Granting backer role.")
@@ -339,7 +377,10 @@ class KickstarterCog(BaseCog):
339 377
 
340 378
 	# -- UI callbacks -----
341 379
 
342
-	async def on_username_upload_submit(self, interaction: Interaction, attachment: Attachment):
380
+	async def on_username_upload_submit(self,
381
+		interaction: Interaction,
382
+		attachment: Attachment
383
+	):
343 384
 		"""Callback for username upload modal."""
344 385
 		await interaction.response.defer(ephemeral=True, thinking=True)
345 386
 		guild = interaction.guild
@@ -357,7 +398,10 @@ class KickstarterCog(BaseCog):
357 398
 
358 399
 	# -- Operations -----
359 400
 
360
-	async def __import_usernames(self, guild: Guild, attachment: Attachment) -> '_ImportResult':
401
+	async def __import_usernames(self,
402
+		guild: Guild,
403
+		attachment: Attachment
404
+	) -> '_ImportResult':
361 405
 		"""Imports Discord usernames from an upload attachment."""
362 406
 		self.__trace(guild, f"Download - start - {attachment.filename} " \
363 407
 			f"({attachment.size} bytes, {attachment.content_type})")
@@ -419,7 +463,8 @@ class KickstarterCog(BaseCog):
419 463
 			self.__trace(guild, "Fetching guild members from API - start")
420 464
 			async for member in guild.fetch_members(limit=None):
421 465
 				username_to_member_id[member.name] = member.id
422
-			self.__trace(guild, f"Fetching guild members from API - complete - got {len(username_to_member_id)}")
466
+			self.__trace(guild, "Fetching guild members from API - complete - " \
467
+				f"got {len(username_to_member_id)}")
423 468
 		async def username_loop_handler(username: _KickstarterDiscordUser):
424 469
 			nonlocal complete_count
425 470
 			nonlocal not_found_count
@@ -442,11 +487,18 @@ class KickstarterCog(BaseCog):
442 487
 				await member.add_roles(backer_role)
443 488
 				complete_count += 1
444 489
 		self.__trace(guild, "Sync loop - start")
445
-		failure_count = await self.__throttled_loop(guild, usernames, username_loop_handler, 100)
446
-		self.__trace(guild, f"Sync loop - complete - {complete_count} completed, {not_found_count} not found")
490
+		failure_count = await self.__throttled_loop(guild, usernames,
491
+			username_loop_handler, update_seconds=10.0)
492
+		self.__trace(guild, f"Sync loop - complete - {complete_count} completed, " \
493
+			f"{not_found_count} not found")
447 494
 		return _SyncUsernamesResult(complete_count, not_found_count, failure_count)
448 495
 
449
-	async def __throttled_loop(self, guild: Guild, iter: Iterable, callback, update_seconds: float | None = None) -> int:
496
+	async def __throttled_loop(self,
497
+		guild: Guild,
498
+		iter: Iterable[_T],
499
+		callback: Callable[[_T], Awaitable[None]],
500
+		update_seconds: float | None = None
501
+	) -> int:
450 502
 		"""Iterates a loop with automatically adjusting sleeps based on
451 503
 		throttling exceptions."""
452 504
 		failure_count = 0
@@ -469,7 +521,8 @@ class KickstarterCog(BaseCog):
469 521
 							f"retry_after={retry_header_value}")
470 522
 						await sleep(retry_after_millis / 1000.0)
471 523
 						sleep_length = 1.0 if sleep_length == 0.0 else sleep_length * 2.0
472
-						self.__trace(guild, f"Sleep increased to {sleep_length}s due to rate limiting")
524
+						self.__trace(guild, f"Sleep increased to {sleep_length}s " \
525
+							"due to rate limiting")
473 526
 					else:
474 527
 						dump_stacktrace(ex)
475 528
 				except DiscordException as ex:
@@ -544,6 +597,7 @@ class KickstarterCog(BaseCog):
544 597
 
545 598
 	def __fetch_kickstarter_username(self,
546 599
 		guild_id: int,
600
+		*,
547 601
 		member_id: int | None = None,
548 602
 		username: str | None = None
549 603
 	) -> Optional['_KickstarterDiscordUser']:
@@ -571,7 +625,10 @@ class KickstarterCog(BaseCog):
571 625
 		cur.close()
572 626
 		return ret_val
573 627
 
574
-	def __fetch_incomplete_kickstarter_usernames(self, guild_id: int, statuses: set['_LookupStatus']) -> list['_KickstarterDiscordUser']:
628
+	def __fetch_incomplete_kickstarter_usernames(self,
629
+		guild_id: int,
630
+		statuses: set['_LookupStatus']
631
+	) -> list['_KickstarterDiscordUser']:
575 632
 		cur = self.con.cursor()
576 633
 		cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
577 634
 		params = [ guild_id ] + list(statuses)
@@ -633,7 +690,12 @@ class KickstarterCog(BaseCog):
633 690
 			username_last_imported_at
634 691
 		)
635 692
 
636
-	def __find_nearest_usernames(self, guild_id: int, username: str, limit: int = 10, max_distance: int = 999) -> list['_KickstarterDiscordUser']:
693
+	def __find_nearest_usernames(self,
694
+		guild_id: int,
695
+		username: str,
696
+		limit: int = 10,
697
+		max_distance: int = 999
698
+	) -> list['_KickstarterDiscordUser']:
637 699
 		cur = self.con.cursor()
638 700
 		cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
639 701
 		cur.execute("""
@@ -686,6 +748,17 @@ class _LookupStatus(IntEnum):
686 748
 	# Mod manually linked this imported username to a Discord member to correct a username typo
687 749
 	misspelled_username = 3
688 750
 
751
+def _describe_lookup_status(status: _LookupStatus) -> str:
752
+	if status == _LookupStatus.unprocessed:
753
+		return 'unprocessed'
754
+	if status == _LookupStatus.username_not_found:
755
+		return 'member not in server'
756
+	if status == _LookupStatus.member_found:
757
+		return 'member found'
758
+	if status == _LookupStatus.misspelled_username:
759
+		return 'manually linked by mod'
760
+	return '-unknown-'
761
+
689 762
 class _KickstarterDiscordUser:
690 763
 	"""kickstarter_discord_users table row"""
691 764
 	def __init__(self,

Laden…
Abbrechen
Speichern