Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

kickstartercog.py 46KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339
  1. import base64
  2. import hashlib
  3. from asyncio import sleep
  4. from enum import IntEnum
  5. from sqlite3 import Connection, connect
  6. from time import time as now_timestamp
  7. from typing import Iterable, Optional
  8. from discord import Attachment, Guild, Interaction, Member, Role, TextStyle
  9. from discord.app_commands import Group, command, guild_only, guilds
  10. from discord.errors import HTTPException
  11. from discord.ui import FileUpload, Label, Modal, TextDisplay, TextInput
  12. from discord.utils import escape_markdown
  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. MOD_PERMISSIONS,
  19. dump_stacktrace,
  20. is_discord_username,
  21. is_email_address,
  22. )
  23. PERMITTED_GUILD_IDS = [
  24. 405011810937339905,
  25. 900805482825007104, # test server
  26. ]
  27. class KickstarterCog(BaseCog):
  28. """
  29. Provides a way for Discord users to self-identify the email address they
  30. used when backing a relevant Kickstarter campaign. If the email address is
  31. found in a sqlite database, a configured backer role will be given to the
  32. user. If the address isn't found, they can either try again or the address
  33. can be saved and linked up the next time the database is refreshed.
  34. Discord does not currently have a native Kickstarter integration, and doing
  35. a full API integration is a bit ambitious, so this is a stopgap solution.
  36. """
  37. shared: Optional['KickstarterCog'] = None
  38. SETTING_ENABLED = CogSetting(
  39. name='enabled',
  40. datatype=bool,
  41. default_value=False,
  42. brief='Kickstarter user linking',
  43. description='Whether this module is enabled for a guild.',
  44. )
  45. SETTING_ROLE = CogSetting(
  46. name='backer_role',
  47. datatype=int,
  48. default_value=0,
  49. brief='role to assign to Kickstarter backers',
  50. description='Role automatically assigned to members who use /link with '
  51. 'a known Kickstarter email address.'
  52. )
  53. def __init__(self, bot: Rocketbot):
  54. super().__init__(
  55. bot,
  56. config_prefix='kickstarter',
  57. short_description='For linking Kickstarter backers to their Discord handles.',
  58. )
  59. Self = KickstarterCog
  60. self.add_setting(Self.SETTING_ENABLED)
  61. self.con: Connection = connect('kickstarter.sqlite3')
  62. Self.shared = self
  63. async def __fetch_backer_role(self, guild: Guild) -> Role:
  64. Self = KickstarterCog
  65. role_id = self.get_guild_setting(guild, Self.SETTING_ROLE)
  66. if role_id is None or role_id == 0:
  67. raise _NoBackerRoleException()
  68. return guild.get_role(role_id) or await guild.fetch_role(role_id)
  69. def __get_cached_backer_role(self, guild: Guild) -> Optional[Role]:
  70. """Synchronously gets the configured backer role from the cache if possible."""
  71. Self = KickstarterCog
  72. role_id = self.get_guild_setting(guild, Self.SETTING_ROLE)
  73. if role_id is None or role_id == 0:
  74. return None
  75. return guild.get_role(role_id)
  76. def __set_backer_role(self, role: Role):
  77. Self = KickstarterCog
  78. self.set_guild_setting(role.guild, Self.SETTING_ROLE, role.id)
  79. # -- Member commands -----
  80. @command(
  81. description='Links your Kickstarter email address to your Discord user'
  82. )
  83. @guild_only()
  84. @guilds(PERMITTED_GUILD_IDS)
  85. async def link_email(self, interaction: Interaction):
  86. # If possible, try to check if they're already a backer before opening
  87. # a modal.
  88. guild = interaction.guild
  89. if not self.get_guild_setting(guild, KickstarterCog.SETTING_ENABLED):
  90. return
  91. backer_role = self.__get_cached_backer_role(guild)
  92. member: Member = interaction.user
  93. if backer_role is not None and backer_role in member.roles:
  94. text = f"{CONFIG['info_emoji']} You have already been given the " \
  95. "backer role and should have access to backer-only areas.\n" \
  96. "\n" \
  97. "Please message a moderator if you're having trouble."
  98. await interaction.response.send_message(text, ephemeral=True)
  99. return
  100. await interaction.response.send_modal(_LinkModal())
  101. @command(
  102. description='Grants you access to Kickstarter backer-only areas'
  103. )
  104. @guild_only()
  105. @guilds(PERMITTED_GUILD_IDS)
  106. async def link_username(self, interaction: Interaction):
  107. await interaction.response.defer(ephemeral=True, thinking=True)
  108. try:
  109. backer_role = await self.__fetch_backer_role(interaction.guild)
  110. except _NoBackerRoleException:
  111. text = f"{CONFIG['failure_emoji']} Oops! The server admin hasn't " \
  112. "setup this feature just yet. Check back later."
  113. await interaction.followup.send(text, ephemeral=True)
  114. return
  115. member: Member = interaction.user
  116. if backer_role is not None and backer_role in member.roles:
  117. text = f"{CONFIG['info_emoji']} You have already been given the " \
  118. "backer role and should have access to backer-only areas.\n" \
  119. "\n" \
  120. "Please message a moderator if you're having trouble."
  121. await interaction.followup.send(text, ephemeral=True)
  122. return
  123. ks_username = self.__fetch_kickstarter_username(username=interaction.user.name)
  124. if ks_username is None:
  125. text = f"{CONFIG['warning_emoji']} We don't have your Discord " \
  126. f"username `{member.name}` in our list of backers. Did you " \
  127. "spell it correctly in the form? Contact a moderator if you " \
  128. "think there was a mistake."
  129. await interaction.followup.send(text, ephemeral=True)
  130. return
  131. ks_username.discord_member_id = member.id
  132. ks_username.lookup_status = _LookupStatus.member_found
  133. self.__update_kickstarter_username(ks_username)
  134. await member.add_roles(backer_role)
  135. text = f"{CONFIG['success_emoji']} Success! You should now have access to " \
  136. "backer-only areas! Thanks for your support!"
  137. await interaction.followup.send(text, ephemeral=True)
  138. @command(
  139. description='Unlinks your Kickstarter email address from your Discord ' \
  140. 'user and removes the backer role.'
  141. )
  142. @guild_only()
  143. @guilds(PERMITTED_GUILD_IDS)
  144. async def unlink(self, interaction: Interaction):
  145. await interaction.response.defer(ephemeral=True, thinking=True)
  146. guild = interaction.guild
  147. member = interaction.user
  148. try:
  149. backer_role: Role = await self.__fetch_backer_role(guild)
  150. except _NoBackerRoleException:
  151. text = f"{CONFIG['failure_emoji']} Oops! The server admin hasn't " \
  152. "setup this feature just yet. Check back later."
  153. await interaction.followup.send(text, ephemeral=True)
  154. return
  155. if backer_role in member.roles:
  156. await member.remove_roles(backer_role)
  157. self.__delete_member_link(guild.id, member.id)
  158. text = f"{CONFIG['success_emoji']} Unlinked as Kickstarter backer. Use " \
  159. "`/link` if you change your mind."
  160. await interaction.followup.send(text, ephemeral=True)
  161. # -- Admin commands -----
  162. kickstarter = Group(
  163. name='kickstarter',
  164. description='Manages roles for users identified as Kickstarter backers.',
  165. guild_only=True,
  166. guild_ids=PERMITTED_GUILD_IDS,
  167. default_permissions=MOD_PERMISSIONS
  168. )
  169. @kickstarter.command(
  170. description='Configures which role to give to Kickstarter backers.'
  171. )
  172. async def set_role(self, interaction: Interaction, role: Role):
  173. self.__set_backer_role(role)
  174. text = f"{CONFIG['info_emoji']} Backer role set to {role.name}"
  175. await interaction.response.send_message(text, ephemeral=True)
  176. @kickstarter.command(
  177. description='Checks the database for users to assign the backer role to.'
  178. )
  179. async def sync_emails(self, interaction: Interaction):
  180. await interaction.response.defer(ephemeral=True, thinking=True)
  181. sync_result: _SyncEmailsResult = await self.__sync_by_email(interaction.guild)
  182. text = f"{CONFIG['success_emoji']} Sync complete.\n" + \
  183. sync_result.summary_markdown()
  184. await interaction.followup.send(text, ephemeral=True)
  185. @kickstarter.command(
  186. description='Checks the database for users to assign the backer role to.'
  187. )
  188. async def sync_usernames(self, interaction: Interaction):
  189. await interaction.response.defer(ephemeral=True, thinking=True)
  190. sync_result: _SyncUsernamesResult = await self.__sync_by_username(interaction.guild)
  191. text = f"{CONFIG['success_emoji']} Sync complete.\n" + \
  192. sync_result.summary_markdown()
  193. await interaction.followup.send(text, ephemeral=True)
  194. @kickstarter.command(
  195. description='DEV TEST - testing timeout'
  196. )
  197. async def timeout_test(self, interaction: Interaction):
  198. await interaction.response.defer(ephemeral=True, thinking=True)
  199. await sleep(300)
  200. await interaction.followup.send("5m timer done", ephemeral=True)
  201. @kickstarter.command(
  202. description='Shows info about Kickstarter linked Discord members.'
  203. )
  204. async def info(self, interaction: Interaction):
  205. await interaction.response.defer(ephemeral=True, thinking=True)
  206. guild = interaction.guild
  207. backer_role = await self.__fetch_backer_role(guild)
  208. stats: _Stats = self.__fetch_stats(guild.id)
  209. lines: list[str] = []
  210. if backer_role is None:
  211. lines.append("- No backer role configured yet (use `/kickstarter set_role`)")
  212. else:
  213. lines.append(f"- Backer role configured as `{backer_role.name}`")
  214. if stats.email_count > 0:
  215. lines.append(f"- **{stats.email_count}** email addresses imported")
  216. lines.append(f"- New emails last imported at <t:{stats.email_last_imported_at}:f>")
  217. if stats.complete_link_count + stats.incomplete_link_count > 0:
  218. lines.append(f"- **{stats.complete_link_count}** members linked by email")
  219. lines.append(f"- **{stats.incomplete_link_count}** members provided emails but weren't linked yet")
  220. if stats.username_count > 0:
  221. lines.append(f"- **{stats.username_count}** Discord usernames imported")
  222. lines.append(f"- New usernames last imported at <t:{stats.username_last_imported_at}:f>")
  223. lines.append(f"- **{stats.username_found_count}** usernames linked successfully")
  224. lines.append(f"- **{stats.username_not_found_count}** usernames not (yet) linked to members")
  225. lines.append(f"- **{stats.username_unprocessed_count}** usernames not yet attempted to sync")
  226. text = f"{CONFIG['info_emoji']} Kickstarter import stats\n"
  227. text += "\n".join(lines)
  228. await interaction.followup.send(text, ephemeral=True)
  229. @kickstarter.command(
  230. description='Shows Kickstarter link details about a specific Discord member.'
  231. )
  232. async def find_member(self, interaction: Interaction, member: Member):
  233. await interaction.response.defer(ephemeral=True, thinking=True)
  234. guild = interaction.guild
  235. try:
  236. backer_role = await self.__fetch_backer_role(guild)
  237. except _NoBackerRoleException:
  238. text = f"{CONFIG['failure_emoji']} Backer role not yet configured!"
  239. await interaction.followup.send(text, ephemeral=True)
  240. return
  241. if backer_role in member.roles:
  242. text = f"{CONFIG['info_emoji']} Member {member.name} has `{backer_role.name}` role."
  243. await interaction.followup.send(text, ephemeral=True)
  244. return
  245. username_record = self.__fetch_kickstarter_username(username=member.name)
  246. if username_record is not None:
  247. await member.add_roles(backer_role)
  248. username_record.lookup_status = _LookupStatus.member_found
  249. self.__update_kickstarter_username(username_record)
  250. text = f"{CONFIG['success_emoji']} Member's username found in Kickstarter export. Assigned `{backer_role.name}` role."
  251. await interaction.followup.send(text, ephemeral=True)
  252. return
  253. # FIXME: Sorta splitting the difference between email and username model here
  254. member_link = self.__fetch_member_link_by_member_id(guild.id, member.id)
  255. if member_link is None:
  256. text = f"No record found for member {member.mention}. Possible reasons:\n" \
  257. "- They haven't used the `/link` command yet to provide their " \
  258. "Kickstarter email address\n" \
  259. "- They linked using a different Discord account. (You can " \
  260. "search by email with `/find_email`)\n" \
  261. "- They used the `/unlink` after being linked"
  262. await interaction.followup.send(text, ephemeral=True)
  263. return
  264. text = f"Member {member.mention} successfully used the `/link` command"
  265. if backer_role in member.roles:
  266. text += f", and they already have the {backer_role.name} Discord role. " \
  267. "They should already have access to backer-only areas. If they " \
  268. "still can't see them, some possible reasons:\n" \
  269. "- Its channel group is collapsed in the channel list.\n" \
  270. "- Some channels are hidden. Direct them to the Browse Channels " \
  271. "area in the channel list to see if it's listed and checked visible.\n" \
  272. "- Their client may need to be refreshed. Have them restart Discord " \
  273. "and see if the channel shows up."
  274. else:
  275. is_backer = self.__is_kickstarter_email_hash(interaction.guild.id, member_link.email_hash)
  276. if is_backer:
  277. await member.add_roles(backer_role)
  278. text += f", and their email address is in the Kickstarter backer list, " \
  279. f"but they didn't have the {backer_role.name} role yet! **This was " \
  280. "just now corrected.** Ask them to check again."
  281. else:
  282. text += ", but we don't have the address they provided in the " \
  283. "Kickstarter backer list yet. Have we refreshed it recently?"
  284. await interaction.followup.send(text, ephemeral=True)
  285. @kickstarter.command(
  286. description='Shows Kickstarter link details for a given email address.'
  287. )
  288. async def find_email(self, interaction: Interaction, email: str):
  289. await interaction.response.defer(ephemeral=True, thinking=True)
  290. guild = interaction.guild
  291. try:
  292. backer_role = await self.__fetch_backer_role(guild)
  293. except _NoBackerRoleException:
  294. text = f"{CONFIG['failure_emoji']} Backer role not yet configured!"
  295. await interaction.followup.send(text, ephemeral=True)
  296. return
  297. member_link = self.__fetch_member_link_by_email(guild.id, email)
  298. is_backer = self.__is_kickstarter_email(guild.id, email)
  299. member = guild.get_member(member_link.member_id) or \
  300. await guild.fetch_member(member_link.member_id) \
  301. if member_link is not None else None
  302. has_role = member is not None and backer_role in member.roles
  303. lines: list[str] = []
  304. if not is_email_address(email):
  305. lines.append("- Email address doesn't look valid but searching anyway.")
  306. if member_link is not None:
  307. if member is not None:
  308. lines.append(f"- Server member {member.mention} (username " \
  309. f"{member.username}, id {member.id}) used `/link` with " \
  310. "this address.")
  311. if has_role:
  312. lines.append(f"- Member has the {backer_role.name} role.")
  313. else:
  314. lines.append(f"- Member does not have the {backer_role.name} role.")
  315. else:
  316. lines.append(f"- User <@{member_link.member_id}> (id {member.id}) " \
  317. "used `/link` but they couldn't be retrieved, perhaps because " \
  318. "they left the server.")
  319. else:
  320. lines.append("- No one has used `/link` with that address yet.")
  321. if is_backer:
  322. lines.append("- Email address is in latest Kickstarter backer export.")
  323. else:
  324. lines.append("- Email address is not found in the latest Kickstarter " \
  325. "backer export.")
  326. if member is not None and is_backer and not has_role:
  327. # Member doesn't have role but should
  328. await member.add_roles(backer_role)
  329. if member_link is not None:
  330. member_link.complete = True
  331. self.__update_member_link(member_link)
  332. else:
  333. member_link = _MemberLink(0, guild.id, member.id,
  334. KickstarterCog.__hash_email_address(email), True)
  335. self.__create_member_link(member_link)
  336. lines.append("- **Fixed:** Member is a backer and has been given " \
  337. f"{backer_role.name} role.")
  338. text = f"{CONFIG['info_emoji']} Email search for `{escape_markdown(email)}`\n" + \
  339. ("\n".join(lines))
  340. await interaction.followup.send(text, ephemeral=True)
  341. @kickstarter.command(
  342. description='Uploads an export of Kickstarter backer email addresses.'
  343. )
  344. async def upload_emails(self, interaction: Interaction):
  345. await interaction.response.send_modal(_UploadEmailsModal())
  346. @kickstarter.command(
  347. description='Uploads an export of backer Discord usernames.'
  348. )
  349. async def upload_usernames(self, interaction: Interaction):
  350. await interaction.response.send_modal(_UploadUsernamesModal())
  351. async def interaction_check(self, interaction: Interaction) -> bool:
  352. if interaction.command is not None:
  353. self.__trace(interaction.guild, f"@{interaction.user.name} used /{interaction.command.qualified_name}")
  354. return True
  355. async def on_email_upload_submit(self, interaction: Interaction, attachment: Attachment):
  356. """Callback for email upload modal."""
  357. await interaction.response.defer(ephemeral=True, thinking=True)
  358. guild = interaction.guild
  359. import_result: _ImportResult = await self.__import_emails(guild, attachment)
  360. try:
  361. sync_result: _SyncEmailsResult = await self.__sync_by_email(guild)
  362. text = f"{CONFIG['success_emoji']} Email import complete.\n" + \
  363. import_result.summary_markdown() + "\n" + \
  364. sync_result.summary_markdown()
  365. except _NoBackerRoleException:
  366. text = f"{CONFIG['failure_emoji']} Backer role must be configured " \
  367. "before importing."
  368. await interaction.followup.send(text, ephemeral=True)
  369. async def on_username_upload_submit(self, interaction: Interaction, attachment: Attachment):
  370. """Callback for username upload modal."""
  371. await interaction.response.defer(ephemeral=True, thinking=True)
  372. guild = interaction.guild
  373. import_result: _ImportResult = await self.__import_usernames(guild, attachment)
  374. try:
  375. sync_result: _SyncUsernamesResult = await self.__sync_by_username(guild)
  376. text = f"{CONFIG['success_emoji']} Username import complete.\n" + \
  377. import_result.summary_markdown() + "\n" + \
  378. sync_result.summary_markdown()
  379. except _NoBackerRoleException:
  380. text = f"{CONFIG['failure_emoji']} Backer role must be configured " \
  381. "before importing."
  382. await interaction.followup.send(text, ephemeral=True)
  383. async def on_link_email_submit(self, interaction: Interaction, email: str):
  384. """Callback for link modal."""
  385. await interaction.response.defer(ephemeral=True, thinking=True)
  386. try:
  387. result: _LinkResult = await self.__link_member(interaction.guild,
  388. interaction.user, email)
  389. except _NoBackerRoleException:
  390. text = f"{CONFIG['failure_emoji']} Oops! The server admin hasn't setup " \
  391. "this feature just yet. Check back later."
  392. await interaction.followup.send(text, ephemeral=True)
  393. return
  394. if result.status == _LinkResultStatus.malformed_address:
  395. text = f"{CONFIG['failure_emoji']} That email address doesn't look " \
  396. "like the right format.\n" \
  397. f"```\n{escape_markdown(email)}\n```\n" \
  398. "It should look something like:\n" \
  399. "```\nyourname@server.com\n```\n" \
  400. "If you need help, please contact a moderator."
  401. elif result.status == _LinkResultStatus.already_have_role:
  402. text = f"{CONFIG['info_emoji']} You already have the backer role " \
  403. "and should have access to backer-only areas. If you're still " \
  404. "having problems or have questions, please contact a moderator."
  405. elif result.status == _LinkResultStatus.linked_to_other_account:
  406. text = f"{CONFIG['failure_emoji']} That email address is already " \
  407. "linked to a different Discord member. Did you link with " \
  408. "an alternate Discord account? Contact a mod if you need help."
  409. elif result.status in (
  410. _LinkResultStatus.link_pending_new,
  411. _LinkResultStatus.link_pending_dupe,
  412. _LinkResultStatus.link_pending_updated
  413. ):
  414. if result.status == _LinkResultStatus.link_pending_new:
  415. text = f"{CONFIG['info_emoji']} Email address linked!\n\n"
  416. else:
  417. text = f"{CONFIG['info_emoji']} Email address updated!\n\n"
  418. text += "We just need to confirm the address is on the backer list, " \
  419. "and then you'll be given access to the backers-only areas of " \
  420. "the server. In the meantime, double check that the email you " \
  421. "gave us is the same one you used to back the project on " \
  422. "Kickstarter:\n" \
  423. "\n" \
  424. f"> `{escape_markdown(email)}`\n" \
  425. "\n" \
  426. "If you need to make a correction, just use `/link` again to update it."
  427. elif result.status == _LinkResultStatus.link_success:
  428. text = f"{CONFIG['success_emoji']} Success! You should now have access to " \
  429. "backer-only areas! Thanks for your support!"
  430. await interaction.followup.send(text, ephemeral=True)
  431. async def __link_member(self, guild: Guild, member: Member, email: str) -> '_LinkResult':
  432. if not is_email_address(email.strip()):
  433. return _LinkResult(_LinkResultStatus.malformed_address)
  434. input_email_hash = KickstarterCog.__hash_email_address(email)
  435. backer_role: Role = await self.__fetch_backer_role(guild)
  436. if backer_role in member.roles:
  437. return _LinkResult(_LinkResultStatus.already_have_role)
  438. existing_email_link = self.__fetch_member_link_by_email(guild.id, email)
  439. if existing_email_link is not None and existing_email_link.member_id != member.id:
  440. return _LinkResult(_LinkResultStatus.linked_to_other_account)
  441. member_link = self.__fetch_member_link_by_member_id(guild.id, member.id)
  442. if self.__is_kickstarter_email(guild.id, email):
  443. await member.add_roles(backer_role)
  444. if member_link is not None:
  445. member_link.email_hash = input_email_hash
  446. member_link.complete = True
  447. self.__update_member_link(member_link)
  448. else:
  449. member_link = _MemberLink(0, guild.id, member.id, input_email_hash, True)
  450. self.__create_member_link(member_link)
  451. return _LinkResult(_LinkResultStatus.link_success)
  452. if member_link is not None:
  453. if member_link.email_hash == input_email_hash:
  454. return _LinkResult(_LinkResultStatus.link_pending_dupe)
  455. member_link.email_hash = input_email_hash
  456. self.__update_member_link(member_link)
  457. return _LinkResult(_LinkResultStatus.link_pending_updated)
  458. member_link = _MemberLink(0, guild.id, member.id, input_email_hash, False)
  459. self.__create_member_link(member_link)
  460. return _LinkResult(_LinkResultStatus.link_pending_new)
  461. async def __import_emails(self, guild: Guild, attachment: Attachment) -> '_ImportResult':
  462. """Imports email addresses from an upload attachment."""
  463. self.__trace(guild, f"Download - start - {attachment.filename} " \
  464. f"({attachment.size} bytes, {attachment.content_type})")
  465. file_bytes = await attachment.read()
  466. file_str = file_bytes.decode('utf-8')
  467. self.__trace(guild, "Download - complete")
  468. self.__trace(guild, "Parse - start")
  469. lines = file_str.splitlines(keepends=False)
  470. addresses = []
  471. malformed_count = 0
  472. for line in lines:
  473. line = line.strip()
  474. if (line.startswith('"') and line.endswith('"')) or \
  475. (line.startswith("'") and line.endswith("'")):
  476. # Remove quotes
  477. line = line[1:-1].strip()
  478. if line == '':
  479. continue
  480. if is_email_address(line):
  481. addresses.append(line)
  482. else:
  483. self.__trace(guild, f"Malformed email address {line}")
  484. malformed_count += 1
  485. self.__trace(guild, f"Parse - complete - {len(lines)} lines, {len(addresses)} " \
  486. f"valid addresses, {malformed_count} malformed addresses")
  487. self.__trace(guild, f"Storing - start - {len(addresses)} addresses")
  488. new_address_count = self.__store_kickstarter_emails(guild.id, addresses)
  489. self.__trace(guild, f"Storing - complete - {new_address_count} unique " \
  490. "addresses stored")
  491. return _ImportResult(
  492. attachment.filename,
  493. attachment.size,
  494. len(lines),
  495. len(addresses),
  496. malformed_count,
  497. new_address_count
  498. )
  499. async def __import_usernames(self, guild: Guild, attachment: Attachment) -> '_ImportResult':
  500. """Imports Discord usernames from an upload attachment."""
  501. self.__trace(guild, f"Download - start - {attachment.filename} " \
  502. f"({attachment.size} bytes, {attachment.content_type})")
  503. file_bytes = await attachment.read()
  504. file_str = file_bytes.decode('utf-8')
  505. self.__trace(guild, "Download - complete")
  506. self.__trace(guild, "Parse - start")
  507. lines = file_str.splitlines(keepends=False)
  508. usernames = []
  509. malformed_count = 0
  510. for line in lines:
  511. line = self.__normalize_discord_username(line)
  512. if (line.startswith('"') and line.endswith('"')) or \
  513. (line.startswith("'") and line.endswith("'")):
  514. # Remove quotes
  515. line = line[1:-1].strip()
  516. if line == '':
  517. continue
  518. if is_discord_username(line):
  519. usernames.append(line)
  520. else:
  521. self.__trace(guild, f"Malformed Discord username \"{line}\"")
  522. malformed_count += 1
  523. self.__trace(guild, f"Parse - complete - {len(lines)} lines, {len(usernames)} " \
  524. f"valid usernames, {malformed_count} malformed usernames")
  525. self.__trace(guild, f"Storing - start - {len(usernames)} usernames")
  526. new_username_count = self.__store_kickstarter_usernames(guild.id, usernames)
  527. self.__trace(guild, f"Storing - complete - {new_username_count} unique " \
  528. "usernames stored")
  529. return _ImportResult(
  530. attachment.filename,
  531. attachment.size,
  532. len(lines),
  533. len(usernames),
  534. malformed_count,
  535. new_username_count
  536. )
  537. async def __sync_by_email(self, guild: Guild) -> '_SyncEmailsResult':
  538. """Looks for members to assign the backer role from Kickstarter imports."""
  539. backer_role = await self.__fetch_backer_role(guild)
  540. incomplete_links: list[_MemberLink] = self.__fetch_incomplete_member_links(guild.id)
  541. complete_count = 0
  542. incomplete_count = 0
  543. member_not_found_count = 0
  544. async def link_loop_handler(link: _MemberLink):
  545. nonlocal member_not_found_count
  546. nonlocal complete_count
  547. nonlocal incomplete_count
  548. if not self.__is_kickstarter_email_hash(guild.id, link.email_hash):
  549. incomplete_count += 1
  550. return
  551. member = guild.get_member(link.member_id) or \
  552. await guild.fetch_member(link.member_id)
  553. if member is None:
  554. member_not_found_count += 1
  555. else:
  556. if backer_role not in member.roles:
  557. await member.add_roles(backer_role)
  558. link.complete = True
  559. self.__update_member_link(link)
  560. complete_count += 1
  561. failure_count = await self.__throttled_loop(guild, incomplete_links, link_loop_handler)
  562. return _SyncEmailsResult(complete_count, incomplete_count, failure_count, member_not_found_count)
  563. async def __sync_by_username(self, guild: Guild) -> '_SyncUsernamesResult':
  564. backer_role = await self.__fetch_backer_role(guild)
  565. complete_count = 0
  566. not_found_count = 0
  567. usernames: list[_KickstarterDiscordUser] = \
  568. self.__fetch_incomplete_kickstarter_usernames(guild.id,
  569. _LookupStatus.username_not_found)
  570. username_to_member_id: dict[str, int] = {}
  571. if self.bot.intents.members:
  572. async for member in guild.fetch_members(limit=None):
  573. username_to_member_id[member.name] = member.id
  574. async def username_loop_handler(username: _KickstarterDiscordUser):
  575. nonlocal complete_count
  576. nonlocal not_found_count
  577. member_id = username_to_member_id.get(username.discord_username)
  578. if member_id is not None:
  579. member = guild.get_member(member_id) or \
  580. await guild.fetch_member(member_id)
  581. else:
  582. member = guild.get_member_named(username.discord_username)
  583. if member is None:
  584. not_found_count += 1
  585. if username.lookup_status != _LookupStatus.username_not_found:
  586. username.lookup_status = _LookupStatus.username_not_found
  587. self.__update_kickstarter_username(username)
  588. return
  589. username.lookup_status = _LookupStatus.member_found
  590. username.discord_member_id = member.id
  591. self.__update_kickstarter_username(username)
  592. if backer_role not in member.roles:
  593. await member.add_roles(backer_role)
  594. complete_count += 1
  595. failure_count = await self.__throttled_loop(guild, usernames, username_loop_handler)
  596. return _SyncUsernamesResult(complete_count, not_found_count, failure_count)
  597. async def __throttled_loop(self, guild: Guild, iter: Iterable, callback) -> int:
  598. """Iterates a loop with automatically adjusting sleeps based on
  599. throttling exceptions."""
  600. failure_count = 0
  601. sleep_length = 0.0
  602. for elem in iter:
  603. complete = False
  604. for _ in range(5):
  605. try:
  606. await sleep(sleep_length)
  607. await callback(elem)
  608. complete = True
  609. break
  610. except HTTPException as ex:
  611. if ex.status == 429: # rate limited
  612. retry_header_value = ex.response.headers.get('retry_after')
  613. retry_after_millis = float(retry_header_value or '1000')
  614. self.__trace(guild, "Rate limited while processing. " \
  615. f"retry_after={retry_header_value}")
  616. await sleep(retry_after_millis / 1000.0)
  617. sleep_length = 1.0 if sleep_length == 0.0 else sleep_length * 2.0
  618. self.__trace(guild, f"Sleep increased to {sleep_length}s due to rate limiting")
  619. else:
  620. dump_stacktrace(ex)
  621. except BaseException as ex:
  622. dump_stacktrace(ex)
  623. if not complete:
  624. failure_count += 1
  625. return failure_count
  626. # -- Database functions -----
  627. def __store_kickstarter_email(self, record: '_KickstarterEmail'):
  628. cur = self.con.cursor()
  629. cur.execute("""
  630. INSERT OR IGNORE INTO kickstarter_emails (
  631. guild_id,
  632. email_hash,
  633. imported_at
  634. ) VALUES (
  635. :guild_id,
  636. :email_hash,
  637. :imported_at
  638. )
  639. """, {
  640. 'guild_id': record.guild_id,
  641. 'email_hash': record.email_hash,
  642. 'imported_at': record.imported_at
  643. })
  644. row_id = cur.lastrowid
  645. self.con.commit()
  646. cur.close()
  647. if row_id is not None and row_id != 0:
  648. record.pk = row_id
  649. def __store_kickstarter_emails(self, guild_id: int, email_addresses: list[str]) -> int:
  650. imported_at: int = int(now_timestamp())
  651. cur = self.con.cursor()
  652. for email_address in email_addresses:
  653. email_hash = KickstarterCog.__hash_email_address(email_address)
  654. cur.execute("""
  655. INSERT OR IGNORE INTO kickstarter_emails (
  656. guild_id,
  657. email_hash,
  658. imported_at
  659. ) VALUES (
  660. :guild_id,
  661. :email_hash,
  662. :imported_at
  663. )
  664. """, {
  665. 'guild_id': guild_id,
  666. 'email_hash': email_hash,
  667. 'imported_at': imported_at
  668. })
  669. cur.execute("""
  670. SELECT COUNT(1)
  671. FROM kickstarter_emails
  672. WHERE imported_at = ?
  673. """, (imported_at, ))
  674. imported_count = cur.fetchone()[0]
  675. self.con.commit()
  676. cur.close()
  677. return imported_count
  678. def __is_kickstarter_email(self, guild_id: int, email_address: str) -> bool:
  679. return self.__is_kickstarter_email_hash(
  680. guild_id, KickstarterCog.__hash_email_address(email_address))
  681. def __is_kickstarter_email_hash(self, guild_id: int, email_hash: str) -> bool:
  682. cur = self.con.cursor()
  683. cur.execute("""
  684. SELECT COUNT(1)
  685. FROM kickstarter_emails
  686. WHERE guild_id = :guild_id AND email_hash = :email_hash
  687. """, {
  688. 'guild_id': guild_id,
  689. 'email_hash': email_hash
  690. })
  691. count: int = cur.fetchone()[0]
  692. cur.close()
  693. return count > 0
  694. def __store_kickstarter_username(self, record: '_KickstarterDiscordUser'):
  695. cur = self.con.cursor()
  696. cur.execute("""
  697. INSERT OR IGNORE INTO kickstarter_discord_users (
  698. guild_id,
  699. discord_username,
  700. discord_member_id,
  701. lookup_status,
  702. imported_at
  703. ) VALUES (
  704. :guild_id,
  705. :discord_username,
  706. :discord_member_id,
  707. :lookup_status,
  708. :imported_at
  709. )
  710. """, {
  711. 'guild_id': record.guild_id,
  712. 'discord_username': self.__normalize_discord_username(record.discord_username),
  713. 'discord_member_id': record.discord_member_id,
  714. 'lookup_status': record.lookup_status,
  715. 'imported_at': record.imported_at
  716. })
  717. row_id = cur.lastrowid
  718. self.con.commit()
  719. cur.close()
  720. if row_id is not None and row_id != 0:
  721. record.pk = row_id
  722. def __store_kickstarter_usernames(self, guild_id: int, usernames: list[str]) -> int:
  723. imported_at: int = int(now_timestamp())
  724. cur = self.con.cursor()
  725. for username in usernames:
  726. cur.execute("""
  727. INSERT OR IGNORE INTO kickstarter_discord_users (
  728. guild_id,
  729. discord_username,
  730. imported_at
  731. ) VALUES (
  732. :guild_id,
  733. :discord_username,
  734. :imported_at
  735. )
  736. """, {
  737. 'guild_id': guild_id,
  738. 'discord_username': self.__normalize_discord_username(username),
  739. 'imported_at': imported_at
  740. })
  741. cur.execute("""
  742. SELECT COUNT(1)
  743. FROM kickstarter_discord_users
  744. WHERE imported_at = ?
  745. """, (imported_at, ))
  746. imported_count = cur.fetchone()[0]
  747. self.con.commit()
  748. cur.close()
  749. return imported_count
  750. def __fetch_kickstarter_username(self, member_id: Optional[int] = None, username: Optional[str] = None) -> Optional['_KickstarterDiscordUser']:
  751. """Fetches an imported Discord username by EITHER member id or username
  752. (must provide exactly one)"""
  753. cur = self.con.cursor()
  754. cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
  755. if member_id is not None:
  756. cur.execute("""
  757. SELECT *
  758. FROM kickstarter_discord_users
  759. WHERE discord_member_id = :member_id
  760. """, { 'member_id': member_id })
  761. elif username is not None:
  762. cur.execute("""
  763. SELECT *
  764. FROM kickstarter_discord_users
  765. WHERE discord_username = :username
  766. """, { 'username': username })
  767. ret_val = cur.fetchone()
  768. cur.close()
  769. return ret_val
  770. def __fetch_incomplete_kickstarter_usernames(self, guild_id: int, max_status: '_LookupStatus') -> list['_KickstarterDiscordUser']:
  771. cur = self.con.cursor()
  772. cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r)
  773. cur.execute("""
  774. SELECT *
  775. FROM kickstarter_discord_users
  776. WHERE guild_id = :guild_id AND lookup_status <= :lookup_status
  777. """, { 'guild_id': guild_id, 'lookup_status': max_status })
  778. ret_val = cur.fetchall()
  779. cur.close()
  780. return ret_val
  781. def __update_kickstarter_username(self, user: '_KickstarterDiscordUser'):
  782. cur = self.con.cursor()
  783. cur.execute("""
  784. UPDATE kickstarter_discord_users
  785. SET discord_member_id = :discord_member_id,
  786. lookup_status = :lookup_status
  787. WHERE pk = :pk
  788. """, {
  789. 'discord_member_id': user.discord_member_id,
  790. 'lookup_status': user.lookup_status,
  791. 'pk': user.pk
  792. })
  793. self.con.commit()
  794. cur.close()
  795. def __create_member_link(self, record: '_MemberLink'):
  796. cur = self.con.cursor()
  797. cur.execute("""
  798. INSERT OR IGNORE INTO member_links (
  799. guild_id,
  800. member_id,
  801. email_hash,
  802. complete
  803. ) VALUES (
  804. :guild_id,
  805. :member_id,
  806. :email_hash,
  807. :complete
  808. )
  809. """, {
  810. 'guild_id': record.guild_id,
  811. 'member_id': record.member_id,
  812. 'email_hash': record.email_hash,
  813. 'complete': record.complete
  814. })
  815. row_id = cur.lastrowid
  816. if row_id is not None and row_id != 0:
  817. record.pk = row_id
  818. self.con.commit()
  819. cur.close()
  820. def __fetch_member_link_by_member_id(self, guild_id: int, member_id: int) -> Optional['_MemberLink']:
  821. cur = self.con.cursor()
  822. cur.row_factory = lambda c, r: _MemberLink(*r)
  823. cur.execute("""
  824. SELECT *
  825. FROM member_links
  826. WHERE guild_id = :guild_id AND member_id = :member_id
  827. """, (guild_id, member_id, ))
  828. ret_val = cur.fetchone()
  829. cur.close()
  830. return ret_val
  831. def __fetch_member_link_by_email(self, guild_id: int, email_address: str) -> Optional['_MemberLink']:
  832. cur = self.con.cursor()
  833. cur.row_factory = lambda c, r: _MemberLink(*r)
  834. email_hash = KickstarterCog.__hash_email_address(email_address)
  835. cur.execute("""
  836. SELECT *
  837. FROM member_links
  838. WHERE guild_id = :guild_id AND email_hash = :email_hash
  839. """, {
  840. 'guild_id': guild_id,
  841. 'email_hash': email_hash
  842. })
  843. ret_val = cur.fetchone()
  844. cur.close()
  845. return ret_val
  846. def __delete_member_link(self, guild_id: int, member_id: int):
  847. cur = self.con.cursor()
  848. cur.execute("""
  849. DELETE FROM member_links
  850. WHERE guild_id = :guild_id AND member_id = :member_id
  851. """, { 'guild_id': guild_id, 'member_id': member_id })
  852. cur.close()
  853. def __update_member_link(self, member_link: '_MemberLink'):
  854. cur = self.con.cursor()
  855. cur.execute("""
  856. UPDATE member_links
  857. SET
  858. email_hash = :email_hash,
  859. complete = :complete
  860. WHERE guild_id = :guild_id AND member_id = :member_id
  861. """, {
  862. 'email_hash': member_link.email_hash,
  863. 'complete': 1 if member_link.complete else 0,
  864. 'guild_id': member_link.guild_id,
  865. 'member_id': member_link.member_id
  866. })
  867. self.con.commit()
  868. cur.close()
  869. def __fetch_incomplete_member_links(self, guild_id: int) -> list['_MemberLink']:
  870. cur = self.con.cursor()
  871. cur.row_factory = lambda c, r: _MemberLink(*r)
  872. cur.execute("""
  873. SELECT *
  874. FROM member_links
  875. WHERE guild_id = ? AND complete = 0
  876. """, (guild_id, ))
  877. ret_val = cur.fetchall()
  878. cur.close()
  879. return ret_val
  880. def __fetch_stats(self, guild_id: int) -> '_Stats':
  881. """Returns a tuple with the number of completed links, incomplete links,
  882. and imported Kickstarter emails."""
  883. cur = self.con.cursor()
  884. cur.execute("""
  885. SELECT
  886. (
  887. SELECT COUNT(1)
  888. FROM member_links
  889. WHERE complete = 1 AND guild_id = :guild_id
  890. ) AS complete,
  891. (
  892. SELECT COUNT(1)
  893. FROM member_links
  894. WHERE complete = 0 AND guild_id = :guild_id
  895. ) AS incomplete
  896. """, { 'guild_id': guild_id })
  897. (
  898. complete_link_count,
  899. incomplete_link_count
  900. ) = cur.fetchone()
  901. cur.execute("""
  902. SELECT
  903. COUNT(1),
  904. MAX(imported_at)
  905. FROM kickstarter_emails
  906. """)
  907. (
  908. email_count,
  909. email_last_imported_at
  910. ) = cur.fetchone()
  911. cur.execute("""
  912. SELECT
  913. COUNT(1) AS total,
  914. SUM(IIF(lookup_status = 0, 1, 0)) AS unprocessed_count,
  915. SUM(IIF(lookup_status = 1, 1, 0)) AS not_found_count,
  916. SUM(IIF(lookup_status = 2, 1, 0)) AS found_count,
  917. MAX(imported_at) AS last_import
  918. FROM kickstarter_discord_users
  919. """)
  920. (
  921. username_count,
  922. username_unprocessed_count,
  923. username_not_found_count,
  924. username_found_count,
  925. username_last_imported_at
  926. ) = cur.fetchone()
  927. cur.close()
  928. return _Stats(
  929. complete_link_count,
  930. incomplete_link_count,
  931. email_count,
  932. email_last_imported_at,
  933. username_count,
  934. username_unprocessed_count,
  935. username_not_found_count,
  936. username_found_count,
  937. username_last_imported_at
  938. )
  939. # -- Utils -----
  940. def __trace(self, guild: Guild, message: str):
  941. self.log(guild, message)
  942. @staticmethod
  943. def __normalize_email_address(email_address: str) -> str:
  944. """Normalizes an email address for consistent equivalence tests."""
  945. return email_address.strip().lower()
  946. @staticmethod
  947. def __hash_email_address(email_address: str) -> str:
  948. """Returns a hash string of the given email address. The address is first
  949. normalized as lowercase and trimmed of whitespace before hashing."""
  950. m = hashlib.sha256()
  951. to_hash = KickstarterCog.__normalize_email_address(email_address)
  952. m.update(to_hash.encode('utf-8'))
  953. digest_bytes = m.digest()
  954. return base64.urlsafe_b64encode(digest_bytes).decode('utf-8')
  955. @staticmethod
  956. def __normalize_discord_username(username: str) -> str:
  957. norm = username.lower().strip()
  958. if norm.startswith('@'):
  959. norm = norm[1:]
  960. return norm
  961. class _KickstarterEmail:
  962. """kickstarter_email table row"""
  963. def __init__(self,
  964. pk: int,
  965. guild_id: int,
  966. email_hash: str,
  967. imported_at: int
  968. ):
  969. self.pk: str = pk
  970. self.guild_id: int = guild_id
  971. self.email_hash: str = email_hash
  972. self.imported_at: int = imported_at
  973. class _LookupStatus(IntEnum):
  974. # Username imported but has not been looked up yet
  975. unprocessed = 0
  976. # Username was searched for in guild but not found (user might not have joined yet)
  977. username_not_found = 1
  978. # Username found in guild and member id stored
  979. member_found = 2
  980. class _KickstarterDiscordUser:
  981. """kickstarter_discord_users table row"""
  982. def __init__(self,
  983. pk: int,
  984. guild_id: int,
  985. discord_username: str,
  986. discord_member_id: Optional[int],
  987. lookup_status: _LookupStatus,
  988. imported_at: int
  989. ):
  990. self.pk: int = pk
  991. self.guild_id: int = guild_id
  992. self.discord_username: str = discord_username
  993. self.discord_member_id: Optional[int] = discord_member_id
  994. self.lookup_status: _LookupStatus = lookup_status
  995. self.imported_at: int = imported_at
  996. class _MemberLink:
  997. """member_link table row"""
  998. def __init__(self,
  999. pk: int,
  1000. guild_id: int,
  1001. member_id: int,
  1002. email_hash: str,
  1003. complete: bool = False
  1004. ):
  1005. self.pk: int = pk
  1006. self.guild_id: int = guild_id
  1007. self.member_id: int = member_id
  1008. self.email_hash: str = email_hash
  1009. self.complete: bool = complete
  1010. class _Stats:
  1011. def __init__(self,
  1012. complete_link_count: int,
  1013. incomplete_link_count: int,
  1014. email_count: int,
  1015. email_last_imported_at: int,
  1016. username_count: int,
  1017. username_unprocessed_count: int,
  1018. username_not_found_count: int,
  1019. username_found_count: int,
  1020. username_last_imported_at: int
  1021. ):
  1022. self.complete_link_count: int = complete_link_count
  1023. self.incomplete_link_count: int = incomplete_link_count
  1024. self.email_count: int = email_count
  1025. self.email_last_imported_at: int = email_last_imported_at
  1026. self.username_count: int = username_count
  1027. self.username_unprocessed_count: int = username_unprocessed_count
  1028. self.username_not_found_count: int = username_not_found_count
  1029. self.username_found_count: int = username_found_count
  1030. self.username_last_imported_at: int = username_last_imported_at
  1031. class _NoBackerRoleException(BaseException):
  1032. pass
  1033. class _LinkResultStatus(IntEnum):
  1034. # Email address is invalid
  1035. malformed_address = 1
  1036. # User already has backer role, doesn't need to link again
  1037. already_have_role = 2
  1038. # Email address associated with a different Discord account
  1039. linked_to_other_account = 3
  1040. # Can't link right now but email linked to Discord account
  1041. link_pending_new = 4
  1042. # Linked same email address to same Discord account (no action taken)
  1043. link_pending_dupe = 5
  1044. # Replaced email address linked to this Discord account
  1045. link_pending_updated = 6
  1046. # Link successful and backer role given
  1047. link_success = 99
  1048. class _LinkResult():
  1049. def __init__(self,
  1050. status: _LinkResultStatus
  1051. ):
  1052. self.status: _LinkResultStatus = status
  1053. class _ImportResult():
  1054. def __init__(self,
  1055. filename: str,
  1056. file_bytes: int,
  1057. line_count: int,
  1058. valid_record_count: int,
  1059. malformed_record_count: int,
  1060. new_record_count: int
  1061. ):
  1062. self.filename: str = filename
  1063. self.file_bytes: int = file_bytes
  1064. self.line_count: int = line_count
  1065. self.valid_record_count: int = valid_record_count
  1066. self.malformed_record_count: int = malformed_record_count
  1067. self.new_record_count: int = new_record_count
  1068. def summary_markdown(self) -> str:
  1069. lines: list[str] = []
  1070. if self.valid_record_count > 0:
  1071. lines.append(f"- Read {self.valid_record_count} valid records")
  1072. else:
  1073. lines.append("- Upload contained **no valid records**")
  1074. if self.malformed_record_count > 0:
  1075. lines.append(f"- Read **{self.malformed_record_count} malformed records**")
  1076. if self.new_record_count > 0:
  1077. lines.append(f"- Imported {self.new_record_count} new unique records")
  1078. else:
  1079. lines.append("- No new unique records (all previously imported)")
  1080. return "\n".join(lines)
  1081. class _SyncEmailsResult():
  1082. def __init__(self,
  1083. complete_count: int,
  1084. incomplete_count: int,
  1085. failure_count: int,
  1086. member_not_found_count: int
  1087. ):
  1088. self.complete_count: int = complete_count
  1089. self.incomplete_count: int = incomplete_count
  1090. self.failure_count: int = failure_count
  1091. self.member_not_found_count: int = member_not_found_count
  1092. def summary_markdown(self) -> str:
  1093. lines: list[str] = []
  1094. if self.complete_count > 0:
  1095. lines.append(f"- Gave backer role to {self.complete_count} new members")
  1096. else:
  1097. lines.append("- No new members found")
  1098. if self.incomplete_count > 0:
  1099. lines.append(f"- {self.incomplete_count} members provided an email "
  1100. "address to the bot but haven't been found in the Kickstarter "
  1101. "imports yet")
  1102. if self.member_not_found_count > 0:
  1103. lines.append(f"- {self.member_not_found_count} users provided an email "
  1104. "address but couldn't be found (likely left the server). They'll "
  1105. "be handled on next sync if they return.")
  1106. if self.failure_count > 0:
  1107. lines.append(f"- Failed to link {self.failure_count} members")
  1108. return "\n".join(lines)
  1109. class _SyncUsernamesResult():
  1110. def __init__(self,
  1111. complete_count: int,
  1112. not_found_count: int,
  1113. failure_count: int
  1114. ):
  1115. self.complete_count: int = complete_count
  1116. self.not_found_count: int = not_found_count
  1117. self.failure_count: int = failure_count
  1118. def summary_markdown(self) -> str:
  1119. lines: list[str] = []
  1120. if self.complete_count > 0:
  1121. lines.append(f"- Gave backer role to {self.complete_count} new members")
  1122. else:
  1123. lines.append("- No new members found")
  1124. if self.not_found_count > 0:
  1125. lines.append(f"- {self.not_found_count} members could not be found " \
  1126. "by the provided username")
  1127. if self.failure_count > 0:
  1128. lines.append(f"- Failed to link {self.failure_count} members")
  1129. return "\n".join(lines)
  1130. class _LinkModal(Modal):
  1131. text_display = TextDisplay(
  1132. "Gain access to backer-only areas of the server by linking your " \
  1133. "Kickstarter email address.\n" \
  1134. "-# This is only used to verify you are on the backer list. We won't " \
  1135. "email you or ask for your Kickstarter password!"
  1136. )
  1137. email_label = Label(
  1138. text='Kickstarter email',
  1139. # description='Enter the email address associated with your Kickstarter account.',
  1140. component=TextInput(
  1141. style=TextStyle.short,
  1142. placeholder='name@example.com',
  1143. min_length=6,
  1144. max_length=100
  1145. )
  1146. )
  1147. def __init__(self):
  1148. super().__init__(title='Link Kickstarter Address', timeout=None)
  1149. async def on_submit(self, interaction: Interaction) -> None:
  1150. # noinspection PyTypeChecker
  1151. email_input: TextInput = self.email_label.component
  1152. await KickstarterCog.shared.on_link_email_submit(interaction, email_input.value)
  1153. async def on_error(self, interaction: Interaction, error: Exception) -> None:
  1154. dump_stacktrace(error)
  1155. try:
  1156. await interaction.response.send_message(
  1157. f'{CONFIG["failure_emoji"]} Something went wrong. Try using `/link` again.',
  1158. ephemeral=True,
  1159. )
  1160. except BaseException:
  1161. pass
  1162. class _UploadEmailsModal(Modal):
  1163. upload_label = Label(
  1164. text='Kickstarter backer email export',
  1165. description='Upload a plain text file containing one backer email address per line.',
  1166. component=FileUpload(
  1167. required=True,
  1168. min_values=1, max_values=1
  1169. )
  1170. )
  1171. def __init__(self):
  1172. super().__init__(title='Upload Emails', timeout=None)
  1173. async def on_submit(self, interaction: Interaction) -> None:
  1174. # noinspection PyTypeChecker
  1175. upload_input: FileUpload = self.upload_label.component
  1176. if len(upload_input.values) < 1:
  1177. text = f"{CONFIG['failure_emoji']} No export file included"
  1178. await interaction.response.send(text, ephemeral=True)
  1179. return
  1180. attachment = upload_input.values[0]
  1181. await KickstarterCog.shared.on_email_upload_submit(interaction, attachment)
  1182. async def on_error(self, interaction: Interaction, error: Exception) -> None:
  1183. dump_stacktrace(error)
  1184. try:
  1185. await interaction.response.send_message(
  1186. f'{CONFIG["failure_emoji"]} Upload failed :(',
  1187. ephemeral=True,
  1188. )
  1189. except BaseException:
  1190. pass
  1191. class _UploadUsernamesModal(Modal):
  1192. upload_label = Label(
  1193. text='Discord user export',
  1194. description='Upload a plain text file containing one backer Discord username per line.',
  1195. component=FileUpload(
  1196. required=True,
  1197. min_values=1, max_values=1
  1198. )
  1199. )
  1200. def __init__(self):
  1201. super().__init__(title='Upload Discord Usernames', timeout=None)
  1202. async def on_submit(self, interaction: Interaction) -> None:
  1203. # noinspection PyTypeChecker
  1204. upload_input: FileUpload = self.upload_label.component
  1205. if len(upload_input.values) < 1:
  1206. text = f"{CONFIG['failure_emoji']} No export file included"
  1207. await interaction.response.send(text, ephemeral=True)
  1208. return
  1209. attachment = upload_input.values[0]
  1210. await KickstarterCog.shared.on_username_upload_submit(interaction, attachment)
  1211. async def on_error(self, interaction: Interaction, error: Exception) -> None:
  1212. dump_stacktrace(error)
  1213. try:
  1214. await interaction.response.send_message(
  1215. f'{CONFIG["failure_emoji"]} Upload failed :(',
  1216. ephemeral=True,
  1217. )
  1218. except BaseException:
  1219. pass