diff --git a/clusters/clusters.py b/clusters/clusters.py index 42a4c4f..3366823 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -1,4 +1,4 @@ -import discord +ο»Ώimport discord from redbot.core import commands, Config import psutil, datetime, json, aiohttp from aiohttp import web @@ -55,15 +55,11 @@ async def clusters(self, ctx): """Shows the status of all clusters using an embed.""" await self.initialize_shard_names() - # Bot uptime (Red tracks this as bot.uptime) bot_start_time = getattr(self.bot, "uptime", None) if bot_start_time is None: bot_uptime_str = "Unknown" else: - if isinstance(bot_start_time, datetime.datetime): - td = datetime.datetime.utcnow() - bot_start_time - else: - td = bot_start_time + td = datetime.datetime.utcnow() - bot_start_time if isinstance(bot_start_time, datetime.datetime) else bot_start_time bot_uptime_str = self.format_timedelta(td) server_uptime = self.format_timedelta(self.get_server_uptime()) @@ -75,24 +71,16 @@ async def clusters(self, ctx): ) for shard_id, name in self.shard_names.items(): - cpu = psutil.cpu_percent(interval=None) - ram = psutil.virtual_memory().used / 1024**3 latency = round(self.bot.shards[shard_id].latency * 1000) - guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] - servers = len(guilds) - users = sum(g.member_count or 0 for g in guilds) - + value = ( f"**Status:** Alive Running\n" - f"**CPU:** {cpu:.1f}%\n" - f"**RAM:** {ram:.1f} GiB\n" f"**Latency:** {latency} ms\n" - f"**Servers:** {servers}\n" - f"**Users:** {users}\n" + f"**Servers:** {len(guilds)}\n" + f"**Users:** {sum(g.member_count or 0 for g in guilds)}\n" f"**Shards:** [{shard_id}]" ) - embed.add_field(name=f"Cluster #{name}", value=value, inline=False) await ctx.send(embed=embed) @@ -109,49 +97,61 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): custom_names[str(shard_id)] = new_name await self.config.custom_names.set(custom_names) self.shard_names[shard_id] = new_name - await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") + async def web_clusters(self, request): """Return cluster data as JSON for web endpoint.""" await self.initialize_shard_names() + + virt_mem = psutil.virtual_memory() + swap_mem = psutil.swap_memory() + proc = psutil.Process() + bot_ram_gb = proc.memory_info().rss / 1024**3 - # Bot uptime bot_start_time = getattr(self.bot, "uptime", None) - if bot_start_time is None: - bot_uptime_str = "Unknown" - else: - if isinstance(bot_start_time, datetime.datetime): - td = datetime.datetime.utcnow() - bot_start_time - else: - td = bot_start_time - bot_uptime_str = self.format_timedelta(td) - + bot_uptime_str = self.format_timedelta(datetime.datetime.utcnow() - bot_start_time) if bot_start_time else "Unknown" server_uptime_str = self.format_timedelta(self.get_server_uptime()) data = { "bot_uptime": bot_uptime_str, "server_uptime": server_uptime_str, + "system_stats": { + "cpu_total_percent": psutil.cpu_percent(interval=None), + "ram_used_gb": round(virt_mem.used / 1024**3, 2), + "ram_total_gb": round(virt_mem.total / 1024**3, 2), + "bot_ram_gb": round(bot_ram_gb, 2), + "bot_ram_limit_gb": 10.0, + "swap_used_gb": round(swap_mem.used / 1024**3, 2), + "swap_total_gb": round(swap_mem.total / 1024**3, 2) + }, "clusters": [] } - for shard_id, name in self.shard_names.items(): + # Use the bot's reported shard count + total_shards = self.bot.shard_count or 1 + for shard_id in range(total_shards): + # 1. Get name safely + name = self.shard_names.get(shard_id, MARVEL_NAMES[shard_id % len(MARVEL_NAMES)]) + + # 2. Get shard object safely + shard = self.bot.get_shard(shard_id) + + # 3. Determine status and latency + # We explicitly check shard health to provide the 'status' key + is_online = shard is not None and not shard.is_closed() + latency = round(shard.latency * 1000) if (is_online and shard.latency is not None) else 0 + + # 4. Count guilds on this shard guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] - servers = len(guilds) - users = sum(g.member_count or 0 for g in guilds) - latency = round(self.bot.shards[shard_id].latency * 1000) - cpu = psutil.cpu_percent(interval=None) - ram = psutil.virtual_memory().used / 1024**3 - - cluster_data = { + + data["clusters"].append({ "shard_id": shard_id, "name": name, - "servers": servers, - "users": users, + "servers": len(guilds), + "users": sum(g.member_count or 0 for g in guilds), "latency_ms": latency, - "cpu_percent": cpu, - "ram_gib": ram - } - data["clusters"].append(cluster_data) + "status": "Online" if is_online else "Offline" + }) - return web.Response(text=json.dumps(data, indent=2), content_type="application/json") + return web.json_response(data) \ No newline at end of file diff --git a/clusters/docs.md b/clusters/docs.md index e118a0a..c53121d 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -43,7 +43,7 @@ for example this is my current output on my bot is a number between 0 and your mac amount of shards - what you want the cluter to be called from now on + what you want the cluster to be called from now on this is how it's used diff --git a/clusters/info.json b/clusters/info.json index 822d56e..77b80cc 100644 --- a/clusters/info.json +++ b/clusters/info.json @@ -1,7 +1,12 @@ { "name": "Clusters", - "author": "bencos17", - "description": "Shows dynamic Marvel-themed cluster statuses for your bot.", + "author": [ + "bencos17" + ], + "description": "Shows dynamic Marvel-themed cluster statuses for your bot, curreently it doesn't support physical clusters though but I do want to add support to my red instance for it so some day it will", "requirements": [], - "tags": ["clusters", "shards", "status"] -} + "tags": [ + "shards", + "status" + ] +} \ No newline at end of file diff --git a/iss/__init__.py b/iss/__init__.py new file mode 100644 index 0000000..91e4e63 --- /dev/null +++ b/iss/__init__.py @@ -0,0 +1,8 @@ +from redbot.core.bot import Red + +from .iss import ISS + +__red_end_user_data_statement__ = "This cog does not store any end user data." + +async def setup(bot: Red): + await bot.add_cog(ISS(bot)) \ No newline at end of file diff --git a/iss/docs.md b/iss/docs.md new file mode 100644 index 0000000..4d14f66 --- /dev/null +++ b/iss/docs.md @@ -0,0 +1,78 @@ +\# πŸ›°οΈ J.A.R.V.I.S. ISS-Mimic Telemetry Suite + + + +This documentation covers the setup, operation, and troubleshooting of the ISS-Mimic Discord Cog. This system bridges NASA's public Lightstreamer telemetry feed directly into your Discord server. + + + +--- + + + +\## πŸ› οΈ Core Commands + + + +| Command | Usage | Description | + +| :--- | :--- | :--- | + +| `\*iss` | `\*iss` | \*\*Interactive Console\*\*: Opens a public dropdown menu to browse telemetry by category. | + +| `\*iss all` | `\*iss all` | \*\*Master Snapshot\*\*: Sends two massive embeds containing every tracked sensor in the system. | + +| `\*iss status` | `\*iss status` | \*\*Stream Health\*\*: Checks which sensors are currently broadcasting live data (updated in the last 60s). | + +| `\*iss \[cat]` | `\*iss gnc` | \*\*Quick View\*\*: Direct access to specific categories (gnc, ethos, robotics, russian, etc.). | + + + +--- + + + +\## 🚦 Understanding Sensor States + + + +Because NASA uses \*\*MERGE\*\* mode for data transmission, sensors will show different states based on station activity: + + + +\* \*\*🟒 Active (Green Circle)\*\*: Data has been received for this system within the last 60 seconds. + +\* \*\*πŸ’€ Standby (Zzz Emoji)\*\*: The system is subscribed, but the value hasn't changed recently, so NASA isn't pushing updates. + +\* \*\*πŸ”Ή Blue Diamond\*\*: Appears next to individual sensors that are currently streaming live data. + +\* \*\*--- / Connecting\*\*: The bot is connected but waiting for the very first data packet to arrive for that specific ID. + + + + + + + +--- + + + +\## πŸ“‚ Configuration (`telemetry.json`) + + + +The Cog is entirely data-driven. To add or rename sensors, edit the `telemetry.json` file. + + + +\*\*Format:\*\* + +```json + +"CATEGORY\_NAME": { + +  "NASA\_OPCODE": "Display Label" + +} + diff --git a/iss/info.json b/iss/info.json new file mode 100644 index 0000000..fcefbba --- /dev/null +++ b/iss/info.json @@ -0,0 +1,12 @@ +{ + "name": "ISS", + "author": [ + "bencos17" + ], + "description": "A cog to track the International Space Station's telemetry data and provide information about its current location, speed, altitude, and other relevant data.", + "requirements": [], + "tags": [ + "issdata", + "iss" + ] +} \ No newline at end of file diff --git a/iss/iss.py b/iss/iss.py new file mode 100644 index 0000000..77500f8 --- /dev/null +++ b/iss/iss.py @@ -0,0 +1,237 @@ +import discord # discord.py import +import json # For loading the telemetry mapping from a JSON file +import logging # For logging connection status and errors with the telemetry feed +import datetime # For handling timestamps and displaying last update times in the embed footers +import time # For timestamps and calculating data freshness +import asyncio # For async sleep in the scan command +import math # For velocity calculations +from pathlib import Path # For loading the telemetry mapping from a JSON file +from redbot.core import commands +from redbot.core.utils.chat_formatting import box, pagify +from redbot.core.utils.menus import menu, DEFAULT_CONTROLS +from lightstreamer.client import LightstreamerClient, Subscription # pip install lightstreamer-client + +log = logging.getLogger("red.iss") + + +class CategorySelect(discord.ui.Select): + def __init__(self, cog): + self.cog = cog + options = [ + discord.SelectOption(label="Primary GNC", value="GNC", description="Altitude, Velocity, Attitude", emoji="πŸš€"), + discord.SelectOption(label="Air Systems", value="ETHOS_AIR", description="Pressure, Temp, CO2", emoji="🌬️"), + discord.SelectOption(label="Water Systems", value="ETHOS_WATER", description="Urine Tank, Clean Water", emoji="πŸ’§"), + discord.SelectOption(label="Power Status", value="SPARTAN_POWER", description="Solar Arrays & SARJ", emoji="⚑"), + discord.SelectOption(label="Robotics", value="ROBOTICS", description="Canadarm2 Joint Data", emoji="🦾"), + discord.SelectOption(label="EVA Suits", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="πŸ‘¨β€πŸš€"), + discord.SelectOption(label="EVA Power", value="EVA_POWER", description="Airlock & IRU Power", emoji="πŸ”‹"), + discord.SelectOption(label="Russian Segment", value="RUSSIAN", description="Docking & RS Mode", emoji="πŸ‡·πŸ‡Ί"), + discord.SelectOption(label="Communications", value="COMMUNICATIONS", description="Radios & Antennas", emoji="πŸ“‘"), + discord.SelectOption(label="Rendezvous", value="RENDEZVOUS", description="Approach Monitor", emoji="πŸ›°οΈ"), + + ] + super().__init__(placeholder="Select a system to monitor...", options=options) + + async def callback(self, interaction: discord.Interaction): + embed = await self.cog.build_embed([self.values[0]], f"πŸ›°οΈ {self.values[0]} Telemetry", 0x2b2d31) + await interaction.response.send_message(embed=embed, ephemeral=False) + +class SelectionView(discord.ui.View): + def __init__(self, cog): + super().__init__(timeout=60) + self.add_item(CategorySelect(cog)) + +class ISS(commands.Cog): + """J.A.R.V.I.S. ISS Command Center - Interactive Build""" + + def __init__(self, bot): + self.bot = bot + self.ls_client = None + self.last_update = None + self.last_item_update = {} + self.discovered_ids = set() + + cog_path = Path(__file__).parent + with open(cog_path / "telemetry.json", "r") as f: + self.telemetry_map = json.load(f) + + self.all_ids = [] + for category in self.telemetry_map.values(): + self.all_ids.extend(category.keys()) + + self.data_cache = {k: "No Data πŸ“Ά" for k in self.all_ids} + self.start_ls_client() + + def start_ls_client(self): + try: + self.ls_client = LightstreamerClient("https://push.lightstreamer.com", "ISSLIVE") + sub = Subscription(mode="MERGE", items=self.all_ids, fields=["Value"]) + + class LSListener: + def __init__(self, outer): + self.outer = outer + + def onItemUpdate(self, update): + item = update.getItemName() + val = update.getValue("Value") + now = time.time() + self.outer.last_update = datetime.datetime.fromtimestamp(now, datetime.timezone.utc) + self.outer.last_item_update[item] = now + + if val is None: return + try: + num = float(val) + if item in ["USLAB000001", "USLAB000002", "USLAB000003", "USLAB000004"]: + self.outer.data_cache[item] = "Active βœ…" if int(num) == 1 else "Standby πŸ’€" + elif "Voltage" in item: self.outer.data_cache[item] = f"{num:.2f} V" + elif "Current" in item: self.outer.data_cache[item] = f"{num:.2f} A" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}Β°" + elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f} kg" + elif "ALT" in item: self.outer.data_cache[item] = f"{num:.2f} km" + else: self.outer.data_cache[item] = f"{num:,.2f}" + except (ValueError, TypeError): + self.outer.data_cache[item] = str(val) + + sub.addListener(LSListener(self)) + self.ls_client.connect() + self.ls_client.subscribe(sub) + except Exception as e: + log.error(f"failed to connect to ISS telemetry: {e}") + + def cog_unload(self): + if self.ls_client: self.ls_client.disconnect() + + async def build_embed(self, category_keys: list, title: str, color: int): + embed = discord.Embed(title=title, color=color) + now = time.time() + + if "GNC" in category_keys: + try: + vx = float(self.data_cache.get("USLAB000035", "0").split()[0].replace(",", "")) + vy = float(self.data_cache.get("USLAB000036", "0").split()[0].replace(",", "")) + vz = float(self.data_cache.get("USLAB000037", "0").split()[0].replace(",", "")) + total_v = math.sqrt(vx**2 + vy**2 + vz**2) + embed.description = f"πŸš€ **Total Orbital Velocity:** `{total_v:,.2f} m/s`" + except: + embed.description = "πŸš€ **Total Orbital Velocity:** `Calculating...`" + + for key in category_keys: + sensors = self.telemetry_map.get(key, {}) + lines = [] + active_in_cat = False + for id_k, label in sensors.items(): + val = self.data_cache.get(id_k, "Connecting...") + is_active = (now - self.last_item_update.get(id_k, 0)) < 60 + if is_active: active_in_cat = True + prefix = "πŸ”Ή " if is_active else "πŸ”Έ " + lines.append(f"{prefix}**{label}:** `{val}`") + + status_emoji = "🟒" if active_in_cat else "πŸ’€" + embed.add_field(name=f"{status_emoji} {key.replace('_', ' ')}", value="\n".join(lines) or "No Data", inline=True) + + if self.last_update: + embed.set_footer(text=f"NASA Live: {self.last_update.strftime('%H:%M:%S UTC')} | Signal: Acquired πŸ“‘") + return embed + + @commands.group(invoke_without_command=True) + async def iss(self, ctx): + """ISS Telemetry Command Hub""" + view = SelectionView(self) + await ctx.send("πŸ“‘ **Mission Control Console**\nSelect a system to view live station telemetry:", view=view) + +# The main command `!iss all` provides a comprehensive overview of all telemetry categories in a dynamic, full-suite feed. It automatically retrieves all category names from the JSON mapping and splits them into two groups to create multiple embeds to not hit the discord embed limits. + @iss.command(name="all") + async def iss_all(self, ctx): + """Station Overview (Dynamic Full-Suite Feed)""" + # Get all category names from JSON file + all_categories = list(self.telemetry_map.keys()) + + # Split them into two groups so the embeds aren't too long for Discord + halfway = len(all_categories) // 2 + group1 = all_categories[:halfway] + group2 = all_categories[halfway:] # aAllows new categories added to the JSON file to be automatically included in the "all" command without needing to hardcode them here. + + e1 = await self.build_embed(group1, "πŸ›°οΈ Station Systems: Alpha", 0x2b2d31) + e2 = await self.build_embed(group2, "πŸ›°οΈ Station Systems: Bravo", 0x2b2d31) + + await ctx.send(embed=e1) + await ctx.send(embed=e2) + +# Individual system commands for direct access, these are also accessible via the dropdown menu in the main command. Each one corresponds to a category in the telemetry.json file and will display the relevant sensors with their latest values and status indicators. + @iss.command(name="gnc") + async def iss_gnc(self, ctx): + """Guidance, Navigation, and Control""" + embed = await self.build_embed(["GNC"], "πŸš€ Orbital GNC Status", 0x2ecc71) + await ctx.send(embed=embed) + + @iss.command(name="ethos") + async def iss_ethos(self, ctx): + """Environment & Life Support""" + embed = await self.build_embed(["ETHOS_AIR", "ETHOS_WATER"], "🌑️ Life Support Systems", 0x3498db) + await ctx.send(embed=embed) + + @iss.command(name="comm") + async def iss_comm(self, ctx): + await ctx.send(embed=await self.build_embed(["COMMUNICATIONS"], "πŸ“‘ Station Comms", 0x607d8b)) + + @iss.command(name="approach") + async def iss_approach(self, ctx): + await ctx.send(embed=await self.build_embed(["RENDEZVOUS"], "πŸ›°οΈ Rendezvous Monitor", 0xe91e63)) + + @iss.command(name="robotics") + async def iss_robotics(self, ctx): + """Canadarm2 Status""" + embed = await self.build_embed(["ROBOTICS"], "🦾 SSRMS Robotics", 0xe67e22) + await ctx.send(embed=embed) + + @iss.command(name="eva") + async def iss_eva(self, ctx): + """Extravehicular Activity & Airlock""" + embed = await self.build_embed(["EVA_SUITS", "EVA_POWER"], "πŸ‘¨β€πŸš€ EVA Operations", 0x9b59b6) + await ctx.send(embed=embed) + + @iss.command(name="status") + async def iss_status(self, ctx): + """Sensor Activity Report""" + now = time.time() + active = [id_k for id_k in self.all_ids if (now - self.last_item_update.get(id_k, 0)) < 60] + embed = discord.Embed(title="πŸ“‘ Stream Health", color=0x2ecc71 if active else 0xe74c3c) + embed.add_field(name="Data Points", value=f"βœ… Active: `{len(active)}` | πŸ’€ Standby: `{len(self.all_ids)-len(active)}`", inline=False) + await ctx.send(embed=embed) + + + + + + +# owner only commands for maintenance and discovery of new telemetry IDs (untested fully but should work) - these are not meant for regular users or even bot owners and may cause spam if misused, so they are hidden from the help command and restricted to the bot owner. + @iss.command(name="reconnect") + @commands.is_owner() + async def iss_reconnect(self, ctx): + """Restart the NASA link (Owner Only)""" + await ctx.send("πŸ”„ Resetting telemetry link...") + if self.ls_client: self.ls_client.disconnect() + self.start_ls_client() + await ctx.send("βœ… Connection re-established.") + + @iss.command(name="scan") + @commands.is_owner() + async def iss_scan(self, ctx): + """Hunt for untracked NASA Opcodes (Owner Only)""" + await ctx.send("πŸ›°οΈ **Scanning broad-range telemetry...** (30s)") + prefixes = ["USLAB", "NODE1", "NODE2", "NODE3", "AIRLOCK", "SSRMS", "S1", "P1"] + test_ids = [f"{p}{str(i).zfill(7)}" for p in prefixes for i in range(1, 20)] + scan_sub = Subscription(mode="MERGE", items=test_ids, fields=["Value"]) + self.ls_client.subscribe(scan_sub) + await asyncio.sleep(30) + self.ls_client.unsubscribe(scan_sub) + await ctx.send(f"βœ… Scan complete. Found `{len(self.discovered_ids)}` new Opcodes.") + + + @iss.command(name="discover") + @commands.is_owner() + async def iss_discover(self, ctx): + """Show untracked IDs caught by the scanner (Owner Only)""" + if not self.discovered_ids: return await ctx.send("❌ No IDs detected.") + pages = [box(p, lang="text") for p in pagify("\n".join(sorted(list(self.discovered_ids))), page_length=1000)] + await menu(ctx, pages, DEFAULT_CONTROLS) \ No newline at end of file diff --git a/iss/telemetry.json b/iss/telemetry.json new file mode 100644 index 0000000..b532562 --- /dev/null +++ b/iss/telemetry.json @@ -0,0 +1,105 @@ +{ + "GNC": { + "USLAB000ALT": "Altitude (km)", + "USLAB000035": "Velocity (m/s)", + "USLAB000PIT": "Pitch", + "USLAB000YAW": "Yaw", + "USLAB000ROL": "Roll", + "USLAB000039": "Station Mass (kg)", + "USLAB000005": "CMGs Online", + "USLAB000010": "CMG Momentum %", + "USLAB000001": "CMG 1 Status", + "USLAB000002": "CMG 2 Status", + "USLAB000003": "CMG 3 Status", + "USLAB000004": "CMG 4 Status" + }, + "ETHOS_AIR": { + "USLAB000058": "Cabin Pressure", + "USLAB000059": "Cabin Temp", + "USLAB000054": "Lab ppN2", + "USLAB000053": "Lab ppO2", + "NODE3000001": "Node 3 O2 PP", + "NODE3000003": "Node 3 CO2 PP", + "NODE3000010": "O2 Gen State", + "AIRLOCK000052": "N2 Supply Valve Position" + }, + "ETHOS_WATER": { + "NODE3000005": "Urine Tank %", + "NODE3000009": "Clean Water %", + "NODE3000008": "Waste Water Tank %", + "NODE3000004": "Urine Processor State", + "NODE3000006": "Water Processor State", + "NODE3000017": "Coolant Water Node 3" + }, + "SPARTAN_POWER": { + "USLAB000059": "Cabin Temperature (Internal)", + "S4000007": "Solar 1A Beta", + "S6000008": "Solar 1B Beta", + "P4000007": "Solar 2A Beta", + "P6000008": "Solar 2B Beta", + "S4000001": "Solar 3A Beta", + "S6000002": "Solar 3B Beta", + "S0000004": "Port SARJ Angle", + "S0000003": "Stbd SARJ Angle" + }, + "ROBOTICS": { + "AMT000001": "MT Position (cm)", + "SSRMS004": "Shoulder Roll", + "SSRMS005": "Shoulder Yaw", + "SSRMS006": "Shoulder Pitch", + "SSRMS007": "Elbow Pitch", + "SSRMS008": "Wrist Pitch", + "SSRMS009": "Wrist Yaw", + "SSRMS010": "Wrist Roll", + "SSRMS011": "Tip Payload Status" + }, + "EVA_SUITS": { + "AIRLOCK000001": "EMU 1 Voltage", + "AIRLOCK000002": "EMU 1 Current", + "AIRLOCK000003": "EMU 2 Voltage", + "AIRLOCK000004": "EMU 2 Current", + "AIRLOCK000049": "Crewlock Pressure", + "AIRLOCK000055": "High P O2 Tank", + "AIRLOCK000056": "Low P O2 Tank" + }, + "EVA_POWER": { + "AIRLOCK000007": "EMU 1 Power Voltage", + "AIRLOCK000008": "EMU 1 Power Current", + "AIRLOCK000009": "EMU 2 Power Voltage", + "AIRLOCK000010": "EMU 2 Power Current", + "AIRLOCK000005": "IRU Voltage", + "AIRLOCK000006": "IRU Current" + }, + "EVA_CHARGERS": { + "AIRLOCK000011": "BCA 1 Voltage", + "AIRLOCK000012": "BCA 1 Current", + "AIRLOCK000023": "BCA 1 Ch 1 Status", + "AIRLOCK000024": "BCA 1 Ch 2 Status", + "AIRLOCK000025": "BCA 1 Ch 3 Status", + "AIRLOCK000026": "BCA 1 Ch 4 Status", + "AIRLOCK000013": "BCA 2 Voltage", + "AIRLOCK000014": "BCA 2 Current" + }, + "RUSSIAN": { + "RUSSEG000001": "RS Mode", + "RUSSEG000013": "Fwd Docking Port", + "RUSSEG000014": "Aft Docking Port", + "RUSSEG000021": "RS Attitude Mode", + "RUSSEG000025": "RS Dynamic Mode", + "RUSSEG000011": "SM Kurs-P Standby" + }, + "COMMUNICATIONS": { + "USLAB000099": "UHF Radio 1 Power", + "USLAB000100": "UHF Radio 2 Power", + "USLAB000101": "UHF Frame Sync Lock", + "Z1000013": "Ku-Band Transmit", + "Z1000014": "Ku-Antenna Elevation", + "Z1000015": "Ku-Antenna Cross-El" + }, + "RENDEZVOUS": { + "USLAB000101": "Vehicle Handshake", + "RUSSEG000013": "Fwd Docking Port", + "RUSSEG000014": "Aft Docking Port", + "RUSSEG000011": "Propulsion Ready" + } +} \ No newline at end of file diff --git a/radiosonde/dashboard.py b/radiosonde/dashboard.py new file mode 100644 index 0000000..00d4540 --- /dev/null +++ b/radiosonde/dashboard.py @@ -0,0 +1,337 @@ +import typing +import discord +import wtforms +from redbot.core import commands +import datetime + + +def dashboard_page(*args, **kwargs): + """Decorator for dashboard pages.""" + def decorator(func): + func.__dashboard_decorator_params__ = (args, kwargs) + return func + return decorator + + +class DashboardIntegration: + """Dashboard integration for Radiosonde cog.""" + + bot: commands.Bot + + @commands.Cog.listener() + async def on_dashboard_cog_add(self, dashboard_cog: commands.Cog) -> None: + """Register dashboard pages when dashboard cog loads.""" + dashboard_cog.rpc.third_parties_handler.add_third_party(self) + + @dashboard_page(name=None, description="Radiosonde Stats Page", methods=("GET",)) + async def dashboard_stats(self, **kwargs) -> typing.Dict[str, typing.Any]: + """Show sonde tracking statistics.""" + cog = getattr(self, "_radiosonde_cog", None) + if not cog: + return {"status": 0, "web_content": {"source": "

