From 09e3238f6bbf810205dce848d514468f5bf75207 Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 13:48:40 -0500 Subject: [PATCH 1/7] Update space.py This version fixes the main commands and adds proper error handling. --- bot/exts/fun/space.py | 401 ++++++++++++++++++++++++++++++------------ 1 file changed, 291 insertions(+), 110 deletions(-) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index c896a37f0..06a6223e9 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -1,10 +1,12 @@ -# XXX: Disabled due to issues with NASA API, see https://github.com/python-discord/sir-lancebot/issues/1709 - +import asyncio +import json import random +from dataclasses import dataclass from datetime import UTC, date, datetime from typing import Any from urllib.parse import urlencode +import aiohttp from discord import Embed from discord.ext import tasks from discord.ext.commands import Cog, Context, group @@ -23,46 +25,204 @@ APOD_MIN_DATE = date(1995, 6, 16) +@dataclass +class NasaResult: + ok: bool + status: int | None + data: Any | None + error: str | None = None + + class Space(Cog): - """Space Cog contains commands, that show images, facts or other information about space.""" + """Space Cog contains commands that show images, facts or other information about space.""" def __init__(self, bot: Bot): self.http_session = bot.http_session self.bot = bot - self.rovers = {} + # Rover metadata is kept for compatibility, but Mars endpoints are archived. + self.rovers: dict[str, dict[str, Any]] = {} self.get_rovers.start() def cog_unload(self) -> None: - """Cancel `get_rovers` task when Cog will unload.""" + """Cancel `get_rovers` task when Cog unloads.""" self.get_rovers.cancel() + # =========================== + # NASA HTTP CLIENT / HELPERS + # =========================== + + async def fetch_from_nasa( + self, + endpoint: str, + additional_params: dict[str, Any] | None = None, + base: str | None = NASA_BASE_URL, + use_api_key: bool = True, + timeout: float = 10.0, + ) -> NasaResult: + """ + Fetch information from a NASA-related API and return a structured result. + + This wrapper: + - Adds the NASA API key when requested. + - Handles non-200 responses. + - Handles non-JSON responses. + - Catches network and timeout errors. + """ + params: dict[str, Any] = {} + + if use_api_key: + try: + api_key = Tokens.nasa.get_secret_value() + except Exception as e: + logger.warning("NASA API key is not configured correctly: %s", e) + return NasaResult(ok=False, status=None, data=None, error="NASA API key is not configured.") + if not api_key: + return NasaResult(ok=False, status=None, data=None, error="NASA API key is missing.") + params["api_key"] = api_key + + if additional_params is not None: + params.update(additional_params) + + if base is None: + base = NASA_BASE_URL + + url = f"{base.rstrip('/')}/{endpoint.lstrip('/')}?{urlencode(params)}" + logger.debug("Requesting NASA endpoint: %s", url) + + try: + async with self.http_session.get(url, timeout=timeout) as resp: + status = resp.status + content_type = resp.headers.get("Content-Type", "") + + # Try JSON first when content type suggests it. + if "application/json" in content_type or "json" in content_type: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, json.JSONDecodeError) as e: + logger.warning("Failed to decode JSON from NASA response (%s): %s", url, e) + text = await resp.text() + return NasaResult( + ok=False, + status=status, + data=None, + error=f"NASA returned invalid JSON (status {status}).", + ) + else: + # Non-JSON response; read as text for logging and user-friendly error. + text = await resp.text() + logger.warning( + "NASA returned non-JSON response (status %s, content-type %s) for %s: %s", + status, + content_type, + url, + text[:500], + ) + return NasaResult( + ok=False, + status=status, + data=None, + error=f"NASA returned an unexpected response (status {status}).", + ) + + if 200 <= status < 300: + return NasaResult(ok=True, status=status, data=data) + else: + logger.warning("NASA API returned non-success status %s for %s", status, url) + return NasaResult( + ok=False, + status=status, + data=data, + error=f"NASA API returned status {status}.", + ) + + except asyncio.TimeoutError: + logger.warning("NASA API request timed out for %s", url) + return NasaResult(ok=False, status=None, data=None, error="NASA API request timed out.") + except aiohttp.ClientError as e: + logger.warning("NASA API request failed for %s: %s", url, e) + return NasaResult(ok=False, status=None, data=None, error="NASA API request failed.") + except Exception as e: + logger.exception("Unexpected error while requesting NASA API for %s: %s", url, e) + return NasaResult(ok=False, status=None, data=None, error="Unexpected error while contacting NASA API.") + + def create_nasa_embed(self, title: str, description: str, image: str, footer: str | None = "") -> Embed: + """Generate NASA command embeds. Required: title, description, and image URL; footer is optional.""" + return ( + Embed( + title=title, + description=description, + ) + .set_image(url=image) + .set_footer(text="Powered by NASA API" + (footer or "")) + ) + + # =========================== + # BACKGROUND TASKS + # =========================== + @tasks.loop(hours=24) async def get_rovers(self) -> None: - """Get listing of rovers from NASA API and info about their start and end dates.""" - data = await self.fetch_from_nasa("mars-photos/api/v1/rovers") + """ + Get listing of rovers from NASA API and info about their start and end dates. - for rover in data["rovers"]: - self.rovers[rover["name"].lower()] = { - "min_date": rover["landing_date"], - "max_date": rover["max_date"], - "max_sol": rover["max_sol"] - } + NOTE: The Mars Rover Photos API has been archived by NASA. + This task is kept for compatibility but will not populate rover data reliably. + """ + logger.info("Refreshing Mars rover metadata from NASA (archived API).") + result = await self.fetch_from_nasa("mars-photos/api/v1/rovers") + + if not result.ok: + logger.warning( + "Failed to refresh rover metadata from NASA (archived API). Status: %s, Error: %s", + result.status, + result.error, + ) + self.rovers.clear() + return + + data = result.data + if not isinstance(data, dict) or "rovers" not in data: + logger.warning("Unexpected rover metadata format from NASA: %r", data) + self.rovers.clear() + return + + self.rovers.clear() + for rover in data.get("rovers", []): + try: + name = rover["name"].lower() + self.rovers[name] = { + "min_date": rover.get("landing_date"), + "max_date": rover.get("max_date"), + "max_sol": rover.get("max_sol"), + } + except KeyError: + logger.warning("Skipping malformed rover entry: %r", rover) + + logger.info("Loaded rover metadata for rovers: %s", ", ".join(self.rovers.keys()) or "none") + + # =========================== + # COMMAND GROUP + # =========================== @group(name="space", invoke_without_command=True) async def space(self, ctx: Context) -> None: """Head command that contains commands about space.""" await self.bot.invoke_help_command(ctx) + # =========================== + # APOD COMMAND + # =========================== + @space.command(name="apod") async def apod(self, ctx: Context, date: str | None) -> None: """ - Get Astronomy Picture of Day from NASA API. Date is optional parameter, what formatting is YYYY-MM-DD. + Get Astronomy Picture of the Day from NASA API. Date is optional, format is YYYY-MM-DD. - If date is not specified, this will get today APOD. + If date is not specified, this will get today's APOD. """ - params = {} - # Parse date to params, when provided. Show error message when invalid formatting + params: dict[str, Any] = {} + if date: try: apod_date = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC).date() @@ -72,49 +232,95 @@ async def apod(self, ctx: Context, date: str | None) -> None: now = datetime.now(tz=UTC).date() if apod_date < APOD_MIN_DATE or now < apod_date: - await ctx.send(f"Date must be between {APOD_MIN_DATE.isoformat()} and {now.isoformat()} (today).") + await ctx.send( + f"Date must be between {APOD_MIN_DATE.isoformat()} and {now.isoformat()} (today)." + ) return params["date"] = apod_date.isoformat() result = await self.fetch_from_nasa("planetary/apod", params) + if not result.ok or not isinstance(result.data, dict): + msg = result.error or "Failed to fetch Astronomy Picture of the Day from NASA." + await ctx.send(msg) + return + + data = result.data + missing = [k for k in ("date", "explanation", "url") if k not in data] + if missing: + logger.warning("APOD response missing keys %s: %r", missing, data) + await ctx.send("NASA returned an unexpected response for APOD.") + return + await ctx.send( embed=self.create_nasa_embed( - f"Astronomy Picture of the Day - {result['date']}", - result["explanation"], - result["url"] + f"Astronomy Picture of the Day - {data['date']}", + data["explanation"], + data["url"], ) ) + # =========================== + # NASA IMAGES SEARCH COMMAND + # =========================== + @space.command(name="nasa") async def nasa(self, ctx: Context, *, search_term: str | None) -> None: - """Get random NASA information/facts + image. Support `search_term` parameter for more specific search.""" - params = { - "media_type": "image" - } + """Get random NASA information/facts + image. Supports `search_term` for more specific search.""" + params: dict[str, Any] = {"media_type": "image"} if search_term: params["q"] = search_term - # Don't use API key, no need for this. - data = await self.fetch_from_nasa("search", params, NASA_IMAGES_BASE_URL, use_api_key=False) - if len(data["collection"]["items"]) == 0: + result = await self.fetch_from_nasa( + "search", + additional_params=params, + base=NASA_IMAGES_BASE_URL, + use_api_key=False, + ) + + if not result.ok: + msg = result.error or "Failed to fetch images from NASA." + await ctx.send(msg) + return + + data = result.data + try: + items = data["collection"]["items"] + except (TypeError, KeyError): + logger.warning("Unexpected NASA images response format: %r", data) + await ctx.send("NASA returned an unexpected response for image search.") + return + + if not items: await ctx.send(f"Can't find any items with search term `{search_term}`.") return - item = random.choice(data["collection"]["items"]) + item = random.choice(items) + try: + title = item["data"][0]["title"] + description = item["data"][0].get("description", "No description provided.") + image_url = item["links"][0]["href"] + except (KeyError, IndexError, TypeError): + logger.warning("Malformed NASA image item: %r", item) + await ctx.send("NASA returned an unexpected image result.") + return await ctx.send( embed=self.create_nasa_embed( - item["data"][0]["title"], - item["data"][0]["description"], - item["links"][0]["href"] + title, + description, + image_url, ) ) + # =========================== + # EPIC COMMAND + # =========================== + @space.command(name="epic") async def epic(self, ctx: Context, date: str | None) -> None: - """Get a random image of the Earth from the NASA EPIC API. Support date parameter, format is YYYY-MM-DD.""" + """Get a random image of the Earth from the NASA EPIC API. Date format is YYYY-MM-DD.""" if date: try: show_date = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC).date().isoformat() @@ -124,116 +330,91 @@ async def epic(self, ctx: Context, date: str | None) -> None: else: show_date = None - # Don't use API key, no need for this. - data = await self.fetch_from_nasa( - f"api/natural{f'/date/{show_date}' if show_date else ''}", + endpoint = f"api/natural{f'/date/{show_date}' if show_date else ''}" + + result = await self.fetch_from_nasa( + endpoint, base=NASA_EPIC_BASE_URL, - use_api_key=False + use_api_key=False, ) - if len(data) < 1: - await ctx.send("Can't find any images in this date.") + + if not result.ok: + msg = result.error or "Failed to fetch EPIC images from NASA." + await ctx.send(msg) + return + + data = result.data + if not isinstance(data, list) or len(data) < 1: + await ctx.send("Can't find any images for this date.") return item = random.choice(data) + try: + date_str = item["date"].split(" ")[0] + year, month, day = date_str.split("-") + image_id = item["image"] + caption = item.get("caption", "No caption provided.") + identifier = item.get("identifier", "Unknown") + except (KeyError, ValueError, AttributeError): + logger.warning("Malformed EPIC item: %r", item) + await ctx.send("NASA returned an unexpected EPIC image result.") + return - year, month, day = item["date"].split(" ")[0].split("-") - image_url = f"{NASA_EPIC_BASE_URL}/archive/natural/{year}/{month}/{day}/jpg/{item['image']}.jpg" + image_url = f"{NASA_EPIC_BASE_URL}/archive/natural/{year}/{month}/{day}/jpg/{image_id}.jpg" await ctx.send( embed=self.create_nasa_embed( - "Earth Image", item["caption"], image_url, f" \u2022 Identifier: {item['identifier']}" + "Earth Image", + caption, + image_url, + f" \u2022 Identifier: {identifier}", ) ) + # =========================== + # MARS COMMANDS (ARCHIVED API) + # =========================== + @space.group(name="mars", invoke_without_command=True) async def mars( self, ctx: Context, date: DateConverter | None, - rover: str = "curiosity" + rover: str = "curiosity", ) -> None: """ - Get random Mars image by date. Support both SOL (martian solar day) and earth date and rovers. + Get random Mars image by date. Supports both SOL (martian solar day) and Earth date and rovers. - Earth date formatting is YYYY-MM-DD. Use `.space mars dates` to get all currently available rovers. + NOTE: The Mars Rover Photos API used by this command has been archived by NASA and is no longer + reliable. This command is kept for compatibility but will currently respond with a notice. """ - rover = rover.lower() - if rover not in self.rovers: - await ctx.send( - f"Invalid rover `{rover}`.\n" - f"**Rovers:** `{'`, `'.join(f'{r.capitalize()}' for r in self.rovers)}`" - ) - return - - # When date not provided, get random SOL date between 0 and rover's max. - if date is None: - date = random.randint(0, self.rovers[rover]["max_sol"]) + await ctx.send( + "The NASA Mars Rover Photos API used by this command has been archived by NASA and is no longer " + "reliably available. As a result, `.space mars` is temporarily disabled." + ) - params = {} - if isinstance(date, int): - params["sol"] = date - else: - params["earth_date"] = date.date().isoformat() - - result = await self.fetch_from_nasa(f"mars-photos/api/v1/rovers/{rover}/photos", params) - if len(result["photos"]) < 1: - err_msg = ( - f"We can't find result in date " - f"{date.date().isoformat() if isinstance(date, datetime) else f'{date} SOL'}.\n" - f"**Note:** Dates must match with rover's working dates. Please use `{ctx.prefix}space mars dates` to " - "see working dates for each rover." + @mars.command(name="dates", aliases=("d", "date", "rover", "rovers", "r")) + async def dates(self, ctx: Context) -> None: + """Get current available rover photo date ranges (informational only; API is archived).""" + if not self.rovers: + await ctx.send( + "Rover metadata could not be loaded because the Mars Rover Photos API has been archived by NASA." ) - await ctx.send(err_msg) return - item = random.choice(result["photos"]) await ctx.send( - embed=self.create_nasa_embed( - f"{item['rover']['name']}'s {item['camera']['full_name']} Mars Image", "", item["img_src"], + "\n".join( + f"**{r.capitalize()}:** {i['min_date']} **-** {i['max_date']}" + for r, i in self.rovers.items() ) ) - @mars.command(name="dates", aliases=("d", "date", "rover", "rovers", "r")) - async def dates(self, ctx: Context) -> None: - """Get current available rovers photo date ranges.""" - await ctx.send("\n".join( - f"**{r.capitalize()}:** {i['min_date']} **-** {i['max_date']}" for r, i in self.rovers.items() - )) - - async def fetch_from_nasa( - self, - endpoint: str, - additional_params: dict[str, Any] | None = None, - base: str | None = NASA_BASE_URL, - use_api_key: bool = True - ) -> dict[str, Any]: - """Fetch information from NASA API, return result.""" - params = {} - if use_api_key: - params["api_key"] = Tokens.nasa.get_secret_value() - - # Add additional parameters to request parameters only when they provided by user - if additional_params is not None: - params.update(additional_params) - - async with self.http_session.get(url=f"{base}/{endpoint}?{urlencode(params)}") as resp: - return await resp.json() - - def create_nasa_embed(self, title: str, description: str, image: str, footer: str | None = "") -> Embed: - """Generate NASA commands embeds. Required: title, description and image URL, footer (addition) is optional.""" - return Embed( - title=title, - description=description - ).set_image(url=image).set_footer(text="Powered by NASA API" + footer) - async def setup(bot: Bot) -> None: """Load the Space cog.""" if not Tokens.nasa: - logger.warning("Can't find NASA API key. Not loading Space Cog.") + logger.warning("Can't find NASA API key. Space Cog will not be loaded.") return - # XXX: Disabled due to issues with NASA API, see https://github.com/python-discord/sir-lancebot/issues/1709 - - # await bot.add_cog(Space(bot)) + await bot.add_cog(Space(bot)) return From 858cb586aec5740aa608487b126eb464549bdd12 Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 14:14:40 -0500 Subject: [PATCH 2/7] Update space.py --- bot/exts/fun/space.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index 06a6223e9..7fa81eb31 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -27,6 +27,7 @@ @dataclass class NasaResult: + """Structured result object returned by NASA API requests.""" ok: bool status: int | None data: Any | None From 0c72478084a36764edfc29acfb667a8203474dab Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 14:16:45 -0500 Subject: [PATCH 3/7] Update space.py --- bot/exts/fun/space.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index 7fa81eb31..96226be7d 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -137,7 +137,7 @@ async def fetch_from_nasa( error=f"NASA API returned status {status}.", ) - except asyncio.TimeoutError: + except TimeoutError: logger.warning("NASA API request timed out for %s", url) return NasaResult(ok=False, status=None, data=None, error="NASA API request timed out.") except aiohttp.ClientError as e: From 31c5fc6081aae7df1293053a4b75329fbb2a885b Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 14:19:05 -0500 Subject: [PATCH 4/7] Update space.py --- bot/exts/fun/space.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index 96226be7d..739e55845 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -1,4 +1,3 @@ -import asyncio import json import random from dataclasses import dataclass From 8200c82bb8e0450629e2ad8bdca81771d9d1d686 Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 14:23:17 -0500 Subject: [PATCH 5/7] Update space.py Removed unnecessary `else:` statement --- bot/exts/fun/space.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index 739e55845..1c0463a86 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -127,14 +127,14 @@ async def fetch_from_nasa( if 200 <= status < 300: return NasaResult(ok=True, status=status, data=data) - else: - logger.warning("NASA API returned non-success status %s for %s", status, url) - return NasaResult( - ok=False, - status=status, - data=data, - error=f"NASA API returned status {status}.", - ) + + logger.warning("NASA API returned non-success status %s for %s", status, url) + return NasaResult( + ok=False, + status=status, + data=data, + error=f"NASA API returned status {status}.", + ) except TimeoutError: logger.warning("NASA API request timed out for %s", url) From f3479588f7d51e8a9a77460e1c0d84c1a39875a2 Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 14:33:42 -0500 Subject: [PATCH 6/7] Update space.py Added more detailed error description for archived API message --- bot/exts/fun/space.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index 1c0463a86..14799a27a 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -391,6 +391,7 @@ async def mars( await ctx.send( "The NASA Mars Rover Photos API used by this command has been archived by NASA and is no longer " "reliably available. As a result, `.space mars` is temporarily disabled." + "> For more details on this issue see [GitHub issue #1709](https://github.com/python-discord/sir-lancebot/issues/1709)" ) @mars.command(name="dates", aliases=("d", "date", "rover", "rovers", "r")) @@ -399,6 +400,7 @@ async def dates(self, ctx: Context) -> None: if not self.rovers: await ctx.send( "Rover metadata could not be loaded because the Mars Rover Photos API has been archived by NASA." + "> For more details on this issue see [GitHub issue #1709](https://github.com/python-discord/sir-lancebot/issues/1709)" ) return From 07b41609fe636ea8d3185177a859c7f259173c89 Mon Sep 17 00:00:00 2001 From: John/Dev Date: Mon, 5 Jan 2026 15:17:18 -0500 Subject: [PATCH 7/7] Update space.py This commit replaces the archived **NASA Mars Rover API** with the **Mars 2020 Perseverance Raw Images API**. --- bot/exts/fun/space.py | 120 ++++++++++++++++++++++-------------------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index 14799a27a..b19c34b1e 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -156,6 +156,21 @@ def create_nasa_embed(self, title: str, description: str, image: str, footer: st .set_image(url=image) .set_footer(text="Powered by NASA API" + (footer or "")) ) + + async def fetch_perseverance_images(self, sol: int | None = None, earth_date: str | None = None): + """Fetch Perseverance raw images from the Mars 2020 API.""" + params = { + "feed": "raw_images", + "category": "mars2020", + "feedtype": "json", + } + if sol is not None: + params["sol"] = sol + if earth_date is not None: + params["earth_date"] = earth_date + + url = "https://mars.nasa.gov/rss/api/" + return await self.fetch_from_nasa("", params, base=url, use_api_key=False) # =========================== # BACKGROUND TASKS @@ -163,43 +178,14 @@ def create_nasa_embed(self, title: str, description: str, image: str, footer: st @tasks.loop(hours=24) async def get_rovers(self) -> None: - """ - Get listing of rovers from NASA API and info about their start and end dates. - - NOTE: The Mars Rover Photos API has been archived by NASA. - This task is kept for compatibility but will not populate rover data reliably. - """ - logger.info("Refreshing Mars rover metadata from NASA (archived API).") - result = await self.fetch_from_nasa("mars-photos/api/v1/rovers") - - if not result.ok: - logger.warning( - "Failed to refresh rover metadata from NASA (archived API). Status: %s, Error: %s", - result.status, - result.error, - ) - self.rovers.clear() - return - - data = result.data - if not isinstance(data, dict) or "rovers" not in data: - logger.warning("Unexpected rover metadata format from NASA: %r", data) - self.rovers.clear() - return - - self.rovers.clear() - for rover in data.get("rovers", []): - try: - name = rover["name"].lower() - self.rovers[name] = { - "min_date": rover.get("landing_date"), - "max_date": rover.get("max_date"), - "max_sol": rover.get("max_sol"), - } - except KeyError: - logger.warning("Skipping malformed rover entry: %r", rover) - - logger.info("Loaded rover metadata for rovers: %s", ", ".join(self.rovers.keys()) or "none") + """Load Perseverance rover metadata (Mars 2020).""" + self.rovers = { + "perseverance": { + "min_date": "2021-02-18", + "max_date": None, # Updated dynamically + "max_sol": None, + } + } # =========================== # COMMAND GROUP @@ -372,7 +358,7 @@ async def epic(self, ctx: Context, date: str | None) -> None: ) # =========================== - # MARS COMMANDS (ARCHIVED API) + # MARS COMMANDS # =========================== @space.group(name="mars", invoke_without_command=True) @@ -380,38 +366,56 @@ async def mars( self, ctx: Context, date: DateConverter | None, - rover: str = "curiosity", + rover: str = "perseverance", ) -> None: - """ - Get random Mars image by date. Supports both SOL (martian solar day) and Earth date and rovers. + """Get a random Perseverance rover image by sol or Earth date.""" + if rover.lower() != "perseverance": + await ctx.send("Only the Perseverance rover is supported with the new NASA API.") + return - NOTE: The Mars Rover Photos API used by this command has been archived by NASA and is no longer - reliable. This command is kept for compatibility but will currently respond with a notice. - """ - await ctx.send( - "The NASA Mars Rover Photos API used by this command has been archived by NASA and is no longer " - "reliably available. As a result, `.space mars` is temporarily disabled." - "> For more details on this issue see [GitHub issue #1709](https://github.com/python-discord/sir-lancebot/issues/1709)" + sol = None + earth_date = None + + if isinstance(date, int): + sol = date + elif isinstance(date, datetime): + earth_date = date.date().isoformat() + + result = await self.fetch_perseverance_images(sol=sol, earth_date=earth_date) + + if not result.ok or "images" not in result.data: + await ctx.send("NASA did not return any images for that date.") + return + + images = result.data["images"] + if not images: + await ctx.send("No images found for that date.") + return + + image = random.choice(images) + img_url = image.get("image_files", [{}])[0].get("file_url") + caption = image.get("caption", "No caption available.") + sol_value = image.get("sol", "Unknown") + + embed = self.create_nasa_embed( + f"Perseverance Rover — Sol {sol_value}", + caption, + img_url, ) + await ctx.send(embed=embed) @mars.command(name="dates", aliases=("d", "date", "rover", "rovers", "r")) async def dates(self, ctx: Context) -> None: """Get current available rover photo date ranges (informational only; API is archived).""" - if not self.rovers: - await ctx.send( - "Rover metadata could not be loaded because the Mars Rover Photos API has been archived by NASA." - "> For more details on this issue see [GitHub issue #1709](https://github.com/python-discord/sir-lancebot/issues/1709)" - ) + if "perseverance" not in self.rovers: + await ctx.send("Rover metadata unavailable.") return + info = self.rovers["perseverance"] await ctx.send( - "\n".join( - f"**{r.capitalize()}:** {i['min_date']} **-** {i['max_date']}" - for r, i in self.rovers.items() - ) + f"**Perseverance:** {info['min_date']} — Present (sol increases daily)" ) - async def setup(bot: Bot) -> None: """Load the Space cog.""" if not Tokens.nasa: