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/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/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..eda47aac 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) @@ -348,3 +357,324 @@ 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] 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, ) 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)