Radiosonde cog not loaded.

"}} + + try: + # Fetch latest sondes to show active tracking + sondes_data, error = await cog.fetch_sondes() + total_sondes = len(sondes_data) if sondes_data else 0 + + # Count tracked sondes across all guilds + tracked_total = 0 + guild_count = 0 + for guild in self.bot.guilds: + tracked = await cog.config.guild(guild).tracked_sondes() + if tracked: + tracked_total += len(tracked) + guild_count += 1 + + stats_html = f""" +
+

πŸ“‘ Radiosonde Stats

+

Real-time radiosonde tracking statistics from SondeHub V2 API.

+ +
+
+

πŸ“Š API Statistics

+
    +
  • Active Sondes: {total_sondes:,}
  • +
  • Sondes Tracked: {tracked_total}
  • +
  • Tracking Guilds: {guild_count}
  • +
+
+ +
+

🌍 API Information

+
    +
  • API Host: api.v2.sondehub.org
  • +
  • Status: {'βœ… Online' if not error else '❌ Offline'}
  • +
  • Error: {error if error else 'None'}
  • +
+
+
+
+ """ + + return { + "status": 0, + "web_content": { + "source": stats_html + } + } + except Exception as e: + return { + "status": 1, + "web_content": { + "source": f"

Error loading statistics: {str(e)}

" + }, + "notifications": [{"message": f"Error loading stats: {str(e)}", "category": "error"}] + } + + @dashboard_page(name="tracked", description="Tracked Sondes Status", methods=("GET",), context_ids=["guild_id"]) + async def dashboard_tracked_sondes(self, guild: discord.Guild, **kwargs) -> typing.Dict[str, typing.Any]: + """Show status of tracked sondes for a guild.""" + cog = getattr(self, "_radiosonde_cog", None) + if not cog: + return {"status": 0, "web_content": {"source": "

Radiosonde cog not loaded.

"}} + + try: + tracked = await cog.config.guild(guild).tracked_sondes() + if not tracked: + return { + "status": 0, + "web_content": { + "source": """ +
+

