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