Experimental Discord bot written in Python
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

kickstartercog.py 47KB

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