Tracked Sondes

+

No sondes are currently tracked in this guild.

+
+ """ + } + } + + sondes_data, error = await cog.fetch_sondes() + if error or not sondes_data: + return { + "status": 1, + "web_content": { + "source": f"

Could not fetch sonde data: {error}

" + } + } + + # Build sonde list HTML + sondes_html = '
' + sondes_html += '

Tracked Sondes Status

' + sondes_html += '
' + + for sonde_id in tracked: + sonde = sondes_data.get(sonde_id) + if not sonde: + sondes_html += f''' +
+ {sonde_id} +

No current data (not in latest API)

+
+ ''' + else: + lat = sonde.get("lat", "β€”") + lon = sonde.get("lon", "β€”") + alt = sonde.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else "β€”" + + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + if isinstance(vel_h, (int, float)) and isinstance(vel_v, (int, float)): + speed = (vel_h ** 2 + vel_v ** 2) ** 0.5 + elif isinstance(vel_h, (int, float)): + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "β€”" + + temp = sonde.get("temp", "β€”") + uploader = sonde.get("uploader_callsign") or sonde.get("uploader") or "β€”" + + sondes_html += f''' +
+ {sonde_id} +

+ Position: {lat}, {lon} | Altitude: {alt_str} | Speed: {speed_str}
+ Temp: {temp}Β°C | Uploader: {uploader} +

+
+ ''' + + sondes_html += '
' + return { + "status": 0, + "web_content": { + "source": sondes_html + } + } + + except Exception as e: + return { + "status": 1, + "web_content": { + "source": f"

Error loading tracked sondes: {str(e)}

" + }, + "notifications": [{"message": f"Error: {str(e)}", "category": "error"}] + } + + @dashboard_page(name="settings", description="Guild Settings", methods=("GET", "POST"), context_ids=["guild_id"]) + async def dashboard_guild_settings(self, guild: discord.Guild, **kwargs) -> typing.Dict[str, typing.Any]: + """Manage guild-specific radiosonde settings.""" + cog = getattr(self, "_radiosonde_cog", None) + if not cog: + return {"status": 0, "web_content": {"source": "

Radiosonde cog not loaded.

"}} + + config = cog.config.guild(guild) + try: + update_channel_id = await config.update_channel() + update_interval = await config.update_interval() + except Exception as e: + return { + "status": 1, + "web_content": {"source": f"

Error loading config: {e}

"}, + "notifications": [{"message": f"Error loading config: {e}", "category": "error"}] + } + + # Get channel name for display + update_channel_name = "None" + if update_channel_id: + channel = guild.get_channel(update_channel_id) + update_channel_name = channel.name if channel else f"Unknown Channel ({update_channel_id})" + + # WTForms form definition + class SettingsForm(kwargs["Form"]): + def __init__(self): + super().__init__(prefix="settings_") + update_channel = wtforms.StringField( + "Update Channel ID", + render_kw={"class": "form-field", "placeholder": "Leave empty to disable updates"} + ) + update_interval = wtforms.IntegerField( + "Update Interval (seconds)", + render_kw={"class": "form-field", "min": "30", "max": "86400"} + ) + submit = wtforms.SubmitField("Save Settings", render_kw={"class": "form-submit"}) + + settings_form = SettingsForm() + result_html = "" + + # Handle form submission + if settings_form.validate_on_submit(): + try: + channel_val = int(settings_form.update_channel.data) if settings_form.update_channel.data else None + interval_val = settings_form.update_interval.data or 300 + + if interval_val < 30 or interval_val > 86400: + result_html = ''' +
+ Error: Interval must be between 30 seconds and 24 hours (86400 seconds). +
+ ''' + elif channel_val and not guild.get_channel(channel_val): + result_html = ''' +
+ Error: Channel not found. Please enter a valid channel ID. +
+ ''' + else: + await config.update_channel.set(channel_val) + await config.update_interval.set(interval_val) + + if channel_val: + channel = guild.get_channel(channel_val) + update_channel_name = channel.name if channel else f"Channel {channel_val}" + else: + update_channel_name = "None" + + result_html = ''' +
+ Success: Settings updated successfully! +
+ ''' + except ValueError: + result_html = ''' +
+ Error: Invalid channel ID. Please enter a valid numeric ID. +
+ ''' + except Exception as e: + result_html = f''' +
+ Error: {str(e)} +
+ ''' + + # Populate form + settings_form.update_channel.data = str(update_channel_id or "") + settings_form.update_interval.data = update_interval + + return { + "status": 0, + "web_content": { + "source": """ +
+

Radiosonde Guild Settings

+

Configure sonde update settings for this guild.

+ +
+

Current Settings:

+
+
    +
  • Update Channel: {{ update_channel_name }}
  • +
  • Update Interval: {{ update_interval }} seconds
  • +
+
+
+ +
+

Update Settings:

