| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339 |
- 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 <t:{stats.email_last_imported_at}:f>")
- 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 <t:{stats.username_last_imported_at}:f>")
- 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
|