Experimental Discord bot written in Python
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

kickstartercog.py 29KB

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