From aa040b7d9ac04a72e2b547228ae8f7b6954d35d4 Mon Sep 17 00:00:00 2001
From: Evanroby <107794516+Evanroby@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:17:07 +0200
Subject: [PATCH 1/4] [NBA]: New commands syncing with API
---
nba/README.md | 88 +++++-
nba/commands/nba_commands.py | 517 +++++++++++++++++++++++++----------
nba/converter.py | 49 ++++
nba/formatters.py | 352 +++++++++++++++++++++++-
4 files changed, 854 insertions(+), 152 deletions(-)
diff --git a/nba/README.md b/nba/README.md
index 805ca3a1..23829b55 100644
--- a/nba/README.md
+++ b/nba/README.md
@@ -1,6 +1,6 @@
# NBA
-NBA information cog.
- Get the current NBA schedule for the next game.
- Get the current NBA scoreboard.
- Get the latest NBA news.
- Set the channel to send NBA game updates to.
+NBA information cog.
- Get the current NBA schedule for the next game.
- Get the current NBA scoreboard.
- Get the latest NBA news.
- Get standings, stat leaders, player info, rosters, and team stats.
- Set the channel to send NBA game updates to.
## [p]nbaset
@@ -54,6 +54,18 @@ You can only set one channel and one team per server.
- Usage: `[p]nbaset channel `
+### [p]nbaset role set
+
+Set a role to ping 30 minutes before game starts.
+
+ - Usage: `[p]nbaset role set `
+
+### [p]nbaset role remove
+
+Remove the pre-game ping role.
+
+ - Usage: `[p]nbaset role remove`
+
## [p]nba (Hybrid Command)
Get the current NBA schedule for next game.
@@ -112,3 +124,77 @@ Get the current NBA scoreboard.
- Slash Usage: `/nba scoreboard [team=None]`
- Aliases: `score and scores`
- Cooldown: `1 per 3.0 seconds`
+
+### [p]nba standings (Hybrid Command)
+
+Get the current NBA standings.
+
+Shows win/loss record, win%, games behind, home/road record, last 10, streak, and clinch indicator for every team.
+
+**Arguments:**
+- `[conference]` - Filter to `east` or `west`. Shows both if omitted.
+
+ - Usage: `[p]nba standings [conference]`
+ - Slash Usage: `/nba standings [conference]`
+ - Cooldown: `1 per 10.0 seconds`
+
+### [p]nba leaders (Hybrid Command)
+
+Get the NBA per-game stat leaders.
+
+**Arguments:**
+- `[category]` - One of `pts`, `reb`, `ast`, `stl`, `blk`. Defaults to `pts`.
+
+**Examples:**
+- `[p]nba leaders` - Returns the top points-per-game leaders.
+- `[p]nba leaders reb` - Returns the top rebounders.
+- `[p]nba leaders ast` - Returns the top assist leaders.
+
+ - Usage: `[p]nba leaders [category=pts]`
+ - Slash Usage: `/nba leaders [category=pts]`
+ - Cooldown: `1 per 10.0 seconds`
+
+### [p]nba player (Hybrid Command)
+
+Get bio and career stats for an NBA player.
+
+**Arguments:**
+- `` - The player's name to look up (e.g. `LeBron James`).
+
+**Examples:**
+- `[p]nba player LeBron James`
+- `[p]nba player curry`
+
+ - Usage: `[p]nba player `
+ - Slash Usage: `/nba player `
+ - Cooldown: `1 per 10.0 seconds`
+
+### [p]nba roster (Hybrid Command)
+
+Get the current roster for an NBA team.
+
+**Arguments:**
+- `` - The team name (e.g. `lakers`, `celtics`).
+
+**Valid Team Names:**
+- heat, bucks, bulls, cavaliers, celtics, clippers, grizzlies, hawks, hornets, jazz, kings, knicks, lakers, magic, mavericks, nets, nuggets, pacers, pelicans, pistons, raptors, rockets, sixers, spurs, suns, thunder, timberwolves, trail blazers, warriors, wizards
+
+ - Usage: `[p]nba roster `
+ - Slash Usage: `/nba roster `
+ - Cooldown: `1 per 10.0 seconds`
+
+### [p]nba teamstats (Hybrid Command)
+
+Get season averages for an NBA team.
+
+Shows per-game averages for points, rebounds, assists, steals, blocks, turnovers, shooting splits, and plus/minus.
+
+**Arguments:**
+- `` - The team name (e.g. `warriors`, `heat`).
+
+**Valid Team Names:**
+- heat, bucks, bulls, cavaliers, celtics, clippers, grizzlies, hawks, hornets, jazz, kings, knicks, lakers, magic, mavericks, nets, nuggets, pacers, pelicans, pistons, raptors, rockets, sixers, spurs, suns, thunder, timberwolves, trail blazers, warriors, wizards
+
+ - Usage: `[p]nba teamstats `
+ - Slash Usage: `/nba teamstats `
+ - Cooldown: `1 per 10.0 seconds`
diff --git a/nba/commands/nba_commands.py b/nba/commands/nba_commands.py
index 2683562c..a6853f93 100644
--- a/nba/commands/nba_commands.py
+++ b/nba/commands/nba_commands.py
@@ -27,33 +27,74 @@
import discord
import orjson
-from nba_api.stats.endpoints import playoffpicture
+from nba_api.stats.endpoints import (
+ commonallplayers,
+ commonplayerinfo,
+ commonteamroster,
+ leaguedashplayerstats,
+ leaguedashteamstats,
+ leaguestandingsv3,
+ playercareerstats,
+ playoffpicture,
+)
from red_commons.logging import getLogger
from redbot.core import app_commands, commands
from redbot.core.utils.views import SimpleMenu
from ..converter import (
- ESPN_NBA_NEWS,
SCHEDULE_URL,
+ STAT_CATEGORY_LABELS,
+ STAT_CATEGORY_MAP,
TEAM_EMOJI_NAMES,
+ TEAM_NAME_TO_API,
+ TEAM_NAME_TO_ID,
TEAM_NAMES,
get_games,
team_emojis,
)
from ..formatters import (
+ build_leaders_embeds,
build_news_embeds,
+ build_player_embeds,
build_playoff_embeds,
+ build_roster_embeds,
build_schedule_embeds,
build_scoreboard_embeds,
+ build_standings_embeds,
+ build_teamstats_embeds,
)
from ..view import GameMenu
log = getLogger("red.maxcogs.nba")
+_NBA_HEADERS = {
+ "Host": "stats.nba.com",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/123.0.0.0 Safari/537.36"
+ ),
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "en-US,en;q=0.9",
+ "x-nba-stats-origin": "stats",
+ "x-nba-stats-token": "true",
+ "Referer": "https://www.nba.com/",
+ "Origin": "https://www.nba.com",
+ "Connection": "keep-alive",
+}
+
class NBACommands:
"""Mixin containing all NBA commands. Inherited by the NBA cog."""
+ async def _run_nba_api(self, endpoint_cls, timeout: int = 40, **kwargs) -> dict:
+ """Run a blocking nba_api endpoint call in a thread pool with timeout."""
+
+ def _inner() -> dict:
+ return endpoint_cls(headers=_NBA_HEADERS, **kwargs).get_dict()
+
+ return await asyncio.wait_for(asyncio.to_thread(_inner), timeout=timeout)
+
@commands.group()
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
@@ -160,94 +201,62 @@ async def nbaset_emojis_sync(self, ctx: commands.Context):
Team logo images are fetched from the NBA CDN.
"""
NBA_LOGO_URL = "https://cdn.nba.com/logos/nba/{team_id}/global/L/logo.svg"
- TEAM_IDS: dict[str, int] = {
- "Heat": 1610612748,
- "Bucks": 1610612749,
- "Bulls": 1610612741,
- "Cavaliers": 1610612739,
- "Celtics": 1610612738,
- "Clippers": 1610612746,
- "Grizzlies": 1610612763,
- "Hawks": 1610612737,
- "Hornets": 1610612766,
- "Jazz": 1610612762,
- "Kings": 1610612758,
- "Knicks": 1610612752,
- "Lakers": 1610612747,
- "Magic": 1610612753,
- "Mavericks": 1610612742,
- "Nets": 1610612751,
- "Nuggets": 1610612743,
- "Pacers": 1610612754,
- "Pelicans": 1610612740,
- "Pistons": 1610612765,
- "Raptors": 1610612761,
- "Rockets": 1610612745,
- "76ers": 1610612755,
- "Spurs": 1610612759,
- "Suns": 1610612756,
- "Thunder": 1610612760,
- "Timberwolves": 1610612750,
- "Trail Blazers": 1610612757,
- "Warriors": 1610612744,
- "Wizards": 1610612764,
- }
-
- await ctx.typing()
- try:
- existing = await self.bot.fetch_application_emojis()
- except discord.HTTPException as e:
- return await ctx.send(f"Failed to fetch existing application emojis: {e}")
-
- existing_names = {e.name for e in existing}
- uploaded, skipped, failed = 0, 0, 0
-
- for team_name, emoji_name in TEAM_EMOJI_NAMES.items():
- if emoji_name in existing_names:
- skipped += 1
- continue
- team_id = TEAM_IDS.get(team_name)
- if not team_id:
- log.warning("No team ID for %s, skipping emoji upload.", team_name)
- failed += 1
- continue
- url = NBA_LOGO_URL.format(team_id=team_id)
+
+ async with ctx.typing():
try:
- async with self.session.get(url) as resp:
- if resp.status != 200:
- log.warning(
- "Failed to fetch logo for %s (status %s)", team_name, resp.status
+ existing = await self.bot.fetch_application_emojis()
+ except discord.HTTPException as e:
+ return await ctx.send(f"Failed to fetch existing application emojis: {e}")
+
+ existing_names = {e.name for e in existing}
+ uploaded, skipped, failed = 0, 0, 0
+
+ for team_name, emoji_name in TEAM_EMOJI_NAMES.items():
+ if emoji_name in existing_names:
+ skipped += 1
+ continue
+ team_id = TEAM_NAME_TO_ID.get(team_name)
+ if not team_id:
+ log.warning("No team ID for %s, skipping emoji upload.", team_name)
+ failed += 1
+ continue
+ url = NBA_LOGO_URL.format(team_id=team_id)
+ try:
+ async with self.session.get(url) as resp:
+ if resp.status != 200:
+ log.warning(
+ "Failed to fetch logo for %s (status %s)", team_name, resp.status
+ )
+ failed += 1
+ continue
+ svg_bytes = await resp.read()
+ try:
+ import cairosvg
+ except ImportError:
+ await ctx.send(
+ "The `cairosvg` library is required to convert SVG to PNG. "
+ "Please install it with `pip install cairosvg --break-system-packages`."
)
+ return
+ try:
+ png_bytes = cairosvg.svg2png(bytestring=svg_bytes)
+ except Exception as e:
+ log.error("Failed to convert SVG to PNG for %s: %s", team_name, e)
failed += 1
continue
- svg_bytes = await resp.read()
- try:
- import cairosvg
- except ImportError:
- await ctx.send(
- "The `cairosvg` library is required to convert SVG to PNG. "
- "Please install it with `pip install cairosvg --break-system-packages`."
- )
- return
- try:
- png_bytes = cairosvg.svg2png(bytestring=svg_bytes)
- except Exception as e:
- log.error("Failed to convert SVG to PNG for %s: %s", team_name, e)
+ await self.bot.create_application_emoji(name=emoji_name, image=png_bytes)
+ uploaded += 1
+ log.info("Uploaded application emoji: %s", emoji_name)
+ except discord.HTTPException as e:
+ log.error("Failed to upload emoji %s: %s", emoji_name, e)
failed += 1
- continue
- await self.bot.create_application_emoji(name=emoji_name, image=png_bytes)
- uploaded += 1
- log.info("Uploaded application emoji: %s", emoji_name)
- except discord.HTTPException as e:
- log.error("Failed to upload emoji %s: %s", emoji_name, e)
- failed += 1
- await self.load_application_emojis()
- await ctx.send(
- f"Emoji sync complete.\n"
- f"✅ Uploaded: **{uploaded}** | ⏭️ Skipped (already exist): **{skipped}** | ❌ Failed: **{failed}**\n"
- f"Cache now has **{len(team_emojis)}** emojis loaded."
- )
+ await self.load_application_emojis()
+ await ctx.send(
+ f"Emoji sync complete.\n"
+ f"✅ Uploaded: **{uploaded}** | ⏭️ Skipped (already exist): **{skipped}** | ❌ Failed: **{failed}**\n"
+ f"Cache now has **{len(team_emojis)}** emojis loaded."
+ )
@nbaset.command(name="settings")
async def nbaset_settings(self, ctx: commands.Context):
@@ -289,24 +298,24 @@ async def schedule(self, ctx: commands.Context, *, team: Optional[str] = None):
**Valid Team Names:**
- heat, bucks, bulls, cavaliers, celtics, clippers, grizzlies, hawks, hornets, jazz, kings, knicks, lakers, magic, mavericks, nets, nuggets, pacers, pelicans, pistons, raptors, rockets, sixers, spurs, suns, thunder, timberwolves, trail blazers, warriors, wizards
"""
- await ctx.typing()
- data = await self.fetch_data(SCHEDULE_URL, ctx)
- if not data:
- return
- try:
- schedule = orjson.loads(data)
- games = get_games(schedule)
- if team:
- team = team.lower()
- if team not in TEAM_NAMES:
- return await ctx.send("Invalid team name.")
- games = [g for g in games if team in (g["home_team"] + g["away_team"]).lower()]
- pages = await build_schedule_embeds(ctx, games, team)
- if pages:
- await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
- except orjson.JSONDecodeError as e:
- log.error("Failed to fetch schedule: %s", e)
- await ctx.send("Error fetching schedule. Try again later.")
+ async with ctx.typing():
+ data = await self.fetch_data(SCHEDULE_URL, ctx)
+ if not data:
+ return
+ try:
+ schedule = orjson.loads(data)
+ games = get_games(schedule)
+ if team:
+ team = team.lower()
+ if team not in TEAM_NAMES:
+ return await ctx.send("Invalid team name.")
+ games = [g for g in games if team in (g["home_team"] + g["away_team"]).lower()]
+ pages = await build_schedule_embeds(ctx, games, team)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+ except orjson.JSONDecodeError as e:
+ log.error("Failed to fetch schedule: %s", e)
+ await ctx.send("Error fetching schedule. Try again later.")
@schedule.autocomplete("team")
async def schedule_autocomplete(
@@ -320,13 +329,13 @@ async def schedule_autocomplete(
@commands.cooldown(1, 3, commands.BucketType.user)
async def news(self, ctx: commands.Context):
"""Get latest NBA news."""
- await ctx.typing()
- news = await self.fetch_news(ctx)
- if not news:
- return
- pages = await build_news_embeds(ctx, news)
- if pages:
- await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+ async with ctx.typing():
+ news = await self.fetch_news(ctx)
+ if not news:
+ return
+ pages = await build_news_embeds(ctx, news)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
@nba.command(aliases=["score", "scores"])
@commands.bot_has_permissions(embed_links=True)
@@ -349,14 +358,14 @@ async def scoreboard(self, ctx: commands.Context, team: Optional[str] = None):
**Arguments:**
- `[team]` - The team you want to get the scoreboard for.
"""
- await ctx.typing()
- games = await self.fetch_scoreboard(ctx)
- if not games:
- return
- pages = await build_scoreboard_embeds(ctx, games, team)
- if pages:
- view = GameMenu(pages, ctx)
- view.message = await ctx.send(embed=pages[0], view=view)
+ async with ctx.typing():
+ games = await self.fetch_scoreboard(ctx)
+ if not games:
+ return
+ pages = await build_scoreboard_embeds(ctx, games, team)
+ if pages:
+ view = GameMenu(pages, ctx)
+ view.message = await ctx.send(embed=pages[0], view=view)
@scoreboard.autocomplete("team")
async def scoreboard_autocomplete(
@@ -373,29 +382,251 @@ async def playoffs(self, ctx: commands.Context):
Also shows Play-In tournament matchups when active.
"""
- await ctx.typing()
- try:
- _headers = {
- "Host": "stats.nba.com",
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
- "Accept": "application/json, text/plain, */*",
- "Accept-Language": "en-US,en;q=0.9",
- "x-nba-stats-origin": "stats",
- "x-nba-stats-token": "true",
- "Referer": "https://www.nba.com/",
- "Origin": "https://www.nba.com",
- "Connection": "keep-alive",
- }
-
- def _fetch():
- return playoffpicture.PlayoffPicture(timeout=30, headers=_headers).get_dict()
-
- data = await asyncio.wait_for(asyncio.to_thread(_fetch), timeout=40)
- except asyncio.TimeoutError:
- return await ctx.send("NBA stats timed out. Try again in a moment.")
- except Exception as e:
- log.error("Failed to fetch playoff picture: %s", e)
- return await ctx.send("Failed to fetch playoff data. Try again later.")
- pages = await build_playoff_embeds(ctx, data)
- if pages:
- await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+ async with ctx.typing():
+ try:
+ data = await self._run_nba_api(playoffpicture.PlayoffPicture, timeout=40)
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch playoff picture: %s", e)
+ return await ctx.send("Failed to fetch playoff data. Try again later.")
+ pages = await build_playoff_embeds(ctx, data)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @nba.command(name="standings")
+ @commands.bot_has_permissions(embed_links=True)
+ @commands.cooldown(1, 10, commands.BucketType.user)
+ @app_commands.describe(conference="Filter to east or west. Shows both if omitted.")
+ @app_commands.choices(
+ conference=[
+ app_commands.Choice(name="East", value="east"),
+ app_commands.Choice(name="West", value="west"),
+ ]
+ )
+ async def standings(self, ctx: commands.Context, conference: Optional[str] = None):
+ """Get the current NBA standings.
+
+ **Arguments:**
+ - `[conference]` - Filter to `east` or `west`. Shows both conferences if omitted.
+ """
+ async with ctx.typing():
+ try:
+ data = await self._run_nba_api(leaguestandingsv3.LeagueStandingsV3)
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch standings: %s", e)
+ return await ctx.send("Failed to fetch standings. Try again later.")
+ pages = await build_standings_embeds(ctx, data, conference)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @nba.command(name="leaders")
+ @commands.bot_has_permissions(embed_links=True)
+ @commands.cooldown(1, 10, commands.BucketType.user)
+ @app_commands.describe(category="The stat category to rank by.")
+ @app_commands.choices(
+ category=[
+ app_commands.Choice(name="Points", value="pts"),
+ app_commands.Choice(name="Rebounds", value="reb"),
+ app_commands.Choice(name="Assists", value="ast"),
+ app_commands.Choice(name="Steals", value="stl"),
+ app_commands.Choice(name="Blocks", value="blk"),
+ ]
+ )
+ async def leaders(self, ctx: commands.Context, category: str = "pts"):
+ """Get the NBA per-game stat leaders.
+
+ **Arguments:**
+ - `[category]` - One of: `pts`, `reb`, `ast`, `stl`, `blk`. Defaults to `pts`.
+ """
+ category = category.lower()
+ if category not in STAT_CATEGORY_MAP:
+ return await ctx.send(
+ f"Invalid category. Choose from: {', '.join(STAT_CATEGORY_MAP)}."
+ )
+ async with ctx.typing():
+ api_column = STAT_CATEGORY_MAP[category]
+ label = STAT_CATEGORY_LABELS[category]
+ try:
+ data = await self._run_nba_api(
+ leaguedashplayerstats.LeagueDashPlayerStats,
+ per_mode_simple="PerGame",
+ season_type_all_star="Regular Season",
+ )
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch player stats: %s", e)
+ return await ctx.send("Failed to fetch player stats. Try again later.")
+ pages = await build_leaders_embeds(ctx, data, api_column, label)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @nba.command(name="player")
+ @commands.bot_has_permissions(embed_links=True)
+ @commands.cooldown(1, 10, commands.BucketType.user)
+ @app_commands.describe(name="Player name to look up, e.g. 'LeBron James'.")
+ async def player(self, ctx: commands.Context, *, name: str):
+ """Get bio and career stats for an NBA player.
+
+ **Arguments:**
+ - `` - The player's name to search for.
+ """
+ async with ctx.typing():
+ try:
+ all_players_data = await self._run_nba_api(
+ commonallplayers.CommonAllPlayers,
+ is_only_current_season=1,
+ )
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch player list: %s", e)
+ return await ctx.send("Failed to fetch player list. Try again later.")
+
+ player_rows = []
+ for rs in all_players_data.get("resultSets", []):
+ if rs.get("name") == "CommonAllPlayers":
+ headers = rs.get("headers", [])
+ player_rows = [dict(zip(headers, row)) for row in rs.get("rowSet", [])]
+ break
+
+ query = name.lower()
+ exact = [p for p in player_rows if p.get("DISPLAY_FIRST_LAST", "").lower() == query]
+ starts = [
+ p
+ for p in player_rows
+ if p.get("DISPLAY_FIRST_LAST", "").lower().startswith(query) and p not in exact
+ ]
+ contains = [
+ p
+ for p in player_rows
+ if query in p.get("DISPLAY_FIRST_LAST", "").lower()
+ and p not in exact
+ and p not in starts
+ ]
+ matches = exact or starts or contains
+
+ if not matches:
+ return await ctx.send(
+ f"No active player found matching **{name}**. Check the spelling and try again."
+ )
+
+ chosen = matches[0]
+ player_id = chosen.get("PERSON_ID")
+ matched_name = chosen.get("DISPLAY_FIRST_LAST", name)
+
+ if len(matches) > 1:
+ other_names = ", ".join(p.get("DISPLAY_FIRST_LAST", "?") for p in matches[1:4])
+ await ctx.send(
+ f"Showing results for **{matched_name}**. " f"Other matches: {other_names}."
+ if len(matches) > 1
+ else ""
+ )
+
+ try:
+ info_data, career_data = await asyncio.gather(
+ self._run_nba_api(commonplayerinfo.CommonPlayerInfo, player_id=player_id),
+ self._run_nba_api(playercareerstats.PlayerCareerStats, player_id=player_id),
+ )
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch player data for %s: %s", player_id, e)
+ return await ctx.send("Failed to fetch player data. Try again later.")
+
+ pages = await build_player_embeds(ctx, info_data, career_data, matched_name)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @nba.command(name="roster")
+ @commands.bot_has_permissions(embed_links=True)
+ @commands.cooldown(1, 10, commands.BucketType.user)
+ @app_commands.describe(team="The team name to get the roster for, e.g. 'lakers'.")
+ async def roster(self, ctx: commands.Context, *, team: str):
+ """Get the current roster for an NBA team.
+
+ **Arguments:**
+ - `` - The team name (e.g. `lakers`, `celtics`).
+
+ **Valid Team Names:**
+ - heat, bucks, bulls, cavaliers, celtics, clippers, grizzlies, hawks, hornets, jazz,
+ kings, knicks, lakers, magic, mavericks, nets, nuggets, pacers, pelicans, pistons,
+ raptors, rockets, sixers, spurs, suns, thunder, timberwolves, trail blazers,
+ warriors, wizards
+ """
+ team_lower = team.lower()
+ if team_lower not in TEAM_NAMES:
+ return await ctx.send(f"Invalid team name. Valid names: {', '.join(TEAM_NAMES)}.")
+ api_name = TEAM_NAME_TO_API[team_lower]
+ team_id = TEAM_NAME_TO_ID.get(api_name)
+ if not team_id:
+ return await ctx.send("Could not resolve team ID. Please try again later.")
+
+ async with ctx.typing():
+ try:
+ data = await self._run_nba_api(
+ commonteamroster.CommonTeamRoster,
+ team_id=team_id,
+ )
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch roster for %s: %s", api_name, e)
+ return await ctx.send("Failed to fetch roster. Try again later.")
+ pages = await build_roster_embeds(ctx, data, api_name)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @roster.autocomplete("team")
+ async def roster_autocomplete(
+ self, interaction: discord.Interaction, current: str
+ ) -> List[app_commands.Choice]:
+ choices = [t for t in TEAM_NAMES if current.lower() in t.lower()]
+ return [app_commands.Choice(name=t, value=t) for t in choices[:25]]
+
+ @nba.command(name="teamstats")
+ @commands.bot_has_permissions(embed_links=True)
+ @commands.cooldown(1, 10, commands.BucketType.user)
+ @app_commands.describe(team="The team name to get season stats for, e.g. 'lakers'.")
+ async def teamstats(self, ctx: commands.Context, *, team: str):
+ """Get season averages for an NBA team.
+
+ **Arguments:**
+ - `` - The team name (e.g. `warriors`, `heat`).
+
+ **Valid Team Names:**
+ - heat, bucks, bulls, cavaliers, celtics, clippers, grizzlies, hawks, hornets, jazz,
+ kings, knicks, lakers, magic, mavericks, nets, nuggets, pacers, pelicans, pistons,
+ raptors, rockets, sixers, spurs, suns, thunder, timberwolves, trail blazers,
+ warriors, wizards
+ """
+ team_lower = team.lower()
+ if team_lower not in TEAM_NAMES:
+ return await ctx.send(f"Invalid team name. Valid names: {', '.join(TEAM_NAMES)}.")
+ api_name = TEAM_NAME_TO_API[team_lower]
+
+ async with ctx.typing():
+ try:
+ data = await self._run_nba_api(
+ leaguedashteamstats.LeagueDashTeamStats,
+ per_mode_simple="PerGame",
+ season_type_all_star="Regular Season",
+ )
+ except asyncio.TimeoutError:
+ return await ctx.send("NBA stats timed out. Try again in a moment.")
+ except Exception as e:
+ log.error("Failed to fetch team stats for %s: %s", api_name, e)
+ return await ctx.send("Failed to fetch team stats. Try again later.")
+ pages = await build_teamstats_embeds(ctx, data, api_name, api_name)
+ if pages:
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @teamstats.autocomplete("team")
+ async def teamstats_autocomplete(
+ self, interaction: discord.Interaction, current: str
+ ) -> List[app_commands.Choice]:
+ choices = [t for t in TEAM_NAMES if current.lower() in t.lower()]
+ return [app_commands.Choice(name=t, value=t) for t in choices[:25]]
diff --git a/nba/converter.py b/nba/converter.py
index 3e801159..4c1c6a4a 100644
--- a/nba/converter.py
+++ b/nba/converter.py
@@ -138,6 +138,55 @@
# Maps team name → "<:emoji_name:emoji_id>" string ready for embed use.
team_emojis: dict[str, str] = {}
+TEAM_NAME_TO_ID: dict[str, int] = {
+ "Heat": 1610612748,
+ "Bucks": 1610612749,
+ "Bulls": 1610612741,
+ "Cavaliers": 1610612739,
+ "Celtics": 1610612738,
+ "Clippers": 1610612746,
+ "Grizzlies": 1610612763,
+ "Hawks": 1610612737,
+ "Hornets": 1610612766,
+ "Jazz": 1610612762,
+ "Kings": 1610612758,
+ "Knicks": 1610612752,
+ "Lakers": 1610612747,
+ "Magic": 1610612753,
+ "Mavericks": 1610612742,
+ "Nets": 1610612751,
+ "Nuggets": 1610612743,
+ "Pacers": 1610612754,
+ "Pelicans": 1610612740,
+ "Pistons": 1610612765,
+ "Raptors": 1610612761,
+ "Rockets": 1610612745,
+ "76ers": 1610612755,
+ "Spurs": 1610612759,
+ "Suns": 1610612756,
+ "Thunder": 1610612760,
+ "Timberwolves": 1610612750,
+ "Trail Blazers": 1610612757,
+ "Warriors": 1610612744,
+ "Wizards": 1610612764,
+}
+
+STAT_CATEGORY_MAP: dict[str, str] = {
+ "pts": "PTS",
+ "reb": "REB",
+ "ast": "AST",
+ "stl": "STL",
+ "blk": "BLK",
+}
+
+STAT_CATEGORY_LABELS: dict[str, str] = {
+ "pts": "Points",
+ "reb": "Rebounds",
+ "ast": "Assists",
+ "stl": "Steals",
+ "blk": "Blocks",
+}
+
def parse_duration(duration: str) -> str:
"""
diff --git a/nba/formatters.py b/nba/formatters.py
index c4e8a77d..9463a10e 100644
--- a/nba/formatters.py
+++ b/nba/formatters.py
@@ -34,6 +34,15 @@
from .converter import get_leaders_info, get_time_bounds, parse_duration, periods, team_emojis
+def _parse_result_set(data: dict, name: str) -> list[dict]:
+ """Extract rows from a named result set in a nba_api get_dict() response."""
+ for rs in data.get("resultSets", []):
+ if rs.get("name") == name:
+ headers = rs.get("headers", [])
+ return [dict(zip(headers, row)) for row in rs.get("rowSet", [])]
+ return []
+
+
async def build_schedule_embeds(
ctx: commands.Context,
games: List[dict],
@@ -106,6 +115,14 @@ async def build_scoreboard_embeds(
return []
pages = []
+ total_pages = len(
+ [
+ g
+ for g in games
+ if not team
+ or team.lower() in (g["homeTeam"]["teamName"] + g["awayTeam"]["teamName"]).lower()
+ ]
+ )
for game in games:
home_team = game["homeTeam"]["teamName"]
away_team = game["awayTeam"]["teamName"]
@@ -187,14 +204,6 @@ async def build_scoreboard_embeds(
if game.get("seriesGameNumber"):
embed.add_field(name="Series Game Number:", value=game["seriesGameNumber"])
- total_pages = len(
- [
- g
- for g in games
- if not team
- or team.lower() in (g["homeTeam"]["teamName"] + g["awayTeam"]["teamName"]).lower()
- ]
- )
embed.set_footer(text=(f"🏀Provided by NBA.com" f" | Page {len(pages) + 1}/{total_pages}"))
pages.append(embed)
@@ -243,6 +252,7 @@ def build_pregame_embed(
arena_city: str,
arena_state: str,
game_id: str,
+ broadcast_str: str = "",
) -> discord.Embed:
"""Build the pre-game notification embed sent 30 minutes before tip-off."""
home_emoji = team_emojis.get(home_team, "")
@@ -258,6 +268,8 @@ def build_pregame_embed(
color=0xEE6730,
)
embed.add_field(name="🏟️ Arena", value=location or "Unknown", inline=False)
+ if broadcast_str:
+ embed.add_field(name="📺 Broadcast", value=broadcast_str, inline=False)
embed.set_footer(text="🏀 Pre-Game Alert | Provided by NBA.com")
return embed
@@ -348,3 +360,327 @@ def build_score_update_embed(
)
embed.set_footer(text="🏀Provided by NBA.com")
return embed
+
+
+async def build_standings_embeds(
+ ctx: commands.Context,
+ data: dict,
+ conference: Optional[str],
+) -> List[discord.Embed]:
+ """Build paginated embeds for NBA standings from LeagueStandingsV3."""
+ rows = _parse_result_set(data, "Standings")
+ if not rows:
+ await ctx.send("No standings data available right now.")
+ return []
+
+ color = await ctx.embed_color()
+ conf_filter = conference.lower() if conference else None
+
+ def _make_embed(teams: list, conf_name: str, page_num: int, total: int) -> discord.Embed:
+ embed = discord.Embed(
+ title=f"🏀 NBA Standings — {conf_name}ern Conference",
+ color=color,
+ )
+ for row in teams:
+ rank = row.get("PlayoffRank", "?")
+ city = row.get("TeamCity", "")
+ name = row.get("TeamName", "")
+ full_name = f"{city} {name}".strip()
+ clinch = row.get("ClinchIndicator") or ""
+ clinch_str = f" `{clinch}`" if clinch else ""
+ w = row.get("WINS", 0)
+ l = row.get("LOSSES", 0)
+ pct = row.get("WinPCT", 0.0)
+ gb = row.get("ConferenceGamesBack") or "—"
+ home = row.get("HOME", "?")
+ road = row.get("ROAD", "?")
+ l10 = row.get("L10", "?")
+ streak = row.get("strCurrentStreak", "?")
+ emoji = team_emojis.get(name, "")
+ prefix = f"{emoji} " if emoji else ""
+ embed.add_field(
+ name=f"`{rank:>2}.` {prefix}**{full_name}**{clinch_str}",
+ value=(
+ f"**{w}‑{l}** ({pct:.3f}) · GB: {gb} · "
+ f"Home: {home} · Road: {road} · L10: {l10} · *{streak}*"
+ ),
+ inline=False,
+ )
+ embed.set_footer(text=f"🏀 Provided by NBA.com | Page {page_num}/{total}")
+ return embed
+
+ east = [r for r in rows if r.get("Conference", "").lower() == "east"]
+ west = [r for r in rows if r.get("Conference", "").lower() == "west"]
+ pages = []
+ pairs = []
+ if conf_filter in (None, "east") and east:
+ pairs.append((east, "East"))
+ if conf_filter in (None, "west") and west:
+ pairs.append((west, "West"))
+ total = len(pairs)
+ for i, (teams, conf_name) in enumerate(pairs, start=1):
+ pages.append(_make_embed(teams, conf_name, i, total))
+ return pages
+
+
+async def build_leaders_embeds(
+ ctx: commands.Context,
+ data: dict,
+ category: str,
+ label: str,
+) -> List[discord.Embed]:
+ """Build paginated embeds for NBA stat leaders from LeagueDashPlayerStats."""
+ rows = _parse_result_set(data, "LeagueDashPlayerStats")
+ if not rows:
+ await ctx.send("No player stats data available right now.")
+ return []
+
+ rows.sort(key=lambda r: r.get(category, 0) or 0, reverse=True)
+ top = rows[:15]
+
+ color = await ctx.embed_color()
+ pages_data = [top[i : i + 5] for i in range(0, len(top), 5)]
+ pages = []
+ for page_idx, chunk in enumerate(pages_data):
+ embed = discord.Embed(
+ title=f"🏀 NBA League Leaders — {label} Per Game",
+ color=color,
+ )
+ for rank, row in enumerate(chunk, start=page_idx * 5 + 1):
+ player = row.get("PLAYER_NAME", "Unknown")
+ team = row.get("TEAM_ABBREVIATION", "?")
+ stat_val = row.get(category, 0) or 0
+ gp = row.get("GP", 0) or 0
+ embed.add_field(
+ name=f"`{rank:>2}.` **{player}** ({team})",
+ value=f"**{stat_val:.1f}** {label.lower()} per game · {gp} GP",
+ inline=False,
+ )
+ embed.set_footer(
+ text=f"🏀 Provided by NBA.com | Page {page_idx + 1}/{len(pages_data)} | Regular Season"
+ )
+ pages.append(embed)
+ return pages
+
+
+async def build_player_embeds(
+ ctx: commands.Context,
+ info_data: dict,
+ career_data: dict,
+ matched_name: str,
+) -> List[discord.Embed]:
+ """Build paginated embeds for a player's bio and career stats."""
+ color = await ctx.embed_color()
+ info_rows = _parse_result_set(info_data, "CommonPlayerInfo")
+ if not info_rows:
+ await ctx.send(f"No player info found for **{matched_name}**.")
+ return []
+
+ p = info_rows[0]
+ display_name = p.get("DISPLAY_FIRST_LAST") or matched_name
+ team_name = p.get("TEAM_NAME") or "Free Agent"
+ team_city = p.get("TEAM_CITY") or ""
+ full_team = f"{team_city} {team_name}".strip() if team_city else team_name
+ position = p.get("POSITION") or "N/A"
+ jersey = p.get("JERSEY") or "N/A"
+ height = p.get("HEIGHT") or "N/A"
+ weight = p.get("WEIGHT") or "N/A"
+ country = p.get("COUNTRY") or "N/A"
+ birthdate = (p.get("BIRTHDATE") or "")[:10] or "N/A"
+ school = p.get("SCHOOL") or "N/A"
+ experience = p.get("SEASON_EXP")
+ exp_str = f"{experience} yr{'s' if experience != 1 else ''}" if experience is not None else "Rookie"
+ draft_year = p.get("DRAFT_YEAR") or "Undrafted"
+ draft_round = p.get("DRAFT_ROUND") or ""
+ draft_number = p.get("DRAFT_NUMBER") or ""
+ draft_str = (
+ f"Round {draft_round}, Pick {draft_number} ({draft_year})"
+ if draft_round and draft_number and draft_year != "Undrafted"
+ else str(draft_year)
+ )
+ greatest_75 = p.get("GREATEST_75_FLAG") == "Y"
+ roster_status = p.get("ROSTERSTATUS") or "Inactive"
+
+ bio_embed = discord.Embed(
+ title=f"🏀 {display_name}",
+ color=color,
+ )
+ bio_embed.add_field(name="Team", value=full_team, inline=True)
+ bio_embed.add_field(name="Position", value=position, inline=True)
+ bio_embed.add_field(name="Jersey", value=f"#{jersey}", inline=True)
+ bio_embed.add_field(name="Height / Weight", value=f"{height} · {weight} lbs", inline=True)
+ bio_embed.add_field(name="Country", value=country, inline=True)
+ bio_embed.add_field(name="Experience", value=exp_str, inline=True)
+ bio_embed.add_field(name="Birthdate", value=birthdate, inline=True)
+ bio_embed.add_field(name="College / School", value=school, inline=True)
+ bio_embed.add_field(name="Draft", value=draft_str, inline=True)
+ bio_embed.add_field(name="Status", value=roster_status, inline=True)
+ if greatest_75:
+ bio_embed.add_field(name="🏆 NBA 75th Anniversary", value="Yes", inline=True)
+ bio_embed.set_footer(text="🏀 Provided by NBA.com | Page 1/2")
+ season_rows = _parse_result_set(career_data, "SeasonTotalsRegularSeason")
+ career_rows = _parse_result_set(career_data, "CareerTotalsRegularSeason")
+ stats_embed = discord.Embed(
+ title=f"🏀 {display_name} — Career Stats (Regular Season)",
+ color=color,
+ )
+ recent = sorted(season_rows, key=lambda r: r.get("SEASON_ID", ""), reverse=True)[:5]
+ for row in recent:
+ season = row.get("SEASON_ID", "?")
+ team_abb = row.get("TEAM_ABBREVIATION", "?")
+ gp = row.get("GP", 0)
+ pts = row.get("PTS", 0) or 0
+ reb = row.get("REB", 0) or 0
+ ast = row.get("AST", 0) or 0
+ stl = row.get("STL", 0) or 0
+ blk = row.get("BLK", 0) or 0
+ fg_pct = row.get("FG_PCT", 0.0) or 0.0
+ fg3_pct = row.get("FG3_PCT", 0.0) or 0.0
+ ft_pct = row.get("FT_PCT", 0.0) or 0.0
+ min_per_g = (row.get("MIN", 0) or 0) / gp if gp else 0
+ pts_per_g = pts / gp if gp else 0
+ reb_per_g = reb / gp if gp else 0
+ ast_per_g = ast / gp if gp else 0
+ stl_per_g = stl / gp if gp else 0
+ blk_per_g = blk / gp if gp else 0
+ stats_embed.add_field(
+ name=f"{season} — {team_abb} ({gp} GP)",
+ value=(
+ f"**{pts_per_g:.1f}** PTS · **{reb_per_g:.1f}** REB · **{ast_per_g:.1f}** AST · "
+ f"**{stl_per_g:.1f}** STL · **{blk_per_g:.1f}** BLK\n"
+ f"FG: {fg_pct:.1%} · 3P: {fg3_pct:.1%} · FT: {ft_pct:.1%} · "
+ f"{min_per_g:.1f} MPG"
+ ),
+ inline=False,
+ )
+
+ if career_rows:
+ c = career_rows[0]
+ c_gp = c.get("GP", 0) or 0
+ c_pts = (c.get("PTS", 0) or 0) / c_gp if c_gp else 0
+ c_reb = (c.get("REB", 0) or 0) / c_gp if c_gp else 0
+ c_ast = (c.get("AST", 0) or 0) / c_gp if c_gp else 0
+ c_stl = (c.get("STL", 0) or 0) / c_gp if c_gp else 0
+ c_blk = (c.get("BLK", 0) or 0) / c_gp if c_gp else 0
+ c_fg = c.get("FG_PCT", 0.0) or 0.0
+ c_fg3 = c.get("FG3_PCT", 0.0) or 0.0
+ c_ft = c.get("FT_PCT", 0.0) or 0.0
+ stats_embed.add_field(
+ name="📊 Career Averages",
+ value=(
+ f"**{c_pts:.1f}** PTS · **{c_reb:.1f}** REB · **{c_ast:.1f}** AST · "
+ f"**{c_stl:.1f}** STL · **{c_blk:.1f}** BLK\n"
+ f"FG: {c_fg:.1%} · 3P: {c_fg3:.1%} · FT: {c_ft:.1%} · {c_gp} GP"
+ ),
+ inline=False,
+ )
+
+ if not season_rows and not career_rows:
+ stats_embed.description = "No career stats available."
+ stats_embed.set_footer(text="🏀 Provided by NBA.com | Page 2/2 | Last 5 seasons shown")
+
+ return [bio_embed, stats_embed]
+
+
+async def build_roster_embeds(
+ ctx: commands.Context,
+ data: dict,
+ team_display_name: str,
+) -> List[discord.Embed]:
+ """Build paginated embeds for a team's current roster from CommonTeamRoster."""
+ rows = _parse_result_set(data, "CommonTeamRoster")
+ if not rows:
+ await ctx.send(f"No roster data found for **{team_display_name}**.")
+ return []
+
+ color = await ctx.embed_color()
+ per_page = 12
+ pages = []
+ total_pages = math.ceil(len(rows) / per_page)
+ for page_idx in range(total_pages):
+ chunk = rows[page_idx * per_page : (page_idx + 1) * per_page]
+ embed = discord.Embed(
+ title=f"🏀 {team_display_name} — Roster",
+ color=color,
+ )
+ for player in chunk:
+ name = player.get("PLAYER", "Unknown")
+ num = player.get("NUM") or "—"
+ pos = player.get("POSITION") or "—"
+ height = player.get("HEIGHT") or "—"
+ weight = player.get("WEIGHT") or "—"
+ age = player.get("AGE") or "—"
+ exp = player.get("EXP") or "R"
+ exp_str = f"{exp} yr{'s' if exp not in ('R', '1') else ''}" if exp != "R" else "Rookie"
+ embed.add_field(
+ name=f"#{num} — **{name}**",
+ value=(
+ f"Pos: {pos} · {height} · {weight} lbs\n"
+ f"Age: {age} · Exp: {exp_str}"
+ ),
+ inline=True,
+ )
+ embed.set_footer(
+ text=f"🏀 Provided by NBA.com | Page {page_idx + 1}/{total_pages}"
+ )
+ pages.append(embed)
+ return pages
+
+
+async def build_teamstats_embeds(
+ ctx: commands.Context,
+ data: dict,
+ team_display_name: str,
+ team_api_name: str,
+) -> List[discord.Embed]:
+ """Build an embed for a team's season averages from LeagueDashTeamStats."""
+ rows = _parse_result_set(data, "LeagueDashTeamStats")
+ team_row = next(
+ (r for r in rows if r.get("TEAM_NAME", "").lower() == team_api_name.lower()),
+ None,
+ )
+ if not team_row:
+ await ctx.send(f"No season stats found for **{team_display_name}**.")
+ return []
+
+ color = await ctx.embed_color()
+ gp = team_row.get("GP") or 0
+ w = team_row.get("W") or 0
+ l = team_row.get("L") or 0
+ w_pct = team_row.get("W_PCT") or 0.0
+ pts = team_row.get("PTS") or 0.0
+ reb = team_row.get("REB") or 0.0
+ ast = team_row.get("AST") or 0.0
+ stl = team_row.get("STL") or 0.0
+ blk = team_row.get("BLK") or 0.0
+ tov = team_row.get("TOV") or 0.0
+ fg_pct = team_row.get("FG_PCT") or 0.0
+ fg3_pct = team_row.get("FG3_PCT") or 0.0
+ ft_pct = team_row.get("FT_PCT") or 0.0
+ plus_minus = team_row.get("PLUS_MINUS") or 0.0
+ oreb = team_row.get("OREB") or 0.0
+ dreb = team_row.get("DREB") or 0.0
+ emoji = team_emojis.get(team_api_name, "")
+ title_prefix = f"{emoji} " if emoji else ""
+
+ embed = discord.Embed(
+ title=f"🏀 {title_prefix}{team_display_name} — Season Averages",
+ color=color,
+ )
+ embed.add_field(name="Record", value=f"**{w}‑{l}** ({w_pct:.3f}) · {gp} GP", inline=False)
+ embed.add_field(name="Points", value=f"**{pts:.1f}** PTS/G", inline=True)
+ embed.add_field(name="Rebounds", value=f"**{reb:.1f}** REB/G", inline=True)
+ embed.add_field(name="Assists", value=f"**{ast:.1f}** AST/G", inline=True)
+ embed.add_field(name="Steals", value=f"**{stl:.1f}** STL/G", inline=True)
+ embed.add_field(name="Blocks", value=f"**{blk:.1f}** BLK/G", inline=True)
+ embed.add_field(name="Turnovers", value=f"**{tov:.1f}** TOV/G", inline=True)
+ embed.add_field(name="Off. Reb.", value=f"**{oreb:.1f}** OREB/G", inline=True)
+ embed.add_field(name="Def. Reb.", value=f"**{dreb:.1f}** DREB/G", inline=True)
+ embed.add_field(name="+/−", value=f"**{plus_minus:+.1f}**", inline=True)
+ embed.add_field(
+ name="Shooting Splits",
+ value=f"FG: {fg_pct:.1%} · 3P: {fg3_pct:.1%} · FT: {ft_pct:.1%}",
+ inline=False,
+ )
+ embed.set_footer(text="🏀 Provided by NBA.com | Regular Season")
+ return [embed]
From 21f7afe4d789444feb48bc8609b5cdd845d032a0 Mon Sep 17 00:00:00 2001
From: Evanroby <107794516+Evanroby@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:17:35 +0200
Subject: [PATCH 2/4] use sync with instead of wait on ctx.typing
---
history/history.py | 120 +++++++-------
honeycombs/honeycombs.py | 46 +++---
pokemon/commands/pokeinfo.py | 28 ++--
pokemon/commands/tcgcard.py | 235 ++++++++++++++--------------
pokemon/commands/whosthatpokemon.py | 126 +++++++--------
themoviedb/tmdb_utils.py | 45 +++---
6 files changed, 302 insertions(+), 298 deletions(-)
diff --git a/history/history.py b/history/history.py
index 639956ec..15b0d690 100644
--- a/history/history.py
+++ b/history/history.py
@@ -115,64 +115,66 @@ async def history(
- `[p]history` - Events for today.
- `[p]history 12 25` - Events for December 25.
"""
- await ctx.typing()
- user_tz: str = await self.config.user(ctx.author).timezone()
- try:
- tz = ZoneInfo(user_tz)
- except ZoneInfoNotFoundError:
- tz = ZoneInfo("UTC")
- log.warning(
- f"Invalid timezone '{user_tz}' for user {ctx.author.id}, falling back to UTC."
- )
-
- if (month is None) != (day is None):
- return await ctx.send("Please provide both month and day, or neither.")
-
- if month is None or day is None:
- today = datetime.now(tz)
- month = today.month
- day = today.day
- display_date: str = today.strftime("%B %d")
- else:
+ async with ctx.typing():
+ user_tz: str = await self.config.user(ctx.author).timezone()
try:
- display_date = datetime(2000, month, day).strftime("%B %d")
- except ValueError:
- return await ctx.send("Invalid date. Please provide a real month (1-12) and day.")
-
- month_str: str = f"{month:02d}"
- day_str: str = f"{day:02d}"
-
- try:
- events: list[dict[str, Any]] = await fetch_events(self.session, month_str, day_str)
- except ValueError as e:
- log.error(
- f"Failed to fetch events for {month_str}/{day_str}: {str(e)}",
- exc_info=True,
- )
- return await ctx.send(
- f"Failed to fetch events for {display_date}. Please try again later."
- )
-
- if not events:
- return await ctx.send(f"No notable events found for {display_date}.")
+ tz = ZoneInfo(user_tz)
+ except ZoneInfoNotFoundError:
+ tz = ZoneInfo("UTC")
+ log.warning(
+ f"Invalid timezone '{user_tz}' for user {ctx.author.id}, falling back to UTC."
+ )
+
+ if (month is None) != (day is None):
+ return await ctx.send("Please provide both month and day, or neither.")
+
+ if month is None or day is None:
+ today = datetime.now(tz)
+ month = today.month
+ day = today.day
+ display_date: str = today.strftime("%B %d")
+ else:
+ try:
+ display_date = datetime(2000, month, day).strftime("%B %d")
+ except ValueError:
+ return await ctx.send(
+ "Invalid date. Please provide a real month (1-12) and day."
+ )
+
+ month_str: str = f"{month:02d}"
+ day_str: str = f"{day:02d}"
- pages: list[discord.Embed] = []
- items_per_page: int = 10
- for i in range(0, len(events), items_per_page):
- chunk = events[i : i + items_per_page]
- embed = discord.Embed(
- title=f"On This Day: {display_date}", color=await ctx.embed_color()
- )
- for event in chunk:
- year: str | int = event.get("year", "Unknown Year")
- text: str = event.get("text", "No description available.")
- display_year: str = format_year(year)
- embed.add_field(name=display_year, value=text, inline=False)
- current_page: int = i // items_per_page + 1
- total_pages: int = (len(events) - 1) // items_per_page + 1
- embed.set_footer(
- text=f"Source: Wikipedia | Timezone: {user_tz} | Page {current_page}/{total_pages}",
- icon_url=WIKIPEDIA_LOGO,
- )
- pages.append(embed)
- await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+ try:
+ events: list[dict[str, Any]] = await fetch_events(self.session, month_str, day_str)
+ except ValueError as e:
+ log.error(
+ f"Failed to fetch events for {month_str}/{day_str}: {str(e)}",
+ exc_info=True,
+ )
+ return await ctx.send(
+ f"Failed to fetch events for {display_date}. Please try again later."
+ )
+
+ if not events:
+ return await ctx.send(f"No notable events found for {display_date}.")
+
+ pages: list[discord.Embed] = []
+ items_per_page: int = 10
+ for i in range(0, len(events), items_per_page):
+ chunk = events[i : i + items_per_page]
+ embed = discord.Embed(
+ title=f"On This Day: {display_date}", color=await ctx.embed_color()
+ )
+ for event in chunk:
+ year: str | int = event.get("year", "Unknown Year")
+ text: str = event.get("text", "No description available.")
+ display_year: str = format_year(year)
+ embed.add_field(name=display_year, value=text, inline=False)
+ current_page: int = i // items_per_page + 1
+ total_pages: int = (len(events) - 1) // items_per_page + 1
+ embed.set_footer(
+ text=f"Source: Wikipedia | Timezone: {user_tz} | Page {current_page}/{total_pages}",
+ icon_url=WIKIPEDIA_LOGO,
+ )
+ pages.append(embed)
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
diff --git a/honeycombs/honeycombs.py b/honeycombs/honeycombs.py
index dd72c29f..fcf3baae 100644
--- a/honeycombs/honeycombs.py
+++ b/honeycombs/honeycombs.py
@@ -434,29 +434,31 @@ async def setimage(self, ctx: commands.Context, *, image_url: Optional[str] = No
You can set the image by providing a URL or by attaching an image/gif to the command.
Only JPG / JPEG / PNG / GIF / WEBP format is supported.
"""
- await ctx.typing()
- if len(ctx.message.attachments) > 0:
- image_url = ctx.message.attachments[0].url
- elif image_url is None:
- return await ctx.send("You must provide a URL or attach an image.")
-
- try:
- async with self.session.get(image_url, timeout=aiohttp.ClientTimeout(total=10)) as r:
- if r.status != 200:
- return await ctx.send("Failed to set the start image. URL is invalid.")
- data = await r.read()
- except aiohttp.ClientError:
- return await ctx.send("Failed to set the start image. Client error.")
- except asyncio.TimeoutError:
- return await ctx.send("Failed to set the start image. Timeout error.")
-
- if not data or not any(data.startswith(sig) for sig in MAGIC_BYTES):
- return await ctx.send(
- f"Failed to set the start image. Only {humanize_list(list(MAGIC_BYTES.values()))} format is supported."
- )
+ async with ctx.typing():
+ if len(ctx.message.attachments) > 0:
+ image_url = ctx.message.attachments[0].url
+ elif image_url is None:
+ return await ctx.send("You must provide a URL or attach an image.")
+
+ try:
+ async with self.session.get(
+ image_url, timeout=aiohttp.ClientTimeout(total=10)
+ ) as r:
+ if r.status != 200:
+ return await ctx.send("Failed to set the start image. URL is invalid.")
+ data = await r.read()
+ except aiohttp.ClientError:
+ return await ctx.send("Failed to set the start image. Client error.")
+ except asyncio.TimeoutError:
+ return await ctx.send("Failed to set the start image. Timeout error.")
+
+ if not data or not any(data.startswith(sig) for sig in MAGIC_BYTES):
+ return await ctx.send(
+ f"Failed to set the start image. Only {humanize_list(list(MAGIC_BYTES.values()))} format is supported."
+ )
- await self.update_guild_config(ctx.guild, "default_start_image", image_url)
- await ctx.send("The start image has been set.")
+ await self.update_guild_config(ctx.guild, "default_start_image", image_url)
+ await ctx.send("The start image has been set.")
@setimage.command(name="clear", aliases=["reset"])
@commands.admin()
diff --git a/pokemon/commands/pokeinfo.py b/pokemon/commands/pokeinfo.py
index a120a301..ef88caf6 100644
--- a/pokemon/commands/pokeinfo.py
+++ b/pokemon/commands/pokeinfo.py
@@ -48,18 +48,18 @@ async def pokeinfo(self, ctx: commands.Context, *, pokemon: str) -> None:
**Arguments:**
- `` - The Pokémon to search for.
"""
- await ctx.typing()
- pokemon_data = await fetch_data(self.session, f"{API_URL}/pokemon/{pokemon.lower()}")
- if not pokemon_data or pokemon_data.get("http_code"):
- code = pokemon_data.get("http_code") if pokemon_data else None
- if code == 404:
- return await ctx.send(f"No Pokémon found for `{pokemon}`.")
- return await ctx.send("Could not fetch Pokémon data. Please try again later.")
+ async with ctx.typing():
+ pokemon_data = await fetch_data(self.session, f"{API_URL}/pokemon/{pokemon.lower()}")
+ if not pokemon_data or pokemon_data.get("http_code"):
+ code = pokemon_data.get("http_code") if pokemon_data else None
+ if code == 404:
+ return await ctx.send(f"No Pokémon found for `{pokemon}`.")
+ return await ctx.send("Could not fetch Pokémon data. Please try again later.")
- view = PokemonView(ctx, self.session, pokemon_data)
- try:
- embed = await create_pokemon_embed(self.session, pokemon_data, "base")
- view.message = await ctx.send(embed=embed, view=view)
- except ValueError as e:
- log.error("Error creating initial embed: %s", e, exc_info=True)
- await ctx.send("Something went wrong building the Pokémon embed.")
+ view = PokemonView(ctx, self.session, pokemon_data)
+ try:
+ embed = await create_pokemon_embed(self.session, pokemon_data, "base")
+ view.message = await ctx.send(embed=embed, view=view)
+ except ValueError as e:
+ log.error("Error creating initial embed: %s", e, exc_info=True)
+ await ctx.send("Something went wrong building the Pokémon embed.")
diff --git a/pokemon/commands/tcgcard.py b/pokemon/commands/tcgcard.py
index 9088a0e4..e7c58f4e 100644
--- a/pokemon/commands/tcgcard.py
+++ b/pokemon/commands/tcgcard.py
@@ -58,125 +58,126 @@ async def tcgcard(self, ctx: commands.Context, *, query: str) -> None:
"""
api_key = (await ctx.bot.get_shared_api_tokens("pokemontcg")).get("api_key")
headers = {"X-Api-Key": api_key} if api_key else None
- await ctx.typing()
- base_url = f"https://api.pokemontcg.io/v2/cards?q=name:{query}&select={_TCG_FIELDS}"
- try:
- async with self.session.get(base_url, headers=headers) as response:
- if response.status != 200:
- log.error("Failed to fetch TCG data — status %s", response.status)
- return await ctx.send(f"https://http.cat/{response.status}")
- output = orjson.loads(await response.read())
- except asyncio.TimeoutError:
- log.error("Timed out fetching TCG data.")
- return await ctx.send("Operation timed out.")
-
- if not output["data"]:
- return await ctx.send("There are no results for that search.")
-
- pages = []
- for i, data in enumerate(output["data"], 1):
- embed = discord.Embed(colour=discord.Color.from_rgb(255, 215, 0))
- card_name = data["name"]
- hp = data.get("hp", "N/A")
- embed.title = f"{card_name} - HP: {hp}"
-
- basic_info = []
- basic_info.append(f"{'Supertype:':<18}{data.get('supertype', 'N/A')}")
- subtypes_str = ", ".join(data.get("subtypes", [])) or "N/A"
- basic_info.append(f"{'Subtypes:':<18}{subtypes_str}")
- types_str = ", ".join(data.get("types", [])) or "N/A"
- basic_info.append(f"{'Types:':<18}{types_str}")
- basic_info.append(f"{'Evolves From:':<18}{data.get('evolvesFrom', 'None')}")
- evolves_to_str = ", ".join(data.get("evolvesTo", [])) or "None"
- basic_info.append(f"{'Evolves To:':<18}{evolves_to_str}")
- basic_info.append(f"{'Rarity:':<18}{data.get('rarity', 'Common')}")
- basic_info.append(f"{'Artist:':<18}{data.get('artist', 'N/A')}")
- basic_info.append(f"{'Regulation Mark:':<18}{data.get('regulationMark', 'N/A')}")
- basic_info.append(f"{'Card Number:':<18}{data.get('number', 'N/A')}")
- basic_info.append(f"{'Set:':<18}{data['set']['name']}")
- nat_dex_str = ", ".join(map(str, data.get("nationalPokedexNumbers", []))) or "N/A"
- basic_info.append(f"{'National Pokedex:':<18}{nat_dex_str}")
- legalities = data.get("legalities", {})
- legal_str = (
- ", ".join(k.capitalize() for k, v in legalities.items() if v == "Legal") or "None"
- )
- basic_info.append(f"{'Legalities:':<18}{legal_str}")
- embed.add_field(
- name="Card Info", value=box("\n".join(basic_info), lang="yaml"), inline=False
- )
- abilities = data.get("abilities", [])
- if abilities:
- abilities_str = []
- for ability in abilities:
- ability_name = ability.get("name", "N/A")
- ability_type = ability.get("type", "N/A")
- ability_text = ability.get("text", "")
- abilities_str.append(f"{'Name:':<18}{ability_name}")
- abilities_str.append(f"{'Type:':<18}{ability_type}")
- if ability_text:
- abilities_str.append(f"{'Text:':<18}{ability_text}")
- abilities_str.append("")
- embed.add_field(
- name="Abilities",
- value=box("\n".join(abilities_str[:-1]), lang="yaml"),
- inline=False,
- )
-
- attacks = data.get("attacks", [])
- if attacks:
- attacks_str = []
- for attack in attacks:
- attack_name = attack.get("name", "N/A")
- cost = attack.get("cost", [])
- damage = attack.get("damage", "N/A")
- attack_text = attack.get("text", "")
- converted_cost = attack.get("convertedEnergyCost", 0)
- attacks_str.append(f"{'Name:':<18}{attack_name}")
- attacks_str.append(f"{'Cost:':<18}{' '.join(cost)} ({converted_cost})")
- attacks_str.append(f"{'Damage:':<18}{damage}")
- if attack_text:
- attacks_str.append(f"{'Text:':<18}{attack_text}")
- attacks_str.append("")
- embed.add_field(
- name="Attacks",
- value=box("\n".join(attacks_str[:-1]), lang="yaml"),
- inline=False,
- )
-
- # Weaknesses / Resistances / Retreat
- wr_info = []
- weaknesses = data.get("weaknesses", [])
- if weaknesses:
- wr_info.append(
- f"{'Weakness:':<18}{weaknesses[0].get('type', 'N/A')} ({weaknesses[0].get('value', 'N/A')})"
- )
- resistances = data.get("resistances", [])
- if resistances:
- wr_info.append(
- f"{'Resistance:':<18}{resistances[0].get('type', 'N/A')} ({resistances[0].get('value', 'N/A')})"
+ async with ctx.typing():
+ base_url = f"https://api.pokemontcg.io/v2/cards?q=name:{query}&select={_TCG_FIELDS}"
+ try:
+ async with self.session.get(base_url, headers=headers) as response:
+ if response.status != 200:
+ log.error("Failed to fetch TCG data — status %s", response.status)
+ return await ctx.send(f"https://http.cat/{response.status}")
+ output = orjson.loads(await response.read())
+ except asyncio.TimeoutError:
+ log.error("Timed out fetching TCG data.")
+ return await ctx.send("Operation timed out.")
+
+ if not output["data"]:
+ return await ctx.send("There are no results for that search.")
+
+ pages = []
+ for i, data in enumerate(output["data"], 1):
+ embed = discord.Embed(colour=discord.Color.from_rgb(255, 215, 0))
+ card_name = data["name"]
+ hp = data.get("hp", "N/A")
+ embed.title = f"{card_name} - HP: {hp}"
+
+ basic_info = []
+ basic_info.append(f"{'Supertype:':<18}{data.get('supertype', 'N/A')}")
+ subtypes_str = ", ".join(data.get("subtypes", [])) or "N/A"
+ basic_info.append(f"{'Subtypes:':<18}{subtypes_str}")
+ types_str = ", ".join(data.get("types", [])) or "N/A"
+ basic_info.append(f"{'Types:':<18}{types_str}")
+ basic_info.append(f"{'Evolves From:':<18}{data.get('evolvesFrom', 'None')}")
+ evolves_to_str = ", ".join(data.get("evolvesTo", [])) or "None"
+ basic_info.append(f"{'Evolves To:':<18}{evolves_to_str}")
+ basic_info.append(f"{'Rarity:':<18}{data.get('rarity', 'Common')}")
+ basic_info.append(f"{'Artist:':<18}{data.get('artist', 'N/A')}")
+ basic_info.append(f"{'Regulation Mark:':<18}{data.get('regulationMark', 'N/A')}")
+ basic_info.append(f"{'Card Number:':<18}{data.get('number', 'N/A')}")
+ basic_info.append(f"{'Set:':<18}{data['set']['name']}")
+ nat_dex_str = ", ".join(map(str, data.get("nationalPokedexNumbers", []))) or "N/A"
+ basic_info.append(f"{'National Pokedex:':<18}{nat_dex_str}")
+ legalities = data.get("legalities", {})
+ legal_str = (
+ ", ".join(k.capitalize() for k, v in legalities.items() if v == "Legal")
+ or "None"
)
- retreat_cost = data.get("retreatCost", [])
- converted_retreat = data.get("convertedRetreatCost", 0)
- retreat_symbols = "🌟" * len(retreat_cost) if retreat_cost else "None"
- wr_info.append(f"{'Retreat Cost:':<18}{retreat_symbols} ({converted_retreat})")
- if wr_info:
+ basic_info.append(f"{'Legalities:':<18}{legal_str}")
embed.add_field(
- name="Weaknesses / Resistances / Retreat",
- value=box("\n".join(wr_info), lang="yaml"),
- inline=False,
+ name="Card Info", value=box("\n".join(basic_info), lang="yaml"), inline=False
)
-
- flavor_text = data.get("flavorText", "")
- if flavor_text:
- embed.add_field(
- name="Flavor Text", value=box(flavor_text, lang="yaml"), inline=False
+ abilities = data.get("abilities", [])
+ if abilities:
+ abilities_str = []
+ for ability in abilities:
+ ability_name = ability.get("name", "N/A")
+ ability_type = ability.get("type", "N/A")
+ ability_text = ability.get("text", "")
+ abilities_str.append(f"{'Name:':<18}{ability_name}")
+ abilities_str.append(f"{'Type:':<18}{ability_type}")
+ if ability_text:
+ abilities_str.append(f"{'Text:':<18}{ability_text}")
+ abilities_str.append("")
+ embed.add_field(
+ name="Abilities",
+ value=box("\n".join(abilities_str[:-1]), lang="yaml"),
+ inline=False,
+ )
+
+ attacks = data.get("attacks", [])
+ if attacks:
+ attacks_str = []
+ for attack in attacks:
+ attack_name = attack.get("name", "N/A")
+ cost = attack.get("cost", [])
+ damage = attack.get("damage", "N/A")
+ attack_text = attack.get("text", "")
+ converted_cost = attack.get("convertedEnergyCost", 0)
+ attacks_str.append(f"{'Name:':<18}{attack_name}")
+ attacks_str.append(f"{'Cost:':<18}{' '.join(cost)} ({converted_cost})")
+ attacks_str.append(f"{'Damage:':<18}{damage}")
+ if attack_text:
+ attacks_str.append(f"{'Text:':<18}{attack_text}")
+ attacks_str.append("")
+ embed.add_field(
+ name="Attacks",
+ value=box("\n".join(attacks_str[:-1]), lang="yaml"),
+ inline=False,
+ )
+
+ # Weaknesses / Resistances / Retreat
+ wr_info = []
+ weaknesses = data.get("weaknesses", [])
+ if weaknesses:
+ wr_info.append(
+ f"{'Weakness:':<18}{weaknesses[0].get('type', 'N/A')} ({weaknesses[0].get('value', 'N/A')})"
+ )
+ resistances = data.get("resistances", [])
+ if resistances:
+ wr_info.append(
+ f"{'Resistance:':<18}{resistances[0].get('type', 'N/A')} ({resistances[0].get('value', 'N/A')})"
+ )
+ retreat_cost = data.get("retreatCost", [])
+ converted_retreat = data.get("convertedRetreatCost", 0)
+ retreat_symbols = "🌟" * len(retreat_cost) if retreat_cost else "None"
+ wr_info.append(f"{'Retreat Cost:':<18}{retreat_symbols} ({converted_retreat})")
+ if wr_info:
+ embed.add_field(
+ name="Weaknesses / Resistances / Retreat",
+ value=box("\n".join(wr_info), lang="yaml"),
+ inline=False,
+ )
+
+ flavor_text = data.get("flavorText", "")
+ if flavor_text:
+ embed.add_field(
+ name="Flavor Text", value=box(flavor_text, lang="yaml"), inline=False
+ )
+
+ embed.set_thumbnail(url=str(data["set"]["images"]["logo"]))
+ embed.set_image(url=str(data["images"]["large"]))
+ embed.set_footer(
+ text=f"Page {i} of {len(output['data'])} • Powered by Pokémon TCG API!"
)
+ pages.append(embed)
- embed.set_thumbnail(url=str(data["set"]["images"]["logo"]))
- embed.set_image(url=str(data["images"]["large"]))
- embed.set_footer(
- text=f"Page {i} of {len(output['data'])} • Powered by Pokémon TCG API!"
- )
- pages.append(embed)
-
- await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
diff --git a/pokemon/commands/whosthatpokemon.py b/pokemon/commands/whosthatpokemon.py
index 3b6e4a36..e5668e8d 100644
--- a/pokemon/commands/whosthatpokemon.py
+++ b/pokemon/commands/whosthatpokemon.py
@@ -76,67 +76,67 @@ async def whosthatpokemon(
**Arguments:**
- `[generation]` - Where you choose any generation from gen 1 to gen 9.
"""
- await ctx.typing()
- poke_id = generation or randint(1, 1010)
- poke_id_str = f"{poke_id:>03}"
- (species_data, pokemon_data), (hidden_temp, revealed_temp) = await asyncio.gather(
- asyncio.gather(
- fetch_data(self.session, f"{API_URL}/pokemon-species/{poke_id}"),
- fetch_data(self.session, f"{API_URL}/pokemon/{poke_id}"),
- ),
- asyncio.gather(
- generate_image(self, poke_id_str, hide=True),
- generate_image(self, poke_id_str, hide=False),
- ),
- )
-
- if not species_data or species_data.get("http_code"):
- log.error("Failed to get species data: %s", species_data)
- return await ctx.send("Failed to get species data from PokéAPI.")
- if not pokemon_data or pokemon_data.get("http_code"):
- log.error("Failed to get Pokémon data: %s", pokemon_data)
- return await ctx.send("Failed to fetch Pokémon data.")
- if hidden_temp is None or revealed_temp is None:
- log.error("Failed to generate image for poke_id %s", poke_id)
- return await ctx.send("Failed to generate whosthatpokemon card image.")
-
- names_data = species_data.get("names", [{}])
- eligible_names = [x["name"].lower() for x in names_data]
- english_name = next(
- (x["name"] for x in names_data if x["language"]["name"] == "en"),
- "Unknown",
- )
-
- img_timeout = discord.utils.format_dt(
- datetime.now(timezone.utc) + timedelta(seconds=30.0), "R"
- )
-
- view = WhosThatPokemonView(eligible_names)
- hint_view = HintView(
- {"species_data": species_data, "pokemon_data": pokemon_data},
- english_name,
- )
- view.add_item(hint_view.hint_button)
-
- view.message = await ctx.send(
- f"**Who's that Pokémon?**\nI need a valid answer at most {img_timeout}.\n"
- "Use the hint button for help (one use only)!",
- file=File(hidden_temp, "guessthatpokemon.png"),
- view=view,
- )
-
- embed = discord.Embed(
- title=":tada: You got it right! :tada:",
- description=f"The Pokemon was... **{english_name}**.",
- color=0x76EE00,
- )
- embed.set_image(url="attachment://whosthatpokemon.png")
- embed.set_footer(text=f"Author: {ctx.author}", icon_url=ctx.author.display_avatar.url)
-
- timed_out = await view.wait()
- if timed_out:
- return await ctx.send(
- f"{ctx.author.mention} You took too long to answer.\n"
- f"The Pokemon was... **{english_name}**."
+ async with ctx.typing():
+ poke_id = generation or randint(1, 1010)
+ poke_id_str = f"{poke_id:>03}"
+ (species_data, pokemon_data), (hidden_temp, revealed_temp) = await asyncio.gather(
+ asyncio.gather(
+ fetch_data(self.session, f"{API_URL}/pokemon-species/{poke_id}"),
+ fetch_data(self.session, f"{API_URL}/pokemon/{poke_id}"),
+ ),
+ asyncio.gather(
+ generate_image(self, poke_id_str, hide=True),
+ generate_image(self, poke_id_str, hide=False),
+ ),
)
- await ctx.send(file=File(revealed_temp, "whosthatpokemon.png"), embed=embed)
+
+ if not species_data or species_data.get("http_code"):
+ log.error("Failed to get species data: %s", species_data)
+ return await ctx.send("Failed to get species data from PokéAPI.")
+ if not pokemon_data or pokemon_data.get("http_code"):
+ log.error("Failed to get Pokémon data: %s", pokemon_data)
+ return await ctx.send("Failed to fetch Pokémon data.")
+ if hidden_temp is None or revealed_temp is None:
+ log.error("Failed to generate image for poke_id %s", poke_id)
+ return await ctx.send("Failed to generate whosthatpokemon card image.")
+
+ names_data = species_data.get("names", [{}])
+ eligible_names = [x["name"].lower() for x in names_data]
+ english_name = next(
+ (x["name"] for x in names_data if x["language"]["name"] == "en"),
+ "Unknown",
+ )
+
+ img_timeout = discord.utils.format_dt(
+ datetime.now(timezone.utc) + timedelta(seconds=30.0), "R"
+ )
+
+ view = WhosThatPokemonView(eligible_names)
+ hint_view = HintView(
+ {"species_data": species_data, "pokemon_data": pokemon_data},
+ english_name,
+ )
+ view.add_item(hint_view.hint_button)
+
+ view.message = await ctx.send(
+ f"**Who's that Pokémon?**\nI need a valid answer at most {img_timeout}.\n"
+ "Use the hint button for help (one use only)!",
+ file=File(hidden_temp, "guessthatpokemon.png"),
+ view=view,
+ )
+
+ embed = discord.Embed(
+ title=":tada: You got it right! :tada:",
+ description=f"The Pokemon was... **{english_name}**.",
+ color=0x76EE00,
+ )
+ embed.set_image(url="attachment://whosthatpokemon.png")
+ embed.set_footer(text=f"Author: {ctx.author}", icon_url=ctx.author.display_avatar.url)
+
+ timed_out = await view.wait()
+ if timed_out:
+ return await ctx.send(
+ f"{ctx.author.mention} You took too long to answer.\n"
+ f"The Pokemon was... **{english_name}**."
+ )
+ await ctx.send(file=File(revealed_temp, "whosthatpokemon.png"), embed=embed)
diff --git a/themoviedb/tmdb_utils.py b/themoviedb/tmdb_utils.py
index e1662641..47009b7b 100644
--- a/themoviedb/tmdb_utils.py
+++ b/themoviedb/tmdb_utils.py
@@ -23,7 +23,6 @@
"""
import asyncio
-import re
import urllib.parse
from datetime import datetime
from typing import Any
@@ -32,7 +31,7 @@
import discord
import orjson
from red_commons.logging import getLogger
-from redbot.core.utils.chat_formatting import box, header, humanize_list, humanize_number
+from redbot.core.utils.chat_formatting import header, humanize_list, humanize_number
from redbot.core.utils.views import SimpleMenu
log = getLogger("red.maxcogs.themoviedb.tmdb_utils")
@@ -289,18 +288,18 @@ async def build_embed(ctx, data, item_id, index, results, item_type="movie"):
async def search_and_display(ctx, query: str, media_type: str):
"""Search TMDB and display results with a paginated layout and selection buttons."""
- await ctx.typing()
- api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key")
- if not api_key:
- return await ctx.send("TMDB API key is missing.")
+ async with ctx.typing():
+ api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key")
+ if not api_key:
+ return await ctx.send("TMDB API key is missing.")
- async with aiohttp.ClientSession() as session:
- initial_data = await search_media(ctx, session, query, media_type, api_key=api_key)
- if not await validate_results(ctx, initial_data, query):
- return
+ async with aiohttp.ClientSession() as session:
+ initial_data = await search_media(ctx, session, query, media_type, api_key=api_key)
+ if not await validate_results(ctx, initial_data, query):
+ return
- total_pages = min(initial_data.get("total_pages", 1), 20)
- sem = asyncio.Semaphore(5)
+ total_pages = min(initial_data.get("total_pages", 1), 20)
+ sem = asyncio.Semaphore(5)
async def fetch_page(page):
async with sem:
@@ -530,19 +529,19 @@ async def callback(self, interaction: discord.Interaction) -> None:
async def person_embed(ctx, query: str):
"""Search and display person information from TMDB."""
- await ctx.typing()
- api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key")
- if not api_key:
- return await ctx.send("TMDB API key is missing.")
+ async with ctx.typing():
+ api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key")
+ if not api_key:
+ return await ctx.send("TMDB API key is missing.")
- async with aiohttp.ClientSession() as session:
- people_data = await search_media(ctx, session, query, "person", api_key=api_key)
- if not await validate_results(ctx, people_data, query):
- return
+ async with aiohttp.ClientSession() as session:
+ people_data = await search_media(ctx, session, query, "person", api_key=api_key)
+ if not await validate_results(ctx, people_data, query):
+ return
- sorted_people = sorted(
- people_data["results"], key=lambda x: x.get("popularity", 0), reverse=True
- )
+ sorted_people = sorted(
+ people_data["results"], key=lambda x: x.get("popularity", 0), reverse=True
+ )
async def fetch_person(person):
return await get_media_data(ctx, session, person["id"], "person", api_key=api_key)
From 066e85d87fa46e7efb5f0ba123f36b674063dd4a Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Mon, 30 Mar 2026 16:21:54 +0000
Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
README.md | 2 +-
easterhunt/info.json | 6 +++---
heist/README.md | 2 +-
nba/formatters.py | 13 +++++--------
4 files changed, 10 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 0227e5ef..dffb1c02 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ To install any cog you want:
[p]cog install maxcogs
```
---------------------------------------------------------------
-## Cogs on this repo:
+## Cogs on this repo:
- Autopublisher
- Automatically publish messages in news channels
- Counting
diff --git a/easterhunt/info.json b/easterhunt/info.json
index 06039ef6..50546c24 100644
--- a/easterhunt/info.json
+++ b/easterhunt/info.json
@@ -9,9 +9,9 @@
"hidden": false,
"min_bot_version": "3.5.21",
"tags": [
- "easter",
- "hunting",
- "easter eggs",
+ "easter",
+ "hunting",
+ "easter eggs",
"economy"
],
"permissions": [],
diff --git a/heist/README.md b/heist/README.md
index 4f3c95bf..e62bc7c7 100644
--- a/heist/README.md
+++ b/heist/README.md
@@ -69,7 +69,7 @@ Check cooldowns for all heists.
- Usage: `[p]heist cooldowns`
- Slash Usage: `/heist cooldowns`
- Aliases: `cooldown`
-
+
# [p]heistset
Manage global heist settings.
- Usage: `[p]heistset`
diff --git a/nba/formatters.py b/nba/formatters.py
index 9463a10e..872b728f 100644
--- a/nba/formatters.py
+++ b/nba/formatters.py
@@ -489,7 +489,9 @@ async def build_player_embeds(
birthdate = (p.get("BIRTHDATE") or "")[:10] or "N/A"
school = p.get("SCHOOL") or "N/A"
experience = p.get("SEASON_EXP")
- exp_str = f"{experience} yr{'s' if experience != 1 else ''}" if experience is not None else "Rookie"
+ exp_str = (
+ f"{experience} yr{'s' if experience != 1 else ''}" if experience is not None else "Rookie"
+ )
draft_year = p.get("DRAFT_YEAR") or "Undrafted"
draft_round = p.get("DRAFT_ROUND") or ""
draft_number = p.get("DRAFT_NUMBER") or ""
@@ -614,15 +616,10 @@ async def build_roster_embeds(
exp_str = f"{exp} yr{'s' if exp not in ('R', '1') else ''}" if exp != "R" else "Rookie"
embed.add_field(
name=f"#{num} — **{name}**",
- value=(
- f"Pos: {pos} · {height} · {weight} lbs\n"
- f"Age: {age} · Exp: {exp_str}"
- ),
+ value=(f"Pos: {pos} · {height} · {weight} lbs\n" f"Age: {age} · Exp: {exp_str}"),
inline=True,
)
- embed.set_footer(
- text=f"🏀 Provided by NBA.com | Page {page_idx + 1}/{total_pages}"
- )
+ embed.set_footer(text=f"🏀 Provided by NBA.com | Page {page_idx + 1}/{total_pages}")
pages.append(embed)
return pages
From 700b7d42a83d4aa997c7d75ecbd1238d2cb6fe5b Mon Sep 17 00:00:00 2001
From: Evanroby <107794516+Evanroby@users.noreply.github.com>
Date: Mon, 30 Mar 2026 19:50:46 +0200
Subject: [PATCH 4/4] remove broadcast
---
nba/formatters.py | 3 ---
nba/nba.py | 7 -------
2 files changed, 10 deletions(-)
diff --git a/nba/formatters.py b/nba/formatters.py
index 9463a10e..c727515e 100644
--- a/nba/formatters.py
+++ b/nba/formatters.py
@@ -252,7 +252,6 @@ def build_pregame_embed(
arena_city: str,
arena_state: str,
game_id: str,
- broadcast_str: str = "",
) -> discord.Embed:
"""Build the pre-game notification embed sent 30 minutes before tip-off."""
home_emoji = team_emojis.get(home_team, "")
@@ -268,8 +267,6 @@ def build_pregame_embed(
color=0xEE6730,
)
embed.add_field(name="🏟️ Arena", value=location or "Unknown", inline=False)
- if broadcast_str:
- embed.add_field(name="📺 Broadcast", value=broadcast_str, inline=False)
embed.set_footer(text="🏀 Pre-Game Alert | Provided by NBA.com")
return embed
diff --git a/nba/nba.py b/nba/nba.py
index dd0238f1..cd464743 100644
--- a/nba/nba.py
+++ b/nba/nba.py
@@ -364,12 +364,6 @@ async def _check_pregame_notifications(self) -> None:
arena = game.get("arenaName", "Unknown")
arena_city = game.get("arenaCity", "")
arena_state = game.get("arenaState", "")
- broadcasters = []
- for b in game.get("broadcasters", {}).get("nationalBroadcasters", []):
- name = b.get("broadcasterDisplay") or b.get("broadcasterAbbreviation")
- if name:
- broadcasters.append(name)
- broadcast_str = ", ".join(broadcasters) if broadcasters else "Check local listings"
embed = build_pregame_embed(
home_team=home_team,
@@ -378,7 +372,6 @@ async def _check_pregame_notifications(self) -> None:
arena=arena,
arena_city=arena_city,
arena_state=arena_state,
- broadcast_str=broadcast_str,
game_id=game_id,
)