import base64 import hashlib from asyncio import sleep from enum import IntEnum from sqlite3 import Connection, connect from time import time as now_timestamp from typing import Iterable, Optional from discord import Attachment, Guild, Interaction, Member, Role, TextStyle from discord.app_commands import Group, command, guild_only, guilds from discord.errors import HTTPException from discord.ui import FileUpload, Label, Modal, TextDisplay, TextInput from discord.utils import escape_markdown from config import CONFIG from rocketbot.bot import Rocketbot from rocketbot.cogs.basecog import BaseCog from rocketbot.cogsetting import CogSetting from rocketbot.utils import ( MOD_PERMISSIONS, dump_stacktrace, is_discord_username, is_email_address, ) PERMITTED_GUILD_IDS = [ 405011810937339905, 900805482825007104, # test server ] class KickstarterCog(BaseCog): """ Provides a way for Discord users to self-identify the email address they used when backing a relevant Kickstarter campaign. If the email address is found in a sqlite database, a configured backer role will be given to the user. If the address isn't found, they can either try again or the address can be saved and linked up the next time the database is refreshed. Discord does not currently have a native Kickstarter integration, and doing a full API integration is a bit ambitious, so this is a stopgap solution. """ shared: Optional['KickstarterCog'] = None SETTING_ENABLED = CogSetting( name='enabled', datatype=bool, default_value=False, brief='Kickstarter user linking', description='Whether this module is enabled for a guild.', ) SETTING_ROLE = CogSetting( name='backer_role', datatype=int, default_value=0, brief='role to assign to Kickstarter backers', description='Role automatically assigned to members who use /link with ' 'a known Kickstarter email address.' ) def __init__(self, bot: Rocketbot): super().__init__( bot, config_prefix='kickstarter', short_description='For linking Kickstarter backers to their Discord handles.', ) Self = KickstarterCog self.add_setting(Self.SETTING_ENABLED) self.con: Connection = connect('kickstarter.sqlite3') Self.shared = self async def __fetch_backer_role(self, guild: Guild) -> Role: Self = KickstarterCog role_id = self.get_guild_setting(guild, Self.SETTING_ROLE) if role_id is None or role_id == 0: raise _NoBackerRoleException() return guild.get_role(role_id) or await guild.fetch_role(role_id) def __get_cached_backer_role(self, guild: Guild) -> Optional[Role]: """Synchronously gets the configured backer role from the cache if possible.""" Self = KickstarterCog role_id = self.get_guild_setting(guild, Self.SETTING_ROLE) if role_id is None or role_id == 0: return None return guild.get_role(role_id) def __set_backer_role(self, role: Role): Self = KickstarterCog self.set_guild_setting(role.guild, Self.SETTING_ROLE, role.id) # -- Member commands ----- @command( description='Links your Kickstarter email address to your Discord user' ) @guild_only() @guilds(PERMITTED_GUILD_IDS) async def link_email(self, interaction: Interaction): # If possible, try to check if they're already a backer before opening # a modal. guild = interaction.guild if not self.get_guild_setting(guild, KickstarterCog.SETTING_ENABLED): return backer_role = self.__get_cached_backer_role(guild) member: Member = interaction.user if backer_role is not None and backer_role in member.roles: text = f"{CONFIG['info_emoji']} You have already been given the " \ "backer role and should have access to backer-only areas.\n" \ "\n" \ "Please message a moderator if you're having trouble." await interaction.response.send_message(text, ephemeral=True) return await interaction.response.send_modal(_LinkModal()) @command( description='Grants you access to Kickstarter backer-only areas' ) @guild_only() @guilds(PERMITTED_GUILD_IDS) async def link_username(self, interaction: Interaction): await interaction.response.defer(ephemeral=True, thinking=True) try: backer_role = await self.__fetch_backer_role(interaction.guild) except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Oops! The server admin hasn't " \ "setup this feature just yet. Check back later." await interaction.followup.send(text, ephemeral=True) return member: Member = interaction.user if backer_role is not None and backer_role in member.roles: text = f"{CONFIG['info_emoji']} You have already been given the " \ "backer role and should have access to backer-only areas.\n" \ "\n" \ "Please message a moderator if you're having trouble." await interaction.followup.send(text, ephemeral=True) return ks_username = self.__fetch_kickstarter_username(username=interaction.user.name) if ks_username is None: text = f"{CONFIG['warning_emoji']} We don't have your Discord " \ f"username `{member.name}` in our list of backers. Did you " \ "spell it correctly in the form? Contact a moderator if you " \ "think there was a mistake." await interaction.followup.send(text, ephemeral=True) return ks_username.discord_member_id = member.id ks_username.lookup_status = _LookupStatus.member_found self.__update_kickstarter_username(ks_username) await member.add_roles(backer_role) text = f"{CONFIG['success_emoji']} Success! You should now have access to " \ "backer-only areas! Thanks for your support!" await interaction.followup.send(text, ephemeral=True) @command( description='Unlinks your Kickstarter email address from your Discord ' \ 'user and removes the backer role.' ) @guild_only() @guilds(PERMITTED_GUILD_IDS) async def unlink(self, interaction: Interaction): await interaction.response.defer(ephemeral=True, thinking=True) guild = interaction.guild member = interaction.user try: backer_role: Role = await self.__fetch_backer_role(guild) except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Oops! The server admin hasn't " \ "setup this feature just yet. Check back later." await interaction.followup.send(text, ephemeral=True) return if backer_role in member.roles: await member.remove_roles(backer_role) self.__delete_member_link(guild.id, member.id) text = f"{CONFIG['success_emoji']} Unlinked as Kickstarter backer. Use " \ "`/link` if you change your mind." await interaction.followup.send(text, ephemeral=True) # -- Admin commands ----- kickstarter = Group( name='kickstarter', description='Manages roles for users identified as Kickstarter backers.', guild_only=True, guild_ids=PERMITTED_GUILD_IDS, default_permissions=MOD_PERMISSIONS ) @kickstarter.command( description='Configures which role to give to Kickstarter backers.' ) async def set_role(self, interaction: Interaction, role: Role): self.__set_backer_role(role) text = f"{CONFIG['info_emoji']} Backer role set to {role.name}" await interaction.response.send_message(text, ephemeral=True) @kickstarter.command( description='Checks the database for users to assign the backer role to.' ) async def sync_emails(self, interaction: Interaction): await interaction.response.defer(ephemeral=True, thinking=True) sync_result: _SyncEmailsResult = await self.__sync_by_email(interaction.guild) text = f"{CONFIG['success_emoji']} Sync complete.\n" + \ sync_result.summary_markdown() await interaction.followup.send(text, ephemeral=True) @kickstarter.command( description='Checks the database for users to assign the backer role to.' ) async def sync_usernames(self, interaction: Interaction): await interaction.response.defer(ephemeral=True, thinking=True) sync_result: _SyncUsernamesResult = await self.__sync_by_username(interaction.guild) text = f"{CONFIG['success_emoji']} Sync complete.\n" + \ sync_result.summary_markdown() await interaction.followup.send(text, ephemeral=True) @kickstarter.command( description='DEV TEST - testing timeout' ) async def timeout_test(self, interaction: Interaction): await interaction.response.defer(ephemeral=True, thinking=True) await sleep(300) await interaction.followup.send("5m timer done", ephemeral=True) @kickstarter.command( description='Shows info about Kickstarter linked Discord members.' ) async def info(self, interaction: Interaction): await interaction.response.defer(ephemeral=True, thinking=True) guild = interaction.guild backer_role = await self.__fetch_backer_role(guild) stats: _Stats = self.__fetch_stats(guild.id) lines: list[str] = [] if backer_role is None: lines.append("- No backer role configured yet (use `/kickstarter set_role`)") else: lines.append(f"- Backer role configured as `{backer_role.name}`") if stats.email_count > 0: lines.append(f"- **{stats.email_count}** email addresses imported") lines.append(f"- New emails last imported at ") if stats.complete_link_count + stats.incomplete_link_count > 0: lines.append(f"- **{stats.complete_link_count}** members linked by email") lines.append(f"- **{stats.incomplete_link_count}** members provided emails but weren't linked yet") if stats.username_count > 0: lines.append(f"- **{stats.username_count}** Discord usernames imported") lines.append(f"- New usernames last imported at ") lines.append(f"- **{stats.username_found_count}** usernames linked successfully") lines.append(f"- **{stats.username_not_found_count}** usernames not (yet) linked to members") lines.append(f"- **{stats.username_unprocessed_count}** usernames not yet attempted to sync") text = f"{CONFIG['info_emoji']} Kickstarter import stats\n" text += "\n".join(lines) await interaction.followup.send(text, ephemeral=True) @kickstarter.command( description='Shows Kickstarter link details about a specific Discord member.' ) async def find_member(self, interaction: Interaction, member: Member): await interaction.response.defer(ephemeral=True, thinking=True) guild = interaction.guild try: backer_role = await self.__fetch_backer_role(guild) except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Backer role not yet configured!" await interaction.followup.send(text, ephemeral=True) return if backer_role in member.roles: text = f"{CONFIG['info_emoji']} Member {member.name} has `{backer_role.name}` role." await interaction.followup.send(text, ephemeral=True) return username_record = self.__fetch_kickstarter_username(username=member.name) if username_record is not None: await member.add_roles(backer_role) username_record.lookup_status = _LookupStatus.member_found self.__update_kickstarter_username(username_record) text = f"{CONFIG['success_emoji']} Member's username found in Kickstarter export. Assigned `{backer_role.name}` role." await interaction.followup.send(text, ephemeral=True) return # FIXME: Sorta splitting the difference between email and username model here member_link = self.__fetch_member_link_by_member_id(guild.id, member.id) if member_link is None: text = f"No record found for member {member.mention}. Possible reasons:\n" \ "- They haven't used the `/link` command yet to provide their " \ "Kickstarter email address\n" \ "- They linked using a different Discord account. (You can " \ "search by email with `/find_email`)\n" \ "- They used the `/unlink` after being linked" await interaction.followup.send(text, ephemeral=True) return text = f"Member {member.mention} successfully used the `/link` command" if backer_role in member.roles: text += f", and they already have the {backer_role.name} Discord role. " \ "They should already have access to backer-only areas. If they " \ "still can't see them, some possible reasons:\n" \ "- Its channel group is collapsed in the channel list.\n" \ "- Some channels are hidden. Direct them to the Browse Channels " \ "area in the channel list to see if it's listed and checked visible.\n" \ "- Their client may need to be refreshed. Have them restart Discord " \ "and see if the channel shows up." else: is_backer = self.__is_kickstarter_email_hash(interaction.guild.id, member_link.email_hash) if is_backer: await member.add_roles(backer_role) text += f", and their email address is in the Kickstarter backer list, " \ f"but they didn't have the {backer_role.name} role yet! **This was " \ "just now corrected.** Ask them to check again." else: text += ", but we don't have the address they provided in the " \ "Kickstarter backer list yet. Have we refreshed it recently?" await interaction.followup.send(text, ephemeral=True) @kickstarter.command( description='Shows Kickstarter link details for a given email address.' ) async def find_email(self, interaction: Interaction, email: str): await interaction.response.defer(ephemeral=True, thinking=True) guild = interaction.guild try: backer_role = await self.__fetch_backer_role(guild) except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Backer role not yet configured!" await interaction.followup.send(text, ephemeral=True) return member_link = self.__fetch_member_link_by_email(guild.id, email) is_backer = self.__is_kickstarter_email(guild.id, email) member = guild.get_member(member_link.member_id) or \ await guild.fetch_member(member_link.member_id) \ if member_link is not None else None has_role = member is not None and backer_role in member.roles lines: list[str] = [] if not is_email_address(email): lines.append("- Email address doesn't look valid but searching anyway.") if member_link is not None: if member is not None: lines.append(f"- Server member {member.mention} (username " \ f"{member.username}, id {member.id}) used `/link` with " \ "this address.") if has_role: lines.append(f"- Member has the {backer_role.name} role.") else: lines.append(f"- Member does not have the {backer_role.name} role.") else: lines.append(f"- User <@{member_link.member_id}> (id {member.id}) " \ "used `/link` but they couldn't be retrieved, perhaps because " \ "they left the server.") else: lines.append("- No one has used `/link` with that address yet.") if is_backer: lines.append("- Email address is in latest Kickstarter backer export.") else: lines.append("- Email address is not found in the latest Kickstarter " \ "backer export.") if member is not None and is_backer and not has_role: # Member doesn't have role but should await member.add_roles(backer_role) if member_link is not None: member_link.complete = True self.__update_member_link(member_link) else: member_link = _MemberLink(0, guild.id, member.id, KickstarterCog.__hash_email_address(email), True) self.__create_member_link(member_link) lines.append("- **Fixed:** Member is a backer and has been given " \ f"{backer_role.name} role.") text = f"{CONFIG['info_emoji']} Email search for `{escape_markdown(email)}`\n" + \ ("\n".join(lines)) await interaction.followup.send(text, ephemeral=True) @kickstarter.command( description='Uploads an export of Kickstarter backer email addresses.' ) async def upload_emails(self, interaction: Interaction): await interaction.response.send_modal(_UploadEmailsModal()) @kickstarter.command( description='Uploads an export of backer Discord usernames.' ) async def upload_usernames(self, interaction: Interaction): await interaction.response.send_modal(_UploadUsernamesModal()) async def interaction_check(self, interaction: Interaction) -> bool: if interaction.command is not None: self.__trace(interaction.guild, f"@{interaction.user.name} used /{interaction.command.qualified_name}") return True async def on_email_upload_submit(self, interaction: Interaction, attachment: Attachment): """Callback for email upload modal.""" await interaction.response.defer(ephemeral=True, thinking=True) guild = interaction.guild import_result: _ImportResult = await self.__import_emails(guild, attachment) try: sync_result: _SyncEmailsResult = await self.__sync_by_email(guild) text = f"{CONFIG['success_emoji']} Email import complete.\n" + \ import_result.summary_markdown() + "\n" + \ sync_result.summary_markdown() except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Backer role must be configured " \ "before importing." await interaction.followup.send(text, ephemeral=True) async def on_username_upload_submit(self, interaction: Interaction, attachment: Attachment): """Callback for username upload modal.""" await interaction.response.defer(ephemeral=True, thinking=True) guild = interaction.guild import_result: _ImportResult = await self.__import_usernames(guild, attachment) try: sync_result: _SyncUsernamesResult = await self.__sync_by_username(guild) text = f"{CONFIG['success_emoji']} Username import complete.\n" + \ import_result.summary_markdown() + "\n" + \ sync_result.summary_markdown() except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Backer role must be configured " \ "before importing." await interaction.followup.send(text, ephemeral=True) async def on_link_email_submit(self, interaction: Interaction, email: str): """Callback for link modal.""" await interaction.response.defer(ephemeral=True, thinking=True) try: result: _LinkResult = await self.__link_member(interaction.guild, interaction.user, email) except _NoBackerRoleException: text = f"{CONFIG['failure_emoji']} Oops! The server admin hasn't setup " \ "this feature just yet. Check back later." await interaction.followup.send(text, ephemeral=True) return if result.status == _LinkResultStatus.malformed_address: text = f"{CONFIG['failure_emoji']} That email address doesn't look " \ "like the right format.\n" \ f"```\n{escape_markdown(email)}\n```\n" \ "It should look something like:\n" \ "```\nyourname@server.com\n```\n" \ "If you need help, please contact a moderator." elif result.status == _LinkResultStatus.already_have_role: text = f"{CONFIG['info_emoji']} You already have the backer role " \ "and should have access to backer-only areas. If you're still " \ "having problems or have questions, please contact a moderator." elif result.status == _LinkResultStatus.linked_to_other_account: text = f"{CONFIG['failure_emoji']} That email address is already " \ "linked to a different Discord member. Did you link with " \ "an alternate Discord account? Contact a mod if you need help." elif result.status in ( _LinkResultStatus.link_pending_new, _LinkResultStatus.link_pending_dupe, _LinkResultStatus.link_pending_updated ): if result.status == _LinkResultStatus.link_pending_new: text = f"{CONFIG['info_emoji']} Email address linked!\n\n" else: text = f"{CONFIG['info_emoji']} Email address updated!\n\n" text += "We just need to confirm the address is on the backer list, " \ "and then you'll be given access to the backers-only areas of " \ "the server. In the meantime, double check that the email you " \ "gave us is the same one you used to back the project on " \ "Kickstarter:\n" \ "\n" \ f"> `{escape_markdown(email)}`\n" \ "\n" \ "If you need to make a correction, just use `/link` again to update it." elif result.status == _LinkResultStatus.link_success: text = f"{CONFIG['success_emoji']} Success! You should now have access to " \ "backer-only areas! Thanks for your support!" await interaction.followup.send(text, ephemeral=True) async def __link_member(self, guild: Guild, member: Member, email: str) -> '_LinkResult': if not is_email_address(email.strip()): return _LinkResult(_LinkResultStatus.malformed_address) input_email_hash = KickstarterCog.__hash_email_address(email) backer_role: Role = await self.__fetch_backer_role(guild) if backer_role in member.roles: return _LinkResult(_LinkResultStatus.already_have_role) existing_email_link = self.__fetch_member_link_by_email(guild.id, email) if existing_email_link is not None and existing_email_link.member_id != member.id: return _LinkResult(_LinkResultStatus.linked_to_other_account) member_link = self.__fetch_member_link_by_member_id(guild.id, member.id) if self.__is_kickstarter_email(guild.id, email): await member.add_roles(backer_role) if member_link is not None: member_link.email_hash = input_email_hash member_link.complete = True self.__update_member_link(member_link) else: member_link = _MemberLink(0, guild.id, member.id, input_email_hash, True) self.__create_member_link(member_link) return _LinkResult(_LinkResultStatus.link_success) if member_link is not None: if member_link.email_hash == input_email_hash: return _LinkResult(_LinkResultStatus.link_pending_dupe) member_link.email_hash = input_email_hash self.__update_member_link(member_link) return _LinkResult(_LinkResultStatus.link_pending_updated) member_link = _MemberLink(0, guild.id, member.id, input_email_hash, False) self.__create_member_link(member_link) return _LinkResult(_LinkResultStatus.link_pending_new) async def __import_emails(self, guild: Guild, attachment: Attachment) -> '_ImportResult': """Imports email addresses from an upload attachment.""" self.__trace(guild, f"Download - start - {attachment.filename} " \ f"({attachment.size} bytes, {attachment.content_type})") file_bytes = await attachment.read() file_str = file_bytes.decode('utf-8') self.__trace(guild, "Download - complete") self.__trace(guild, "Parse - start") lines = file_str.splitlines(keepends=False) addresses = [] malformed_count = 0 for line in lines: line = line.strip() if (line.startswith('"') and line.endswith('"')) or \ (line.startswith("'") and line.endswith("'")): # Remove quotes line = line[1:-1].strip() if line == '': continue if is_email_address(line): addresses.append(line) else: self.__trace(guild, f"Malformed email address {line}") malformed_count += 1 self.__trace(guild, f"Parse - complete - {len(lines)} lines, {len(addresses)} " \ f"valid addresses, {malformed_count} malformed addresses") self.__trace(guild, f"Storing - start - {len(addresses)} addresses") new_address_count = self.__store_kickstarter_emails(guild.id, addresses) self.__trace(guild, f"Storing - complete - {new_address_count} unique " \ "addresses stored") return _ImportResult( attachment.filename, attachment.size, len(lines), len(addresses), malformed_count, new_address_count ) async def __import_usernames(self, guild: Guild, attachment: Attachment) -> '_ImportResult': """Imports Discord usernames from an upload attachment.""" self.__trace(guild, f"Download - start - {attachment.filename} " \ f"({attachment.size} bytes, {attachment.content_type})") file_bytes = await attachment.read() file_str = file_bytes.decode('utf-8') self.__trace(guild, "Download - complete") self.__trace(guild, "Parse - start") lines = file_str.splitlines(keepends=False) usernames = [] malformed_count = 0 for line in lines: line = self.__normalize_discord_username(line) if (line.startswith('"') and line.endswith('"')) or \ (line.startswith("'") and line.endswith("'")): # Remove quotes line = line[1:-1].strip() if line == '': continue if is_discord_username(line): usernames.append(line) else: self.__trace(guild, f"Malformed Discord username \"{line}\"") malformed_count += 1 self.__trace(guild, f"Parse - complete - {len(lines)} lines, {len(usernames)} " \ f"valid usernames, {malformed_count} malformed usernames") self.__trace(guild, f"Storing - start - {len(usernames)} usernames") new_username_count = self.__store_kickstarter_usernames(guild.id, usernames) self.__trace(guild, f"Storing - complete - {new_username_count} unique " \ "usernames stored") return _ImportResult( attachment.filename, attachment.size, len(lines), len(usernames), malformed_count, new_username_count ) async def __sync_by_email(self, guild: Guild) -> '_SyncEmailsResult': """Looks for members to assign the backer role from Kickstarter imports.""" backer_role = await self.__fetch_backer_role(guild) incomplete_links: list[_MemberLink] = self.__fetch_incomplete_member_links(guild.id) complete_count = 0 incomplete_count = 0 member_not_found_count = 0 async def link_loop_handler(link: _MemberLink): nonlocal member_not_found_count nonlocal complete_count nonlocal incomplete_count if not self.__is_kickstarter_email_hash(guild.id, link.email_hash): incomplete_count += 1 return member = guild.get_member(link.member_id) or \ await guild.fetch_member(link.member_id) if member is None: member_not_found_count += 1 else: if backer_role not in member.roles: await member.add_roles(backer_role) link.complete = True self.__update_member_link(link) complete_count += 1 failure_count = await self.__throttled_loop(guild, incomplete_links, link_loop_handler) return _SyncEmailsResult(complete_count, incomplete_count, failure_count, member_not_found_count) async def __sync_by_username(self, guild: Guild) -> '_SyncUsernamesResult': backer_role = await self.__fetch_backer_role(guild) complete_count = 0 not_found_count = 0 usernames: list[_KickstarterDiscordUser] = \ self.__fetch_incomplete_kickstarter_usernames(guild.id, _LookupStatus.username_not_found) username_to_member_id: dict[str, int] = {} if self.bot.intents.members: async for member in guild.fetch_members(limit=None): username_to_member_id[member.name] = member.id async def username_loop_handler(username: _KickstarterDiscordUser): nonlocal complete_count nonlocal not_found_count member_id = username_to_member_id.get(username.discord_username) if member_id is not None: member = guild.get_member(member_id) or \ await guild.fetch_member(member_id) else: member = guild.get_member_named(username.discord_username) if member is None: not_found_count += 1 if username.lookup_status != _LookupStatus.username_not_found: username.lookup_status = _LookupStatus.username_not_found self.__update_kickstarter_username(username) return username.lookup_status = _LookupStatus.member_found username.discord_member_id = member.id self.__update_kickstarter_username(username) if backer_role not in member.roles: await member.add_roles(backer_role) complete_count += 1 failure_count = await self.__throttled_loop(guild, usernames, username_loop_handler) return _SyncUsernamesResult(complete_count, not_found_count, failure_count) async def __throttled_loop(self, guild: Guild, iter: Iterable, callback) -> int: """Iterates a loop with automatically adjusting sleeps based on throttling exceptions.""" failure_count = 0 sleep_length = 0.0 for elem in iter: complete = False for _ in range(5): try: await sleep(sleep_length) await callback(elem) complete = True break except HTTPException as ex: if ex.status == 429: # rate limited retry_header_value = ex.response.headers.get('retry_after') retry_after_millis = float(retry_header_value or '1000') self.__trace(guild, "Rate limited while processing. " \ f"retry_after={retry_header_value}") await sleep(retry_after_millis / 1000.0) sleep_length = 1.0 if sleep_length == 0.0 else sleep_length * 2.0 self.__trace(guild, f"Sleep increased to {sleep_length}s due to rate limiting") else: dump_stacktrace(ex) except BaseException as ex: dump_stacktrace(ex) if not complete: failure_count += 1 return failure_count # -- Database functions ----- def __store_kickstarter_email(self, record: '_KickstarterEmail'): cur = self.con.cursor() cur.execute(""" INSERT OR IGNORE INTO kickstarter_emails ( guild_id, email_hash, imported_at ) VALUES ( :guild_id, :email_hash, :imported_at ) """, { 'guild_id': record.guild_id, 'email_hash': record.email_hash, 'imported_at': record.imported_at }) row_id = cur.lastrowid self.con.commit() cur.close() if row_id is not None and row_id != 0: record.pk = row_id def __store_kickstarter_emails(self, guild_id: int, email_addresses: list[str]) -> int: imported_at: int = int(now_timestamp()) cur = self.con.cursor() for email_address in email_addresses: email_hash = KickstarterCog.__hash_email_address(email_address) cur.execute(""" INSERT OR IGNORE INTO kickstarter_emails ( guild_id, email_hash, imported_at ) VALUES ( :guild_id, :email_hash, :imported_at ) """, { 'guild_id': guild_id, 'email_hash': email_hash, 'imported_at': imported_at }) cur.execute(""" SELECT COUNT(1) FROM kickstarter_emails WHERE imported_at = ? """, (imported_at, )) imported_count = cur.fetchone()[0] self.con.commit() cur.close() return imported_count def __is_kickstarter_email(self, guild_id: int, email_address: str) -> bool: return self.__is_kickstarter_email_hash( guild_id, KickstarterCog.__hash_email_address(email_address)) def __is_kickstarter_email_hash(self, guild_id: int, email_hash: str) -> bool: cur = self.con.cursor() cur.execute(""" SELECT COUNT(1) FROM kickstarter_emails WHERE guild_id = :guild_id AND email_hash = :email_hash """, { 'guild_id': guild_id, 'email_hash': email_hash }) count: int = cur.fetchone()[0] cur.close() return count > 0 def __store_kickstarter_username(self, record: '_KickstarterDiscordUser'): cur = self.con.cursor() cur.execute(""" INSERT OR IGNORE INTO kickstarter_discord_users ( guild_id, discord_username, discord_member_id, lookup_status, imported_at ) VALUES ( :guild_id, :discord_username, :discord_member_id, :lookup_status, :imported_at ) """, { 'guild_id': record.guild_id, 'discord_username': self.__normalize_discord_username(record.discord_username), 'discord_member_id': record.discord_member_id, 'lookup_status': record.lookup_status, 'imported_at': record.imported_at }) row_id = cur.lastrowid self.con.commit() cur.close() if row_id is not None and row_id != 0: record.pk = row_id def __store_kickstarter_usernames(self, guild_id: int, usernames: list[str]) -> int: imported_at: int = int(now_timestamp()) cur = self.con.cursor() for username in usernames: cur.execute(""" INSERT OR IGNORE INTO kickstarter_discord_users ( guild_id, discord_username, imported_at ) VALUES ( :guild_id, :discord_username, :imported_at ) """, { 'guild_id': guild_id, 'discord_username': self.__normalize_discord_username(username), 'imported_at': imported_at }) cur.execute(""" SELECT COUNT(1) FROM kickstarter_discord_users WHERE imported_at = ? """, (imported_at, )) imported_count = cur.fetchone()[0] self.con.commit() cur.close() return imported_count def __fetch_kickstarter_username(self, member_id: Optional[int] = None, username: Optional[str] = None) -> Optional['_KickstarterDiscordUser']: """Fetches an imported Discord username by EITHER member id or username (must provide exactly one)""" cur = self.con.cursor() cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r) if member_id is not None: cur.execute(""" SELECT * FROM kickstarter_discord_users WHERE discord_member_id = :member_id """, { 'member_id': member_id }) elif username is not None: cur.execute(""" SELECT * FROM kickstarter_discord_users WHERE discord_username = :username """, { 'username': username }) ret_val = cur.fetchone() cur.close() return ret_val def __fetch_incomplete_kickstarter_usernames(self, guild_id: int, max_status: '_LookupStatus') -> list['_KickstarterDiscordUser']: cur = self.con.cursor() cur.row_factory = lambda c, r: _KickstarterDiscordUser(*r) cur.execute(""" SELECT * FROM kickstarter_discord_users WHERE guild_id = :guild_id AND lookup_status <= :lookup_status """, { 'guild_id': guild_id, 'lookup_status': max_status }) ret_val = cur.fetchall() cur.close() return ret_val def __update_kickstarter_username(self, user: '_KickstarterDiscordUser'): cur = self.con.cursor() cur.execute(""" UPDATE kickstarter_discord_users SET discord_member_id = :discord_member_id, lookup_status = :lookup_status WHERE pk = :pk """, { 'discord_member_id': user.discord_member_id, 'lookup_status': user.lookup_status, 'pk': user.pk }) self.con.commit() cur.close() def __create_member_link(self, record: '_MemberLink'): cur = self.con.cursor() cur.execute(""" INSERT OR IGNORE INTO member_links ( guild_id, member_id, email_hash, complete ) VALUES ( :guild_id, :member_id, :email_hash, :complete ) """, { 'guild_id': record.guild_id, 'member_id': record.member_id, 'email_hash': record.email_hash, 'complete': record.complete }) row_id = cur.lastrowid if row_id is not None and row_id != 0: record.pk = row_id self.con.commit() cur.close() def __fetch_member_link_by_member_id(self, guild_id: int, member_id: int) -> Optional['_MemberLink']: cur = self.con.cursor() cur.row_factory = lambda c, r: _MemberLink(*r) cur.execute(""" SELECT * FROM member_links WHERE guild_id = :guild_id AND member_id = :member_id """, (guild_id, member_id, )) ret_val = cur.fetchone() cur.close() return ret_val def __fetch_member_link_by_email(self, guild_id: int, email_address: str) -> Optional['_MemberLink']: cur = self.con.cursor() cur.row_factory = lambda c, r: _MemberLink(*r) email_hash = KickstarterCog.__hash_email_address(email_address) cur.execute(""" SELECT * FROM member_links WHERE guild_id = :guild_id AND email_hash = :email_hash """, { 'guild_id': guild_id, 'email_hash': email_hash }) ret_val = cur.fetchone() cur.close() return ret_val def __delete_member_link(self, guild_id: int, member_id: int): cur = self.con.cursor() cur.execute(""" DELETE FROM member_links WHERE guild_id = :guild_id AND member_id = :member_id """, { 'guild_id': guild_id, 'member_id': member_id }) cur.close() def __update_member_link(self, member_link: '_MemberLink'): cur = self.con.cursor() cur.execute(""" UPDATE member_links SET email_hash = :email_hash, complete = :complete WHERE guild_id = :guild_id AND member_id = :member_id """, { 'email_hash': member_link.email_hash, 'complete': 1 if member_link.complete else 0, 'guild_id': member_link.guild_id, 'member_id': member_link.member_id }) self.con.commit() cur.close() def __fetch_incomplete_member_links(self, guild_id: int) -> list['_MemberLink']: cur = self.con.cursor() cur.row_factory = lambda c, r: _MemberLink(*r) cur.execute(""" SELECT * FROM member_links WHERE guild_id = ? AND complete = 0 """, (guild_id, )) ret_val = cur.fetchall() cur.close() return ret_val def __fetch_stats(self, guild_id: int) -> '_Stats': """Returns a tuple with the number of completed links, incomplete links, and imported Kickstarter emails.""" cur = self.con.cursor() cur.execute(""" SELECT ( SELECT COUNT(1) FROM member_links WHERE complete = 1 AND guild_id = :guild_id ) AS complete, ( SELECT COUNT(1) FROM member_links WHERE complete = 0 AND guild_id = :guild_id ) AS incomplete """, { 'guild_id': guild_id }) ( complete_link_count, incomplete_link_count ) = cur.fetchone() cur.execute(""" SELECT COUNT(1), MAX(imported_at) FROM kickstarter_emails """) ( email_count, email_last_imported_at ) = cur.fetchone() cur.execute(""" SELECT COUNT(1) AS total, SUM(IIF(lookup_status = 0, 1, 0)) AS unprocessed_count, SUM(IIF(lookup_status = 1, 1, 0)) AS not_found_count, SUM(IIF(lookup_status = 2, 1, 0)) AS found_count, MAX(imported_at) AS last_import FROM kickstarter_discord_users """) ( username_count, username_unprocessed_count, username_not_found_count, username_found_count, username_last_imported_at ) = cur.fetchone() cur.close() return _Stats( complete_link_count, incomplete_link_count, email_count, email_last_imported_at, username_count, username_unprocessed_count, username_not_found_count, username_found_count, username_last_imported_at ) # -- Utils ----- def __trace(self, guild: Guild, message: str): self.log(guild, message) @staticmethod def __normalize_email_address(email_address: str) -> str: """Normalizes an email address for consistent equivalence tests.""" return email_address.strip().lower() @staticmethod def __hash_email_address(email_address: str) -> str: """Returns a hash string of the given email address. The address is first normalized as lowercase and trimmed of whitespace before hashing.""" m = hashlib.sha256() to_hash = KickstarterCog.__normalize_email_address(email_address) m.update(to_hash.encode('utf-8')) digest_bytes = m.digest() return base64.urlsafe_b64encode(digest_bytes).decode('utf-8') @staticmethod def __normalize_discord_username(username: str) -> str: norm = username.lower().strip() if norm.startswith('@'): norm = norm[1:] return norm class _KickstarterEmail: """kickstarter_email table row""" def __init__(self, pk: int, guild_id: int, email_hash: str, imported_at: int ): self.pk: str = pk self.guild_id: int = guild_id self.email_hash: str = email_hash self.imported_at: int = imported_at class _LookupStatus(IntEnum): # Username imported but has not been looked up yet unprocessed = 0 # Username was searched for in guild but not found (user might not have joined yet) username_not_found = 1 # Username found in guild and member id stored member_found = 2 class _KickstarterDiscordUser: """kickstarter_discord_users table row""" def __init__(self, pk: int, guild_id: int, discord_username: str, discord_member_id: Optional[int], lookup_status: _LookupStatus, imported_at: int ): self.pk: int = pk self.guild_id: int = guild_id self.discord_username: str = discord_username self.discord_member_id: Optional[int] = discord_member_id self.lookup_status: _LookupStatus = lookup_status self.imported_at: int = imported_at class _MemberLink: """member_link table row""" def __init__(self, pk: int, guild_id: int, member_id: int, email_hash: str, complete: bool = False ): self.pk: int = pk self.guild_id: int = guild_id self.member_id: int = member_id self.email_hash: str = email_hash self.complete: bool = complete class _Stats: def __init__(self, complete_link_count: int, incomplete_link_count: int, email_count: int, email_last_imported_at: int, username_count: int, username_unprocessed_count: int, username_not_found_count: int, username_found_count: int, username_last_imported_at: int ): self.complete_link_count: int = complete_link_count self.incomplete_link_count: int = incomplete_link_count self.email_count: int = email_count self.email_last_imported_at: int = email_last_imported_at self.username_count: int = username_count self.username_unprocessed_count: int = username_unprocessed_count self.username_not_found_count: int = username_not_found_count self.username_found_count: int = username_found_count self.username_last_imported_at: int = username_last_imported_at class _NoBackerRoleException(BaseException): pass class _LinkResultStatus(IntEnum): # Email address is invalid malformed_address = 1 # User already has backer role, doesn't need to link again already_have_role = 2 # Email address associated with a different Discord account linked_to_other_account = 3 # Can't link right now but email linked to Discord account link_pending_new = 4 # Linked same email address to same Discord account (no action taken) link_pending_dupe = 5 # Replaced email address linked to this Discord account link_pending_updated = 6 # Link successful and backer role given link_success = 99 class _LinkResult(): def __init__(self, status: _LinkResultStatus ): self.status: _LinkResultStatus = status class _ImportResult(): def __init__(self, filename: str, file_bytes: int, line_count: int, valid_record_count: int, malformed_record_count: int, new_record_count: int ): self.filename: str = filename self.file_bytes: int = file_bytes self.line_count: int = line_count self.valid_record_count: int = valid_record_count self.malformed_record_count: int = malformed_record_count self.new_record_count: int = new_record_count def summary_markdown(self) -> str: lines: list[str] = [] if self.valid_record_count > 0: lines.append(f"- Read {self.valid_record_count} valid records") else: lines.append("- Upload contained **no valid records**") if self.malformed_record_count > 0: lines.append(f"- Read **{self.malformed_record_count} malformed records**") if self.new_record_count > 0: lines.append(f"- Imported {self.new_record_count} new unique records") else: lines.append("- No new unique records (all previously imported)") return "\n".join(lines) class _SyncEmailsResult(): def __init__(self, complete_count: int, incomplete_count: int, failure_count: int, member_not_found_count: int ): self.complete_count: int = complete_count self.incomplete_count: int = incomplete_count self.failure_count: int = failure_count self.member_not_found_count: int = member_not_found_count def summary_markdown(self) -> str: lines: list[str] = [] if self.complete_count > 0: lines.append(f"- Gave backer role to {self.complete_count} new members") else: lines.append("- No new members found") if self.incomplete_count > 0: lines.append(f"- {self.incomplete_count} members provided an email " "address to the bot but haven't been found in the Kickstarter " "imports yet") if self.member_not_found_count > 0: lines.append(f"- {self.member_not_found_count} users provided an email " "address but couldn't be found (likely left the server). They'll " "be handled on next sync if they return.") if self.failure_count > 0: lines.append(f"- Failed to link {self.failure_count} members") return "\n".join(lines) class _SyncUsernamesResult(): def __init__(self, complete_count: int, not_found_count: int, failure_count: int ): self.complete_count: int = complete_count self.not_found_count: int = not_found_count self.failure_count: int = failure_count def summary_markdown(self) -> str: lines: list[str] = [] if self.complete_count > 0: lines.append(f"- Gave backer role to {self.complete_count} new members") else: lines.append("- No new members found") if self.not_found_count > 0: lines.append(f"- {self.not_found_count} members could not be found " \ "by the provided username") if self.failure_count > 0: lines.append(f"- Failed to link {self.failure_count} members") return "\n".join(lines) class _LinkModal(Modal): text_display = TextDisplay( "Gain access to backer-only areas of the server by linking your " \ "Kickstarter email address.\n" \ "-# This is only used to verify you are on the backer list. We won't " \ "email you or ask for your Kickstarter password!" ) email_label = Label( text='Kickstarter email', # description='Enter the email address associated with your Kickstarter account.', component=TextInput( style=TextStyle.short, placeholder='name@example.com', min_length=6, max_length=100 ) ) def __init__(self): super().__init__(title='Link Kickstarter Address', timeout=None) async def on_submit(self, interaction: Interaction) -> None: # noinspection PyTypeChecker email_input: TextInput = self.email_label.component await KickstarterCog.shared.on_link_email_submit(interaction, email_input.value) async def on_error(self, interaction: Interaction, error: Exception) -> None: dump_stacktrace(error) try: await interaction.response.send_message( f'{CONFIG["failure_emoji"]} Something went wrong. Try using `/link` again.', ephemeral=True, ) except BaseException: pass class _UploadEmailsModal(Modal): upload_label = Label( text='Kickstarter backer email export', description='Upload a plain text file containing one backer email address per line.', component=FileUpload( required=True, min_values=1, max_values=1 ) ) def __init__(self): super().__init__(title='Upload Emails', timeout=None) async def on_submit(self, interaction: Interaction) -> None: # noinspection PyTypeChecker upload_input: FileUpload = self.upload_label.component if len(upload_input.values) < 1: text = f"{CONFIG['failure_emoji']} No export file included" await interaction.response.send(text, ephemeral=True) return attachment = upload_input.values[0] await KickstarterCog.shared.on_email_upload_submit(interaction, attachment) async def on_error(self, interaction: Interaction, error: Exception) -> None: dump_stacktrace(error) try: await interaction.response.send_message( f'{CONFIG["failure_emoji"]} Upload failed :(', ephemeral=True, ) except BaseException: pass class _UploadUsernamesModal(Modal): upload_label = Label( text='Discord user export', description='Upload a plain text file containing one backer Discord username per line.', component=FileUpload( required=True, min_values=1, max_values=1 ) ) def __init__(self): super().__init__(title='Upload Discord Usernames', timeout=None) async def on_submit(self, interaction: Interaction) -> None: # noinspection PyTypeChecker upload_input: FileUpload = self.upload_label.component if len(upload_input.values) < 1: text = f"{CONFIG['failure_emoji']} No export file included" await interaction.response.send(text, ephemeral=True) return attachment = upload_input.values[0] await KickstarterCog.shared.on_username_upload_submit(interaction, attachment) async def on_error(self, interaction: Interaction, error: Exception) -> None: dump_stacktrace(error) try: await interaction.response.send_message( f'{CONFIG["failure_emoji"]} Upload failed :(', ephemeral=True, ) except BaseException: pass