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