+
+ +
+ {{ settings_form|safe }} +
+
+
+ + {{ result_html|safe }} +
+ """, + "settings_form": settings_form, + "result_html": result_html, + "update_channel_name": update_channel_name, + "update_interval": update_interval, + }, + } diff --git a/radiosonde/docs.md b/radiosonde/docs.md index f2b8812..b673e18 100644 --- a/radiosonde/docs.md +++ b/radiosonde/docs.md @@ -52,9 +52,23 @@ The `Radiosonde` cog allows you to track radiosondes (weather balloons) using th - Default is 300 seconds (5 minutes). - Example: `[p]sonde interval 60` (check every minute) -- **`[p]sonde site `** - - Look up a radiosonde launch site by station ID (from the [SondeHub sites](https://api.v2.sondehub.org/sites) list). Shows name, position, altitude, sonde types, and launch times. - - Example: `[p]sonde site 03953` or `[p]sonde site 94767` +- **`[p]sonde site `** + - Look up a radiosonde launch site by **station ID** or by **name** (substring, case-insensitive). Shows name, position, altitude, sonde types, and launch times. If multiple names match, lists them with their IDs so you can pick one. + - Examples: `[p]sonde site 10238` | `[p]sonde site Bergen-Hohne` | `[p]sonde site Germany` + +- **`[p]sonde history [limit]`** + - Show recent telemetry history for a radiosonde serial. `limit` defaults to 25. + - Example: `[p]sonde history U1234567 10` + +- **`[p]sonde nearby [distance]`** + - List sondes near a latitude/longitude within `distance` (units passed directly to the SondeHub API; default `100`). + - Example: `[p]sonde nearby 53.3 -6.2 500` + +- **`[p]sonde realtime`** + - Fetch and display the MQTT-over-WebSocket endpoint returned by `/sondes/websocket` so admins can configure realtime listeners. + +- **`[p]sonde listeners`** + - Show aggregated listener/uploader statistics from `/listeners/stats`. --- diff --git a/radiosonde/info.json b/radiosonde/info.json new file mode 100644 index 0000000..cdb51f9 --- /dev/null +++ b/radiosonde/info.json @@ -0,0 +1,21 @@ +{ + "author": [ + "bencos167" + ], + "name": "Radiosonde", + "short": "Track radiosondes using the SondeHub API", + "description": "A Red Discord bot cog that tracks radiosondes using the SondeHub V2 API. Features include tracking specific sondes, querying by location, viewing telemetry history, and listening to live updates.", + "install_msg": "Thanks for installing Radiosonde! Use `[p]help sonde` to get started.", + "end_user_data_statement": "This cog does not store any end user data.", + "requirements": [ + "aiohttp" + ], + "tags": [ + "radiosonde", + "tracking", + "api", + "sondehub" + ], + "hidden": false, + "disabled": false +} \ No newline at end of file diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index a5b19a9..fe60be7 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -3,12 +3,16 @@ from redbot.core import commands, Config, checks import aiohttp import asyncio +from .dashboard import DashboardIntegration -class Radiosonde(commands.Cog): +__version__ = "1.0.3" + +class Radiosonde(DashboardIntegration, commands.Cog): """Track radiosondes using the SondeHub API.""" def __init__(self, bot): self.bot = bot + self._radiosonde_cog = self # For dashboard integration self.config = Config.get_conf( self, identifier=492089091320446976, force_registration=True ) @@ -21,6 +25,8 @@ def __init__(self, bot): self.session = aiohttp.ClientSession() self.bg_task = self.bot.loop.create_task(self.update_sondes()) + # track last update times per guild to respect configured intervals + self._last_updates = {} def cog_unload(self): self.bg_task.cancel() @@ -65,43 +71,152 @@ async def fetch_sites(self): except OSError as e: return {}, f"Network/OS error: {type(e).__name__}: {e}" + async def fetch_telemetry(self, serial: str): + """Fetch telemetry history for a given sonde serial. Returns (data, error).""" + # Individual sonde telemetry is served at /sonde/{serial} (singular) + url = f"https://api.v2.sondehub.org/sonde/{serial}" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + if resp.status == 404: + return None, f"Not found (HTTP 404): no telemetry for {serial}" + return None, f"API returned HTTP {resp.status}" + data = await resp.json() + return data, None + except asyncio.TimeoutError: + return None, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return None, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return None, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return None, f"Network/OS error: {type(e).__name__}: {e}" + + async def fetch_sondes_near(self, lat: float, lon: float, distance: float = 100.0): + """Query sondes by location. `distance` is passed directly to API (units used by API). + Returns (data_dict, error).""" + url = f"https://api.v2.sondehub.org/sondes?lat={lat}&lon={lon}&distance={distance}" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + return data if isinstance(data, dict) else {}, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" + + async def fetch_realtime_endpoint(self): + """Return the MQTT-over-WebSocket endpoint from /sondes/websocket.""" + url = "https://api.v2.sondehub.org/sondes/websocket" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + return None, f"API returned HTTP {resp.status}" + data = await resp.json() + return data, None + except Exception as e: + return None, str(e) + + async def fetch_listeners_stats(self): + """Fetch aggregated listener/uploader statistics from /listeners/stats.""" + url = "https://api.v2.sondehub.org/listeners/stats" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + return data if isinstance(data, dict) else {}, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" + async def update_sondes(self): await self.bot.wait_until_ready() while not self.bot.is_closed(): + now = self.bot.loop.time() for guild in self.bot.guilds: guild_config = await self.config.guild(guild).all() tracked = guild_config.get("tracked_sondes", []) channel_id = guild_config.get("update_channel") interval = guild_config.get("update_interval", 300) - if tracked and channel_id: - sondes_data, _ = await self.fetch_sondes() - channel = self.bot.get_channel(channel_id) - if not channel: + if not tracked or not channel_id: + continue + + last = self._last_updates.get(guild.id, 0) + if now - last < interval: + continue + + sondes_data, error = await self.fetch_sondes() + channel = self.bot.get_channel(channel_id) + if error: + # Inform channel of failures optionally (only once) + try: + if channel: + await channel.send(f"Could not fetch sondes for updates: {error}") + except Exception: + pass + continue + if not channel: + continue + + for sonde_id in tracked: + sonde = sondes_data.get(sonde_id) + if not sonde: + e = discord.Embed(title=f"{sonde_id}", description="No current data (not in latest API)", colour=0xDD5555) + await channel.send(embed=e) continue - for sonde_id in tracked: - sonde = sondes_data.get(sonde_id) - if sonde: - vel_h = sonde.get("vel_h") - vel_v = sonde.get("vel_v") - # Calculate speed from horizontal and vertical velocity - if vel_h is not None and vel_v is not None: - speed = (vel_h**2 + vel_v**2)**0.5 - elif vel_h is not None: - speed = vel_h - else: - speed = None - speed_str = f"{speed:.1f} m/s" if speed is not None else "β€”" - msg = ( - f"**Sonde {sonde_id} Update**\n" - f"Lat: {sonde.get('lat')}\n" - f"Lon: {sonde.get('lon')}\n" - f"Alt: {sonde.get('alt'):.1f} m\n" - f"Speed: {speed_str}\n" - ) - await channel.send(msg) + embed = self._sonde_to_embed(sonde_id, sonde) + await channel.send(embed=embed) + + self._last_updates[guild.id] = now await asyncio.sleep(1) # small delay between guilds - await asyncio.sleep(60) # wait 1 minute before next batch + await asyncio.sleep(5) # short polling delay + + def _format_sonde_message(self, sonde_id: str, sonde: dict) -> str: + """Create a safe, readable message for a single sonde dict.""" + def fmt_num(v, prec=5): + return f"{v:.{prec}f}" if isinstance(v, (int, float)) else (str(v) if v is not None else "β€”") + + lat = fmt_num(sonde.get("lat"), 5) + lon = fmt_num(sonde.get("lon"), 5) + alt = sonde.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "β€”") + + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + if isinstance(vel_h, (int, float)) and isinstance(vel_v, (int, float)): + speed = (vel_h ** 2 + vel_v ** 2) ** 0.5 + elif isinstance(vel_h, (int, float)): + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "β€”" + + heading = sonde.get("heading") + sats = sonde.get("sats") + temp = sonde.get("temp") + batt = sonde.get("batt") + + lines = [f"**Sonde {sonde_id} Update**"] + lines.append(f"Lat: {lat} | Lon: {lon} | Alt: {alt_str}") + lines.append(f"Speed: {speed_str} | Heading: {heading if heading is not None else 'β€”'}") + lines.append(f"Sats: {sats if sats is not None else 'β€”'} | Temp: {temp if temp is not None else 'β€”'}Β°C | Batt: {batt if batt is not None else 'β€”'} V") + uploader = sonde.get("uploader_callsign") or sonde.get("uploader") + if uploader: + lines.append(f"Uploader: {uploader}") + return "\n".join(lines) @commands.group() async def sonde(self, ctx): @@ -139,6 +254,34 @@ async def list(self, ctx): return await ctx.send("Tracked sondes: " + ", ".join(tracked)) + @sonde.command() + async def settings(self, ctx): + """Show current guild settings.""" + update_channel_id = await self.config.guild(ctx.guild).update_channel() + update_interval = await self.config.guild(ctx.guild).update_interval() + + channel_name = "Not set" + if update_channel_id: + channel = ctx.guild.get_channel(update_channel_id) + channel_name = channel.mention if channel else f"Unknown (ID: {update_channel_id})" + + # Convert seconds to human-readable format + def format_interval(seconds): + if seconds < 60: + return f"{seconds}s" + elif seconds < 3600: + return f"{seconds // 60}m" + elif seconds < 86400: + return f"{seconds // 3600}h" + else: + return f"{seconds // 86400}d" + + e = discord.Embed(title="Radiosonde Guild Settings", colour=0x55AAFF) + e.add_field(name="Update Channel", value=channel_name, inline=False) + e.add_field(name="Update Interval", value=format_interval(update_interval), inline=True) + e.add_field(name="Interval (seconds)", value=str(update_interval), inline=True) + await ctx.send(embed=e) + @sonde.command() async def status(self, ctx): """List current status of all tracked sondes (lat, lon, alt, speed).""" @@ -154,30 +297,65 @@ async def status(self, ctx): f"Could not fetch sonde data from the API.{detail} Try again later." ) return - lines = [] + embeds = [] for sonde_id in tracked: s = sondes_data.get(sonde_id) if s is None: - lines.append(f"**{sonde_id}** β€” No current data (not in latest API)") + e = discord.Embed(title=f"{sonde_id}", description="No current data (not in latest API)", colour=0xDD5555) + embeds.append(e) continue - lat = s.get("lat", "β€”") - lon = s.get("lon", "β€”") - alt = s.get("alt") - vel_h = s.get("vel_h") - vel_v = s.get("vel_v") - # Calculate speed from horizontal and vertical velocity - if vel_h is not None and vel_v is not None: - speed = (vel_h**2 + vel_v**2)**0.5 - elif vel_h is not None: - speed = vel_h - else: - speed = None - alt_str = f"{alt:.1f} m" if alt is not None else "β€”" - vel_str = f"{speed:.1f} m/s" if speed is not None else "β€”" - lines.append( - f"**{sonde_id}** β€” Lat: {lat} | Lon: {lon} | Alt: {alt_str} | Speed: {vel_str}" - ) - await ctx.send("**Tracked sondes status**\n" + "\n".join(lines)) + embeds.append(self._sonde_to_embed(sonde_id, s)) + + # Send embeds in small groups to avoid hitting limits + batch = [] + for e in embeds: + batch.append(e) + if len(batch) >= 5: + # Discord API supports multiple embeds; send this batch + await ctx.send(embeds=batch) + batch = [] + if batch: + await ctx.send(embeds=batch) + + def _sonde_to_embed(self, sonde_id: str, sonde: dict) -> discord.Embed: + """Build a Discord embed summarizing a sonde's current data.""" + def fmt_num(v, prec=5): + return f"{v:.{prec}f}" if isinstance(v, (int, float)) else (str(v) if v is not None else "β€”") + + lat = fmt_num(sonde.get("lat"), 5) + lon = fmt_num(sonde.get("lon"), 5) + alt = sonde.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "β€”") + + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + if isinstance(vel_h, (int, float)) and isinstance(vel_v, (int, float)): + speed = (vel_h ** 2 + vel_v ** 2) ** 0.5 + elif isinstance(vel_h, (int, float)): + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "β€”" + + heading = sonde.get("heading") + sats = sonde.get("sats") + temp = sonde.get("temp") + batt = sonde.get("batt") + uploader = sonde.get("uploader_callsign") or sonde.get("uploader") + + title = f"Sonde {sonde_id}" + desc = f"Lat: {lat} | Lon: {lon}\nAlt: {alt_str} | Speed: {speed_str}" + e = discord.Embed(title=title, description=desc, colour=0x55AAFF) + e.add_field(name="Heading", value=str(heading) if heading is not None else "β€”", inline=True) + e.add_field(name="Sats", value=str(sats) if sats is not None else "β€”", inline=True) + e.add_field(name="Temp (Β°C)", value=str(temp) if temp is not None else "β€”", inline=True) + e.add_field(name="Battery (V)", value=str(batt) if batt is not None else "β€”", inline=True) + if uploader: + e.set_footer(text=f"Uploader: {uploader}") + last = sonde.get("last_seen") or sonde.get("datetime") or sonde.get("time") + if last: + e.add_field(name="Last seen", value=str(last), inline=False) + return e @sonde.command() async def setchannel(self, ctx, channel: discord.TextChannel): @@ -239,7 +417,7 @@ async def site(self, ctx, query: str): # Try exact match by station ID first site = sites_data.get(query) if site is not None: - await ctx.send(self._format_site_message(query, site)) + await ctx.send(embed=self._site_to_embed(query, site)) return # Search by station name (case-insensitive, substring) query_lower = query.lower() @@ -253,13 +431,161 @@ async def site(self, ctx, query: str): return if len(matches) == 1: sid, s = matches[0] - await ctx.send(self._format_site_message(sid, s)) + await ctx.send(embed=self._site_to_embed(sid, s)) return # Multiple matches: list them (up to 15) - lines = [f"**Multiple sites matching \"{query}\"** β€” use station ID for one:\n"] + desc_lines = [f"Multiple sites matching \"{query}\" β€” use station ID for one:"] for sid, s in sorted(matches, key=lambda x: (x[1].get("station_name") or ""))[:15]: name = s.get("station_name", "β€”") - lines.append(f"β€’ `{sid}` β€” {name}") + desc_lines.append(f"`{sid}` β€” {name}") if len(matches) > 15: - lines.append(f"*… and {len(matches) - 15} more. Narrow your search.*") - await ctx.send("\n".join(lines)) + desc_lines.append(f"… and {len(matches) - 15} more. Narrow your search.") + e = discord.Embed(title=f"Sites matching '{query}'", description="\n".join(desc_lines), colour=0x55AAFF) + await ctx.send(embed=e) + + @sonde.command() + async def history(self, ctx, serial: str, limit: int = 25): + """Show recent telemetry history for a radiosonde serial.""" + async with ctx.typing(): + data, error = await self.fetch_telemetry(serial) + if error: + await ctx.send( + f"Could not fetch telemetry for `{serial}`: {error}. Check the serial number or try again later." + ) + return + if not data: + await ctx.send(f"No telemetry found for `{serial}`. It may be expired or never uploaded.") + return + # Try to find telemetry list in response + telemetry = None + if isinstance(data, dict): + telemetry = data.get("telemetry") or data.get("history") or data.get("data") + if telemetry is None and isinstance(data, list): + telemetry = data + if not telemetry: + await ctx.send(f"No telemetry history found for `{serial}`.") + return + # Take last `limit` points + points = telemetry[-limit:] + desc_lines = [] + for p in reversed(points): + t = p.get("time") or p.get("timestamp") or p.get("ts") or "β€”" + lat = p.get("lat") if p.get("lat") is not None else "β€”" + lon = p.get("lon") if p.get("lon") is not None else "β€”" + alt = p.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "β€”") + desc_lines.append(f"{t} β€” Lat: {lat} | Lon: {lon} | Alt: {alt_str}") + desc = "\n".join(desc_lines) + if len(desc) > 3500: + desc = desc[:3490] + "\n…output truncated…" + e = discord.Embed(title=f"Telemetry for {serial} (last {len(points)})", description=desc, colour=0x55AAFF) + await ctx.send(embed=e) + + @sonde.command() + async def nearby(self, ctx, lat: float, lon: float, distance: float = 100.0): + """List sondes near a given lat/lon within `distance` (API units).""" + async with ctx.typing(): + data, error = await self.fetch_sondes_near(lat, lon, distance) + if error: + await ctx.send(f"Could not query sondes near location: {error}. Check parameters or try again later.") + return + # data expected as dict keyed by serial + if not isinstance(data, dict) or not data: + await ctx.send("No sondes found near that location.") + return + desc_lines = [f"Sondes within {distance} of {lat},{lon}"] + for sid, s in sorted(data.items(), key=lambda x: x[0])[:25]: + la = s.get("lat", "β€”") + lo = s.get("lon", "β€”") + alt = s.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "β€”") + desc_lines.append(f"`{sid}` β€” Lat: {la} | Lon: {lo} | Alt: {alt_str}") + if len(data) > 25: + desc_lines.append(f"… and {len(data) - 25} more.") + e = discord.Embed(title="Nearby sondes", description="\n".join(desc_lines), colour=0x55AAFF) + await ctx.send(embed=e) + + @sonde.command() + async def realtime(self, ctx): + """Show the MQTT-over-WebSocket endpoint used for realtime sonde streaming.""" + async with ctx.typing(): + data, error = await self.fetch_realtime_endpoint() + if error: + await ctx.send(f"Could not fetch realtime endpoint: {error}. Try again later or check API status.") + return + if not data: + await ctx.send("Realtime endpoint returned unexpected data; try again later.") + return + # Present the returned JSON or common `url` key + url = None + if isinstance(data, dict): + url = data.get("url") or data.get("endpoint") or data.get("ws") + if not url: + await ctx.send(embed=discord.Embed(title="Realtime endpoint", description=str(data), colour=0xDD5555)) + return + e = discord.Embed(title="Realtime MQTT-over-WebSocket endpoint", description=url, colour=0x55AAFF) + await ctx.send(embed=e) + + @sonde.command() + async def listeners(self, ctx): + """Show aggregated listener/uploader statistics from SondeHub.""" + async with ctx.typing(): + data, error = await self.fetch_listeners_stats() + if error: + await ctx.send(f"Could not fetch listener stats: {error}. Try again later.") + return + if not data: + await ctx.send("No listener statistics available at the moment.") + return + # Present a brief summary of top-level keys + e = discord.Embed(title="Listener statistics (summary)", colour=0x55AAFF) + if isinstance(data, dict): + if "uploaders" in data: + e.add_field(name="Uploaders", value=str(len(data.get("uploaders") or [])), inline=True) + if "stations" in data: + e.add_field(name="Stations", value=str(len(data.get("stations") or [])), inline=True) + if "listeners" in data: + e.add_field(name="Listeners", value=str(len(data.get("listeners") or [])), inline=True) + # show some other top-level values + for k, v in list(data.items())[:8]: + if k in ("uploaders", "stations", "listeners"): + continue + if isinstance(v, (int, float, str)): + e.add_field(name=k, value=str(v), inline=True) + else: + e.description = str(data) + await ctx.send(embed=e) + + def _site_to_embed(self, site_id: str, site: dict) -> discord.Embed: + name = site.get("station_name", "β€”") + pos = site.get("position") + if isinstance(pos, (list, tuple)) and len(pos) >= 2: + lon, lat = pos[0], pos[1] + pos_str = f"Lat: {lat}, Lon: {lon}" + else: + pos_str = "β€”" + alt = site.get("alt") + alt_str = f"{alt} m" if alt is not None else "β€”" + rs = site.get("rs_types", []) + rs_str = ", ".join(str(r) for r in rs[:10]) if rs else "β€”" + if rs and len(rs) > 10: + rs_str += f" (+{len(rs) - 10} more)" + times = site.get("times", []) + times_str = ", ".join(str(t) for t in times[:6]) if times else "β€”" + if times and len(times) > 6: + times_str += f" (+{len(times) - 6} more)" + notes = site.get("notes", "").strip() + e = discord.Embed(title=f"{name} ({site_id})", colour=0x55AAFF) + e.add_field(name="Position", value=pos_str, inline=False) + e.add_field(name="Altitude", value=alt_str, inline=True) + e.add_field(name="Radiosonde types", value=rs_str, inline=True) + e.add_field(name="Launch times (UTC)", value=times_str, inline=False) + if notes: + e.add_field(name="Notes", value=notes[:300] + ("…" if len(notes) > 300 else ""), inline=False) + return e + @sonde.command() + async def version(self, ctx): + """Show the cog version.""" + e = discord.Embed(title="Radiosonde Cog", description=f"Version {__version__}", colour=0x55AAFF) + e.add_field(name="API", value="SondeHub V2", inline=False) + await ctx.send(embed=e) \ No newline at end of file diff --git a/skysearch/README.md b/skysearch/README.md index 4094349..2e781ea 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -60,6 +60,8 @@ To use the SkySearch cog, follow these steps: - `[p]airport runway ` - Get runway information - `[p]airport navaid ` - Get navigational aids - `[p]airport forecast ` - Get weather forecast +- `[p]airport faastatus [code]` - Get FAA National Airspace Status (delays/closures). Optionally filter by airport code (e.g., SAN, LAS). Use the dropdown to filter by type; use **Refresh** to re-fetch. +- **FAA status alerts** (like squawk alerts): `[p]airport faaalertchannel [#channel]` `[p]airport faaalertrole [@role]` `[p]airport faaalertcooldown [minutes]` `[p]airport showfaaalerts` β€” get notified when FAA delays/closures change (task runs every 5 minutes). ### Admin Commands - `[p]aircraft alertchannel [#channel]` - Set alert channel @@ -103,3 +105,40 @@ there is 4 total pages in it - `Apistats` - shows apistats for the cog itself - `Guild` - allows you to change cog settings in the dashboard (uses ids, to get them enable developer mode on discord) - `Lookup` - allows you to lookup data directly in the cog dashboard page + +## πŸ”§ Utilities + +SkySearch includes several utility modules for common operations: + +### XML Parser (`utils/xml_parser.py`) +Utility class for parsing XML data from APIs with safe error handling. + +**Features:** +- Parse XML strings safely with error handling +- Find elements using XPath expressions +- Extract text content from XML elements +- Fetch and parse XML from URLs in one call + +**Usage:** +```python +from ..utils.xml_parser import XMLParser + +parser = XMLParser() + +# Fetch and parse XML from a URL +async with aiohttp.ClientSession() as session: + root = await parser.fetch_and_parse_xml(session, "https://api.example.com/data.xml") + if root: + elements = parser.find_elements(root, ".//Airport") + for element in elements: + code = parser.get_text(element, "ARPT") +``` + +**Methods:** +- `parse_xml_string(xml_string)` - Parse XML string safely +- `find_elements(root, xpath)` - Find elements using XPath +- `get_text(element, tag, default="")` - Extract text from child elements +- `fetch_and_parse_xml(session, url, headers=None)` - Fetch and parse XML from URL + +Used by: +- FAA National Airspace Status command (`airport faastatus`) diff --git a/skysearch/commands/admin.py b/skysearch/commands/admin.py index a28831c..fb666ff 100644 --- a/skysearch/commands/admin.py +++ b/skysearch/commands/admin.py @@ -98,6 +98,117 @@ async def set_alert_cooldown(self, ctx, duration: str = None): else: await ctx.send(_("Current emergency alert cooldown is {minutes} minutes.").format(minutes=int(cooldown))) + async def set_faa_alert_channel(self, ctx, channel: discord.TextChannel = None): + """Set or clear the channel for FAA status change notifications. Clear with no channel.""" + if channel: + try: + await self.cog.config.guild(ctx.guild).faa_alert_channel.set(channel.id) + embed = discord.Embed( + description=_("FAA status alert channel set to {channel}").format(channel=channel.mention), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error setting FAA alert channel: {e}", color=0xff4545) + await ctx.send(embed=embed) + else: + try: + await self.cog.config.guild(ctx.guild).faa_alert_channel.clear() + embed = discord.Embed( + description=_("FAA status alert channel cleared. No more FAA change notifications will be sent."), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error clearing FAA alert channel: {e}", color=0xff4545) + await ctx.send(embed=embed) + + async def set_faa_alert_role(self, ctx, role: discord.Role = None): + """Set or clear the role to mention when FAA status changes. Clear with no role.""" + if role: + try: + await self.cog.config.guild(ctx.guild).faa_alert_role.set(role.id) + embed = discord.Embed( + description=_("FAA status alert role set to {role}").format(role=role.mention), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error setting FAA alert role: {e}", color=0xff4545) + await ctx.send(embed=embed) + else: + try: + await self.cog.config.guild(ctx.guild).faa_alert_role.clear() + embed = discord.Embed( + description=_("FAA status alert role cleared."), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error clearing FAA alert role: {e}", color=0xff4545) + await ctx.send(embed=embed) + + async def set_faa_alert_cooldown(self, ctx, duration: str = None): + """Set or show cooldown for FAA status change notifications (minutes). Default 5.""" + if duration is not None: + try: + if duration.endswith("s"): + seconds = int(duration[:-1]) + minutes = seconds / 60 + elif duration.endswith("m"): + minutes = int(duration[:-1]) + else: + minutes = int(duration) + if minutes < 0: + await ctx.send(_("Cooldown must be a positive number.")) + return + await self.cog.config.guild(ctx.guild).faa_alert_cooldown.set(minutes) + if minutes < 1: + await ctx.send(_("FAA alert cooldown set to {seconds} seconds.").format(seconds=int(minutes * 60))) + else: + await ctx.send(_("FAA alert cooldown set to {minutes} minutes.").format(minutes=int(minutes))) + except ValueError: + await ctx.send(_("Invalid duration. Use a number, '5m', or '30s'.")) + else: + cooldown = await self.cog.config.guild(ctx.guild).faa_alert_cooldown() + if cooldown < 1: + await ctx.send(_("Current FAA alert cooldown is {seconds} seconds.").format(seconds=int(cooldown * 60))) + else: + await ctx.send(_("Current FAA alert cooldown is {minutes} minutes.").format(minutes=int(cooldown))) + + async def list_faa_alert_channels(self, ctx): + """Show FAA status alert channel and role for this server.""" + guild = ctx.guild + embed = discord.Embed(title=f"FAA status alerts for {guild.name}", color=0xfffffe) + channel_id = await self.cog.config.guild(guild).faa_alert_channel() + role_id = await self.cog.config.guild(guild).faa_alert_role() + cooldown = await self.cog.config.guild(guild).faa_alert_cooldown() + if channel_id: + ch = self.cog.bot.get_channel(channel_id) + embed.add_field( + name="Channel", + value=ch.mention if ch else f"Unknown ({channel_id})", + inline=True + ) + else: + embed.add_field(name="Channel", value="Not set", inline=True) + if role_id: + role = guild.get_role(role_id) + embed.add_field( + name="Role", + value=role.mention if role else f"Unknown ({role_id})", + inline=True + ) + else: + embed.add_field(name="Role", value="Not set", inline=True) + embed.add_field( + name="Cooldown", + value=f"{int(cooldown)} min" if cooldown >= 1 else f"{int(cooldown * 60)} sec", + inline=True + ) + embed.set_footer(text="Use airport faaalertchannel / faaalertrole / faaalertcooldown to change.") + await ctx.send(embed=embed) + async def autoicao(self, ctx, state: bool = None): """Enable or disable automatic ICAO lookup.""" if state is None: diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index f80266b..6f1c2e0 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -5,10 +5,13 @@ import discord import aiohttp import asyncio +import re +from datetime import datetime from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from ..utils.api import APIManager from ..utils.helpers import HelperUtils +from ..utils.xml_parser import XMLParser from redbot.core import commands from redbot.core.i18n import Translator, cog_i18n @@ -16,6 +19,232 @@ _ = Translator("Skysearch", __file__) +class FAAStatusRefreshButton(discord.ui.Button): + """Button to refresh FAA status data.""" + + def __init__(self): + super().__init__(style=discord.ButtonStyle.secondary, label="Refresh", emoji="πŸ”„", custom_id="faa_status_refresh") + + async def callback(self, interaction: discord.Interaction): + view = self.view + if not isinstance(view, FAAStatusView) or view.airport_commands is None: + await interaction.response.defer() + return + await interaction.response.defer() + result = await view.airport_commands._faa_fetch_data(view.airport_code) + if result is None: + await interaction.followup.send("Could not refresh; FAA API may be unavailable.", ephemeral=True) + return + ground_delays, arrival_departure_delays, closures, update_time = result + view.ground_delays = ground_delays + view.arrival_departure_delays = arrival_departure_delays + view.closures = closures + view.update_time = update_time + embed = view.build_embed(view.current_filter) + await interaction.edit_original_response(embed=embed, view=view) + + +class FAAStatusView(discord.ui.View): + """View with dropdown to filter FAA status by delay type and optional Refresh button.""" + + def __init__(self, ground_delays, arrival_departure_delays, closures, update_time, airport_code=None, airport_commands=None): + super().__init__(timeout=600) # 10 minute timeout + self.ground_delays = ground_delays + self.arrival_departure_delays = arrival_departure_delays + self.closures = closures + self.update_time = update_time + self.airport_code = airport_code + self.airport_commands = airport_commands + self.current_filter = "all" + + # Build dropdown options + options = [ + discord.SelectOption( + label="All Issues", + value="all", + description="Show all delays and closures", + emoji="✈️", + default=True + ) + ] + + if ground_delays: + options.append(discord.SelectOption( + label="Ground Delays", + value="ground", + description=f"{len(ground_delays)} ground delay program(s)", + emoji="πŸ›«" + )) + + if arrival_departure_delays: + options.append(discord.SelectOption( + label="Arrival/Departure Delays", + value="arrdep", + description=f"{len(arrival_departure_delays)} delay(s)", + emoji="πŸ›¬" + )) + + if closures: + options.append(discord.SelectOption( + label="Closures", + value="closures", + description=f"{len(closures)} closure(s)", + emoji="🚫" + )) + + if len(options) > 1: + self.select_menu = discord.ui.Select( + placeholder="Filter by delay type...", + options=options, + min_values=1, + max_values=1 + ) + self.select_menu.callback = self.on_select + self.add_item(self.select_menu) + + # Refresh button (when airport_commands is provided so we can re-fetch) + if airport_commands is not None: + self.add_item(FAAStatusRefreshButton()) + + async def on_select(self, interaction: discord.Interaction): + """Handle dropdown selection.""" + selected = interaction.data['values'][0] + self.current_filter = selected + + # Update default option + for option in self.select_menu.options: + option.default = (option.value == selected) + + # Build new embed + embed = self.build_embed(selected) + await interaction.response.edit_message(embed=embed, view=self) + + def build_embed(self, filter_type="all"): + """Build embed based on filter type.""" + embed = discord.Embed( + title="✈️ FAA National Airspace Status", + color=0x2b2d31 + ) + + # Add filter indicator to footer + filter_text = { + "all": "All Issues", + "ground": "Ground Delays Only", + "arrdep": "Arrival/Departure Delays Only", + "closures": "Closures Only" + }.get(filter_type, "All Issues") + + embed.set_footer(text=f"{filter_text} β€’ Updated: {self.update_time} β€’ Times in UTC β€’ Data refreshes every 60s") + + field_count = 0 + max_fields = 25 + + # Add Ground Delay Programs + if filter_type in ("all", "ground") and self.ground_delays: + for delay in self.ground_delays[:8]: + if field_count >= max_fields: + break + delay_info = f"`{delay['avg']}` avg β€’ `{delay['max']}` max" + value = f"{delay_info}\n**Reason:** {delay['reason']}" + embed.add_field( + name=f"πŸ›« `{delay['arpt']}` Ground Delay", + value=value, + inline=False + ) + field_count += 1 + + # Add Arrival/Departure Delays + if filter_type in ("all", "arrdep") and self.arrival_departure_delays: + for delay in self.arrival_departure_delays[:8]: + if field_count >= max_fields: + break + emoji = "πŸ›¬" if delay['type'].lower() == "arrival" else "πŸ›«" + type_name = delay['type'] + + delay_parts = [] + if delay['min'] and delay['max']: + delay_parts.append(f"`{delay['min']}` - `{delay['max']}`") + elif delay['min']: + delay_parts.append(f"`{delay['min']}` min") + elif delay['max']: + delay_parts.append(f"`{delay['max']}` max") + + value = "" + if delay_parts: + value = f"{' β€’ '.join(delay_parts)}\n" + + value += f"**Reason:** {delay['reason']}" + + if delay['trend']: + trend_emoji = "πŸ“ˆ" if delay['trend'].lower() == "increasing" else "πŸ“‰" if delay['trend'].lower() == "decreasing" else "➑️" + value += f" {trend_emoji}" + + embed.add_field( + name=f"{emoji} `{delay['arpt']}` {type_name} Delay", + value=value, + inline=False + ) + field_count += 1 + + # Add Airport Closures + if filter_type in ("all", "closures") and self.closures: + for closure in self.closures[:8]: + if field_count >= max_fields: + break + reason = closure['reason'] + phone_match = re.search(r'(\d{3}-\d{3}-\d{4})', reason) + phone = phone_match.group(1) if phone_match else None + + if phone: + reason = reason.replace(phone, "").strip() + reason = re.sub(r'\s+', ' ', reason) + + value = f"**{reason}**" + if phone: + value += f"\nπŸ“ž Contact: `{phone}`" + + timing_parts = [] + if closure['start']: + timing_parts.append(f"Started: `{closure['start']}`") + if closure['reopen']: + timing_parts.append(f"Reopens: `{closure['reopen']}`") + + if timing_parts: + value += f"\n\n{' β€’ '.join(timing_parts)}" + + embed.add_field( + name=f"🚫 `{closure['arpt']}` Closure", + value=value, + inline=False + ) + field_count += 1 + + # Add note if no results for filter + if field_count == 0: + embed.description = f"βœ… No {filter_text.lower()} found." + + # Add note if we hit the limit + if field_count >= max_fields: + embed.add_field( + name="⚠️ Display Limit", + value="Showing first 25 items. Use `*airport faastatus ` to filter by airport.", + inline=False + ) + + return embed + + +def _clean_closure_reason(raw): + """Clean and humanize closure reason text from FAA XML.""" + clean_msg = re.sub(r'^![A-Z0-9]{3,4}\s\d+/\d+\s[A-Z0-9]{3,4}\s', '', raw) + clean_msg = re.sub(r'\s\d{10}-\d{10}$', '', clean_msg) + clean_msg = (clean_msg.replace("AD AP CLSD TO NON SKED TRANSIENT GA ACFT EXC", "Closed to non-scheduled/private flights except") + .replace("PPR", "Prior Permission Required") + .replace("EXC", "except") + .strip()) + return clean_msg + + @cog_i18n(_) class AirportCommands: """Airport-related commands for SkySearch.""" @@ -24,6 +253,68 @@ def __init__(self, cog): self.cog = cog self.api = APIManager(cog) self.helpers = HelperUtils(cog) + self.xml_parser = XMLParser() + + async def _faa_fetch_data(self, airport_code: str = None): + """Fetch and parse FAA airport status. Returns (ground_delays, arrival_departure_delays, closures, update_time) or None.""" + try: + headers = {} + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent + async with aiohttp.ClientSession() as session: + root = await self.xml_parser.fetch_and_parse_xml( + session, + "https://nasstatus.faa.gov/api/airport-status-information", + headers if headers else None + ) + if root is None: + return None + update_time = self.xml_parser.get_text(root, "Update_Time", "Unknown") + ground_delays = [] + arrival_departure_delays = [] + closures = [] + ground_delay_list = root.find(".//Ground_Delay_List") + if ground_delay_list is not None: + for delay in ground_delay_list.findall(".//Ground_Delay"): + arpt = self.xml_parser.get_text(delay, "ARPT") + if not airport_code or arpt == airport_code.upper(): + ground_delays.append({ + 'arpt': arpt, + 'reason': self.xml_parser.get_text(delay, "Reason"), + 'avg': self.xml_parser.get_text(delay, "Avg"), + 'max': self.xml_parser.get_text(delay, "Max") + }) + arrival_departure_list = root.find(".//Arrival_Departure_Delay_List") + if arrival_departure_list is not None: + for delay in arrival_departure_list.findall(".//Delay"): + arpt = self.xml_parser.get_text(delay, "ARPT") + if not airport_code or arpt == airport_code.upper(): + arr_dep = delay.find("Arrival_Departure") + if arr_dep is not None: + delay_type = arr_dep.get("Type", "Unknown") + arrival_departure_delays.append({ + 'arpt': arpt, + 'reason': self.xml_parser.get_text(delay, "Reason"), + 'type': delay_type, + 'min': self.xml_parser.get_text(arr_dep, "Min"), + 'max': self.xml_parser.get_text(arr_dep, "Max"), + 'trend': self.xml_parser.get_text(arr_dep, "Trend") + }) + closure_list = root.find(".//Airport_Closure_List") + if closure_list is not None: + for airport in closure_list.findall(".//Airport"): + arpt = self.xml_parser.get_text(airport, "ARPT") + if not airport_code or arpt == airport_code.upper(): + closures.append({ + 'arpt': arpt, + 'reason': _clean_closure_reason(self.xml_parser.get_text(airport, "Reason")), + 'start': self.xml_parser.get_text(airport, "Start"), + 'reopen': self.xml_parser.get_text(airport, "Reopen") + }) + return (ground_delays, arrival_departure_delays, closures, update_time) + except Exception: + return None async def airport_info(self, ctx, airport_code: str): """Get airport information by ICAO or IATA code.""" @@ -378,4 +669,42 @@ async def owmkey(self, ctx): async def clearowmkey(self, ctx): """Clear the OpenWeatherMap API key.""" await self.cog.config.openweathermap_api.set(None) - await ctx.send("OpenWeatherMap API key cleared.") \ No newline at end of file + await ctx.send("OpenWeatherMap API key cleared.") + + async def faa_status(self, ctx, airport_code: str = None): + """Get FAA National Airspace Status for airports with delays or closures. + + If airport_code is provided, filters to that specific airport (e.g., 'SAN' or 'LAS'). + If not provided, shows all airports with active delays/closures. + """ + try: + result = await self._faa_fetch_data(airport_code) + if result is None: + await ctx.send("❌ FAA API Unavailable.") + return + ground_delays, arrival_departure_delays, closures, update_time = result + total_items = len(ground_delays) + len(arrival_departure_delays) + len(closures) + if total_items == 0: + embed = discord.Embed( + description="βœ… No active delays or closures reported.", + color=discord.Color.green() + ) + await ctx.send(embed=embed) + return + view = FAAStatusView( + ground_delays=ground_delays, + arrival_departure_delays=arrival_departure_delays, + closures=closures, + update_time=update_time, + airport_code=airport_code, + airport_commands=self + ) + embed = view.build_embed("all") + await ctx.send(embed=embed, view=view) + except Exception as e: + embed = discord.Embed( + title="Error", + description=f"Failed to fetch FAA status: {str(e)}", + color=0xff4545 + ) + await ctx.send(embed=embed) \ No newline at end of file diff --git a/skysearch/docs.md b/skysearch/docs.md index 206935e..93b794c 100644 --- a/skysearch/docs.md +++ b/skysearch/docs.md @@ -80,6 +80,23 @@ Shows runway details like length, width, and surface type. ``` Shows current weather and 3-day forecast. +### FAA National Airspace Status +``` +*airport faastatus +``` +Shows all airports with active delays or closures reported by the FAA. + +``` +*airport faastatus SAN +``` +Filters to show only San Diego International Airport (or any specific airport code). + +**What you'll see:** +- Airport codes with active delays/closures +- Human-readable descriptions of the issues +- Expiration times for each status update +- Data refreshes every 60 seconds from the FAA + ## Advanced Aircraft Search ### Find Aircraft Near You diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index e377817..d775067 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -24,7 +24,7 @@ from .utils.helpers import HelperUtils from .utils.export import ExportManager from .commands.aircraft import AircraftCommands -from .commands.airport import AirportCommands +from .commands.airport import AirportCommands, FAAStatusView from .commands.admin import AdminCommands from .dashboard.dashboard_integration import DashboardIntegration from .api.squawk_api import SquawkAlertAPI @@ -50,7 +50,7 @@ def __init__(self, bot): self.config.register_global(api_mode="primary") # API mode: 'primary' or 'fallback (going to remove this when airplanes.live removes the public api because of companies abusing it...when that happens you'll need an api key for it)' self.config.register_global(user_agent=None) # Optional custom User-Agent header for all outbound HTTP requests self.config.register_global(api_stats=None) # API request statistics for persistence - self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}) + self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}, faa_alert_channel=None, faa_alert_role=None, faa_alert_cooldown=5, last_faa_status=None, faa_last_alert_time=None) self.config.register_user(watchlist=[], watchlist_notifications={}, watchlist_cooldown=10, watchlist_aircraft_state={}) # User watchlist: list of ICAO codes, dict of last notification times, cooldown in minutes (default: 10), and dict of last known aircraft state (flying/landed) # Initialize utility managers @@ -77,6 +77,7 @@ def __init__(self, bot): # Start background tasks self.check_emergency_squawks.start() self.check_watched_aircraft.start() + self.check_faa_status_changes.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() @@ -155,6 +156,7 @@ async def cog_unload(self): """Clean up when the cog is unloaded.""" self.check_emergency_squawks.cancel() self.check_watched_aircraft.cancel() + self.check_faa_status_changes.cancel() await self.api.close() @commands.guild_only() @@ -567,7 +569,8 @@ async def airport_group(self, ctx): """Command center for airport related commands""" embed = discord.Embed(title="Airport Commands", description="Available airport-related commands:", color=0xfffffe) embed.add_field(name="Information", value="`info` - Get airport information by ICAO/IATA code", inline=False) - embed.add_field(name="Details", value="`runway` - Get runway information\n`navaid` - Get navigational aids\n`forecast` - Get weather forecast", inline=False) + embed.add_field(name="Details", value="`runway` - Get runway information\n`navaid` - Get navigational aids\n`forecast` - Get weather forecast\n`faastatus [code]` - Get FAA National Airspace Status (delays/closures)", inline=False) + embed.add_field(name="FAA Alerts", value="`faaalertchannel` `faaalertrole` `faaalertcooldown` `showfaaalerts` - Notify when FAA status changes", inline=False) embed.add_field(name="Detailed Help", value="Use `*help airport` for detailed command information", inline=False) await ctx.send(embed=embed) @@ -592,6 +595,31 @@ async def airport_forecast(self, ctx, code: str): """Get the weather for an airport by ICAO or IATA code (US airports only).""" await self.airport_commands.forecast(ctx, code) + @airport_group.command(name='faastatus', aliases=['faa'], help='Get FAA National Airspace Status for airports with delays or closures. Optionally filter by airport code.') + async def airport_faa_status(self, ctx, airport_code: str = None): + """Get FAA National Airspace Status for airports with delays or closures. Optionally filter by airport code (e.g., SAN, LAS).""" + await self.airport_commands.faa_status(ctx, airport_code) + + @airport_group.command(name='faaalertchannel', help='Set or clear the channel for FAA status change notifications.') + async def airport_faa_alert_channel(self, ctx, channel: discord.TextChannel = None): + """Set or clear the channel for FAA status change notifications.""" + await self.admin_commands.set_faa_alert_channel(ctx, channel) + + @airport_group.command(name='faaalertrole', help='Set or clear the role to mention when FAA status changes.') + async def airport_faa_alert_role(self, ctx, role: discord.Role = None): + """Set or clear the role to mention when FAA status changes.""" + await self.admin_commands.set_faa_alert_role(ctx, role) + + @airport_group.command(name='faaalertcooldown', help='Set or show cooldown for FAA status change notifications (minutes).') + async def airport_faa_alert_cooldown(self, ctx, duration: str = None): + """Set or show cooldown for FAA status change notifications.""" + await self.admin_commands.set_faa_alert_cooldown(ctx, duration) + + @airport_group.command(name='showfaaalerts', help='Show current FAA status alert channel and role.') + async def airport_show_faa_alerts(self, ctx): + """Show current FAA status alert settings.""" + await self.admin_commands.list_faa_alert_channels(ctx) + @commands.is_owner() @airport_group.command(name="setowmkey") async def airport_setowmkey(self, ctx, api_key: str): @@ -832,6 +860,84 @@ async def check_emergency_squawks(self): async def before_check_emergency_squawks(self): """Wait for bot to be ready before starting the task.""" await self.bot.wait_until_ready() + + def _faa_snapshot_signature(self, ground_delays, arrival_departure_delays, closures, update_time): + """Build a comparable signature for FAA status (for change detection).""" + g = tuple(sorted((d["arpt"], d["reason"], d["avg"], d["max"]) for d in ground_delays)) + a = tuple(sorted((d["arpt"], d["reason"], d["type"], d["min"], d["max"], d["trend"]) for d in arrival_departure_delays)) + c = tuple(sorted((d["arpt"], d["reason"], d["start"], d["reopen"]) for d in closures)) + return (update_time, g, a, c) + + @tasks.loop(minutes=5) + async def check_faa_status_changes(self): + """Background task to check for FAA status changes and notify guilds with FAA alerts enabled.""" + try: + result = await self.airport_commands._faa_fetch_data(None) + if result is None: + return + ground_delays, arrival_departure_delays, closures, update_time = result + current_sig = self._faa_snapshot_signature(ground_delays, arrival_departure_delays, closures, update_time) + now = datetime.datetime.now(datetime.timezone.utc) + + for guild in self.bot.guilds: + try: + await set_contextual_locales_from_guild(self.bot, guild) + guild_config = self.config.guild(guild) + channel_id = await guild_config.faa_alert_channel() + if not channel_id: + continue + last = await guild_config.last_faa_status() + last_sig = None + if last and isinstance(last, dict): + last_sig = self._faa_snapshot_signature( + last.get("ground_delays") or [], + last.get("arrival_departure_delays") or [], + last.get("closures") or [], + last.get("update_time") or "" + ) + if last_sig is None: + await guild_config.last_faa_status.set({ + "update_time": update_time, + "ground_delays": ground_delays, + "arrival_departure_delays": arrival_departure_delays, + "closures": closures, + }) + continue + if current_sig == last_sig: + continue + cooldown_min = await guild_config.faa_alert_cooldown() + last_alert = await guild_config.faa_last_alert_time() + if last_alert is not None: + last_alert_dt = datetime.datetime.fromtimestamp(last_alert, tz=datetime.timezone.utc) + if (now - last_alert_dt).total_seconds() < cooldown_min * 60: + continue + channel = self.bot.get_channel(channel_id) + if not channel: + continue + role_id = await guild_config.faa_alert_role() + role_mention = f"<@&{role_id}>" if role_id else "" + view = FAAStatusView( + ground_delays, arrival_departure_delays, closures, update_time, + airport_code=None, airport_commands=None + ) + embed = view.build_embed("all") + embed.set_footer(text=f"FAA status changed β€’ Updated: {update_time} β€’ Times in UTC") + await channel.send(content=role_mention or None, embed=embed) + await guild_config.last_faa_status.set({ + "update_time": update_time, + "ground_delays": ground_delays, + "arrival_departure_delays": arrival_departure_delays, + "closures": closures, + }) + await guild_config.faa_last_alert_time.set(now.timestamp()) + except Exception as e: + log.debug(f"FAA status check guild {guild.id}: {e}") + except Exception as e: + log.error(f"Error checking FAA status changes: {e}", exc_info=True) + + @check_faa_status_changes.before_loop + async def before_check_faa_status_changes(self): + await self.bot.wait_until_ready() @tasks.loop(minutes=3) async def check_watched_aircraft(self): @@ -1276,38 +1382,33 @@ async def on_message(self, message): if message.guild is None: return - + + content = message.content.strip() + # Fastest check first: does message look like ICAO? (sync, no I/O) + # Most messages fail this - skip all async work for non-matches + if not self._icao_pattern.match(content): + return + guild_id = message.guild.id - + # Fast cache check - avoid expensive config reads if auto_icao is disabled - if guild_id in self._auto_icao_enabled_guilds: - # Guild is known to have auto_icao enabled - proceed with processing - # Double-check config in case cache is stale (should be rare) - auto_icao = await self.config.guild(message.guild).auto_icao() - if not auto_icao: - # Update cache if it was stale - self._auto_icao_enabled_guilds.discard(guild_id) - return - elif guild_id in self._auto_icao_checked_guilds: + if guild_id in self._auto_icao_checked_guilds: # Guild is known to have auto_icao disabled - fast return return - else: + if guild_id not in self._auto_icao_enabled_guilds: # First time seeing this guild - do one-time config check auto_icao = await self.config.guild(message.guild).auto_icao() self._auto_icao_checked_guilds.add(guild_id) - if auto_icao: - self._auto_icao_enabled_guilds.add(guild_id) - else: + if not auto_icao: return + self._auto_icao_enabled_guilds.add(guild_id) + # else: guild in _auto_icao_enabled_guilds - trust cache (updated on config change) # Ensure locales for non-command listener (only if auto_icao is enabled) await set_contextual_locales_from_guild(self.bot, message.guild) - content = message.content - # Use pre-compiled pattern - if self._icao_pattern.match(content): - ctx = await self.bot.get_context(message) - await self.aircraft_commands.aircraft_by_icao(ctx, content) + ctx = await self.bot.get_context(message) + await self.aircraft_commands.aircraft_by_icao(ctx, content) @commands.is_owner() @aircraft_group.command(name="simulateemergency") diff --git a/skysearch/utils/__init__.py b/skysearch/utils/__init__.py index effde3c..6a69b6f 100644 --- a/skysearch/utils/__init__.py +++ b/skysearch/utils/__init__.py @@ -6,5 +6,6 @@ - helpers.py: Provides helper functions for formatting, embeds, and data processing. - export.py: Manages exporting aircraft data to CSV, PDF, TXT, or HTML formats. - stats.py: handles api stats for airplanes.live requests. +- xml_parser.py: Utility class for parsing XML data from APIs with safe error handling. """ \ No newline at end of file diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 0e01a6a..89bc79b 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -5,7 +5,7 @@ import json import aiohttp import discord -from urllib.parse import quote_plus +from urllib.parse import quote_plus, urlparse, parse_qs class HelperUtils: @@ -414,6 +414,53 @@ async def get_navaid_data(self, airport_code: str): # for feeder link command stuff + def _globe_feed_url(self, ids: list, param: str = "uuid") -> str: + """ + Build a globe.airplanes.live URL for one or more feed UUIDs. + Supports both ?uuid= and ?feed= so either param works for feed info. + """ + if not ids: + return "https://globe.airplanes.live/" + # IDs are 16-char hex (no hyphens) + value = ",".join(ids) if len(ids) > 1 else ids[0] + return f"https://globe.airplanes.live/?{param}={value}" + + def _normalize_globe_feed_link(self, url: str) -> str: + """ + Normalize a globe.airplanes.live URL so both ?uuid and ?feed work. + If the URL has ?feed= or ?uuid=, keep it as-is (both work on the site). + """ + if not url or "globe.airplanes.live" not in url: + return url + parsed = urlparse(url) + qs = parse_qs(parsed.query) + # Prefer uuid for multi-feed, otherwise keep existing param + feed_val = qs.get("feed", qs.get("uuid")) + if feed_val: + # Keep first param we found so link format is preserved + return url + return url + + def get_feed_map_link(self, json_data: dict) -> str | None: + """ + Get map link for feeder info. Uses map_link from JSON if present + (supports both ?uuid= and ?feed=). Otherwise builds from beast_clients. + """ + map_link = json_data.get("map_link") if json_data else None + if map_link: + return self._normalize_globe_feed_link(map_link) + beast_clients = json_data.get("beast_clients", []) if json_data else [] + ids = [] + for client in beast_clients: + uuid_val = client.get("uuid") or "" + if uuid_val: + feed_id = uuid_val.replace("-", "")[:16] + if feed_id and feed_id not in ids: + ids.append(feed_id) + if ids: + return self._globe_feed_url(ids, param="uuid") + return None + async def parse_json_input(self, json_input: str): """ Parse JSON input from either a URL or direct JSON text. @@ -464,8 +511,8 @@ def create_feeder_embed(self, json_data: dict): host = json_data.get('host', 'Unknown') embed.add_field(name="Host", value=host, inline=True) - # Extract map link if available - map_link = json_data.get('map_link') + # Extract map link if available (?uuid and ?feed both supported) + map_link = self.get_feed_map_link(json_data) if map_link: embed.add_field(name="Map Link", value=f"[View on Globe]({map_link})", inline=False) embed.url = map_link @@ -521,8 +568,8 @@ def create_feeder_view(self, json_input: str, json_data: dict = None): """ view = discord.ui.View() - # Add main map link button - map_link = json_data.get('map_link') if json_data else None + # Add main map link button (?uuid and ?feed both supported) + map_link = self.get_feed_map_link(json_data) if json_data else None if map_link: view.add_item(discord.ui.Button( label="View on Globe", @@ -531,16 +578,15 @@ def create_feeder_view(self, json_input: str, json_data: dict = None): style=discord.ButtonStyle.link )) - # Add individual Beast client feed buttons + # Add individual Beast client feed buttons (use ?feed= for single; ?uuid= also works) if json_data: beast_clients = json_data.get('beast_clients', []) for i, client in enumerate(beast_clients[:5]): # Limit to 5 buttons uuid = client.get('uuid', '') if uuid: - # Create individual feed URL - use first 16 characters of UUID without hyphens - # This matches the pattern from the map_link in the JSON + # Create individual feed URL - both ?feed= and ?uuid= work on globe feed_uuid = uuid.replace('-', '')[:16] - feed_url = f"https://globe.airplanes.live/?feed={feed_uuid}" + feed_url = self._globe_feed_url([feed_uuid], param="feed") # Create feed name using first part of UUID # Use first 8 characters of UUID for a clean, short identifier diff --git a/skysearch/utils/xml_parser.py b/skysearch/utils/xml_parser.py new file mode 100644 index 0000000..b0ca777 --- /dev/null +++ b/skysearch/utils/xml_parser.py @@ -0,0 +1,124 @@ +""" +XML parsing utilities for SkySearch cog + +This module provides a utility class for parsing XML data from APIs, with +safe error handling and convenient methods for common XML operations. + +Example usage: + ```python + from ..utils.xml_parser import XMLParser + + parser = XMLParser() + + # Fetch and parse XML from a URL + async with aiohttp.ClientSession() as session: + root = await parser.fetch_and_parse_xml(session, "https://api.example.com/data.xml") + if root: + airports = parser.find_elements(root, ".//Airport") + for airport in airports: + code = parser.get_text(airport, "ARPT") + print(f"Airport code: {code}") + ``` +""" + +import xml.etree.ElementTree as ET +from typing import Optional, List, Dict, Any +import aiohttp + + +class XMLParser: + """ + Utility class for parsing XML data from APIs. + + Provides safe, convenient methods for common XML parsing operations + with built-in error handling. All methods return None or empty lists + on failure, making it easy to handle errors gracefully. + + All methods are static, so you can use them without instantiating the class: + ```python + root = XMLParser.parse_xml_string(xml_data) + ``` + + Or instantiate for convenience: + ```python + parser = XMLParser() + root = parser.parse_xml_string(xml_data) + ``` + """ + + @staticmethod + def parse_xml_string(xml_string: str) -> Optional[ET.Element]: + """ + Parse an XML string into an ElementTree Element. + + Args: + xml_string: The XML string to parse + + Returns: + ElementTree Element root, or None if parsing fails + """ + try: + return ET.fromstring(xml_string) + except ET.ParseError: + return None + + @staticmethod + def find_elements(root: ET.Element, xpath: str) -> List[ET.Element]: + """ + Find elements matching an XPath expression. + + Args: + root: The root ElementTree Element + xpath: XPath expression (e.g., ".//Airport") + + Returns: + List of matching elements + """ + try: + return root.findall(xpath) + except Exception: + return [] + + @staticmethod + def get_text(element: ET.Element, tag: str, default: str = "") -> str: + """ + Get text content from a child element. + + Args: + element: The parent ElementTree Element + tag: Tag name of the child element + default: Default value if element not found + + Returns: + Text content of the child element, or default + """ + child = element.find(tag) + if child is not None and child.text: + return child.text.strip() + return default + + @staticmethod + async def fetch_and_parse_xml( + session: aiohttp.ClientSession, + url: str, + headers: Optional[Dict[str, str]] = None + ) -> Optional[ET.Element]: + """ + Fetch XML from a URL and parse it. + + Args: + session: aiohttp ClientSession + url: URL to fetch XML from + headers: Optional HTTP headers + + Returns: + Parsed ElementTree Element root, or None if fetch/parse fails + """ + try: + async with session.get(url, headers=headers) as response: + if response.status != 200: + return None + xml_text = await response.text() + return XMLParser.parse_xml_string(xml_text) + except Exception: + return None diff --git a/tips/__init__.py b/tips/__init__.py new file mode 100644 index 0000000..9091e16 --- /dev/null +++ b/tips/__init__.py @@ -0,0 +1,5 @@ +from .tips import Tips + + +async def setup(bot): + await bot.add_cog(Tips(bot)) diff --git a/tips/docs.md b/tips/docs.md new file mode 100644 index 0000000..5c29c41 --- /dev/null +++ b/tips/docs.md @@ -0,0 +1,91 @@ +# Tips Cog + +A small Red cog that posts random tips and provides an interactive button-based settings menu for the bot owner. + +**Overview** + +- Posts a random tip with the `tip` command. +- Owner-only commands let you add/remove tips and open a button-based settings UI. +- Settings (cooldown, color, title, tips list) are persisted to Red `Config` (global scope), so they survive restarts. + +**Commands** + +- `tip` + - Usage: `[prefix]tip` + - Posts a random tip embed. Each user has a per-user cooldown (default 60s). + +- `addtip ` (owner-only) + - Usage: `[prefix]addtip This is a new tip.` + - Adds a tip to the saved tips list and persists it. + +- `removetip ` (owner-only) + - Usage: `[prefix]removetip 2` + - Removes the tip at the given zero-based index from the saved list. + + `tipset` (owner-only) + - Usage: `[prefix]tipset` + - Opens an interactive embed with buttons for configuring cooldown, color, title, and closing the menu. +**Button UI behaviour** + +- Only the user who invoked `tipconfig` may interact with the buttons. +- Buttons and flows: + - `Cooldown` (Primary): Bot asks (ephemeral) "Please type the new cooldown in seconds." Type a number in the same channel within 60 seconds. The value is validated and saved to config. + - `Color` (Secondary): Bot asks (ephemeral) "Please type a color name (blue, red, green)." Type one of the supported names within 60 seconds. The chosen color name is saved to config (the embed color updates). + - `Title` (Success): Bot asks (ephemeral) "Please type the new title for tips." Type the title within 120 seconds; it is saved to config and the embed updates. + - `Close` (Danger): Deletes the settings message. +- Timeouts: the view will time out after 120 seconds; individual prompts have the timeouts described above. + +**Config (global)** + +The cog uses Red `Config` with the following global keys (registered with defaults): + +- `cooldown` (int) + - Default: `60` + - Per-user cooldown in seconds between `tip` requests. + +- `tip_color` (str) + - Default: `"blue"` + - Stored as a string; the cog maps it to a `discord.Color` (supported names: `blue`, `red`, `green`). + +- `tip_title` (str) + - Default: `"πŸ’‘ Random Tip"` + - The embed title used when posting tips. + +- `tips` (list[str]) + - Default: initial example tips included with the cog. + - The full list of tips the cog will choose from. + +Notes: +- All changes made via commands or the button UI are persisted immediately using `Config.set`. +- If you need additional color options, edit the cog to add more mappings in the color map. + +**Examples** + +- Add a tip (owner): + - `[p]addtip Remember to check pinned messages.` + +- Remove tip index 0 (owner): + - `[p]removetip 0` + +- Open the settings UI (owner): + - `[p]tipconfig` + - Click `Cooldown`, type `30` in the channel when prompted (ephemeral prompts are used for the request itself). + +**Troubleshooting** + +- Buttons don't respond: + - Confirm you are the user who ran `[p]tipconfig`. + - The view times out after 120s; re-run `[p]tipconfig` to re-open. + +- Config changes didn't persist: + - Confirm the bot has permission to write to its data store (Red handles this normally). + - Check that the cog is loaded and that no exceptions appear in the bot logs. + +**Extending** + +- Add more color names by updating the `color_map` in `tips/tips.py` and adding the corresponding `discord.Color` entries. +- Change permissions (for example allow server admins to use `tipconfig`) by replacing the owner-only check on the command with an appropriate Red permission decorator. + +--- + +File: `tips/tips.py` β€” this document documents the behavior of the interactive settings UI and the persistence keys used in Red `Config`. diff --git a/tips/info.json b/tips/info.json new file mode 100644 index 0000000..961bf81 --- /dev/null +++ b/tips/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "bencos18" + ], + "install_msg": "Thank you for installing the tips cog. Use `[p]help Tips` to see available commands. For support, message me on Discord (bencos18) or open a GitHub issue.", + "short": "Tips cog for redbot to show random tips when a command is ran.", + "description": "Tips cog for redbot to show random tips when a command is ran..", + "requirements": [], + "tags": [ + "server", + "tools", + "tips" + ], + "min_bot_version": "3.4.0", + "end_user_data_statement": "", + "type": "COG" +} \ No newline at end of file diff --git a/tips/tips.py b/tips/tips.py new file mode 100644 index 0000000..e660231 --- /dev/null +++ b/tips/tips.py @@ -0,0 +1,371 @@ +import discord +from redbot.core import commands, checks, Config +import asyncio +import random +from typing import Optional + + +class TipSettingsView(discord.ui.View): + def __init__(self, cog, author_id): + super().__init__(timeout=120) + self.cog = cog + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.author_id + + def build_embed(self) -> discord.Embed: + e = discord.Embed( + title=self.cog.tip_title, + description="Configure tip settings using the buttons below.", + color=self.cog.tip_color, + ) + e.add_field(name="Cooldown (s)", value=str(self.cog.cooldown), inline=False) + e.add_field(name="Color", value=str(self.cog.tip_color), inline=False) + e.add_field(name="Total tips", value=str(len(self.cog.tips)), inline=False) + return e + + @discord.ui.button(label="Cooldown", style=discord.ButtonStyle.primary) + async def cooldown_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message("Please type the new cooldown in seconds.", ephemeral=True) + + def check(m: discord.Message): + return m.author.id == self.author_id and m.channel == interaction.channel + + try: + msg = await self.cog.bot.wait_for("message", check=check, timeout=60) + try: + self.cog.cooldown = int(msg.content) + await self.cog.config.cooldown.set(self.cog.cooldown) + await interaction.followup.send(f"βœ… Cooldown set to {msg.content} seconds.", ephemeral=True) + if interaction.message is not None: + await interaction.message.edit(embed=self.build_embed()) + except ValueError: + await interaction.followup.send("Cooldown must be a number.", ephemeral=True) + except asyncio.TimeoutError: + await interaction.followup.send("Timed out waiting for input.", ephemeral=True) + + @discord.ui.button(label="Color", style=discord.ButtonStyle.secondary) + async def color_button(self, interaction: discord.Interaction, button: discord.ui.Button): + color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} + await interaction.response.send_message( + "Please type a color name (blue, red, green).", ephemeral=True + ) + + def check(m: discord.Message): + return m.author.id == self.author_id and m.channel == interaction.channel + + try: + msg = await self.cog.bot.wait_for("message", check=check, timeout=60) + val = msg.content.lower() + if val in color_map: + self.cog.tip_color = color_map[val] + await self.cog.config.tip_color.set(val) + await interaction.followup.send(f"βœ… Color set to {val}.", ephemeral=True) + if interaction.message is not None: + await interaction.message.edit(embed=self.build_embed()) + else: + await interaction.followup.send("Invalid color.", ephemeral=True) + except asyncio.TimeoutError: + await interaction.followup.send("Timed out waiting for input.", ephemeral=True) + + @discord.ui.button(label="Title", style=discord.ButtonStyle.success) + async def title_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message("Please type the new title for tips.", ephemeral=True) + + def check(m: discord.Message): + return m.author.id == self.author_id and m.channel == interaction.channel + + try: + msg = await self.cog.bot.wait_for("message", check=check, timeout=120) + self.cog.tip_title = msg.content + await self.cog.config.tip_title.set(msg.content) + await interaction.followup.send(f"βœ… Title set to {msg.content}.", ephemeral=True) + if interaction.message is not None: + await interaction.message.edit(embed=self.build_embed()) + except asyncio.TimeoutError: + await interaction.followup.send("Timed out waiting for input.", ephemeral=True) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.danger) + async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.defer() + if interaction.message is not None: + await interaction.message.delete() + +class Tips(commands.Cog): + """A cog that displays random tips at intervals.""" + + def __init__(self, bot): + self.bot = bot + self.tips = [ + "Tip 1: Use `help` command to see all available commands!", + "Tip 2: Use reactions or buttons to interact with bot messages.", + "Tip 3: Commands are case sensitive.", + "Tip 4: You can use prefixes to customize your experience ", + ] + self.last_tip_time = {} + # Config setup + self.config = Config.get_conf(self, identifier=492089091320446976) + default_global = { + "cooldown": 60, + "tip_color": "blue", + "tip_title": "πŸ’‘ Random Tip", + "tips": self.tips, + "post_on_command": False, + } + default_guild = {"cooldown": None, "post_on_command": None} + default_user = {"cooldown": None} + self.config.register_global(**default_global) + self.config.register_guild(**default_guild) + self.config.register_user(**default_user) + + # Runtime values (will be loaded from config in cog_load) + self.cooldown = 60 + self.tip_color = discord.Color.blue() + self.tip_title = "πŸ’‘ Random Tip" + + @commands.command() + async def tip(self, ctx): + """Get a random tip.""" + # Refresh runtime values from config + try: + # Refresh runtime values from config (global values and tips list) + self.tip_title = await self.config.tip_title() + color_name = await self.config.tip_color() + color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} + self.tip_color = color_map.get(color_name, discord.Color.blue()) + self.tips = await self.config.tips() + except Exception: + pass + user_id = ctx.author.id + current_time = asyncio.get_event_loop().time() + # Determine effective cooldown (user -> guild -> global) + effective_cd = await self._get_effective_cooldown(ctx.author, ctx.guild) + key = (user_id, ctx.guild.id if ctx.guild else None) + + last = self.last_tip_time.get(key, 0) + if current_time - last < (effective_cd or 0): + await ctx.send(f"You can only get a tip once every {effective_cd} seconds.") + return + + self.last_tip_time[key] = current_time + random_tip = random.choice(self.tips) if self.tips else "No tips available." + + # Always post compact plain-text tips + await ctx.send(f"πŸ’‘ {random_tip}") + + @commands.Cog.listener() + async def on_command(self, ctx): + """Listener: when any command is run, optionally post a tip to the same channel. + + Respects guild/global `post_on_command` setting and the same cooldown resolution. + """ + # Ignore commands from bots or from this cog to avoid recursion + if ctx.author.bot: + return + if ctx.cog and getattr(ctx.cog, "__class__", None) is not None and ctx.cog.qualified_name == getattr(self, "qualified_name", "Tips"): + return + + # Decide whether we should post for this context + should = await self._should_post_on_command(ctx.guild) + if not should: + return + + # Use the same cooldown rules and attempt to post + effective_cd = await self._get_effective_cooldown(ctx.author, ctx.guild) + user_id = ctx.author.id + current_time = asyncio.get_event_loop().time() + key = (user_id, ctx.guild.id if ctx.guild else None) + last = self.last_tip_time.get(key, 0) + if current_time - last < (effective_cd or 0): + return + + # Send a tip + random_tip = random.choice(self.tips) if self.tips else None + if not random_tip: + return + + # Tiny tip marker: prefix a tip with '-#' to post a compact plain-text tip. + if isinstance(random_tip, str) and random_tip.startswith("-#"): + content = random_tip[2:].strip() + await ctx.channel.send(f"πŸ’‘ {content}") + self.last_tip_time[key] = current_time + return + + embed = discord.Embed(description=random_tip, color=self.tip_color) + await ctx.channel.send(embed=embed) + self.last_tip_time[key] = current_time + + async def _should_post_on_command(self, guild: Optional[discord.Guild]) -> bool: + """Resolve whether to post a tip when a command runs (guild override -> global).""" + try: + if guild is not None: + guild_val = await self.config.guild(guild).post_on_command() + else: + guild_val = None + except Exception: + guild_val = None + if guild_val is not None: + return bool(guild_val) + try: + return bool(await self.config.post_on_command()) + except Exception: + return False + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def disablepostoncommand(self, ctx): + """Disable posting tips automatically when commands are run in this server.""" + await self.config.guild(ctx.guild).post_on_command.set(False) + await ctx.send("βœ… Disabled automatic tip posts on commands for this server.") + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def enablepostoncommand(self, ctx): + """Enable posting tips automatically when commands are run in this server.""" + await self.config.guild(ctx.guild).post_on_command.set(True) + await ctx.send("βœ… Enabled automatic tip posts on commands for this server.") + + @checks.is_owner() + @commands.command() + async def addtip(self, ctx, *, tip: str): + """Add a new tip to the list.""" + self.tips.append(tip) + await self.config.tips.set(self.tips) + await ctx.send(f"βœ… Tip added! Total tips: {len(self.tips)}") + + @checks.is_owner() + @commands.command() + async def removetip(self, ctx, index: int): + """Remove a tip by index.""" + if 0 <= index < len(self.tips): + removed = self.tips.pop(index) + await self.config.tips.set(self.tips) + await ctx.send(f"βœ… Tip removed: {removed}") + else: + await ctx.send("Invalid tip index.") + + @checks.is_owner() + @commands.command(name="tipset") + async def tipset(self, ctx): + """Open a button-style settings menu for tips.""" + view = TipSettingsView(self, ctx.author.id) + embed = discord.Embed( + title=self.tip_title, + description="Configure tip settings using the buttons below.", + color=self.tip_color, + ) + embed.add_field(name="Cooldown (s)", value=str(self.cooldown), inline=False) + embed.add_field(name="Color", value=str(self.tip_color), inline=False) + embed.add_field(name="Total tips", value=str(len(self.tips)), inline=False) + await ctx.send(embed=embed, view=view) + + async def cog_load(self) -> None: + """Load values from config into runtime attributes and merge defaults.""" + try: + # Refresh runtime values from config + self.tip_title = await self.config.tip_title() + color_name = await self.config.tip_color() + color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} + self.tip_color = color_map.get(color_name, discord.Color.blue()) + self.tips = await self.config.tips() + + # Merge new defaults safely + await self._force_merge_defaults() + except Exception: + pass + + async def _force_merge_defaults(self): + """Ensure default tips are present and remove old ones.""" + current_tips = await self.config.tips() + changed = False + + # Add missing defaults + for tip in self.tips: + if tip not in current_tips: + current_tips.append(tip) + changed = True + + # Remove tips that are no longer in DEFAULT_TIPS + for tip in list(current_tips): + if tip not in self.tips: + current_tips.remove(tip) + changed = True + + if changed: + await self.config.tips.set(current_tips) + + self.tips = current_tips + + async def _get_effective_cooldown(self, user: discord.User, guild: Optional[discord.Guild]) -> int: + """Resolve cooldown with priority: user -> guild -> global.""" + try: + user_cd = await self.config.user(user).cooldown() + except Exception: + user_cd = None + if user_cd is not None: + return int(user_cd) + try: + if guild: + guild_cd = await self.config.guild(guild).cooldown() + else: + guild_cd = None + except Exception: + guild_cd = None + if guild_cd is not None: + return int(guild_cd) + try: + global_cd = await self.config.cooldown() + return int(global_cd) + except Exception: + return 0 + + + @commands.command() + async def setmycooldown(self, ctx, seconds: int): + """Set a personal cooldown (affects only you across servers).""" + if seconds < 0: + await ctx.send("Cooldown cannot be negative.") + return + await self.config.user(ctx.author).cooldown.set(seconds) + await ctx.send(f"βœ… Your personal tip cooldown is now {seconds} seconds.") + + @commands.command() + async def clearmycooldown(self, ctx): + """Clear your personal cooldown override.""" + await self.config.user(ctx.author).cooldown.set(None) + await ctx.send("βœ… Your personal tip cooldown override has been cleared.") + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def setguildcooldown(self, ctx, seconds: int): + """Set a guild-wide cooldown (affects all users in this server).""" + if seconds < 0: + await ctx.send("Cooldown cannot be negative.") + return + await self.config.guild(ctx.guild).cooldown.set(seconds) + await ctx.send(f"βœ… Server tip cooldown set to {seconds} seconds.") + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def clearguildcooldown(self, ctx): + """Clear the guild-wide cooldown override.""" + await self.config.guild(ctx.guild).cooldown.set(None) + await ctx.send("βœ… Server tip cooldown override cleared.") + + + @checks.is_owner() + @commands.command() + async def listtips(self, ctx): + """List all current tips stored in config.""" + tips = await self.config.tips() + if not tips: + await ctx.send("No tips stored.") + return + + out = "\n".join(f"{i}: {t}" for i, t in enumerate(tips)) + await ctx.send(f"```{out}```") \ No newline at end of file