Experimental Discord bot written in Python
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

kickstartercog.py 31KB

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