feat: Add Scouting feature (Wonder Pick-style social pack opening)

When a player opens a pack, a scout opportunity is posted to #pack-openings
with face-down card buttons. Other players can blind-pick one card using
daily scout tokens (2/day), receiving a copy. The opener keeps all cards.

New files:
- discord_ui/scout_view.py: ScoutView with dynamic buttons and claim logic
- helpers/scouting.py: create_scout_opportunity() and embed builder
- cogs/economy_new/scouting.py: /scout-tokens command and cleanup task

Modified:
- helpers/main.py: Hook into open_st_pr_packs() after display_cards()
- paperdynasty.py: Register scouting cog

Requires new API endpoints in paper-dynasty-database (scout_opportunities).
Tracks #44.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-04 08:13:31 -06:00
parent 3a32e52971
commit 409370ed0e
7 changed files with 716 additions and 59 deletions

View File

@ -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))

View File

@ -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'
]
"Question",
"Confirm",
"ButtonOptions",
"Pagination",
"SelectChoicePackTeam",
"SelectOpenPack",
"SelectPaperdexCardset",
"SelectPaperdexTeam",
"SelectBuyPacksCardset",
"SelectBuyPacksTeam",
"SelectUpdatePlayerTeam",
"SelectView",
"Dropdown",
"DropdownView",
"ScoutView",
]

339
discord_ui/scout_view.py Normal file
View File

@ -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)

View File

@ -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 *
from .search_utils import *
from .discord_utils import *
from .scouting import *

View File

@ -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,

173
helpers/scouting.py Normal file
View File

@ -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}")

View File

@ -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())