diff --git a/cogs/economy_new/scouting.py b/cogs/economy_new/scouting.py new file mode 100644 index 0000000..9e895e5 --- /dev/null +++ b/cogs/economy_new/scouting.py @@ -0,0 +1,104 @@ +""" +Scouting Cog — Scout token management and expired opportunity cleanup. +""" + +import datetime +import logging + +import discord +from discord import app_commands +from discord.ext import commands, tasks + +from api_calls import db_get +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.main import get_team_by_owner +from helpers.constants import PD_SEASON, IMAGES + +logger = logging.getLogger("discord_app") + +SCOUT_TOKENS_PER_DAY = 2 + + +class Scouting(commands.Cog): + """Scout token tracking and expired opportunity cleanup.""" + + def __init__(self, bot): + self.bot = bot + self.cleanup_expired.start() + + async def cog_unload(self): + self.cleanup_expired.cancel() + + @app_commands.command( + name="scout-tokens", + description="Check how many scout tokens you have left today", + ) + async def scout_tokens_command(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + team = await get_team_by_owner(interaction.user.id) + if not team: + await interaction.followup.send( + "You need a Paper Dynasty team first!", + ephemeral=True, + ) + return + + now = datetime.datetime.now() + midnight = int_timestamp( + datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + ) + + used_today = await db_get( + "rewards", + params=[ + ("name", "Scout Token"), + ("team_id", team["id"]), + ("created_after", midnight), + ], + ) + tokens_used = used_today["count"] if used_today else 0 + tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used) + + embed = get_team_embed(title="Scout Tokens", team=team) + embed.description = ( + f"**{tokens_remaining}** of **{SCOUT_TOKENS_PER_DAY}** tokens remaining today.\n\n" + f"Tokens reset at midnight Central." + ) + + if tokens_remaining == 0: + embed.description += "\n\nYou've used all your tokens! Check back tomorrow." + + await interaction.followup.send(embed=embed, ephemeral=True) + + @tasks.loop(minutes=15) + async def cleanup_expired(self): + """Log expired unclaimed scout opportunities. + + This is a safety net — the ScoutView's on_timeout handles the UI side. + If the bot restarted mid-scout, those views are lost; this just logs it. + """ + try: + now = int_timestamp(datetime.datetime.now()) + expired = await db_get( + "scout_opportunities", + params=[ + ("claimed", False), + ("expired_before", now), + ], + ) + if expired and expired.get("count", 0) > 0: + logger.info( + f"Found {expired['count']} expired unclaimed scout opportunities" + ) + except Exception as e: + logger.debug(f"Scout cleanup check failed (API may not be ready): {e}") + + @cleanup_expired.before_loop + async def before_cleanup(self): + await self.bot.wait_until_ready() + + +async def setup(bot): + await bot.add_cog(Scouting(bot)) diff --git a/discord_ui/__init__.py b/discord_ui/__init__.py index 6e40231..88a3f9d 100644 --- a/discord_ui/__init__.py +++ b/discord_ui/__init__.py @@ -7,17 +7,32 @@ This package contains all Discord UI classes and components used throughout the from .confirmations import Question, Confirm, ButtonOptions from .pagination import Pagination from .selectors import ( - SelectChoicePackTeam, SelectOpenPack, SelectPaperdexCardset, - SelectPaperdexTeam, SelectBuyPacksCardset, SelectBuyPacksTeam, - SelectUpdatePlayerTeam, SelectView + SelectChoicePackTeam, + SelectOpenPack, + SelectPaperdexCardset, + SelectPaperdexTeam, + SelectBuyPacksCardset, + SelectBuyPacksTeam, + SelectUpdatePlayerTeam, + SelectView, ) from .dropdowns import Dropdown, DropdownView +from .scout_view import ScoutView __all__ = [ - 'Question', 'Confirm', 'ButtonOptions', - 'Pagination', - 'SelectChoicePackTeam', 'SelectOpenPack', 'SelectPaperdexCardset', - 'SelectPaperdexTeam', 'SelectBuyPacksCardset', 'SelectBuyPacksTeam', - 'SelectUpdatePlayerTeam', 'SelectView', - 'Dropdown', 'DropdownView' -] \ No newline at end of file + "Question", + "Confirm", + "ButtonOptions", + "Pagination", + "SelectChoicePackTeam", + "SelectOpenPack", + "SelectPaperdexCardset", + "SelectPaperdexTeam", + "SelectBuyPacksCardset", + "SelectBuyPacksTeam", + "SelectUpdatePlayerTeam", + "SelectView", + "Dropdown", + "DropdownView", + "ScoutView", +] diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py new file mode 100644 index 0000000..2e7dfb8 --- /dev/null +++ b/discord_ui/scout_view.py @@ -0,0 +1,339 @@ +""" +Scout View — Face-down card button UI for the Scouting feature. + +When a player opens a pack, a ScoutView is posted with one button per card. +Other players can click a button to "scout" (blind-pick) one card, receiving +a copy. The opener keeps all their cards. Multiple players can scout different +cards from the same pack — each costs one scout token. +""" + +import datetime +import logging + +import discord + +from api_calls import db_get, db_post, db_patch +from helpers.main import get_team_by_owner, get_card_embeds +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.constants import IMAGES, PD_SEASON + +logger = logging.getLogger("discord_app") + +SCOUT_TOKENS_PER_DAY = 2 + + +class ScoutView(discord.ui.View): + """Displays face-down card buttons for a scout opportunity. + + - One button per card, labeled "Card 1" ... "Card N" + - Any player EXCEPT the pack opener can interact + - Each card can be scouted once; multiple players can scout different cards + - One scout per player per pack + - Timeout: 30 minutes + """ + + def __init__( + self, + scout_opp_id: int, + cards: list[dict], + opener_team: dict, + opener_user_id: int, + bot, + ): + super().__init__(timeout=1800.0) + self.scout_opp_id = scout_opp_id + self.cards = cards + self.opener_team = opener_team + self.opener_user_id = opener_user_id + self.bot = bot + self.message: discord.Message | None = None + self.card_lines: list[tuple[int, str]] = [] + + # Per-card claim tracking: position -> scouter team name + self.claimed_positions: dict[int, str] = {} + # Per-user lock: user IDs who have already scouted this pack + self.scouted_users: set[int] = set() + # Positions currently being processed (prevent double-click race) + self.processing: set[int] = set() + + for i, card in enumerate(cards): + button = ScoutButton( + card=card, + position=i, + scout_view=self, + ) + self.add_item(button) + + @property + def all_claimed(self) -> bool: + return len(self.claimed_positions) >= len(self.cards) + + async def update_message(self): + """Refresh the embed with current claim state.""" + if not self.message: + return + + from helpers.scouting import build_scouted_card_list + + scouted_ids = {} + for pos, team_name in self.claimed_positions.items(): + player_id = self.cards[pos]["player"]["player_id"] + scouted_ids[player_id] = team_name + + card_list = build_scouted_card_list(self.card_lines, scouted_ids) + claim_count = len(self.claimed_positions) + + if self.all_claimed: + title = "Fully Scouted!" + footer_text = f"Paper Dynasty Season {PD_SEASON} \u2022 All cards scouted" + else: + title = f"Scout Opportunity! ({claim_count}/{len(self.cards)} scouted)" + footer_text = ( + f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player" + ) + + embed = get_team_embed(title=title, team=self.opener_team) + embed.description = ( + f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}\n\n" + ) + if not self.all_claimed: + embed.description += ( + "Pick a card — but which is which?\n" + "Costs 1 Scout Token (2 per day, resets at midnight Central)." + ) + + embed.set_footer(text=footer_text, icon_url=IMAGES["logo"]) + + try: + await self.message.edit(embed=embed, view=self) + except Exception as e: + logger.error(f"Failed to update scout message: {e}") + + async def on_timeout(self): + """Disable all buttons and update the embed when the window expires.""" + for item in self.children: + item.disabled = True + + if self.message: + try: + from helpers.scouting import build_scouted_card_list + + scouted_ids = {} + for pos, team_name in self.claimed_positions.items(): + player_id = self.cards[pos]["player"]["player_id"] + scouted_ids[player_id] = team_name + + card_list = build_scouted_card_list(self.card_lines, scouted_ids) + claim_count = len(self.claimed_positions) + + if claim_count > 0: + title = ( + f"Scout Window Closed ({claim_count}/{len(self.cards)} scouted)" + ) + else: + title = "Scout Window Closed" + + embed = get_team_embed(title=title, team=self.opener_team) + embed.description = ( + f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}" + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", + icon_url=IMAGES["logo"], + ) + await self.message.edit(embed=embed, view=self) + except Exception as e: + logger.error(f"Failed to edit expired scout message: {e}") + + +class ScoutButton(discord.ui.Button): + """A single face-down card button in a ScoutView.""" + + def __init__(self, card: dict, position: int, scout_view: ScoutView): + super().__init__( + label=f"Card {position + 1}", + style=discord.ButtonStyle.secondary, + row=0, + ) + self.card = card + self.position = position + self.scout_view: ScoutView = scout_view + + async def callback(self, interaction: discord.Interaction): + view = self.scout_view + + # Block the opener + if interaction.user.id == view.opener_user_id: + await interaction.response.send_message( + "You can't scout your own pack!", + ephemeral=True, + ) + return + + # One scout per player per pack + if interaction.user.id in view.scouted_users: + await interaction.response.send_message( + "You already scouted a card from this pack!", + ephemeral=True, + ) + return + + # This card already taken + if self.position in view.claimed_positions: + await interaction.response.send_message( + "This card was already scouted! Try a different one.", + ephemeral=True, + ) + return + + # Prevent double-click race on same card + if self.position in view.processing: + await interaction.response.send_message( + "Hold on, someone's claiming this card right now...", + ephemeral=True, + ) + return + + view.processing.add(self.position) + await interaction.response.defer(ephemeral=True) + + try: + # Get scouting player's team + scouter_team = await get_team_by_owner(interaction.user.id) + if not scouter_team: + await interaction.followup.send( + "You need a Paper Dynasty team to scout! Ask an admin to set one up.", + ephemeral=True, + ) + return + + # Check scout token balance + now = datetime.datetime.now() + midnight = int_timestamp( + datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + ) + used_today = await db_get( + "rewards", + params=[ + ("name", "Scout Token"), + ("team_id", scouter_team["id"]), + ("created_after", midnight), + ], + ) + tokens_used = used_today["count"] if used_today else 0 + + if tokens_used >= SCOUT_TOKENS_PER_DAY: + await interaction.followup.send( + "You're out of scout tokens for today! You get 2 per day, resetting at midnight Central.", + ephemeral=True, + ) + return + + # Record the claim in the database + try: + await db_post( + "scout_claims", + payload={ + "scout_opportunity_id": view.scout_opp_id, + "card_id": self.card["id"], + "claimed_by_team_id": scouter_team["id"], + }, + ) + except Exception as e: + logger.error(f"Failed to record scout claim: {e}") + await interaction.followup.send( + "Something went wrong claiming this scout. Try again!", + ephemeral=True, + ) + return + + # Consume a scout token + current = await db_get("current") + await db_post( + "rewards", + payload={ + "name": "Scout Token", + "team_id": scouter_team["id"], + "season": current["season"] if current else PD_SEASON, + "week": current["week"] if current else 1, + "created": int_timestamp(now), + }, + ) + + # Create a copy of the card for the scouter + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": self.card["player"]["player_id"], + "team_id": scouter_team["id"], + } + ], + }, + ) + + # Track the claim + view.claimed_positions[self.position] = scouter_team["lname"] + view.scouted_users.add(interaction.user.id) + + # Update this button + self.disabled = True + self.style = discord.ButtonStyle.success + self.label = "Scouted!" + + # If all cards claimed, disable remaining buttons and stop + if view.all_claimed: + for item in view.children: + item.disabled = True + view.stop() + + # Update the shared embed + await view.update_message() + + # Send the scouter their card details (ephemeral) + player_name = self.card["player"]["p_name"] + rarity_name = self.card["player"]["rarity"]["name"] + + card_for_embed = { + "player": self.card["player"], + "team": scouter_team, + } + card_embeds = await get_card_embeds(card_for_embed) + await interaction.followup.send( + content=f"You scouted a **{rarity_name}** {player_name}!", + embeds=card_embeds, + ephemeral=True, + ) + + # Notify for shiny scouts (rarity >= 5) + if self.card["player"]["rarity"]["value"] >= 5: + try: + from helpers.discord_utils import send_to_channel + + notif_embed = get_team_embed(title="Rare Scout!", team=scouter_team) + notif_embed.description = ( + f"**{scouter_team['lname']}** scouted a " + f"**{rarity_name}** {player_name}!" + ) + notif_embed.set_thumbnail( + url=self.card["player"].get("headshot", IMAGES["logo"]) + ) + await send_to_channel( + view.bot, "pd-network-news", embed=notif_embed + ) + except Exception as e: + logger.error(f"Failed to send shiny scout notification: {e}") + + except Exception as e: + logger.error(f"Unexpected error in scout callback: {e}", exc_info=True) + try: + await interaction.followup.send( + "Something went wrong. Please try again.", + ephemeral=True, + ) + except Exception: + pass + finally: + view.processing.discard(self.position) diff --git a/helpers/__init__.py b/helpers/__init__.py index 4b62f4e..8c7e39d 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -6,7 +6,7 @@ The package is organized into logical modules for better maintainability. Modules: - constants: Application constants and configuration -- utils: General utility functions +- utils: General utility functions - random_content: Random content generators - search_utils: Search and fuzzy matching functionality - discord_utils: Discord helper functions @@ -21,9 +21,10 @@ Modules: # This allows existing code to continue working during the migration from helpers.main import * -# Import from migrated modules +# Import from migrated modules from .constants import * from .utils import * from .random_content import * -from .search_utils import * -from .discord_utils import * \ No newline at end of file +from .search_utils import * +from .discord_utils import * +from .scouting import * diff --git a/helpers/main.py b/helpers/main.py index ed16a3b..4dfc659 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -8,7 +8,7 @@ import traceback import discord import pygsheets -import requests +import aiohttp from discord.ext import commands from api_calls import * @@ -43,17 +43,21 @@ async def get_player_photo(player): ) try: - resp = requests.get(req_url, timeout=0.5) - except Exception as e: + async with aiohttp.ClientSession() as session: + async with session.get( + req_url, timeout=aiohttp.ClientTimeout(total=0.5) + ) as resp: + if resp.status == 200: + data = await resp.json() + if data["player"] and data["player"][0]["strSport"] == "Baseball": + await db_patch( + "players", + object_id=player["player_id"], + params=[("headshot", data["player"][0]["strThumb"])], + ) + return data["player"][0]["strThumb"] + except Exception: return None - if resp.status_code == 200 and resp.json()["player"]: - if resp.json()["player"][0]["strSport"] == "Baseball": - await db_patch( - "players", - object_id=player["player_id"], - params=[("headshot", resp.json()["player"][0]["strThumb"])], - ) - return resp.json()["player"][0]["strThumb"] return None @@ -1681,9 +1685,9 @@ async def paperdex_team_embed(team: dict, mlb_team: dict) -> list[discord.Embed] for cardset_id in coll_data: if cardset_id != "total_owned": if coll_data[cardset_id]["players"]: - coll_data[cardset_id]["embeds"][0].description = ( - f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}" - ) + coll_data[cardset_id]["embeds"][ + 0 + ].description = f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}" coll_data[cardset_id]["embeds"][0].add_field( name="# Collected / # Total Cards", value=f"{coll_data[cardset_id]['owned']} / {len(coll_data[cardset_id]['players'])}", @@ -1749,6 +1753,8 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): all_cards = [] for p_id in pack_ids: new_cards = await db_get("cards", params=[("pack_id", p_id)]) + for card in new_cards["cards"]: + card.setdefault("pack_id", p_id) all_cards.extend(new_cards["cards"]) if not all_cards: @@ -1764,6 +1770,18 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): await context.channel.send(content=f"Let's head down to {pack_channel.mention}!") await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover) + # Create scout opportunities for each pack + from helpers.scouting import create_scout_opportunity + + for p_id in pack_ids: + pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] + if pack_cards: + await create_scout_opportunity( + pack_cards, team, pack_channel, author, context + ) + if len(pack_ids) > 1: + await asyncio.sleep(2) + async def get_choice_from_cards( interaction: discord.Interaction, diff --git a/helpers/scouting.py b/helpers/scouting.py new file mode 100644 index 0000000..32536fc --- /dev/null +++ b/helpers/scouting.py @@ -0,0 +1,173 @@ +""" +Scouting Helper Functions + +Handles creation of scout opportunities after pack openings +and embed formatting for the scouting feature. +""" + +import asyncio +import datetime +import logging +import random + +import discord + +from api_calls import db_post +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.constants import IMAGES, PD_SEASON + +logger = logging.getLogger("discord_app") + +SCOUT_WINDOW_SECONDS = 1800 # 30 minutes + +# Rarity value → display symbol +RARITY_SYMBOLS = { + 8: "\U0001f7e1", # HoF — yellow + 5: "\U0001f7e3", # MVP — purple + 3: "\U0001f535", # All-Star — blue + 2: "\U0001f7e2", # Starter — green + 1: "\u26aa", # Reserve — white + 0: "\u26ab", # Replacement — black +} + + +def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: + """Build a shuffled list of (player_id, display_line) tuples.""" + lines = [] + for card in cards: + player = card["player"] + rarity_val = player["rarity"]["value"] + symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab") + lines.append( + ( + player["player_id"], + f"{symbol} {player['rarity']['name']} — {player['p_name']}", + ) + ) + random.shuffle(lines) + return lines + + +def build_scout_embed( + opener_team: dict, + cards: list[dict], + card_lines: list[tuple[int, str]] = None, +) -> discord.Embed: + """Build the embed shown above the scout buttons. + + Shows a shuffled list of cards (rarity + player name) so scouters + know what's in the pack but not which button maps to which card. + Returns (embed, card_lines) so the view can store the shuffled order. + """ + embed = get_team_embed(title="Scout Opportunity!", team=opener_team) + + if card_lines is None: + card_lines = _build_card_lines(cards) + + card_list = "\n".join(line for _, line in card_lines) + + embed.description = ( + f"**{opener_team['lname']}** just opened a pack!\n\n" + f"**Cards in this pack:**\n{card_list}\n\n" + f"Pick a card — but which is which?\n" + f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" + f"This window closes in **30 minutes**." + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack", + icon_url=IMAGES["logo"], + ) + return embed, card_lines + + +def build_scouted_card_list( + card_lines: list[tuple[int, str]], + scouted_cards: dict[int, str], +) -> str: + """Rebuild the card list marking scouted cards with the scouter's team name. + + Parameters + ---------- + card_lines : shuffled list of (player_id, display_line) tuples + scouted_cards : {player_id: scouter_team_name} for each claimed card + """ + result = [] + for player_id, line in card_lines: + if player_id in scouted_cards: + team_name = scouted_cards[player_id] + result.append(f"{line} \u2014 \u2714\ufe0f *{team_name}*") + else: + result.append(line) + return "\n".join(result) + + +async def create_scout_opportunity( + pack_cards: list[dict], + opener_team: dict, + channel: discord.TextChannel, + opener_user, + context, +) -> None: + """Create a scout opportunity and post the ScoutView to the channel. + + Called after display_cards() completes in open_st_pr_packs(). + Wrapped in try/except so scouting failures never crash pack opening. + + Parameters + ---------- + pack_cards : list of card dicts from a single pack + opener_team : team dict for the pack opener + channel : the #pack-openings channel + opener_user : discord.Member or discord.User who opened the pack + context : the command context (Context or Interaction), used to get bot + """ + from discord_ui.scout_view import ScoutView + + # Only create scout opportunities in the pack-openings channel + if not channel or channel.name != "pack-openings": + return + + if not pack_cards: + return + + now = datetime.datetime.now() + expires_at = int_timestamp(now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)) + created = int_timestamp(now) + + card_ids = [c["id"] for c in pack_cards] + + try: + scout_opp = await db_post( + "scout_opportunities", + payload={ + "pack_id": pack_cards[0].get("pack_id"), + "opener_team_id": opener_team["id"], + "card_ids": card_ids, + "expires_at": expires_at, + "created": created, + }, + ) + except Exception as e: + logger.error(f"Failed to create scout opportunity: {e}") + return + + embed, card_lines = build_scout_embed(opener_team, pack_cards) + + # Get bot reference from context + bot = getattr(context, "bot", None) or getattr(context, "client", None) + + view = ScoutView( + scout_opp_id=scout_opp["id"], + cards=pack_cards, + opener_team=opener_team, + opener_user_id=opener_user.id, + bot=bot, + ) + view.card_lines = card_lines + + try: + msg = await channel.send(embed=embed, view=view) + view.message = msg + except Exception as e: + logger.error(f"Failed to post scout opportunity message: {e}") diff --git a/paperdynasty.py b/paperdynasty.py index d7d9ea1..951654a 100644 --- a/paperdynasty.py +++ b/paperdynasty.py @@ -12,12 +12,12 @@ from in_game.gameplay_queries import get_channel_game_or_none from health_server import run_health_server from notify_restart import send_restart_notification -raw_log_level = os.getenv('LOG_LEVEL') -if raw_log_level == 'DEBUG': +raw_log_level = os.getenv("LOG_LEVEL") +if raw_log_level == "DEBUG": log_level = logging.DEBUG -elif raw_log_level == 'INFO': +elif raw_log_level == "INFO": log_level = logging.INFO -elif raw_log_level == 'WARN': +elif raw_log_level == "WARN": log_level = logging.WARNING else: log_level = logging.ERROR @@ -29,17 +29,17 @@ else: # level=log_level # ) # logger.getLogger('discord.http').setLevel(logger.INFO) -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") logger.setLevel(log_level) handler = RotatingFileHandler( - filename='logs/discord.log', + filename="logs/discord.log", # encoding='utf-8', maxBytes=32 * 1024 * 1024, # 32 MiB backupCount=5, # Rotate through 5 files ) -formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler.setFormatter(formatter) # dt_fmt = '%Y-%m-%d %H:%M:%S' @@ -48,27 +48,30 @@ handler.setFormatter(formatter) logger.addHandler(handler) COGS = [ - 'cogs.owner', - 'cogs.admins', - 'cogs.economy', - 'cogs.players', - 'cogs.gameplay', + "cogs.owner", + "cogs.admins", + "cogs.economy", + "cogs.players", + "cogs.gameplay", + "cogs.economy_new.scouting", ] intents = discord.Intents.default() intents.members = True intents.message_content = True -bot = commands.Bot(command_prefix='.', - intents=intents, - # help_command=None, - description='The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.', - case_insensitive=True, - owner_id=258104532423147520) +bot = commands.Bot( + command_prefix=".", + intents=intents, + # help_command=None, + description="The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.", + case_insensitive=True, + owner_id=258104532423147520, +) @bot.event async def on_ready(): - logger.info('Logged in as:') + logger.info("Logged in as:") logger.info(bot.user.name) logger.info(bot.user.id) @@ -77,9 +80,11 @@ async def on_ready(): @bot.tree.error -async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError): +async def on_app_command_error( + interaction: discord.Interaction, error: discord.app_commands.AppCommandError +): """Global error handler for all app commands (slash commands).""" - logger.error(f'App command error in {interaction.command}: {error}', exc_info=error) + logger.error(f"App command error in {interaction.command}: {error}", exc_info=error) # CRITICAL: Release play lock if command failed during gameplay # This prevents permanent user lockouts when exceptions occur @@ -97,22 +102,23 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord. session.add(current_play) session.commit() except Exception as lock_error: - logger.error(f'Failed to release play lock after error: {lock_error}', exc_info=lock_error) + logger.error( + f"Failed to release play lock after error: {lock_error}", + exc_info=lock_error, + ) # Try to respond to the user try: if not interaction.response.is_done(): await interaction.response.send_message( - f'❌ An error occurred: {str(error)}', - ephemeral=True + f"❌ An error occurred: {str(error)}", ephemeral=True ) else: await interaction.followup.send( - f'❌ An error occurred: {str(error)}', - ephemeral=True + f"❌ An error occurred: {str(error)}", ephemeral=True ) except Exception as e: - logger.error(f'Failed to send error message to user: {e}') + logger.error(f"Failed to send error message to user: {e}") async def main(): @@ -120,10 +126,10 @@ async def main(): for c in COGS: try: await bot.load_extension(c) - logger.info(f'Loaded cog: {c}') + logger.info(f"Loaded cog: {c}") except Exception as e: - logger.error(f'Failed to load cog: {c}') - logger.error(f'{e}') + logger.error(f"Failed to load cog: {c}") + logger.error(f"{e}") # Start health server and bot concurrently async with bot: @@ -132,7 +138,7 @@ async def main(): try: # Start bot (this blocks until bot stops) - await bot.start(os.environ.get('BOT_TOKEN', 'NONE')) + await bot.start(os.environ.get("BOT_TOKEN", "NONE")) finally: # Cleanup: cancel health server when bot stops health_task.cancel() @@ -141,4 +147,5 @@ async def main(): except asyncio.CancelledError: pass + asyncio.run(main())