paper-dynasty-discord/utilities/buttons.py
Cal Corum 04d6cf3b5e fix: replace star imports with explicit named imports across codebase
Convert all `from x import *` to explicit imports in 12 files, resolving
925 F403/F405 ruff violations. Each name traced to its canonical source
module. Also fixes: duplicate Session import (players.py), missing
sample_team_data fixture param, duplicate method name paperdex_cardset_slash,
unused commands import in package __init__ files, and Play type hint in
play_lock.py.

3 pre-existing code bugs remain (F811 duplicate test names, F821 undefined
`question` variable) — these need investigation, not mechanical fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:48:07 -05:00

554 lines
20 KiB
Python

import logging
import discord
from typing import Literal
from dice import ab_roll, jump_roll
from exceptions import ButtonOptionNotChosen, InvalidResponder, log_exception
from helpers import random_insult
from in_game.gameplay_models import Play
logger = logging.getLogger("discord_app")
# def check_responder(func):
# def wrap(*args, **kwargs):
# try:
# result = func(*args, **kwargs)
# except Exc
class Confirm(discord.ui.View):
def __init__(
self,
responders: list,
timeout: float = 300.0,
label_type: Literal["yes", "confirm"] = "confirm",
):
super().__init__(timeout=timeout)
if not isinstance(responders, list):
raise TypeError("responders must be a list")
self.value = None
self.responders = responders
if label_type == "yes":
self.confirm.label = "Yes"
self.cancel.label = "No"
# When the confirm button is pressed, set the inner value to `True` and
# stop the View from listening to more input.
# We also send the user an ephemeral message that we're confirming their choice.
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.green)
async def confirm(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.value = True
self.clear_items()
self.stop()
# This one is similar to the confirmation button except sets the inner value to `False`
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.value = False
self.clear_items()
self.stop()
class ButtonOptions(discord.ui.View):
def __init__(
self,
labels: list[str],
responders: list,
timeout: float = 300.0,
disable_chosen: bool = False,
):
logger.info(
f"ButtonOptions - labels: {labels} / responders: {responders} / timeout: {timeout} / disable_chosen: {disable_chosen}"
)
super().__init__(timeout=timeout)
if not isinstance(responders, list):
raise TypeError("responders must be a list")
if len(labels) > 5 or len(labels) < 1:
log_exception(ValueError, "ButtonOptions support between 1 and 5 options")
self.value = None
self.responders = responders
self.options = labels
self.disable_chosen = disable_chosen
# if len(labels) == 5:
# for count, x in enumerate(labels):
# if count == 0:
# self.option1.label = x
# if x is None or x.lower() == 'na' or x == 'N/A':
# self.remove_item(self.option1)
# if count == 1:
# self.option2.label = x
# if x is None or x.lower() == 'na' or x == 'N/A':
# self.remove_item(self.option2)
# if count == 2:
# self.option3.label = x
# if x is None or x.lower() == 'na' or x == 'N/A':
# self.remove_item(self.option3)
# if count == 3:
# self.option4.label = x
# if x is None or x.lower() == 'na' or x == 'N/A':
# self.remove_item(self.option4)
# if count == 4:
# self.option5.label = x
# if x is None or x.lower() == 'na' or x == 'N/A':
# self.remove_item(self.option5)
# else:
all_options = [
self.option1,
self.option2,
self.option3,
self.option4,
self.option5,
]
logger.info(f"all_options: {all_options}")
for count, x in enumerate(labels):
if x is None or x.lower() == "na" or x.lower() == "n/a":
self.remove_item(all_options[count])
else:
all_options[count].label = x
if len(labels) < 2:
self.remove_item(self.option2)
if len(labels) < 3:
self.remove_item(self.option3)
if len(labels) < 4:
self.remove_item(self.option4)
if len(labels) < 5:
self.remove_item(self.option5)
@discord.ui.button(label="Option 1", style=discord.ButtonStyle.primary)
async def option1(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.stop()
if self.disable_chosen:
button.disabled = True
self.value = self.options[0]
await interaction.edit_original_response(view=self)
@discord.ui.button(label="Option 2", style=discord.ButtonStyle.primary)
async def option2(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.stop()
if self.disable_chosen:
button.disabled = True
self.value = self.options[1]
await interaction.edit_original_response(view=self)
@discord.ui.button(label="Option 3", style=discord.ButtonStyle.primary)
async def option3(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.stop()
if self.disable_chosen:
button.disabled = True
self.value = self.options[2]
await interaction.edit_original_response(view=self)
@discord.ui.button(label="Option 4", style=discord.ButtonStyle.primary)
async def option4(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.stop()
if self.disable_chosen:
button.disabled = True
self.value = self.options[3]
await interaction.edit_original_response(view=self)
@discord.ui.button(label="Option 5", style=discord.ButtonStyle.primary)
async def option5(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user not in self.responders:
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=10.0
)
self.stop()
if self.disable_chosen:
button.disabled = True
self.value = self.options[4]
await interaction.edit_original_response(view=self)
class SelectPosition(discord.ui.View):
def __init__(self, *, timeout: int = 30, responders: list[discord.User]):
if not isinstance(responders, list):
raise TypeError("responders must be a list")
super().__init__(timeout=timeout)
self.value = None
self.responders = responders
async def check_responders(self, interaction: discord.Interaction):
if interaction.user not in self.responders:
await interaction.response.send_message(
content=random_insult(), ephemeral=True, delete_after=10.0
)
log_exception(
InvalidResponder,
f"{interaction.user.name} not in responders: {self.responders}",
)
async def button_press(self, interaction: discord.Interaction, this_position: str):
await self.check_responders(interaction)
self.stop()
self.value = this_position
await interaction.edit_original_response(view=self)
@discord.ui.button(label="LF", style=discord.ButtonStyle.blurple, row=0)
async def left_field(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="CF", style=discord.ButtonStyle.blurple, row=0)
async def center_field(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="RF", style=discord.ButtonStyle.blurple, row=0)
async def right_field(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="3B", style=discord.ButtonStyle.green, row=1)
async def third_base(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="SS", style=discord.ButtonStyle.green, row=1)
async def shortstop(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="2B", style=discord.ButtonStyle.green, row=1)
async def second_base(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="1B", style=discord.ButtonStyle.green, row=1)
async def first_base(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="C", style=discord.ButtonStyle.gray, row=2)
async def catcher(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="P", style=discord.ButtonStyle.gray, row=2)
async def pitcher(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
@discord.ui.button(label="PH/PR", style=discord.ButtonStyle.gray, row=2)
async def pinch_hitter(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self.button_press(interaction, button.label)
async def ask_position(interaction: discord.Interaction):
view = SelectPosition(responders=[interaction.user], timeout=15)
p_message = await interaction.channel.send(
content="Please select a position", view=view
)
await view.wait()
if view.value:
await p_message.delete()
return view.value
else:
await p_message.edit(
content="To move things along, I will set the position to PH.", view=None
)
return "PH"
async def ask_confirm(
interaction: discord.Interaction,
question: str,
label_type: Literal["yes", "confirm"] = "confirm",
timeout: int = 60,
delete_question: bool = True,
custom_confirm_label: str = None,
custom_cancel_label: str = None,
embed: discord.Embed = None,
delete_embed: bool = False,
) -> bool:
"""
button_callbacks: keys are button values, values are async functions
"""
try:
view = Confirm(
responders=[interaction.user], timeout=timeout, label_type=label_type
)
except AttributeError:
view = Confirm(
responders=[interaction.author], timeout=timeout, label_type=label_type
)
if custom_confirm_label:
view.confirm.label = custom_confirm_label
if custom_cancel_label:
view.cancel.label = custom_cancel_label
q_message = await interaction.channel.send(question, view=view)
await view.wait()
if view.value:
if delete_question:
await q_message.delete()
else:
await q_message.edit(content=question, view=None)
return True
else:
if delete_question:
await q_message.delete()
else:
await q_message.edit(content=question, view=None)
return False
async def ask_with_buttons(
interaction: discord.Interaction,
button_options: list[str],
question: str = None,
timeout: int = 60,
delete_question: bool = True,
embeds: list[discord.Embed] = None,
delete_embeds: bool = False,
edit_original_interaction: bool = False,
none_okay: bool = False,
confirmation_message: str = None,
) -> str:
"""
Returns text of button pressed
"""
logger.info(
f"ask_with_buttons - button_options: {button_options} / question: {question} / timeout: {timeout} / delete_question: {delete_question} / embeds: {embeds} / delete_embeds: {delete_embeds} / edit_original_transaction: {edit_original_interaction} / confirmation_message: {confirmation_message}"
)
if question is None and embeds is None:
log_exception(KeyError, "At least one of question or embed must be provided")
if confirmation_message is not None and (delete_question or delete_embeds):
log_exception(
KeyError,
"Posting a confirmation message is not supported while deleting the message or embeds",
)
view = ButtonOptions(
responders=[interaction.user], timeout=timeout, labels=button_options
)
logger.info(f"view: {view}")
# if edit_original_interaction:
# logger.info(f'editing message')
# q_message = await interaction.edit_original_response(
# content=question,
# view=view,
# embeds=embeds
# )
# logger.info(f'edited')
# else:
# logger.info(f'posting message')
# q_message = await interaction.channel.send(
# content=question,
# view=view,
# embeds=embeds
# )
logger.info("posting message")
if edit_original_interaction:
q_message = await interaction.edit_original_response(
content=question, view=view, embeds=embeds
)
else:
q_message = await interaction.channel.send(
content=question, view=view, embeds=embeds
)
logger.info("waiting for response")
await view.wait()
if view.value:
return_val = view.value
else:
return_val = None
logger.info(f"return_val: {return_val}")
if question is not None and embeds is not None:
logger.info("checking for deletion with question and embeds")
if delete_question and delete_embeds:
logger.info("delete it all")
await q_message.delete()
elif delete_question:
logger.info("delete question")
await q_message.edit(content=None)
elif delete_embeds:
logger.info("delete embeds")
await q_message.edit(embeds=None)
elif return_val is None:
logger.info("remove view")
new_content = (
confirmation_message if confirmation_message is not None else question
)
logger.info(f"Confirmation message: {new_content}")
await q_message.edit(content=new_content, view=None)
elif (question is not None and delete_question) or (
embeds is not None and delete_embeds
):
logger.info("deleting message")
await q_message.delete()
elif return_val is None:
logger.info("No reponse, remove view")
await q_message.edit(view=None)
if return_val is not None or none_okay:
logger.info(f"Returning: {return_val}")
if confirmation_message is not None:
new_content = (
confirmation_message if confirmation_message is not None else question
)
logger.info(f"Confirmation message: {new_content}")
await q_message.edit(content=new_content, view=None)
return return_val
log_exception(ButtonOptionNotChosen, "Selecting an option is mandatory")
class ScorebugButtons(discord.ui.View):
def __init__(self, play: Play, embed: discord.Embed, timeout: float = 30):
super().__init__(timeout=timeout)
self.value = None
self.batting_team = play.batter.team
self.pitching_team = play.pitcher.team
self.pitcher_card_url = play.pitcher.player.pitcher_card_url
self.batter_card_url = play.batter.player.batter_card_url
self.team = play.batter.team
self.play = play
self.had_chaos = False
self.embed = embed
if play.on_base_code == 0:
self.remove_item(self.button_jump)
async def interaction_check(
self, interaction: discord.Interaction[discord.Client]
) -> bool:
logger.info(
f"user id: {interaction.user.id} / batting_team: {self.batting_team}"
)
if interaction.user.id == self.batting_team.gmid:
logger.info(
f"User {interaction.user.id} rolling in Game {self.play.game.id}"
)
return True
elif self.batting_team.is_ai and interaction.user.id == self.pitching_team.gmid:
logger.info(
f"User {interaction.user.id} rolling for AI in Game {self.play.game.id}"
)
return True
logger.info(f"User {interaction.user.id} rejected in Game {self.play.game.id}")
await interaction.response.send_message(
content="Get out of here", ephemeral=True, delete_after=5.0
)
return False
# async def on_timeout(self) -> Coroutine[Any, Any, None]:
# await self.interaction
@discord.ui.button(label="Roll AB", style=discord.ButtonStyle.primary)
async def button_ab(
self, interaction: discord.Interaction, button: discord.ui.Button
):
logger.info(
f"User {interaction.user.id} rolling AB in Game {self.play.game.id}"
)
this_roll = ab_roll(
self.team,
self.play.game,
allow_chaos=not self.had_chaos and self.play.on_base_code > 0,
)
logger.info(f"this_roll: {this_roll}")
if this_roll.is_chaos:
logger.info("AB Roll Is Chaos")
self.had_chaos = True
else:
button.disabled = True
await interaction.channel.send(content=None, embeds=this_roll.embeds)
if this_roll.d_six_one is not None and this_roll.d_six_one > 3:
logger.info("ScorebugButton - updating embed card to pitcher")
self.embed.set_image(url=self.pitcher_card_url)
logger.debug(f"embed image url: {self.embed.image}")
logger.debug(f"new embed: {self.embed}")
await interaction.response.edit_message(view=self, embed=self.embed)
@discord.ui.button(label="Check Jump", style=discord.ButtonStyle.secondary)
async def button_jump(
self, interaction: discord.Interaction, button: discord.ui.Button
):
logger.info(
f"User {interaction.user.id} rolling jump in Game {self.play.game.id}"
)
this_roll = jump_roll(self.team, self.play.game)
button.disabled = True
await interaction.channel.send(content=None, embeds=this_roll.embeds)
await interaction.response.edit_message(view=self)