From 0fdf6914319bdf6b39d141aff0913634fdacbbe1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:04:52 +0000 Subject: [PATCH 01/56] fixes for airport navid stuff --- skysearch/API_TRACKING_README.md | 8 --- skysearch/utils/helpers.py | 101 +++++++++++++++++++++++-------- 2 files changed, 76 insertions(+), 33 deletions(-) diff --git a/skysearch/API_TRACKING_README.md b/skysearch/API_TRACKING_README.md index 9210620..0ce6fce 100644 --- a/skysearch/API_TRACKING_README.md +++ b/skysearch/API_TRACKING_README.md @@ -224,14 +224,6 @@ The tracking system is **enabled by default** and requires no configuration: - **Understanding** of system reliability - **Monitoring** of service health -## πŸ§ͺ Testing - -### Test Script -Run the included test script to verify functionality: -```bash -python test_api_tracking.py -``` - ### Manual Testing 1. **Make API requests** through normal SkySearch commands 2. **Check statistics** with `skysearch apistats` diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 89bc79b..6b303f5 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -5,7 +5,8 @@ import json import aiohttp import discord -from urllib.parse import quote_plus, urlparse, parse_qs +from urllib.parse import quote_plus, urlparse, parse_qs, urlencode +import asyncio class HelperUtils: @@ -377,41 +378,91 @@ async def get_airport_image(self, lat: str, lon: str): async def get_runway_data(self, airport_code: str): """Get runway information for an airport.""" self._ensure_http_client() - try: - # Try airportdb.io API - url = f"https://airportdb.io/api/v1/airports/{airport_code}" - async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: - if response.status == 200: - data = await response.json() - if data and 'runways' in data: - return { - 'runways': data['runways'] - } - except (aiohttp.ClientError, KeyError, ValueError): + # Try airportdb.io API (support both /airport/ and legacy /airports/ paths) + token = await self._get_airportdb_token() + base_paths = [ + f"https://airportdb.io/api/v1/airport/{airport_code}", + f"https://airportdb.io/api/v1/airports/{airport_code}", + ] + + for base in base_paths: + url = base + if token: + url = f"{base}?{urlencode({'apiToken': token})}" + + try: + async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: + if response.status == 200: + data = await response.json() + if data and 'runways' in data: + return {'runways': data['runways']} + except (aiohttp.ClientError, KeyError, ValueError): + # Try next path variant + continue + except Exception: pass - + return None async def get_navaid_data(self, airport_code: str): """Get navigational aids for an airport.""" self._ensure_http_client() - try: - # Try airportdb.io API for navaids - url = f"https://airportdb.io/api/v1/airports/{airport_code}/navaids" - async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: - if response.status == 200: - data = await response.json() - if data and 'navaids' in data: - return { - 'navaids': data['navaids'] - } - except (aiohttp.ClientError, KeyError, ValueError): + # Prefer documented single-airport endpoint which accepts `apiToken` as query param + token = await self._get_airportdb_token() + base_paths = [ + f"https://airportdb.io/api/v1/airport/{airport_code}", + f"https://airportdb.io/api/v1/airport/{airport_code}/navaids", + f"https://airportdb.io/api/v1/airports/{airport_code}/navaids", + ] + + for base in base_paths: + url = base + if token: + url = f"{base}?{urlencode({'apiToken': token})}" + + try: + async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: + if response.status == 200: + data = await response.json() + # If the airport object contains navaids + if data and isinstance(data, dict) and 'navaids' in data: + return {'navaids': data['navaids']} + # Some endpoints may return a wrapper with 'navaids' key at top-level + if data and isinstance(data, dict) and 'data' in data and isinstance(data['data'], dict) and 'navaids' in data['data']: + return {'navaids': data['data']['navaids']} + except (aiohttp.ClientError, KeyError, ValueError): + # Try next path variant + continue + except Exception: pass - + return None + async def _get_airportdb_token(self) -> str | None: + """Retrieve Airportdb API token from Red's shared API tokens. + + Looks for the `airportdbio` shared token and returns the `api_token` value + (supports async or sync `get_shared_api_tokens` implementations). + """ + try: + getter = getattr(self.cog.bot, 'get_shared_api_tokens', None) + if not getter: + return None + + tokens = getter('airportdbio') + if asyncio.iscoroutine(tokens): + tokens = await tokens + + if not tokens: + return None + + # Common key from install instructions is `api_token` + return tokens.get('api_token') or tokens.get('apiToken') or tokens.get('token') + except Exception: + return None + # for feeder link command stuff def _globe_feed_url(self, ids: list, param: str = "uuid") -> str: From 845f9150bfa3908358166d56fe44cec9f8f75184 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:13:03 +0000 Subject: [PATCH 02/56] Update helpers.py --- skysearch/utils/helpers.py | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 6b303f5..d6b6091 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -409,32 +409,17 @@ async def get_navaid_data(self, airport_code: str): """Get navigational aids for an airport.""" self._ensure_http_client() try: - # Prefer documented single-airport endpoint which accepts `apiToken` as query param token = await self._get_airportdb_token() - base_paths = [ - f"https://airportdb.io/api/v1/airport/{airport_code}", - f"https://airportdb.io/api/v1/airport/{airport_code}/navaids", - f"https://airportdb.io/api/v1/airports/{airport_code}/navaids", - ] + base = f"https://airportdb.io/api/v1/airport/{airport_code}" + url = f"{base}?{urlencode({'apiToken': token})}" if token else base - for base in base_paths: - url = base - if token: - url = f"{base}?{urlencode({'apiToken': token})}" - - try: - async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: - if response.status == 200: - data = await response.json() - # If the airport object contains navaids - if data and isinstance(data, dict) and 'navaids' in data: - return {'navaids': data['navaids']} - # Some endpoints may return a wrapper with 'navaids' key at top-level - if data and isinstance(data, dict) and 'data' in data and isinstance(data['data'], dict) and 'navaids' in data['data']: - return {'navaids': data['data']['navaids']} - except (aiohttp.ClientError, KeyError, ValueError): - # Try next path variant - continue + async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: + if response.status == 200: + data = await response.json() + if data and isinstance(data, dict) and 'navaids' in data: + return {'navaids': data['navaids']} + if data and isinstance(data, dict) and 'data' in data and isinstance(data['data'], dict) and 'navaids' in data['data']: + return {'navaids': data['data']['navaids']} except Exception: pass From 63a409eb30b27cb04218e45394e45d0ed83adaeb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:46:29 +0000 Subject: [PATCH 03/56] navid fixes --- skysearch/commands/airport.py | 53 ++++++++++++++++++++++++----------- skysearch/utils/helpers.py | 14 ++++++--- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 6f1c2e0..77c8070 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -403,26 +403,45 @@ async def navaid_info(self, ctx, airport_code: str): # Get navaid data navaid_data = await self.helpers.get_navaid_data(airport_code) - - if navaid_data and navaid_data.get('navaids'): - embed = discord.Embed(title=f"Navigational Aids - {airport_code}", color=0xfffffe) - embed.set_thumbnail(url="https://www.beehive.systems/hubfs/Icon%20Packs/White/airplane.png") - - navaids = navaid_data['navaids'] - for navaid in navaids: - navaid_name = navaid.get('name', 'N/A') - navaid_type = navaid.get('type', 'N/A') - frequency = navaid.get('frequency', 'N/A') - - navaid_info = f"**Type:** {navaid_type}\n" - navaid_info += f"**Frequency:** {frequency}" - - embed.add_field(name=navaid_name, value=navaid_info, inline=True) - + # Distinguish between a fetch error (None) and a successful fetch with + # no navaids (empty list). If helper returned None, treat as an error. + if navaid_data is None: + embed = discord.Embed(title="Navaid Lookup Failed", description=f"Could not fetch navaid information for {airport_code}.", color=0xff4545) await ctx.send(embed=embed) - else: + return + + navaids = navaid_data.get('navaids', []) + if not navaids: embed = discord.Embed(title="No Navaid Data", description=f"No navigational aid information found for {airport_code}.", color=0xff4545) await ctx.send(embed=embed) + return + + embed = discord.Embed(title=f"Navigational Aids - {airport_code}", color=0xfffffe) + + def _format_frequency(n): + # Prefer common keys used by airportdb.io + freq = n.get('frequency') or n.get('frequency_khz') or n.get('dme_frequency_khz') or n.get('dme_frequency') + if not freq: + return 'N/A' + try: + fstr = str(freq) + # If looks like kHz integer (e.g. 115900), convert to MHz + if fstr.isdigit() and len(fstr) >= 4: + mhz = float(fstr) / 1000.0 + return f"{mhz:.3f} MHz" + return fstr + except Exception: + return str(freq) + + for navaid in navaids: + # Use ident if available as the concise label + ident = navaid.get('ident') or navaid.get('filename') or navaid.get('name') or 'Unknown' + frequency = _format_frequency(navaid) + + # Minimal field: ident -> frequency + embed.add_field(name=ident, value=frequency, inline=True) + + await ctx.send(embed=embed) async def weather_forecast(self, ctx, airport_code: str): """Get weather forecast for an airport.""" diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index d6b6091..5d8475e 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -416,10 +416,16 @@ async def get_navaid_data(self, airport_code: str): async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: if response.status == 200: data = await response.json() - if data and isinstance(data, dict) and 'navaids' in data: - return {'navaids': data['navaids']} - if data and isinstance(data, dict) and 'data' in data and isinstance(data['data'], dict) and 'navaids' in data['data']: - return {'navaids': data['data']['navaids']} + # Return navaids if present. If the endpoint returned a valid + # airport object but no navaids key, return an empty list so + # callers can distinguish between "no data" and an error. + if data and isinstance(data, dict): + if 'navaids' in data: + return {'navaids': data['navaids']} + if 'data' in data and isinstance(data['data'], dict) and 'navaids' in data['data']: + return {'navaids': data['data']['navaids']} + # Successful fetch but no navaids key -> explicit empty list + return {'navaids': []} except Exception: pass From 28fe40b78c0cf7c0c2d9a1feae175291c18d6b0b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:48:32 +0000 Subject: [PATCH 04/56] Update helpers.py --- skysearch/utils/helpers.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 5d8475e..0530072 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -426,10 +426,23 @@ async def get_navaid_data(self, airport_code: str): return {'navaids': data['data']['navaids']} # Successful fetch but no navaids key -> explicit empty list return {'navaids': []} + else: + try: + body = await response.text() + except Exception: + body = '' + short = (body[:400] + '...') if body and len(body) > 400 else body + return {'error': f'HTTP {response.status}: {short or response.reason}'} except Exception: - pass + # Return error info for callers to display + import traceback as _tb + try: + return {'error': str(_tb.format_exc().splitlines()[-1])} + except Exception: + return {'error': 'Unknown exception while fetching navaids'} - return None + # Fallback error + return {'error': 'Unknown failure during navaid lookup'} async def _get_airportdb_token(self) -> str | None: """Retrieve Airportdb API token from Red's shared API tokens. From 10e69ac5ad394895ba4bd4002510a7115828c9ac Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:48:52 +0000 Subject: [PATCH 05/56] Update airport.py --- skysearch/commands/airport.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 77c8070..e3e7278 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -403,17 +403,21 @@ async def navaid_info(self, ctx, airport_code: str): # Get navaid data navaid_data = await self.helpers.get_navaid_data(airport_code) - # Distinguish between a fetch error (None) and a successful fetch with - # no navaids (empty list). If helper returned None, treat as an error. + + # If helper returned None, treat as an unexpected error if navaid_data is None: - embed = discord.Embed(title="Navaid Lookup Failed", description=f"Could not fetch navaid information for {airport_code}.", color=0xff4545) - await ctx.send(embed=embed) + await ctx.send(embed=discord.Embed(title="Navaid Lookup Failed", description=f"Could not fetch navaid information for {airport_code}.", color=0xff4545)) return - navaids = navaid_data.get('navaids', []) + # If helper returned an error dict, surface the reason to the user + if isinstance(navaid_data, dict) and 'error' in navaid_data: + reason = navaid_data.get('error') or 'Unknown error' + await ctx.send(embed=discord.Embed(title="Navaid Lookup Failed", description=f"Could not fetch navaid information for {airport_code}.\nReason: {reason}", color=0xff4545)) + return + + navaids = navaid_data.get('navaids', []) or [] if not navaids: - embed = discord.Embed(title="No Navaid Data", description=f"No navigational aid information found for {airport_code}.", color=0xff4545) - await ctx.send(embed=embed) + await ctx.send(embed=discord.Embed(title="No Navaid Data", description=f"No navigational aid information found for {airport_code}.", color=0xff4545)) return embed = discord.Embed(title=f"Navigational Aids - {airport_code}", color=0xfffffe) From 8ca6fc87bc1c98cbdb208088b73f98750a9a3c07 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:52:21 +0000 Subject: [PATCH 06/56] Update helpers.py --- skysearch/utils/helpers.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 0530072..53139c5 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -389,7 +389,8 @@ async def get_runway_data(self, airport_code: str): for base in base_paths: url = base if token: - url = f"{base}?{urlencode({'apiToken': token})}" + # Use the documented endpoint format: ?apiToken=YOUR_TOKEN + url = f"{base}?apiToken={token}" try: async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: @@ -410,8 +411,11 @@ async def get_navaid_data(self, airport_code: str): self._ensure_http_client() try: token = await self._get_airportdb_token() - base = f"https://airportdb.io/api/v1/airport/{airport_code}" - url = f"{base}?{urlencode({'apiToken': token})}" if token else base + base = f"https://airportdb.io/api/v1/airport/{airport_code}/" + if not token: + return {'error': 'Airportdb API token not configured'} + # Use the documented endpoint format exactly + url = f"{base}?apiToken={token}" async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: if response.status == 200: From 388d19c5d1b1c76d4982008341e6b3a383f12025 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:56:07 +0000 Subject: [PATCH 07/56] Update helpers.py --- skysearch/utils/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 53139c5..9c8f117 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -411,7 +411,7 @@ async def get_navaid_data(self, airport_code: str): self._ensure_http_client() try: token = await self._get_airportdb_token() - base = f"https://airportdb.io/api/v1/airport/{airport_code}/" + base = f"https://airportdb.io/api/v1/airport/{airport_code}" if not token: return {'error': 'Airportdb API token not configured'} # Use the documented endpoint format exactly From 24f80f2a675475457cbc3a655177964c1a5bdfab Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:57:47 +0000 Subject: [PATCH 08/56] Update helpers.py --- skysearch/utils/helpers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 9c8f117..e59f667 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -382,8 +382,7 @@ async def get_runway_data(self, airport_code: str): # Try airportdb.io API (support both /airport/ and legacy /airports/ paths) token = await self._get_airportdb_token() base_paths = [ - f"https://airportdb.io/api/v1/airport/{airport_code}", - f"https://airportdb.io/api/v1/airports/{airport_code}", + f"https://airportdb.io/api/v1/airport/{airport_code}?apiToken={token}", ] for base in base_paths: From a14c8c4d39633fd7e67f60161d93aaa0087ec6b0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:00:59 +0000 Subject: [PATCH 09/56] Update helpers.py --- skysearch/utils/helpers.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index e59f667..919c69c 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -382,14 +382,12 @@ async def get_runway_data(self, airport_code: str): # Try airportdb.io API (support both /airport/ and legacy /airports/ paths) token = await self._get_airportdb_token() base_paths = [ - f"https://airportdb.io/api/v1/airport/{airport_code}?apiToken={token}", + f"https://airportdb.io/api/v1/airport/{airport_code}", ] for base in base_paths: - url = base - if token: - # Use the documented endpoint format: ?apiToken=YOUR_TOKEN - url = f"{base}?apiToken={token}" + # Build URL using the documented query param format exactly once + url = f"{base}?apiToken={token}" if token else base try: async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: From 3bf03c080790a2db0e496519a6355c6cd3cb2755 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:05:10 +0000 Subject: [PATCH 10/56] troubleshoooting --- skysearch/commands/airport.py | 16 +++++++++++- skysearch/utils/helpers.py | 48 ++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index e3e7278..49d86c9 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -374,6 +374,16 @@ async def runway_info(self, ctx, airport_code: str): # Get runway data runway_data = await self.helpers.get_runway_data(airport_code) + + # Surface HTTP/errors from helper (includes redacted URL when available) + if isinstance(runway_data, dict) and 'error' in runway_data: + reason = runway_data.get('error') or 'Unknown error' + url = runway_data.get('url') + desc = f"Could not fetch runway information for {airport_code}.\nReason: {reason}" + if url: + desc += f"\nURL: {url}" + await ctx.send(embed=discord.Embed(title="Runway Lookup Failed", description=desc, color=0xff4545)) + return if runway_data and runway_data.get('runways'): embed = discord.Embed(title=f"Runway Information - {airport_code}", color=0xfffffe) @@ -412,7 +422,11 @@ async def navaid_info(self, ctx, airport_code: str): # If helper returned an error dict, surface the reason to the user if isinstance(navaid_data, dict) and 'error' in navaid_data: reason = navaid_data.get('error') or 'Unknown error' - await ctx.send(embed=discord.Embed(title="Navaid Lookup Failed", description=f"Could not fetch navaid information for {airport_code}.\nReason: {reason}", color=0xff4545)) + url = navaid_data.get('url') + desc = f"Could not fetch navaid information for {airport_code}.\nReason: {reason}" + if url: + desc += f"\nURL: {url}" + await ctx.send(embed=discord.Embed(title="Navaid Lookup Failed", description=desc, color=0xff4545)) return navaids = navaid_data.get('navaids', []) or [] diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 919c69c..805d84c 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, urlparse, parse_qs, urlencode +from urllib.parse import quote_plus, urlparse, parse_qs, urlencode, urlunparse import asyncio @@ -395,6 +395,13 @@ async def get_runway_data(self, airport_code: str): data = await response.json() if data and 'runways' in data: return {'runways': data['runways']} + else: + try: + body = await response.text() + except Exception: + body = '' + short = (body[:400] + '...') if body and len(body) > 400 else body + return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': self._redact_airportdb_url(url)} except (aiohttp.ClientError, KeyError, ValueError): # Try next path variant continue @@ -433,12 +440,16 @@ async def get_navaid_data(self, airport_code: str): except Exception: body = '' short = (body[:400] + '...') if body and len(body) > 400 else body - return {'error': f'HTTP {response.status}: {short or response.reason}'} + return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': self._redact_airportdb_url(url)} except Exception: # Return error info for callers to display import traceback as _tb try: - return {'error': str(_tb.format_exc().splitlines()[-1])} + exc_msg = str(_tb.format_exc().splitlines()[-1]) + if 'url' in locals(): + redacted = self._redact_airportdb_url(url) + return {'error': exc_msg, 'url': redacted} + return {'error': exc_msg} except Exception: return {'error': 'Unknown exception while fetching navaids'} @@ -468,6 +479,37 @@ async def _get_airportdb_token(self) -> str | None: except Exception: return None + def _redact_airportdb_url(self, url: str) -> str: + """Return the URL with the Airportdb API token redacted for safe logging. + + Replaces the value of `apiToken` or `api_token` query parameters with + the literal 'REDACTED'. If parsing fails, falls back to a simple regex + replacement or a placeholder. + """ + try: + parsed = urlparse(url) + qs = parse_qs(parsed.query, keep_blank_values=True) + changed = False + if 'apiToken' in qs: + qs['apiToken'] = ['REDACTED'] + changed = True + if 'api_token' in qs: + qs['api_token'] = ['REDACTED'] + changed = True + if changed: + # parse_qs produces lists; urlencode expects key->value mapping + safe_q = {k: v[0] for k, v in qs.items()} + new_q = urlencode(safe_q) + return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, new_q, parsed.fragment)) + return url + except Exception: + try: + import re + + return re.sub(r'(apiToken=)[^&]+', r"\1REDACTED", url) + except Exception: + return 'REDACTED_URL' + # for feeder link command stuff def _globe_feed_url(self, ids: list, param: str = "uuid") -> str: From acca4322962021815bba8ae93283e77858b22c81 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:05:19 +0000 Subject: [PATCH 11/56] Update helpers.py --- skysearch/utils/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 805d84c..8389615 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -413,6 +413,7 @@ async def get_runway_data(self, airport_code: str): async def get_navaid_data(self, airport_code: str): """Get navigational aids for an airport.""" self._ensure_http_client() + url = None try: token = await self._get_airportdb_token() base = f"https://airportdb.io/api/v1/airport/{airport_code}" From 7ddc110783c114fd5f39e33ff0f6c4cd4296543d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:00:34 +0000 Subject: [PATCH 12/56] Update helpers.py --- skysearch/utils/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 8389615..c4290e0 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -413,7 +413,7 @@ async def get_runway_data(self, airport_code: str): async def get_navaid_data(self, airport_code: str): """Get navigational aids for an airport.""" self._ensure_http_client() - url = None + url = '' try: token = await self._get_airportdb_token() base = f"https://airportdb.io/api/v1/airport/{airport_code}" From 9bb45f98b57cf62d8edc459ea036ade1be03afc9 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:02:45 +0000 Subject: [PATCH 13/56] Update helpers.py --- skysearch/utils/helpers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index c4290e0..6f9c08c 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -401,7 +401,8 @@ async def get_runway_data(self, airport_code: str): except Exception: body = '' short = (body[:400] + '...') if body and len(body) > 400 else body - return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': self._redact_airportdb_url(url)} + redacted_url = self._redact_airportdb_url(url) if url else '' + return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': redacted_url} except (aiohttp.ClientError, KeyError, ValueError): # Try next path variant continue @@ -441,7 +442,9 @@ async def get_navaid_data(self, airport_code: str): except Exception: body = '' short = (body[:400] + '...') if body and len(body) > 400 else body - return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': self._redact_airportdb_url(url)} + redacted_url = self._redact_airportdb_url(url) if url else '' + return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': redacted_url + return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': redacted_url} except Exception: # Return error info for callers to display import traceback as _tb From 6f067d5ff007d1fbea6a26b83b1dbcf6e8c5e20d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:03:07 +0000 Subject: [PATCH 14/56] Update helpers.py --- skysearch/utils/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 6f9c08c..f5ceb76 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -443,7 +443,6 @@ async def get_navaid_data(self, airport_code: str): body = '' short = (body[:400] + '...') if body and len(body) > 400 else body redacted_url = self._redact_airportdb_url(url) if url else '' - return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': redacted_url return {'error': f'HTTP {response.status}: {short or response.reason}', 'url': redacted_url} except Exception: # Return error info for callers to display From 1ac612190438398c954f33e5ce49edc87a7f08e4 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:05:24 +0000 Subject: [PATCH 15/56] Update helpers.py --- skysearch/utils/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index f5ceb76..3c43c54 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -478,7 +478,9 @@ async def _get_airportdb_token(self) -> str | None: return None # Common key from install instructions is `api_token` - return tokens.get('api_token') or tokens.get('apiToken') or tokens.get('token') + token = tokens.get('api_token') or tokens.get('apiToken') or tokens.get('token') + # Strip any whitespace that might be stored with the token + return token.strip() if token else None except Exception: return None From 3a62f6b04878fd1f72b6612c28252c0505f6620d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:11:57 +0000 Subject: [PATCH 16/56] Update helpers.py --- skysearch/utils/helpers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 3c43c54..63299fe 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -386,8 +386,9 @@ async def get_runway_data(self, airport_code: str): ] for base in base_paths: - # Build URL using the documented query param format exactly once - url = f"{base}?apiToken={token}" if token else base + # Build URL using the documented query param format exactly once, with URL-encoded token + encoded_token = quote_plus(token) if token else None + url = f"{base}?apiToken={encoded_token}" if encoded_token else base try: async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: @@ -420,8 +421,9 @@ async def get_navaid_data(self, airport_code: str): base = f"https://airportdb.io/api/v1/airport/{airport_code}" if not token: return {'error': 'Airportdb API token not configured'} - # Use the documented endpoint format exactly - url = f"{base}?apiToken={token}" + # Use the documented endpoint format exactly, with URL-encoded token + encoded_token = quote_plus(token) + url = f"{base}?apiToken={encoded_token}" async with self.cog._http_client.get(url, headers=await self._get_http_headers()) as response: if response.status == 200: From adbfc971c26e37f0e4cbb52eabea7ccfabb01ed7 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:19:23 +0000 Subject: [PATCH 17/56] Update helpers.py --- skysearch/utils/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 63299fe..ce47f27 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -480,7 +480,7 @@ async def _get_airportdb_token(self) -> str | None: return None # Common key from install instructions is `api_token` - token = tokens.get('api_token') or tokens.get('apiToken') or tokens.get('token') + token = tokens.get('api_token') or tokens.get('apiToken') or tokens.get('token') or tokens.get('client_id') # Strip any whitespace that might be stored with the token return token.strip() if token else None except Exception: From 1d99f1c55614235aac496fda48114171b1a23d63 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:23:03 +0000 Subject: [PATCH 18/56] Update skysearch.py --- skysearch/skysearch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index d775067..e51f638 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -1392,8 +1392,8 @@ async def on_message(self, message): guild_id = message.guild.id # Fast cache check - avoid expensive config reads if auto_icao is disabled - if guild_id in self._auto_icao_checked_guilds: - # Guild is known to have auto_icao disabled - fast return + if guild_id in self._auto_icao_checked_guilds and guild_id not in self._auto_icao_enabled_guilds: + # Guild was checked and auto_icao is disabled - fast return return if guild_id not in self._auto_icao_enabled_guilds: # First time seeing this guild - do one-time config check From de36904ff84e23c2155d685e22f5691d1dd2297c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:58:38 +0000 Subject: [PATCH 19/56] add geofence and add to watchlist on embeds --- skysearch/commands/aircraft.py | 174 +++++++++++------------ skysearch/skysearch.py | 128 ++++++++++++++++- skysearch/utils/add_to_watchlist_view.py | 123 ++++++++++++++++ skysearch/utils/helpers.py | 14 ++ 4 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 skysearch/utils/add_to_watchlist_view.py diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 8c7224d..ce51a4a 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -4,10 +4,9 @@ import asyncio +import datetime import json import os -from urllib.parse import quote_plus - import discord from discord.ext import commands, tasks from redbot.core import commands as red_commands @@ -43,49 +42,8 @@ async def send_aircraft_info(self, ctx, response): image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) # Create embed embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - # Create view with buttons - view = discord.ui.View() - icao = aircraft_data.get('hex', '') - if icao: - icao = icao.upper() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=f"{link}", style=discord.ButtonStyle.link)) - - # Social media sharing logic - ground_speed_knots = aircraft_data.get('gs', 'N/A') - ground_speed_mph = 'unknown' - if ground_speed_knots != 'N/A' and ground_speed_knots is not None: - try: - ground_speed_mph = round(float(ground_speed_knots) * 1.15078) - except Exception: - ground_speed_mph = 'unknown' - squawk_code = aircraft_data.get('squawk', 'N/A') - emergency_squawk_codes = ['7500', '7600', '7700'] - lat = aircraft_data.get('lat', 'N/A') - lon = aircraft_data.get('lon', 'N/A') - if lat != 'N/A' and lat is not None: - try: - lat = round(float(lat), 2) - lat_dir = "N" if lat >= 0 else "S" - lat = f"{abs(lat)}{lat_dir}" - except Exception: - pass - if lon != 'N/A' and lon is not None: - try: - lon = round(float(lon), 2) - lon_dir = "E" if lon >= 0 else "W" - lon = f"{abs(lon)}{lon_dir}" - except Exception: - pass - if squawk_code in emergency_squawk_codes: - tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - else: - tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" - view.add_item(discord.ui.Button(label=f"Post on 𝕏", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) - whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" - view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + # Create view with buttons including Add to Watchlist + view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) await ctx.send(embed=embed, view=view) else: @@ -632,13 +590,8 @@ async def closest_aircraft(self, ctx, lat: str, lon: str, radius: str = "100"): embed.set_thumbnail(url="https://www.beehive.systems/hubfs/Icon%20Packs/White/airplane.png") embed.set_footer(text="No photo available") - # Create view with buttons - view = discord.ui.View() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) - - # Add tracking button - view.add_item(discord.ui.Button(label="Track Live", emoji="✈️", url=link, style=discord.ButtonStyle.link)) + # Create view with buttons including Add to Watchlist + view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) await ctx.send(embed=embed, view=view) @@ -728,48 +681,9 @@ async def _get_aircraft_embed_and_view(self, ctx, response): aircraft_list = response.get('aircraft') or response.get('ac') if aircraft_list: aircraft_data = aircraft_list[0] - icao = aircraft_data.get('hex', None) - if icao: - icao = icao.upper() image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - view = discord.ui.View() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=f"{link}", style=discord.ButtonStyle.link)) - ground_speed_knots = aircraft_data.get('gs', 'N/A') - ground_speed_mph = 'unknown' - if ground_speed_knots != 'N/A' and ground_speed_knots is not None: - try: - ground_speed_mph = round(float(ground_speed_knots) * 1.15078) - except Exception: - ground_speed_mph = 'unknown' - squawk_code = aircraft_data.get('squawk', 'N/A') - emergency_squawk_codes = ['7500', '7600', '7700'] - lat = aircraft_data.get('lat', 'N/A') - lon = aircraft_data.get('lon', 'N/A') - if lat != 'N/A' and lat is not None: - try: - lat = round(float(lat), 2) - lat_dir = "N" if lat >= 0 else "S" - lat = f"{abs(lat)}{lat_dir}" - except Exception: - pass - if lon != 'N/A' and lon is not None: - try: - lon = round(float(lon), 2) - lon_dir = "E" if lon >= 0 else "W" - lon = f"{abs(lon)}{lon_dir}" - except Exception: - pass - if squawk_code in emergency_squawk_codes: - tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - else: - tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - tweet_url = f"https://twitter.com/intent/tweet?text={quote_plus(tweet_text)}" - view.add_item(discord.ui.Button(label=f"Post on 𝕏", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) - whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" - view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) return embed, view else: embed = discord.Embed(title='No results found for your query', color=discord.Colour(0xff4545)) @@ -1219,4 +1133,78 @@ async def watchlist_cooldown(self, ctx, duration: str = None): description=_("Invalid duration format. Use a number (e.g. '20'), minutes ('20m'), seconds ('30s'), or hours ('1h').\n\nExamples:\nβ€’ `20m` - 20 minutes\nβ€’ `30s` - 30 seconds\nβ€’ `1h` - 1 hour\nβ€’ `15.5m` - 15.5 minutes"), color=0xff4545 ) - await ctx.send(embed=embed) \ No newline at end of file + await ctx.send(embed=embed) + + # Geo-fence commands + async def geofence_add(self, ctx, name: str, lat: float, lon: float, radius_nm: float, alert_on: str = "both", cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): + """Add a geo-fence alert. Notify when aircraft enter and/or leave the area.""" + if alert_on.lower() not in ("entry", "exit", "both"): + embed = discord.Embed(title="❌ Invalid alert_on", description="Use: entry, exit, or both", color=0xff0000) + await ctx.send(embed=embed) + return + if radius_nm <= 0 or radius_nm > 500: + embed = discord.Embed(title="❌ Invalid radius", description="Radius must be 0-500 nautical miles.", color=0xff0000) + await ctx.send(embed=embed) + return + if cooldown < 1 or cooldown > 1440: + embed = discord.Embed(title="❌ Invalid cooldown", description="Cooldown must be 1-1440 minutes.", color=0xff0000) + await ctx.send(embed=embed) + return + channel = channel or self.cog.bot.get_channel(await self.cog.config.guild(ctx.guild).alert_channel()) + if not channel: + embed = discord.Embed(title="❌ No channel", description="Set alert channel or pass a channel.", color=0xff0000) + await ctx.send(embed=embed) + return + fence_id = f"geofence_{name.lower().replace(' ', '_')}_{int(datetime.datetime.utcnow().timestamp())}" + geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() + geofence_alerts[fence_id] = { + "name": name, + "lat": lat, + "lon": lon, + "radius_nm": radius_nm, + "alert_on": alert_on.lower(), + "cooldown": cooldown, + "channel_id": channel.id, + "role_id": role.id if role else None, + "aircraft_inside": {}, + "last_alert_time": None, + } + await self.cog.config.guild(ctx.guild).geofence_alerts.set(geofence_alerts) + embed = discord.Embed( + title="βœ… Geo-fence added", + description=f"**{name}** at ({lat}, {lon}), radius {radius_nm} nm\nAlerts: {alert_on} | Cooldown: {cooldown}m | Channel: {channel.mention}", + color=0x00ff00, + ) + embed.add_field(name="ID", value=f"`{fence_id}`", inline=False) + await ctx.send(embed=embed) + + async def geofence_remove(self, ctx, fence_id: str): + """Remove a geo-fence alert by ID.""" + geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() + if fence_id not in geofence_alerts: + await ctx.send(f"❌ Geo-fence `{fence_id}` not found.") + return + name = geofence_alerts[fence_id].get("name", fence_id) + del geofence_alerts[fence_id] + await self.cog.config.guild(ctx.guild).geofence_alerts.set(geofence_alerts) + await ctx.send(f"βœ… Removed geo-fence **{name}** (`{fence_id}`).") + + async def geofence_list(self, ctx): + """List all geo-fence alerts for this server.""" + geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() + if not geofence_alerts: + embed = discord.Embed(title="Geo-fence alerts", description="No geo-fences configured.", color=0x00aaff) + await ctx.send(embed=embed) + return + embed = discord.Embed(title="Geo-fence alerts", description=f"**{len(geofence_alerts)}** geo-fence(s)", color=0x00aaff) + for fence_id, fence in geofence_alerts.items(): + ch = self.cog.bot.get_channel(fence.get("channel_id")) + ch_mention = ch.mention if ch else str(fence.get("channel_id")) + role_id = fence.get("role_id") + role_mention = f"<@&{role_id}>" if role_id else "β€”" + embed.add_field( + name=fence.get("name", fence_id), + value=f"**ID:** `{fence_id}`\n**Coords:** ({fence.get('lat')}, {fence.get('lon')}) {fence.get('radius_nm')} nm\n**Alert on:** {fence.get('alert_on', 'both')} | **Cooldown:** {fence.get('cooldown', 5)}m\n**Channel:** {ch_mention} | **Role:** {role_mention}", + inline=False, + ) + await ctx.send(embed=embed) \ No newline at end of file diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index e51f638..4cd3c14 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -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={}, faa_alert_channel=None, faa_alert_role=None, faa_alert_cooldown=5, last_faa_status=None, faa_last_alert_time=None) + 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, geofence_alerts={}) 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 @@ -78,6 +78,7 @@ def __init__(self, bot): self.check_emergency_squawks.start() self.check_watched_aircraft.start() self.check_faa_status_changes.start() + self.check_geofence_alerts.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() @@ -157,6 +158,7 @@ async def cog_unload(self): self.check_emergency_squawks.cancel() self.check_watched_aircraft.cancel() self.check_faa_status_changes.cancel() + self.check_geofence_alerts.cancel() await self.api.close() @commands.guild_only() @@ -259,6 +261,7 @@ async def aircraft_group(self, ctx): if await ctx.bot.is_owner(ctx.author): embed.add_field(name=_("Custom Alert Admin"), value="`forcealert` (owner) `clearalertcooldown`", inline=False) embed.add_field(name=_("Watchlist"), value="`watchlist` - Manage your personal aircraft watchlist\n`watchlist add ` - Add aircraft to watchlist\n`watchlist remove ` - Remove from watchlist\n`watchlist list` - List watched aircraft\n`watchlist status` - Get detailed status\n`watchlist cooldown [minutes]` - Set notification cooldown", inline=False) + embed.add_field(name=_("Geo-fence"), value="`geofence add` - Alert when aircraft enter/leave an area\n`geofence remove` - Remove a geo-fence\n`geofence list` - List all geo-fences", inline=False) embed.add_field(name=_("Other"), value=_("`scroll` - Scroll through available planes\n`feeder` - Parse feeder JSON data (secure modal)"), inline=False) # Only show debug command to bot owners if await ctx.bot.is_owner(ctx.author): @@ -385,7 +388,38 @@ async def aircraft_watchlist_cooldown(self, ctx, duration: str = None): """Set or view the watchlist notification cooldown. Accepts formats like '20m', '30s', '1h', or '15.5m'. Use without a value to check current setting.""" await self.aircraft_commands.watchlist_cooldown(ctx, duration) + # Geo-fence commands + @commands.guild_only() + @aircraft_group.group(name='geofence', invoke_without_command=True) + async def aircraft_geofence(self, ctx): + """Geo-fence alerts: get notified when aircraft enter or leave an area.""" + embed = discord.Embed( + title=_("Geo-fence Commands"), + description=_("Alert when aircraft enter or leave a geographic area."), + color=0xfffffe, + ) + embed.add_field(name="add", value=_("`geofence add [alert_on] [cooldown] [channel] [role]`"), inline=False) + embed.add_field(name="remove", value=_("`geofence remove `"), inline=False) + embed.add_field(name="list", value=_("`geofence list` - List all geo-fences"), inline=False) + await ctx.send(embed=embed) + + @commands.guild_only() + @aircraft_geofence.command(name='add') + async def aircraft_geofence_add(self, ctx, name: str, lat: float, lon: float, radius_nm: float, alert_on: str = "both", cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): + """Add a geo-fence. Alerts when aircraft enter/leave the area. radius_nm in nautical miles. alert_on: entry, exit, or both.""" + await self.aircraft_commands.geofence_add(ctx, name, lat, lon, radius_nm, alert_on, cooldown, channel, role) + + @commands.guild_only() + @aircraft_geofence.command(name='remove') + async def aircraft_geofence_remove(self, ctx, fence_id: str): + """Remove a geo-fence by ID (from geofence list).""" + await self.aircraft_commands.geofence_remove(ctx, fence_id) + @commands.guild_only() + @aircraft_geofence.command(name='list') + async def aircraft_geofence_list(self, ctx): + """List all geo-fence alerts for this server.""" + await self.aircraft_commands.geofence_list(ctx) @@ -938,6 +972,98 @@ async def check_faa_status_changes(self): @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_geofence_alerts(self): + """Background task to check geo-fence alerts (aircraft entering/leaving areas).""" + try: + api_mode = await self.config.api_mode() + key = "aircraft" if api_mode == "primary" else "ac" + for guild in self.bot.guilds: + await set_contextual_locales_from_guild(self.bot, guild) + guild_config = self.config.guild(guild) + geofence_alerts = await guild_config.geofence_alerts() + if not geofence_alerts: + continue + now = datetime.datetime.now(datetime.timezone.utc) + for fence_id, fence in geofence_alerts.items(): + try: + lat = fence.get("lat") + lon = fence.get("lon") + radius_nm = fence.get("radius_nm", 50) + channel_id = fence.get("channel_id") + alert_on = fence.get("alert_on", "both") # entry, exit, both + cooldown_min = fence.get("cooldown", 5) + if not channel_id or lat is None or lon is None: + continue + channel = self.bot.get_channel(channel_id) + if not channel: + continue + # Check cooldown + last_alert = fence.get("last_alert_time") + if last_alert: + last_dt = datetime.datetime.fromtimestamp(last_alert, tz=datetime.timezone.utc) + if (now - last_dt).total_seconds() < cooldown_min * 60: + continue + url = f"{await self.api.get_api_url()}/?circle={lat},{lon},{radius_nm}" + response = await self.api.make_request(url) + aircraft_list = response.get(key, []) if response else [] + current_inside = {a.get("hex", "").upper(): a for a in aircraft_list if a.get("hex") and a.get("hex") != "00000000"} + prev_inside = fence.get("aircraft_inside") or {} + if not isinstance(prev_inside, dict): + prev_inside = {k: 1 for k in prev_inside} if isinstance(prev_inside, list) else {} + # Entry: aircraft in current, not in prev + # Exit: aircraft in prev, not in current + entries = [current_inside[icao] for icao in current_inside if icao not in prev_inside] + exits = [icao for icao in prev_inside if icao not in current_inside] + role_id = fence.get("role_id") + role_mention = f"<@&{role_id}>" if role_id else "" + sent_alert = False + if entries and alert_on in ("entry", "both"): + for aircraft_info in entries: + await self._send_geofence_alert(channel, fence, aircraft_info, "entry", role_mention) + sent_alert = True + break # One alert per cycle per fence + if exits and alert_on in ("exit", "both") and not sent_alert: + # For exit we don't have full aircraft info; fetch first exited + icao_exit = exits[0] + url_hex = f"{await self.api.get_api_url()}/?find_hex={icao_exit}" + r = await self.api.make_request(url_hex) + ac_list = r.get(key, []) if r else [] + aircraft_info = ac_list[0] if ac_list else {"hex": icao_exit, "flight": "N/A", "lat": lat, "lon": lon} + await self._send_geofence_alert(channel, fence, aircraft_info, "exit", role_mention) + sent_alert = True + fence["aircraft_inside"] = {icao: 1 for icao in current_inside} + if sent_alert: + fence["last_alert_time"] = now.timestamp() + await guild_config.geofence_alerts.set(geofence_alerts) + await asyncio.sleep(1) + except Exception as e: + log.debug(f"Geofence {fence_id} error: {e}") + except Exception as e: + log.error(f"Error checking geofence alerts: {e}", exc_info=True) + + @check_geofence_alerts.before_loop + async def before_check_geofence_alerts(self): + await self.bot.wait_until_ready() + + async def _send_geofence_alert(self, channel, fence, aircraft_info, event_type, role_mention): + """Send a geo-fence alert (entry or exit).""" + fence_name = fence.get("name", "Unnamed") + image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_info) + embed = self.helpers.create_aircraft_embed(aircraft_info, image_url, photographer) + if event_type == "entry": + embed.title = f"🟒 Geo-fence: {aircraft_info.get('desc', 'Aircraft')} entered **{fence_name}**" + embed.color = 0x00ff00 + else: + embed.title = f"πŸ”΄ Geo-fence: {aircraft_info.get('desc', 'Aircraft')} left **{fence_name}**" + embed.color = 0xff4545 + icao = (aircraft_info.get("hex") or "").upper() + view = discord.ui.View() + link = f"https://globe.airplanes.live/?icao={icao}" + view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) + allowed_mentions = discord.AllowedMentions(roles=True) if role_mention else None + await channel.send(content=role_mention or None, embed=embed, view=view, allowed_mentions=allowed_mentions) @tasks.loop(minutes=3) async def check_watched_aircraft(self): diff --git a/skysearch/utils/add_to_watchlist_view.py b/skysearch/utils/add_to_watchlist_view.py new file mode 100644 index 0000000..5fca523 --- /dev/null +++ b/skysearch/utils/add_to_watchlist_view.py @@ -0,0 +1,123 @@ +""" +Add to Watchlist button view for aircraft embeds +""" + +import discord +from urllib.parse import quote_plus + +from redbot.core.i18n import Translator + +_ = Translator("Skysearch", __file__) + + +class AddToWatchlistView(discord.ui.View): + """View with aircraft link buttons and Add to Watchlist button.""" + + def __init__(self, cog, aircraft_data, *, include_watchlist=True, timeout=300): + super().__init__(timeout=timeout) + self.cog = cog + self.aircraft_data = aircraft_data + icao = (aircraft_data.get("hex", "") or "").upper() + if not icao: + include_watchlist = False + + # Link buttons + link = f"https://globe.airplanes.live/?icao={icao}" + self.add_item( + discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link) + ) + + # Social media buttons + ground_speed_knots = aircraft_data.get("gs") or aircraft_data.get("ground_speed") + ground_speed_mph = "unknown" + if ground_speed_knots is not None and ground_speed_knots != "N/A": + try: + ground_speed_mph = round(float(ground_speed_knots) * 1.15078) + except (ValueError, TypeError): + pass + + lat = aircraft_data.get("lat", "N/A") + lon = aircraft_data.get("lon", "N/A") + if lat not in ("N/A", None): + try: + lat_f = round(float(lat), 2) + lat_dir = "N" if lat_f >= 0 else "S" + lat = f"{abs(lat_f)}{lat_dir}" + except (ValueError, TypeError): + pass + if lon not in ("N/A", None): + try: + lon_f = round(float(lon), 2) + lon_dir = "E" if lon_f >= 0 else "W" + lon = f"{abs(lon_f)}{lon_dir}" + except (ValueError, TypeError): + pass + + squawk_code = aircraft_data.get("squawk", "N/A") + emergency_squawk_codes = ["7500", "7600", "7700"] + if squawk_code in emergency_squawk_codes: + tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + else: + tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" + self.add_item(discord.ui.Button(label="Post on X", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) + + whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" + whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" + self.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + + # Add to Watchlist button (interactive) + if include_watchlist and icao: + self.add_item(AddToWatchlistButton(cog=cog, icao=icao)) + + +class AddToWatchlistButton(discord.ui.Button): + """Button that adds aircraft to the user's watchlist when clicked.""" + + def __init__(self, *, cog, icao: str): + super().__init__( + label=_("Add to Watchlist"), + emoji="βž•", + style=discord.ButtonStyle.secondary, + custom_id=None, # Ephemeral views don't need custom_id + ) + self.cog = cog + self.icao = icao + + async def callback(self, interaction: discord.Interaction): + """Handle button click - add aircraft to user's watchlist.""" + user = interaction.user + user_config = self.cog.config.user(user) + + # Validate ICAO + is_valid, error_msg = self.cog.helpers.validate_icao(self.icao) + if not is_valid: + await interaction.response.send_message( + _("❌ Invalid ICAO: {error}").format(error=error_msg), + ephemeral=True, + ) + return + + watchlist = await user_config.watchlist() + + if self.icao in watchlist: + await interaction.response.send_message( + _("**{icao}** is already in your watchlist.").format(icao=self.icao), + ephemeral=True, + ) + return + + watchlist.append(self.icao) + await user_config.watchlist.set(watchlist) + + # Initialize aircraft state + aircraft_state = await user_config.watchlist_aircraft_state() + aircraft_state[self.icao] = "unknown" + await user_config.watchlist_aircraft_state.set(aircraft_state) + + await interaction.response.send_message( + _("βœ… Added **{icao}** to your watchlist. You'll be notified when it comes online, takes off, or lands.").format( + icao=self.icao + ), + ephemeral=True, + ) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index ce47f27..12b814f 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -874,6 +874,20 @@ def create_watchlist_view(self, icao): style=discord.ButtonStyle.link )) return view + + def create_aircraft_view_with_watchlist(self, aircraft_data, include_watchlist_button=True): + """ + Create a view with aircraft buttons (globe link, social sharing) plus Add to Watchlist. + + Args: + aircraft_data: Aircraft data dict (for building social links) + include_watchlist_button: If True, add interactive Add to Watchlist button + + Returns: + discord.ui.View: View with all buttons + """ + from .add_to_watchlist_view import AddToWatchlistView + return AddToWatchlistView(self.cog, aircraft_data, include_watchlist=include_watchlist_button) def extract_aircraft_status(self, aircraft_data): """ From 6ad5dcb279ebfdb1b211343a11bdcbb5eda2e403 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:02:14 +0000 Subject: [PATCH 20/56] Update aircraft.py --- skysearch/commands/aircraft.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index ce51a4a..b71454b 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -117,11 +117,16 @@ async def aircraft_by_icao(self, ctx, hex_id: str): key = 'aircraft' if api_mode == 'primary' else 'ac' aircraft_list = response.get(key) if response else None if aircraft_list and len(aircraft_list) > 0: - if len(aircraft_list) > 1: - for aircraft_info in aircraft_list: - await self.send_aircraft_info(ctx, {key: [aircraft_info]}) - else: - await self.send_aircraft_info(ctx, {key: aircraft_list}) + # Deduplicate by hex - API may return same aircraft multiple times + seen_hex = set() + unique_list = [] + for ac in aircraft_list: + h = (ac.get("hex") or "").upper() + if h and h != "00000000" and h not in seen_hex: + seen_hex.add(h) + unique_list.append(ac) + if unique_list: + await self.send_aircraft_info(ctx, {key: unique_list}) else: embed = discord.Embed(title=_("No results found for your query"), color=discord.Colour(0xff4545)) embed.add_field(name=_("Details"), value=_("No aircraft information found or the response format is incorrect."), inline=False) From 1e5929ea1000d72ebb44f48d5f093a90fccad78a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:11:09 +0000 Subject: [PATCH 21/56] Revert "Update aircraft.py" This reverts commit 6ad5dcb279ebfdb1b211343a11bdcbb5eda2e403. --- skysearch/commands/aircraft.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index b71454b..ce51a4a 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -117,16 +117,11 @@ async def aircraft_by_icao(self, ctx, hex_id: str): key = 'aircraft' if api_mode == 'primary' else 'ac' aircraft_list = response.get(key) if response else None if aircraft_list and len(aircraft_list) > 0: - # Deduplicate by hex - API may return same aircraft multiple times - seen_hex = set() - unique_list = [] - for ac in aircraft_list: - h = (ac.get("hex") or "").upper() - if h and h != "00000000" and h not in seen_hex: - seen_hex.add(h) - unique_list.append(ac) - if unique_list: - await self.send_aircraft_info(ctx, {key: unique_list}) + if len(aircraft_list) > 1: + for aircraft_info in aircraft_list: + await self.send_aircraft_info(ctx, {key: [aircraft_info]}) + else: + await self.send_aircraft_info(ctx, {key: aircraft_list}) else: embed = discord.Embed(title=_("No results found for your query"), color=discord.Colour(0xff4545)) embed.add_field(name=_("Details"), value=_("No aircraft information found or the response format is incorrect."), inline=False) From b66bd5be2492f92523358261141521cebd243395 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:11:14 +0000 Subject: [PATCH 22/56] Revert "add geofence and add to watchlist on embeds" This reverts commit de36904ff84e23c2155d685e22f5691d1dd2297c. --- skysearch/commands/aircraft.py | 174 ++++++++++++----------- skysearch/skysearch.py | 128 +---------------- skysearch/utils/add_to_watchlist_view.py | 123 ---------------- skysearch/utils/helpers.py | 14 -- 4 files changed, 94 insertions(+), 345 deletions(-) delete mode 100644 skysearch/utils/add_to_watchlist_view.py diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index ce51a4a..8c7224d 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -4,9 +4,10 @@ import asyncio -import datetime import json import os +from urllib.parse import quote_plus + import discord from discord.ext import commands, tasks from redbot.core import commands as red_commands @@ -42,8 +43,49 @@ async def send_aircraft_info(self, ctx, response): image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) # Create embed embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - # Create view with buttons including Add to Watchlist - view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) + # Create view with buttons + view = discord.ui.View() + icao = aircraft_data.get('hex', '') + if icao: + icao = icao.upper() + link = f"https://globe.airplanes.live/?icao={icao}" + view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=f"{link}", style=discord.ButtonStyle.link)) + + # Social media sharing logic + ground_speed_knots = aircraft_data.get('gs', 'N/A') + ground_speed_mph = 'unknown' + if ground_speed_knots != 'N/A' and ground_speed_knots is not None: + try: + ground_speed_mph = round(float(ground_speed_knots) * 1.15078) + except Exception: + ground_speed_mph = 'unknown' + squawk_code = aircraft_data.get('squawk', 'N/A') + emergency_squawk_codes = ['7500', '7600', '7700'] + lat = aircraft_data.get('lat', 'N/A') + lon = aircraft_data.get('lon', 'N/A') + if lat != 'N/A' and lat is not None: + try: + lat = round(float(lat), 2) + lat_dir = "N" if lat >= 0 else "S" + lat = f"{abs(lat)}{lat_dir}" + except Exception: + pass + if lon != 'N/A' and lon is not None: + try: + lon = round(float(lon), 2) + lon_dir = "E" if lon >= 0 else "W" + lon = f"{abs(lon)}{lon_dir}" + except Exception: + pass + if squawk_code in emergency_squawk_codes: + tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + else: + tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" + view.add_item(discord.ui.Button(label=f"Post on 𝕏", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) + whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" + whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" + view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) await ctx.send(embed=embed, view=view) else: @@ -590,8 +632,13 @@ async def closest_aircraft(self, ctx, lat: str, lon: str, radius: str = "100"): embed.set_thumbnail(url="https://www.beehive.systems/hubfs/Icon%20Packs/White/airplane.png") embed.set_footer(text="No photo available") - # Create view with buttons including Add to Watchlist - view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) + # Create view with buttons + view = discord.ui.View() + link = f"https://globe.airplanes.live/?icao={icao}" + view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) + + # Add tracking button + view.add_item(discord.ui.Button(label="Track Live", emoji="✈️", url=link, style=discord.ButtonStyle.link)) await ctx.send(embed=embed, view=view) @@ -681,9 +728,48 @@ async def _get_aircraft_embed_and_view(self, ctx, response): aircraft_list = response.get('aircraft') or response.get('ac') if aircraft_list: aircraft_data = aircraft_list[0] + icao = aircraft_data.get('hex', None) + if icao: + icao = icao.upper() image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) + view = discord.ui.View() + link = f"https://globe.airplanes.live/?icao={icao}" + view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=f"{link}", style=discord.ButtonStyle.link)) + ground_speed_knots = aircraft_data.get('gs', 'N/A') + ground_speed_mph = 'unknown' + if ground_speed_knots != 'N/A' and ground_speed_knots is not None: + try: + ground_speed_mph = round(float(ground_speed_knots) * 1.15078) + except Exception: + ground_speed_mph = 'unknown' + squawk_code = aircraft_data.get('squawk', 'N/A') + emergency_squawk_codes = ['7500', '7600', '7700'] + lat = aircraft_data.get('lat', 'N/A') + lon = aircraft_data.get('lon', 'N/A') + if lat != 'N/A' and lat is not None: + try: + lat = round(float(lat), 2) + lat_dir = "N" if lat >= 0 else "S" + lat = f"{abs(lat)}{lat_dir}" + except Exception: + pass + if lon != 'N/A' and lon is not None: + try: + lon = round(float(lon), 2) + lon_dir = "E" if lon >= 0 else "W" + lon = f"{abs(lon)}{lon_dir}" + except Exception: + pass + if squawk_code in emergency_squawk_codes: + tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + else: + tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + tweet_url = f"https://twitter.com/intent/tweet?text={quote_plus(tweet_text)}" + view.add_item(discord.ui.Button(label=f"Post on 𝕏", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) + whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" + whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" + view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) return embed, view else: embed = discord.Embed(title='No results found for your query', color=discord.Colour(0xff4545)) @@ -1133,78 +1219,4 @@ async def watchlist_cooldown(self, ctx, duration: str = None): description=_("Invalid duration format. Use a number (e.g. '20'), minutes ('20m'), seconds ('30s'), or hours ('1h').\n\nExamples:\nβ€’ `20m` - 20 minutes\nβ€’ `30s` - 30 seconds\nβ€’ `1h` - 1 hour\nβ€’ `15.5m` - 15.5 minutes"), color=0xff4545 ) - await ctx.send(embed=embed) - - # Geo-fence commands - async def geofence_add(self, ctx, name: str, lat: float, lon: float, radius_nm: float, alert_on: str = "both", cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): - """Add a geo-fence alert. Notify when aircraft enter and/or leave the area.""" - if alert_on.lower() not in ("entry", "exit", "both"): - embed = discord.Embed(title="❌ Invalid alert_on", description="Use: entry, exit, or both", color=0xff0000) - await ctx.send(embed=embed) - return - if radius_nm <= 0 or radius_nm > 500: - embed = discord.Embed(title="❌ Invalid radius", description="Radius must be 0-500 nautical miles.", color=0xff0000) - await ctx.send(embed=embed) - return - if cooldown < 1 or cooldown > 1440: - embed = discord.Embed(title="❌ Invalid cooldown", description="Cooldown must be 1-1440 minutes.", color=0xff0000) - await ctx.send(embed=embed) - return - channel = channel or self.cog.bot.get_channel(await self.cog.config.guild(ctx.guild).alert_channel()) - if not channel: - embed = discord.Embed(title="❌ No channel", description="Set alert channel or pass a channel.", color=0xff0000) - await ctx.send(embed=embed) - return - fence_id = f"geofence_{name.lower().replace(' ', '_')}_{int(datetime.datetime.utcnow().timestamp())}" - geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() - geofence_alerts[fence_id] = { - "name": name, - "lat": lat, - "lon": lon, - "radius_nm": radius_nm, - "alert_on": alert_on.lower(), - "cooldown": cooldown, - "channel_id": channel.id, - "role_id": role.id if role else None, - "aircraft_inside": {}, - "last_alert_time": None, - } - await self.cog.config.guild(ctx.guild).geofence_alerts.set(geofence_alerts) - embed = discord.Embed( - title="βœ… Geo-fence added", - description=f"**{name}** at ({lat}, {lon}), radius {radius_nm} nm\nAlerts: {alert_on} | Cooldown: {cooldown}m | Channel: {channel.mention}", - color=0x00ff00, - ) - embed.add_field(name="ID", value=f"`{fence_id}`", inline=False) - await ctx.send(embed=embed) - - async def geofence_remove(self, ctx, fence_id: str): - """Remove a geo-fence alert by ID.""" - geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() - if fence_id not in geofence_alerts: - await ctx.send(f"❌ Geo-fence `{fence_id}` not found.") - return - name = geofence_alerts[fence_id].get("name", fence_id) - del geofence_alerts[fence_id] - await self.cog.config.guild(ctx.guild).geofence_alerts.set(geofence_alerts) - await ctx.send(f"βœ… Removed geo-fence **{name}** (`{fence_id}`).") - - async def geofence_list(self, ctx): - """List all geo-fence alerts for this server.""" - geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() - if not geofence_alerts: - embed = discord.Embed(title="Geo-fence alerts", description="No geo-fences configured.", color=0x00aaff) - await ctx.send(embed=embed) - return - embed = discord.Embed(title="Geo-fence alerts", description=f"**{len(geofence_alerts)}** geo-fence(s)", color=0x00aaff) - for fence_id, fence in geofence_alerts.items(): - ch = self.cog.bot.get_channel(fence.get("channel_id")) - ch_mention = ch.mention if ch else str(fence.get("channel_id")) - role_id = fence.get("role_id") - role_mention = f"<@&{role_id}>" if role_id else "β€”" - embed.add_field( - name=fence.get("name", fence_id), - value=f"**ID:** `{fence_id}`\n**Coords:** ({fence.get('lat')}, {fence.get('lon')}) {fence.get('radius_nm')} nm\n**Alert on:** {fence.get('alert_on', 'both')} | **Cooldown:** {fence.get('cooldown', 5)}m\n**Channel:** {ch_mention} | **Role:** {role_mention}", - inline=False, - ) - await ctx.send(embed=embed) \ No newline at end of file + await ctx.send(embed=embed) \ No newline at end of file diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 4cd3c14..e51f638 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -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={}, faa_alert_channel=None, faa_alert_role=None, faa_alert_cooldown=5, last_faa_status=None, faa_last_alert_time=None, geofence_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 @@ -78,7 +78,6 @@ def __init__(self, bot): self.check_emergency_squawks.start() self.check_watched_aircraft.start() self.check_faa_status_changes.start() - self.check_geofence_alerts.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() @@ -158,7 +157,6 @@ async def cog_unload(self): self.check_emergency_squawks.cancel() self.check_watched_aircraft.cancel() self.check_faa_status_changes.cancel() - self.check_geofence_alerts.cancel() await self.api.close() @commands.guild_only() @@ -261,7 +259,6 @@ async def aircraft_group(self, ctx): if await ctx.bot.is_owner(ctx.author): embed.add_field(name=_("Custom Alert Admin"), value="`forcealert` (owner) `clearalertcooldown`", inline=False) embed.add_field(name=_("Watchlist"), value="`watchlist` - Manage your personal aircraft watchlist\n`watchlist add ` - Add aircraft to watchlist\n`watchlist remove ` - Remove from watchlist\n`watchlist list` - List watched aircraft\n`watchlist status` - Get detailed status\n`watchlist cooldown [minutes]` - Set notification cooldown", inline=False) - embed.add_field(name=_("Geo-fence"), value="`geofence add` - Alert when aircraft enter/leave an area\n`geofence remove` - Remove a geo-fence\n`geofence list` - List all geo-fences", inline=False) embed.add_field(name=_("Other"), value=_("`scroll` - Scroll through available planes\n`feeder` - Parse feeder JSON data (secure modal)"), inline=False) # Only show debug command to bot owners if await ctx.bot.is_owner(ctx.author): @@ -388,38 +385,7 @@ async def aircraft_watchlist_cooldown(self, ctx, duration: str = None): """Set or view the watchlist notification cooldown. Accepts formats like '20m', '30s', '1h', or '15.5m'. Use without a value to check current setting.""" await self.aircraft_commands.watchlist_cooldown(ctx, duration) - # Geo-fence commands - @commands.guild_only() - @aircraft_group.group(name='geofence', invoke_without_command=True) - async def aircraft_geofence(self, ctx): - """Geo-fence alerts: get notified when aircraft enter or leave an area.""" - embed = discord.Embed( - title=_("Geo-fence Commands"), - description=_("Alert when aircraft enter or leave a geographic area."), - color=0xfffffe, - ) - embed.add_field(name="add", value=_("`geofence add [alert_on] [cooldown] [channel] [role]`"), inline=False) - embed.add_field(name="remove", value=_("`geofence remove `"), inline=False) - embed.add_field(name="list", value=_("`geofence list` - List all geo-fences"), inline=False) - await ctx.send(embed=embed) - - @commands.guild_only() - @aircraft_geofence.command(name='add') - async def aircraft_geofence_add(self, ctx, name: str, lat: float, lon: float, radius_nm: float, alert_on: str = "both", cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): - """Add a geo-fence. Alerts when aircraft enter/leave the area. radius_nm in nautical miles. alert_on: entry, exit, or both.""" - await self.aircraft_commands.geofence_add(ctx, name, lat, lon, radius_nm, alert_on, cooldown, channel, role) - - @commands.guild_only() - @aircraft_geofence.command(name='remove') - async def aircraft_geofence_remove(self, ctx, fence_id: str): - """Remove a geo-fence by ID (from geofence list).""" - await self.aircraft_commands.geofence_remove(ctx, fence_id) - @commands.guild_only() - @aircraft_geofence.command(name='list') - async def aircraft_geofence_list(self, ctx): - """List all geo-fence alerts for this server.""" - await self.aircraft_commands.geofence_list(ctx) @@ -972,98 +938,6 @@ async def check_faa_status_changes(self): @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_geofence_alerts(self): - """Background task to check geo-fence alerts (aircraft entering/leaving areas).""" - try: - api_mode = await self.config.api_mode() - key = "aircraft" if api_mode == "primary" else "ac" - for guild in self.bot.guilds: - await set_contextual_locales_from_guild(self.bot, guild) - guild_config = self.config.guild(guild) - geofence_alerts = await guild_config.geofence_alerts() - if not geofence_alerts: - continue - now = datetime.datetime.now(datetime.timezone.utc) - for fence_id, fence in geofence_alerts.items(): - try: - lat = fence.get("lat") - lon = fence.get("lon") - radius_nm = fence.get("radius_nm", 50) - channel_id = fence.get("channel_id") - alert_on = fence.get("alert_on", "both") # entry, exit, both - cooldown_min = fence.get("cooldown", 5) - if not channel_id or lat is None or lon is None: - continue - channel = self.bot.get_channel(channel_id) - if not channel: - continue - # Check cooldown - last_alert = fence.get("last_alert_time") - if last_alert: - last_dt = datetime.datetime.fromtimestamp(last_alert, tz=datetime.timezone.utc) - if (now - last_dt).total_seconds() < cooldown_min * 60: - continue - url = f"{await self.api.get_api_url()}/?circle={lat},{lon},{radius_nm}" - response = await self.api.make_request(url) - aircraft_list = response.get(key, []) if response else [] - current_inside = {a.get("hex", "").upper(): a for a in aircraft_list if a.get("hex") and a.get("hex") != "00000000"} - prev_inside = fence.get("aircraft_inside") or {} - if not isinstance(prev_inside, dict): - prev_inside = {k: 1 for k in prev_inside} if isinstance(prev_inside, list) else {} - # Entry: aircraft in current, not in prev - # Exit: aircraft in prev, not in current - entries = [current_inside[icao] for icao in current_inside if icao not in prev_inside] - exits = [icao for icao in prev_inside if icao not in current_inside] - role_id = fence.get("role_id") - role_mention = f"<@&{role_id}>" if role_id else "" - sent_alert = False - if entries and alert_on in ("entry", "both"): - for aircraft_info in entries: - await self._send_geofence_alert(channel, fence, aircraft_info, "entry", role_mention) - sent_alert = True - break # One alert per cycle per fence - if exits and alert_on in ("exit", "both") and not sent_alert: - # For exit we don't have full aircraft info; fetch first exited - icao_exit = exits[0] - url_hex = f"{await self.api.get_api_url()}/?find_hex={icao_exit}" - r = await self.api.make_request(url_hex) - ac_list = r.get(key, []) if r else [] - aircraft_info = ac_list[0] if ac_list else {"hex": icao_exit, "flight": "N/A", "lat": lat, "lon": lon} - await self._send_geofence_alert(channel, fence, aircraft_info, "exit", role_mention) - sent_alert = True - fence["aircraft_inside"] = {icao: 1 for icao in current_inside} - if sent_alert: - fence["last_alert_time"] = now.timestamp() - await guild_config.geofence_alerts.set(geofence_alerts) - await asyncio.sleep(1) - except Exception as e: - log.debug(f"Geofence {fence_id} error: {e}") - except Exception as e: - log.error(f"Error checking geofence alerts: {e}", exc_info=True) - - @check_geofence_alerts.before_loop - async def before_check_geofence_alerts(self): - await self.bot.wait_until_ready() - - async def _send_geofence_alert(self, channel, fence, aircraft_info, event_type, role_mention): - """Send a geo-fence alert (entry or exit).""" - fence_name = fence.get("name", "Unnamed") - image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_info) - embed = self.helpers.create_aircraft_embed(aircraft_info, image_url, photographer) - if event_type == "entry": - embed.title = f"🟒 Geo-fence: {aircraft_info.get('desc', 'Aircraft')} entered **{fence_name}**" - embed.color = 0x00ff00 - else: - embed.title = f"πŸ”΄ Geo-fence: {aircraft_info.get('desc', 'Aircraft')} left **{fence_name}**" - embed.color = 0xff4545 - icao = (aircraft_info.get("hex") or "").upper() - view = discord.ui.View() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) - allowed_mentions = discord.AllowedMentions(roles=True) if role_mention else None - await channel.send(content=role_mention or None, embed=embed, view=view, allowed_mentions=allowed_mentions) @tasks.loop(minutes=3) async def check_watched_aircraft(self): diff --git a/skysearch/utils/add_to_watchlist_view.py b/skysearch/utils/add_to_watchlist_view.py deleted file mode 100644 index 5fca523..0000000 --- a/skysearch/utils/add_to_watchlist_view.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Add to Watchlist button view for aircraft embeds -""" - -import discord -from urllib.parse import quote_plus - -from redbot.core.i18n import Translator - -_ = Translator("Skysearch", __file__) - - -class AddToWatchlistView(discord.ui.View): - """View with aircraft link buttons and Add to Watchlist button.""" - - def __init__(self, cog, aircraft_data, *, include_watchlist=True, timeout=300): - super().__init__(timeout=timeout) - self.cog = cog - self.aircraft_data = aircraft_data - icao = (aircraft_data.get("hex", "") or "").upper() - if not icao: - include_watchlist = False - - # Link buttons - link = f"https://globe.airplanes.live/?icao={icao}" - self.add_item( - discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link) - ) - - # Social media buttons - ground_speed_knots = aircraft_data.get("gs") or aircraft_data.get("ground_speed") - ground_speed_mph = "unknown" - if ground_speed_knots is not None and ground_speed_knots != "N/A": - try: - ground_speed_mph = round(float(ground_speed_knots) * 1.15078) - except (ValueError, TypeError): - pass - - lat = aircraft_data.get("lat", "N/A") - lon = aircraft_data.get("lon", "N/A") - if lat not in ("N/A", None): - try: - lat_f = round(float(lat), 2) - lat_dir = "N" if lat_f >= 0 else "S" - lat = f"{abs(lat_f)}{lat_dir}" - except (ValueError, TypeError): - pass - if lon not in ("N/A", None): - try: - lon_f = round(float(lon), 2) - lon_dir = "E" if lon_f >= 0 else "W" - lon = f"{abs(lon_f)}{lon_dir}" - except (ValueError, TypeError): - pass - - squawk_code = aircraft_data.get("squawk", "N/A") - emergency_squawk_codes = ["7500", "7600", "7700"] - if squawk_code in emergency_squawk_codes: - tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - else: - tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" - self.add_item(discord.ui.Button(label="Post on X", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) - - whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" - self.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) - - # Add to Watchlist button (interactive) - if include_watchlist and icao: - self.add_item(AddToWatchlistButton(cog=cog, icao=icao)) - - -class AddToWatchlistButton(discord.ui.Button): - """Button that adds aircraft to the user's watchlist when clicked.""" - - def __init__(self, *, cog, icao: str): - super().__init__( - label=_("Add to Watchlist"), - emoji="βž•", - style=discord.ButtonStyle.secondary, - custom_id=None, # Ephemeral views don't need custom_id - ) - self.cog = cog - self.icao = icao - - async def callback(self, interaction: discord.Interaction): - """Handle button click - add aircraft to user's watchlist.""" - user = interaction.user - user_config = self.cog.config.user(user) - - # Validate ICAO - is_valid, error_msg = self.cog.helpers.validate_icao(self.icao) - if not is_valid: - await interaction.response.send_message( - _("❌ Invalid ICAO: {error}").format(error=error_msg), - ephemeral=True, - ) - return - - watchlist = await user_config.watchlist() - - if self.icao in watchlist: - await interaction.response.send_message( - _("**{icao}** is already in your watchlist.").format(icao=self.icao), - ephemeral=True, - ) - return - - watchlist.append(self.icao) - await user_config.watchlist.set(watchlist) - - # Initialize aircraft state - aircraft_state = await user_config.watchlist_aircraft_state() - aircraft_state[self.icao] = "unknown" - await user_config.watchlist_aircraft_state.set(aircraft_state) - - await interaction.response.send_message( - _("βœ… Added **{icao}** to your watchlist. You'll be notified when it comes online, takes off, or lands.").format( - icao=self.icao - ), - ephemeral=True, - ) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 12b814f..ce47f27 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -874,20 +874,6 @@ def create_watchlist_view(self, icao): style=discord.ButtonStyle.link )) return view - - def create_aircraft_view_with_watchlist(self, aircraft_data, include_watchlist_button=True): - """ - Create a view with aircraft buttons (globe link, social sharing) plus Add to Watchlist. - - Args: - aircraft_data: Aircraft data dict (for building social links) - include_watchlist_button: If True, add interactive Add to Watchlist button - - Returns: - discord.ui.View: View with all buttons - """ - from .add_to_watchlist_view import AddToWatchlistView - return AddToWatchlistView(self.cog, aircraft_data, include_watchlist=include_watchlist_button) def extract_aircraft_status(self, aircraft_data): """ From c7633297cca3fb714685b5716f56f74b7ac4146b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:14:55 +0000 Subject: [PATCH 23/56] Update aircraft.py --- skysearch/commands/aircraft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 8c7224d..b8b103e 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -19,7 +19,7 @@ # Internationalization _ = Translator("Skysearch", __file__) - +# todo soooon @cog_i18n(_) class AircraftCommands: From c35376f4f8a1e5f922dc987d2f0bf876321b8fd2 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:18:04 +0000 Subject: [PATCH 24/56] Reapply "add geofence and add to watchlist on embeds" This reverts commit b66bd5be2492f92523358261141521cebd243395. --- skysearch/commands/aircraft.py | 174 +++++++++++------------ skysearch/skysearch.py | 128 ++++++++++++++++- skysearch/utils/add_to_watchlist_view.py | 123 ++++++++++++++++ skysearch/utils/helpers.py | 14 ++ 4 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 skysearch/utils/add_to_watchlist_view.py diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index b8b103e..a24d4b1 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -4,10 +4,9 @@ import asyncio +import datetime import json import os -from urllib.parse import quote_plus - import discord from discord.ext import commands, tasks from redbot.core import commands as red_commands @@ -43,49 +42,8 @@ async def send_aircraft_info(self, ctx, response): image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) # Create embed embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - # Create view with buttons - view = discord.ui.View() - icao = aircraft_data.get('hex', '') - if icao: - icao = icao.upper() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=f"{link}", style=discord.ButtonStyle.link)) - - # Social media sharing logic - ground_speed_knots = aircraft_data.get('gs', 'N/A') - ground_speed_mph = 'unknown' - if ground_speed_knots != 'N/A' and ground_speed_knots is not None: - try: - ground_speed_mph = round(float(ground_speed_knots) * 1.15078) - except Exception: - ground_speed_mph = 'unknown' - squawk_code = aircraft_data.get('squawk', 'N/A') - emergency_squawk_codes = ['7500', '7600', '7700'] - lat = aircraft_data.get('lat', 'N/A') - lon = aircraft_data.get('lon', 'N/A') - if lat != 'N/A' and lat is not None: - try: - lat = round(float(lat), 2) - lat_dir = "N" if lat >= 0 else "S" - lat = f"{abs(lat)}{lat_dir}" - except Exception: - pass - if lon != 'N/A' and lon is not None: - try: - lon = round(float(lon), 2) - lon_dir = "E" if lon >= 0 else "W" - lon = f"{abs(lon)}{lon_dir}" - except Exception: - pass - if squawk_code in emergency_squawk_codes: - tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - else: - tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" - view.add_item(discord.ui.Button(label=f"Post on 𝕏", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) - whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" - view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + # Create view with buttons including Add to Watchlist + view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) await ctx.send(embed=embed, view=view) else: @@ -632,13 +590,8 @@ async def closest_aircraft(self, ctx, lat: str, lon: str, radius: str = "100"): embed.set_thumbnail(url="https://www.beehive.systems/hubfs/Icon%20Packs/White/airplane.png") embed.set_footer(text="No photo available") - # Create view with buttons - view = discord.ui.View() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) - - # Add tracking button - view.add_item(discord.ui.Button(label="Track Live", emoji="✈️", url=link, style=discord.ButtonStyle.link)) + # Create view with buttons including Add to Watchlist + view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) await ctx.send(embed=embed, view=view) @@ -728,48 +681,9 @@ async def _get_aircraft_embed_and_view(self, ctx, response): aircraft_list = response.get('aircraft') or response.get('ac') if aircraft_list: aircraft_data = aircraft_list[0] - icao = aircraft_data.get('hex', None) - if icao: - icao = icao.upper() image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - view = discord.ui.View() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=f"{link}", style=discord.ButtonStyle.link)) - ground_speed_knots = aircraft_data.get('gs', 'N/A') - ground_speed_mph = 'unknown' - if ground_speed_knots != 'N/A' and ground_speed_knots is not None: - try: - ground_speed_mph = round(float(ground_speed_knots) * 1.15078) - except Exception: - ground_speed_mph = 'unknown' - squawk_code = aircraft_data.get('squawk', 'N/A') - emergency_squawk_codes = ['7500', '7600', '7700'] - lat = aircraft_data.get('lat', 'N/A') - lon = aircraft_data.get('lon', 'N/A') - if lat != 'N/A' and lat is not None: - try: - lat = round(float(lat), 2) - lat_dir = "N" if lat >= 0 else "S" - lat = f"{abs(lat)}{lat_dir}" - except Exception: - pass - if lon != 'N/A' and lon is not None: - try: - lon = round(float(lon), 2) - lon_dir = "E" if lon >= 0 else "W" - lon = f"{abs(lon)}{lon_dir}" - except Exception: - pass - if squawk_code in emergency_squawk_codes: - tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - else: - tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" - tweet_url = f"https://twitter.com/intent/tweet?text={quote_plus(tweet_text)}" - view.add_item(discord.ui.Button(label=f"Post on 𝕏", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) - whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" - view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + view = self.helpers.create_aircraft_view_with_watchlist(aircraft_data) return embed, view else: embed = discord.Embed(title='No results found for your query', color=discord.Colour(0xff4545)) @@ -1219,4 +1133,78 @@ async def watchlist_cooldown(self, ctx, duration: str = None): description=_("Invalid duration format. Use a number (e.g. '20'), minutes ('20m'), seconds ('30s'), or hours ('1h').\n\nExamples:\nβ€’ `20m` - 20 minutes\nβ€’ `30s` - 30 seconds\nβ€’ `1h` - 1 hour\nβ€’ `15.5m` - 15.5 minutes"), color=0xff4545 ) - await ctx.send(embed=embed) \ No newline at end of file + await ctx.send(embed=embed) + + # Geo-fence commands + async def geofence_add(self, ctx, name: str, lat: float, lon: float, radius_nm: float, alert_on: str = "both", cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): + """Add a geo-fence alert. Notify when aircraft enter and/or leave the area.""" + if alert_on.lower() not in ("entry", "exit", "both"): + embed = discord.Embed(title="❌ Invalid alert_on", description="Use: entry, exit, or both", color=0xff0000) + await ctx.send(embed=embed) + return + if radius_nm <= 0 or radius_nm > 500: + embed = discord.Embed(title="❌ Invalid radius", description="Radius must be 0-500 nautical miles.", color=0xff0000) + await ctx.send(embed=embed) + return + if cooldown < 1 or cooldown > 1440: + embed = discord.Embed(title="❌ Invalid cooldown", description="Cooldown must be 1-1440 minutes.", color=0xff0000) + await ctx.send(embed=embed) + return + channel = channel or self.cog.bot.get_channel(await self.cog.config.guild(ctx.guild).alert_channel()) + if not channel: + embed = discord.Embed(title="❌ No channel", description="Set alert channel or pass a channel.", color=0xff0000) + await ctx.send(embed=embed) + return + fence_id = f"geofence_{name.lower().replace(' ', '_')}_{int(datetime.datetime.utcnow().timestamp())}" + geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() + geofence_alerts[fence_id] = { + "name": name, + "lat": lat, + "lon": lon, + "radius_nm": radius_nm, + "alert_on": alert_on.lower(), + "cooldown": cooldown, + "channel_id": channel.id, + "role_id": role.id if role else None, + "aircraft_inside": {}, + "last_alert_time": None, + } + await self.cog.config.guild(ctx.guild).geofence_alerts.set(geofence_alerts) + embed = discord.Embed( + title="βœ… Geo-fence added", + description=f"**{name}** at ({lat}, {lon}), radius {radius_nm} nm\nAlerts: {alert_on} | Cooldown: {cooldown}m | Channel: {channel.mention}", + color=0x00ff00, + ) + embed.add_field(name="ID", value=f"`{fence_id}`", inline=False) + await ctx.send(embed=embed) + + async def geofence_remove(self, ctx, fence_id: str): + """Remove a geo-fence alert by ID.""" + geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() + if fence_id not in geofence_alerts: + await ctx.send(f"❌ Geo-fence `{fence_id}` not found.") + return + name = geofence_alerts[fence_id].get("name", fence_id) + del geofence_alerts[fence_id] + await self.cog.config.guild(ctx.guild).geofence_alerts.set(geofence_alerts) + await ctx.send(f"βœ… Removed geo-fence **{name}** (`{fence_id}`).") + + async def geofence_list(self, ctx): + """List all geo-fence alerts for this server.""" + geofence_alerts = await self.cog.config.guild(ctx.guild).geofence_alerts() + if not geofence_alerts: + embed = discord.Embed(title="Geo-fence alerts", description="No geo-fences configured.", color=0x00aaff) + await ctx.send(embed=embed) + return + embed = discord.Embed(title="Geo-fence alerts", description=f"**{len(geofence_alerts)}** geo-fence(s)", color=0x00aaff) + for fence_id, fence in geofence_alerts.items(): + ch = self.cog.bot.get_channel(fence.get("channel_id")) + ch_mention = ch.mention if ch else str(fence.get("channel_id")) + role_id = fence.get("role_id") + role_mention = f"<@&{role_id}>" if role_id else "β€”" + embed.add_field( + name=fence.get("name", fence_id), + value=f"**ID:** `{fence_id}`\n**Coords:** ({fence.get('lat')}, {fence.get('lon')}) {fence.get('radius_nm')} nm\n**Alert on:** {fence.get('alert_on', 'both')} | **Cooldown:** {fence.get('cooldown', 5)}m\n**Channel:** {ch_mention} | **Role:** {role_mention}", + inline=False, + ) + await ctx.send(embed=embed) \ No newline at end of file diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index e51f638..4cd3c14 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -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={}, faa_alert_channel=None, faa_alert_role=None, faa_alert_cooldown=5, last_faa_status=None, faa_last_alert_time=None) + 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, geofence_alerts={}) 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 @@ -78,6 +78,7 @@ def __init__(self, bot): self.check_emergency_squawks.start() self.check_watched_aircraft.start() self.check_faa_status_changes.start() + self.check_geofence_alerts.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() @@ -157,6 +158,7 @@ async def cog_unload(self): self.check_emergency_squawks.cancel() self.check_watched_aircraft.cancel() self.check_faa_status_changes.cancel() + self.check_geofence_alerts.cancel() await self.api.close() @commands.guild_only() @@ -259,6 +261,7 @@ async def aircraft_group(self, ctx): if await ctx.bot.is_owner(ctx.author): embed.add_field(name=_("Custom Alert Admin"), value="`forcealert` (owner) `clearalertcooldown`", inline=False) embed.add_field(name=_("Watchlist"), value="`watchlist` - Manage your personal aircraft watchlist\n`watchlist add ` - Add aircraft to watchlist\n`watchlist remove ` - Remove from watchlist\n`watchlist list` - List watched aircraft\n`watchlist status` - Get detailed status\n`watchlist cooldown [minutes]` - Set notification cooldown", inline=False) + embed.add_field(name=_("Geo-fence"), value="`geofence add` - Alert when aircraft enter/leave an area\n`geofence remove` - Remove a geo-fence\n`geofence list` - List all geo-fences", inline=False) embed.add_field(name=_("Other"), value=_("`scroll` - Scroll through available planes\n`feeder` - Parse feeder JSON data (secure modal)"), inline=False) # Only show debug command to bot owners if await ctx.bot.is_owner(ctx.author): @@ -385,7 +388,38 @@ async def aircraft_watchlist_cooldown(self, ctx, duration: str = None): """Set or view the watchlist notification cooldown. Accepts formats like '20m', '30s', '1h', or '15.5m'. Use without a value to check current setting.""" await self.aircraft_commands.watchlist_cooldown(ctx, duration) + # Geo-fence commands + @commands.guild_only() + @aircraft_group.group(name='geofence', invoke_without_command=True) + async def aircraft_geofence(self, ctx): + """Geo-fence alerts: get notified when aircraft enter or leave an area.""" + embed = discord.Embed( + title=_("Geo-fence Commands"), + description=_("Alert when aircraft enter or leave a geographic area."), + color=0xfffffe, + ) + embed.add_field(name="add", value=_("`geofence add [alert_on] [cooldown] [channel] [role]`"), inline=False) + embed.add_field(name="remove", value=_("`geofence remove `"), inline=False) + embed.add_field(name="list", value=_("`geofence list` - List all geo-fences"), inline=False) + await ctx.send(embed=embed) + + @commands.guild_only() + @aircraft_geofence.command(name='add') + async def aircraft_geofence_add(self, ctx, name: str, lat: float, lon: float, radius_nm: float, alert_on: str = "both", cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): + """Add a geo-fence. Alerts when aircraft enter/leave the area. radius_nm in nautical miles. alert_on: entry, exit, or both.""" + await self.aircraft_commands.geofence_add(ctx, name, lat, lon, radius_nm, alert_on, cooldown, channel, role) + + @commands.guild_only() + @aircraft_geofence.command(name='remove') + async def aircraft_geofence_remove(self, ctx, fence_id: str): + """Remove a geo-fence by ID (from geofence list).""" + await self.aircraft_commands.geofence_remove(ctx, fence_id) + @commands.guild_only() + @aircraft_geofence.command(name='list') + async def aircraft_geofence_list(self, ctx): + """List all geo-fence alerts for this server.""" + await self.aircraft_commands.geofence_list(ctx) @@ -938,6 +972,98 @@ async def check_faa_status_changes(self): @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_geofence_alerts(self): + """Background task to check geo-fence alerts (aircraft entering/leaving areas).""" + try: + api_mode = await self.config.api_mode() + key = "aircraft" if api_mode == "primary" else "ac" + for guild in self.bot.guilds: + await set_contextual_locales_from_guild(self.bot, guild) + guild_config = self.config.guild(guild) + geofence_alerts = await guild_config.geofence_alerts() + if not geofence_alerts: + continue + now = datetime.datetime.now(datetime.timezone.utc) + for fence_id, fence in geofence_alerts.items(): + try: + lat = fence.get("lat") + lon = fence.get("lon") + radius_nm = fence.get("radius_nm", 50) + channel_id = fence.get("channel_id") + alert_on = fence.get("alert_on", "both") # entry, exit, both + cooldown_min = fence.get("cooldown", 5) + if not channel_id or lat is None or lon is None: + continue + channel = self.bot.get_channel(channel_id) + if not channel: + continue + # Check cooldown + last_alert = fence.get("last_alert_time") + if last_alert: + last_dt = datetime.datetime.fromtimestamp(last_alert, tz=datetime.timezone.utc) + if (now - last_dt).total_seconds() < cooldown_min * 60: + continue + url = f"{await self.api.get_api_url()}/?circle={lat},{lon},{radius_nm}" + response = await self.api.make_request(url) + aircraft_list = response.get(key, []) if response else [] + current_inside = {a.get("hex", "").upper(): a for a in aircraft_list if a.get("hex") and a.get("hex") != "00000000"} + prev_inside = fence.get("aircraft_inside") or {} + if not isinstance(prev_inside, dict): + prev_inside = {k: 1 for k in prev_inside} if isinstance(prev_inside, list) else {} + # Entry: aircraft in current, not in prev + # Exit: aircraft in prev, not in current + entries = [current_inside[icao] for icao in current_inside if icao not in prev_inside] + exits = [icao for icao in prev_inside if icao not in current_inside] + role_id = fence.get("role_id") + role_mention = f"<@&{role_id}>" if role_id else "" + sent_alert = False + if entries and alert_on in ("entry", "both"): + for aircraft_info in entries: + await self._send_geofence_alert(channel, fence, aircraft_info, "entry", role_mention) + sent_alert = True + break # One alert per cycle per fence + if exits and alert_on in ("exit", "both") and not sent_alert: + # For exit we don't have full aircraft info; fetch first exited + icao_exit = exits[0] + url_hex = f"{await self.api.get_api_url()}/?find_hex={icao_exit}" + r = await self.api.make_request(url_hex) + ac_list = r.get(key, []) if r else [] + aircraft_info = ac_list[0] if ac_list else {"hex": icao_exit, "flight": "N/A", "lat": lat, "lon": lon} + await self._send_geofence_alert(channel, fence, aircraft_info, "exit", role_mention) + sent_alert = True + fence["aircraft_inside"] = {icao: 1 for icao in current_inside} + if sent_alert: + fence["last_alert_time"] = now.timestamp() + await guild_config.geofence_alerts.set(geofence_alerts) + await asyncio.sleep(1) + except Exception as e: + log.debug(f"Geofence {fence_id} error: {e}") + except Exception as e: + log.error(f"Error checking geofence alerts: {e}", exc_info=True) + + @check_geofence_alerts.before_loop + async def before_check_geofence_alerts(self): + await self.bot.wait_until_ready() + + async def _send_geofence_alert(self, channel, fence, aircraft_info, event_type, role_mention): + """Send a geo-fence alert (entry or exit).""" + fence_name = fence.get("name", "Unnamed") + image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_info) + embed = self.helpers.create_aircraft_embed(aircraft_info, image_url, photographer) + if event_type == "entry": + embed.title = f"🟒 Geo-fence: {aircraft_info.get('desc', 'Aircraft')} entered **{fence_name}**" + embed.color = 0x00ff00 + else: + embed.title = f"πŸ”΄ Geo-fence: {aircraft_info.get('desc', 'Aircraft')} left **{fence_name}**" + embed.color = 0xff4545 + icao = (aircraft_info.get("hex") or "").upper() + view = discord.ui.View() + link = f"https://globe.airplanes.live/?icao={icao}" + view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) + allowed_mentions = discord.AllowedMentions(roles=True) if role_mention else None + await channel.send(content=role_mention or None, embed=embed, view=view, allowed_mentions=allowed_mentions) @tasks.loop(minutes=3) async def check_watched_aircraft(self): diff --git a/skysearch/utils/add_to_watchlist_view.py b/skysearch/utils/add_to_watchlist_view.py new file mode 100644 index 0000000..5fca523 --- /dev/null +++ b/skysearch/utils/add_to_watchlist_view.py @@ -0,0 +1,123 @@ +""" +Add to Watchlist button view for aircraft embeds +""" + +import discord +from urllib.parse import quote_plus + +from redbot.core.i18n import Translator + +_ = Translator("Skysearch", __file__) + + +class AddToWatchlistView(discord.ui.View): + """View with aircraft link buttons and Add to Watchlist button.""" + + def __init__(self, cog, aircraft_data, *, include_watchlist=True, timeout=300): + super().__init__(timeout=timeout) + self.cog = cog + self.aircraft_data = aircraft_data + icao = (aircraft_data.get("hex", "") or "").upper() + if not icao: + include_watchlist = False + + # Link buttons + link = f"https://globe.airplanes.live/?icao={icao}" + self.add_item( + discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link) + ) + + # Social media buttons + ground_speed_knots = aircraft_data.get("gs") or aircraft_data.get("ground_speed") + ground_speed_mph = "unknown" + if ground_speed_knots is not None and ground_speed_knots != "N/A": + try: + ground_speed_mph = round(float(ground_speed_knots) * 1.15078) + except (ValueError, TypeError): + pass + + lat = aircraft_data.get("lat", "N/A") + lon = aircraft_data.get("lon", "N/A") + if lat not in ("N/A", None): + try: + lat_f = round(float(lat), 2) + lat_dir = "N" if lat_f >= 0 else "S" + lat = f"{abs(lat_f)}{lat_dir}" + except (ValueError, TypeError): + pass + if lon not in ("N/A", None): + try: + lon_f = round(float(lon), 2) + lon_dir = "E" if lon_f >= 0 else "W" + lon = f"{abs(lon_f)}{lon_dir}" + except (ValueError, TypeError): + pass + + squawk_code = aircraft_data.get("squawk", "N/A") + emergency_squawk_codes = ["7500", "7600", "7700"] + if squawk_code in emergency_squawk_codes: + tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + else: + tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - https://discord.gg/X8huyaeXrA" + tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" + self.add_item(discord.ui.Button(label="Post on X", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) + + whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" + whatsapp_url = f"https://api.whatsapp.com/send?text={quote_plus(whatsapp_text)}" + self.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + + # Add to Watchlist button (interactive) + if include_watchlist and icao: + self.add_item(AddToWatchlistButton(cog=cog, icao=icao)) + + +class AddToWatchlistButton(discord.ui.Button): + """Button that adds aircraft to the user's watchlist when clicked.""" + + def __init__(self, *, cog, icao: str): + super().__init__( + label=_("Add to Watchlist"), + emoji="βž•", + style=discord.ButtonStyle.secondary, + custom_id=None, # Ephemeral views don't need custom_id + ) + self.cog = cog + self.icao = icao + + async def callback(self, interaction: discord.Interaction): + """Handle button click - add aircraft to user's watchlist.""" + user = interaction.user + user_config = self.cog.config.user(user) + + # Validate ICAO + is_valid, error_msg = self.cog.helpers.validate_icao(self.icao) + if not is_valid: + await interaction.response.send_message( + _("❌ Invalid ICAO: {error}").format(error=error_msg), + ephemeral=True, + ) + return + + watchlist = await user_config.watchlist() + + if self.icao in watchlist: + await interaction.response.send_message( + _("**{icao}** is already in your watchlist.").format(icao=self.icao), + ephemeral=True, + ) + return + + watchlist.append(self.icao) + await user_config.watchlist.set(watchlist) + + # Initialize aircraft state + aircraft_state = await user_config.watchlist_aircraft_state() + aircraft_state[self.icao] = "unknown" + await user_config.watchlist_aircraft_state.set(aircraft_state) + + await interaction.response.send_message( + _("βœ… Added **{icao}** to your watchlist. You'll be notified when it comes online, takes off, or lands.").format( + icao=self.icao + ), + ephemeral=True, + ) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index ce47f27..12b814f 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -874,6 +874,20 @@ def create_watchlist_view(self, icao): style=discord.ButtonStyle.link )) return view + + def create_aircraft_view_with_watchlist(self, aircraft_data, include_watchlist_button=True): + """ + Create a view with aircraft buttons (globe link, social sharing) plus Add to Watchlist. + + Args: + aircraft_data: Aircraft data dict (for building social links) + include_watchlist_button: If True, add interactive Add to Watchlist button + + Returns: + discord.ui.View: View with all buttons + """ + from .add_to_watchlist_view import AddToWatchlistView + return AddToWatchlistView(self.cog, aircraft_data, include_watchlist=include_watchlist_button) def extract_aircraft_status(self, aircraft_data): """ From 273838dae7842f19e0db3707f48956028130a36e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:18:07 +0000 Subject: [PATCH 25/56] Reapply "Update aircraft.py" This reverts commit 1e5929ea1000d72ebb44f48d5f093a90fccad78a. --- skysearch/commands/aircraft.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index a24d4b1..6244978 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -117,11 +117,16 @@ async def aircraft_by_icao(self, ctx, hex_id: str): key = 'aircraft' if api_mode == 'primary' else 'ac' aircraft_list = response.get(key) if response else None if aircraft_list and len(aircraft_list) > 0: - if len(aircraft_list) > 1: - for aircraft_info in aircraft_list: - await self.send_aircraft_info(ctx, {key: [aircraft_info]}) - else: - await self.send_aircraft_info(ctx, {key: aircraft_list}) + # Deduplicate by hex - API may return same aircraft multiple times + seen_hex = set() + unique_list = [] + for ac in aircraft_list: + h = (ac.get("hex") or "").upper() + if h and h != "00000000" and h not in seen_hex: + seen_hex.add(h) + unique_list.append(ac) + if unique_list: + await self.send_aircraft_info(ctx, {key: unique_list}) else: embed = discord.Embed(title=_("No results found for your query"), color=discord.Colour(0xff4545)) embed.add_field(name=_("Details"), value=_("No aircraft information found or the response format is incorrect."), inline=False) From 6987d32b0a8034c2ad0dba2b54bcea06a2d8b84c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:36:52 +0000 Subject: [PATCH 26/56] Update skysearch.py --- skysearch/skysearch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 4cd3c14..50874d3 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -1534,6 +1534,10 @@ async def on_message(self, message): await set_contextual_locales_from_guild(self.bot, message.guild) ctx = await self.bot.get_context(message) + # If the message was recognized as a valid bot command, the command system + # will handle it β€” don't also run the auto-ICAO lookup or the embed sends twice. + if ctx.valid: + return await self.aircraft_commands.aircraft_by_icao(ctx, content) @commands.is_owner() From a26725b1a61ddeca1b80c9c532ae71dfbc91a124 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:40:57 +0000 Subject: [PATCH 27/56] Update add_to_watchlist_view.py --- skysearch/utils/add_to_watchlist_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/skysearch/utils/add_to_watchlist_view.py b/skysearch/utils/add_to_watchlist_view.py index 5fca523..f978fd7 100644 --- a/skysearch/utils/add_to_watchlist_view.py +++ b/skysearch/utils/add_to_watchlist_view.py @@ -79,7 +79,6 @@ def __init__(self, *, cog, icao: str): label=_("Add to Watchlist"), emoji="βž•", style=discord.ButtonStyle.secondary, - custom_id=None, # Ephemeral views don't need custom_id ) self.cog = cog self.icao = icao From 9e12c1f00e79721fd1548e39165a32adad205134 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:42:32 +0000 Subject: [PATCH 28/56] Update aircraft.py --- skysearch/commands/aircraft.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 6244978..0a870c8 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -33,10 +33,21 @@ def __init__(self, cog): async def send_aircraft_info(self, ctx, response): """Send aircraft information as an embed.""" + # Deduplicate: if we already sent a response for this message, don't send again + msg_id = ctx.message.id if ctx.message else None + if msg_id: + if not hasattr(self, '_sent_message_ids'): + self._sent_message_ids = set() + if msg_id in self._sent_message_ids: + return + self._sent_message_ids.add(msg_id) + # Keep the set from growing unbounded + if len(self._sent_message_ids) > 1000: + self._sent_message_ids.clear() + # Support both 'aircraft' and 'ac' keys aircraft_list = response.get('aircraft') or response.get('ac') if aircraft_list: - await ctx.typing() aircraft_data = aircraft_list[0] # Get photo for the aircraft using full aircraft data image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) From 997232e95094d414d278dafbf3f6beb21434cdc8 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:46:28 +0000 Subject: [PATCH 29/56] back to working --- skysearch/commands/aircraft.py | 30 ++++++------------------ skysearch/skysearch.py | 4 ---- skysearch/utils/add_to_watchlist_view.py | 1 + 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 0a870c8..ce51a4a 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -18,7 +18,7 @@ # Internationalization _ = Translator("Skysearch", __file__) -# todo soooon + @cog_i18n(_) class AircraftCommands: @@ -33,21 +33,10 @@ def __init__(self, cog): async def send_aircraft_info(self, ctx, response): """Send aircraft information as an embed.""" - # Deduplicate: if we already sent a response for this message, don't send again - msg_id = ctx.message.id if ctx.message else None - if msg_id: - if not hasattr(self, '_sent_message_ids'): - self._sent_message_ids = set() - if msg_id in self._sent_message_ids: - return - self._sent_message_ids.add(msg_id) - # Keep the set from growing unbounded - if len(self._sent_message_ids) > 1000: - self._sent_message_ids.clear() - # Support both 'aircraft' and 'ac' keys aircraft_list = response.get('aircraft') or response.get('ac') if aircraft_list: + await ctx.typing() aircraft_data = aircraft_list[0] # Get photo for the aircraft using full aircraft data image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) @@ -128,16 +117,11 @@ async def aircraft_by_icao(self, ctx, hex_id: str): key = 'aircraft' if api_mode == 'primary' else 'ac' aircraft_list = response.get(key) if response else None if aircraft_list and len(aircraft_list) > 0: - # Deduplicate by hex - API may return same aircraft multiple times - seen_hex = set() - unique_list = [] - for ac in aircraft_list: - h = (ac.get("hex") or "").upper() - if h and h != "00000000" and h not in seen_hex: - seen_hex.add(h) - unique_list.append(ac) - if unique_list: - await self.send_aircraft_info(ctx, {key: unique_list}) + if len(aircraft_list) > 1: + for aircraft_info in aircraft_list: + await self.send_aircraft_info(ctx, {key: [aircraft_info]}) + else: + await self.send_aircraft_info(ctx, {key: aircraft_list}) else: embed = discord.Embed(title=_("No results found for your query"), color=discord.Colour(0xff4545)) embed.add_field(name=_("Details"), value=_("No aircraft information found or the response format is incorrect."), inline=False) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 50874d3..4cd3c14 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -1534,10 +1534,6 @@ async def on_message(self, message): await set_contextual_locales_from_guild(self.bot, message.guild) ctx = await self.bot.get_context(message) - # If the message was recognized as a valid bot command, the command system - # will handle it β€” don't also run the auto-ICAO lookup or the embed sends twice. - if ctx.valid: - return await self.aircraft_commands.aircraft_by_icao(ctx, content) @commands.is_owner() diff --git a/skysearch/utils/add_to_watchlist_view.py b/skysearch/utils/add_to_watchlist_view.py index f978fd7..5fca523 100644 --- a/skysearch/utils/add_to_watchlist_view.py +++ b/skysearch/utils/add_to_watchlist_view.py @@ -79,6 +79,7 @@ def __init__(self, *, cog, icao: str): label=_("Add to Watchlist"), emoji="βž•", style=discord.ButtonStyle.secondary, + custom_id=None, # Ephemeral views don't need custom_id ) self.cog = cog self.icao = icao From b033b611b372df7b55aa52700c37d0334edf30a6 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:28:20 +0000 Subject: [PATCH 30/56] Update check-cogs.yml --- .github/workflows/check-cogs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml index ed8c11e..bbb5e6c 100644 --- a/.github/workflows/check-cogs.yml +++ b/.github/workflows/check-cogs.yml @@ -9,6 +9,9 @@ name: "Check Cogs" on: pull_request: +permissions: + contents: read + env: BUILD_ARTIFACT_NAME: "my-build-artifact" COG_PATHS: "ampremover,bell,bible,currency,dank,earthquake,emojilink,enumbers,example,facebookdownloader,imgen,invoice,loan,scp,seasons,servertools,skysearch,spamatron,talknotifier,xkcd" # comma-separated list of cog folder names From c33b2b9f80cbfda00e998950561b57759390774f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:38:43 +0000 Subject: [PATCH 31/56] avwx maybe --- skysearch/README.md | 7 + skysearch/commands/admin.py | 21 +++ skysearch/commands/airport.py | 337 +++++++++++++++++++++++++++++++++- skysearch/skysearch.py | 36 +++- skysearch/utils/api.py | 82 ++++++++- 5 files changed, 478 insertions(+), 5 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index 2e781ea..0880930 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -11,6 +11,7 @@ To use the SkySearch cog, follow these steps: 2. **Configure API Keys** : - Set up airplanes.live API key: `[p]setapikey ` + - Optional: Set up AVWX token for aviation weather: `[p]airport setavwxtoken ` - Optional: Set a custom User-Agent for outbound HTTP (useful for APIs that require it): `[p]setuseragent ` - Optional: Configure Google Maps API for airport imagery - Optional: Configure OpenAI API for airport summaries @@ -60,6 +61,9 @@ 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 avwx ` - Get AVWX aviation weather overview with refresh/select controls +- `[p]airport metar ` - Get current AVWX METAR +- `[p]airport taf ` - Get current AVWX TAF - `[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). @@ -91,6 +95,9 @@ Notes: - `[p]aircraft setuseragent ` - Set a custom User-Agent header for outbound HTTP requests - `[p]aircraft useragent` - Show current User-Agent setting - `[p]aircraft clearuseragent` - Clear User-Agent setting (use aiohttp default) +- `[p]airport setavwxtoken ` - Set AVWX API token +- `[p]airport avwxtoken` - Check AVWX token status +- `[p]airport clearavwxtoken` - Clear AVWX token ### API Monitoring Commands - `[p]skysearch apistats` - View comprehensive API request statistics and performance metrics diff --git a/skysearch/commands/admin.py b/skysearch/commands/admin.py index fb666ff..ef48daa 100644 --- a/skysearch/commands/admin.py +++ b/skysearch/commands/admin.py @@ -617,6 +617,27 @@ async def clear_owm_key(self, ctx): await self.cog.config.openweathermap_api.set(None) await ctx.send("OpenWeatherMap API key cleared.") + async def set_avwx_token(self, ctx, token: str): + """Set the AVWX API token.""" + await self.cog.config.avwx_token.set(token) + embed = discord.Embed(title="AVWX Token Updated", description="The AVWX API token has been set successfully.", color=0x2BBD8E) + embed.add_field(name="Status", value="βœ… AVWX token configured", inline=True) + await ctx.send(embed=embed) + + async def check_avwx_token(self, ctx): + """Show the current AVWX API token status.""" + token = await self.cog.config.avwx_token() + if token: + masked = f"{token[:4]}{'*' * (len(token) - 8)}{token[-4:]}" if len(token) > 8 else token + await ctx.send(f"AVWX API token: `{masked}`") + else: + await ctx.send("No AVWX API token set.") + + async def clear_avwx_token(self, ctx): + """Clear the AVWX API token.""" + await self.cog.config.avwx_token.set(None) + await ctx.send("AVWX API token cleared.") + async def apistats(self, ctx): """Show comprehensive API request statistics and charts.""" await self.cog.api.wait_for_stats_initialization() diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 49d86c9..38bfe6d 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -7,6 +7,7 @@ import asyncio import re from datetime import datetime +from typing import Optional from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from ..utils.api import APIManager @@ -108,7 +109,11 @@ def __init__(self, ground_delays, arrival_departure_delays, closures, update_tim async def on_select(self, interaction: discord.Interaction): """Handle dropdown selection.""" - selected = interaction.data['values'][0] + values = interaction.data.get('values') if isinstance(interaction.data, dict) else None + if not values: + await interaction.response.defer() + return + selected = values[0] self.current_filter = selected # Update default option @@ -234,6 +239,279 @@ def build_embed(self, filter_type="all"): return embed +class AVWXRefreshButton(discord.ui.Button): + """Button to refresh AVWX data.""" + + def __init__(self): + super().__init__(style=discord.ButtonStyle.secondary, label="Refresh", emoji="πŸ”„", custom_id="avwx_refresh") + + async def callback(self, interaction: discord.Interaction): + view = self.view + if not isinstance(view, AVWXWeatherView) or view.airport_commands is None: + await interaction.response.defer() + return + await interaction.response.defer() + reports, error = await view.airport_commands._avwx_fetch_bundle(view.station_code) + if error: + await interaction.followup.send(error, ephemeral=True) + return + view.report_data = reports + embed = view.build_embed(view.current_filter) + await interaction.edit_original_response(embed=embed, view=view) + + +class AVWXWeatherView(discord.ui.View): + """View for switching between AVWX overview, METAR, and TAF.""" + + def __init__(self, station_code, report_data, airport_commands=None, initial_filter="overview"): + super().__init__(timeout=600) + self.station_code = station_code.upper() + self.report_data = report_data + self.airport_commands = airport_commands + self.current_filter = initial_filter + + options = [ + discord.SelectOption(label="Overview", value="overview", description="Show combined aviation weather overview", emoji="🧭", default=initial_filter == "overview"), + discord.SelectOption(label="METAR", value="metar", description="Show current observation details", emoji="🌀️", default=initial_filter == "metar"), + discord.SelectOption(label="TAF", value="taf", description="Show current terminal forecast", emoji="πŸ“ˆ", default=initial_filter == "taf"), + ] + + self.select_menu = discord.ui.Select( + placeholder="Select AVWX report view...", + options=options, + min_values=1, + max_values=1, + ) + self.select_menu.callback = self.on_select + self.add_item(self.select_menu) + self.add_item(AVWXRefreshButton()) + + async def on_select(self, interaction: discord.Interaction): + values = interaction.data.get("values") if isinstance(interaction.data, dict) else None + if not values: + await interaction.response.defer() + return + selected = values[0] + self.current_filter = selected + for option in self.select_menu.options: + option.default = option.value == selected + embed = self.build_embed(selected) + await interaction.response.edit_message(embed=embed, view=self) + + def build_embed(self, filter_type="overview"): + report_data = self.report_data or {} + metar = report_data.get("metar") or {} + taf = report_data.get("taf") or {} + summary = report_data.get("summary") or {} + info = (metar.get("info") or taf.get("info") or summary.get("info") or {}) + + station_name = info.get("name") or self.station_code + title_prefix = { + "overview": "AVWX Overview", + "metar": "AVWX METAR", + "taf": "AVWX TAF", + }.get(filter_type, "AVWX Weather") + embed = discord.Embed(title=f"{title_prefix} - {self.station_code}", color=0xfffffe) + + location_bits = [info.get("city"), info.get("country")] + location = ", ".join(part for part in location_bits if part) + description_parts = [] + if station_name and station_name != self.station_code: + description_parts.append(f"**{station_name}**") + if location: + description_parts.append(location) + if description_parts: + embed.description = "\n".join(description_parts) + + meta = metar.get("meta") or taf.get("meta") or summary.get("meta") or {} + timestamp = meta.get("timestamp") + if timestamp: + embed.set_footer(text=f"AVWX data refreshed: {timestamp}") + + if filter_type == "overview": + self._add_overview_fields(embed, summary, metar, taf) + elif filter_type == "metar": + self._add_metar_fields(embed, metar) + else: + self._add_taf_fields(embed, taf) + + return embed + + def _add_overview_fields(self, embed: discord.Embed, summary: dict, metar: dict, taf: dict): + summary_metar = summary.get("metar") or {} + summary_taf = summary.get("taf") or {} + + rules = summary_metar.get("flight_rules") or metar.get("flight_rules") or "Unknown" + visibility = (summary_metar.get("visibility") or {}).get("repr") or (metar.get("visibility") or {}).get("repr") or "Unknown" + wx_codes = summary_metar.get("wx_codes") or metar.get("wx_codes") or [] + wx_parts = [ + str(code.get("value") or code.get("repr")) + for code in wx_codes + if isinstance(code, dict) and (code.get("value") or code.get("repr")) + ] + wx_text = ", ".join(wx_parts) if wx_parts else "None" + embed.add_field(name="Current Conditions", value=f"**Flight Rules:** {rules}\n**Visibility:** {visibility}\n**Weather:** {wx_text}", inline=False) + + metar_summary = metar.get("summary") or self._truncate_report(metar.get("raw"), 500) + if metar_summary: + embed.add_field(name="METAR Summary", value=self._truncate_report(metar_summary, 1024), inline=False) + + taf_periods = summary_taf.get("forecast") or [] + if taf_periods: + period_lines = [] + for period in taf_periods[:4]: + start = self._short_dt(period.get("start_time")) + end = self._short_dt(period.get("end_time")) + rules = period.get("flight_rules") or "Unknown" + period_lines.append(f"`{start}` β†’ `{end}`: **{rules}**") + embed.add_field(name="TAF Trend", value="\n".join(period_lines), inline=False) + + raw_taf = taf.get("raw") + if raw_taf: + embed.add_field(name="Raw TAF", value=self._truncate_report(raw_taf, 1024), inline=False) + + def _add_metar_fields(self, embed: discord.Embed, metar: dict): + if not metar: + embed.description = "No METAR data available for this station." + return + + rules = metar.get("flight_rules") or "Unknown" + wind = self._format_wind(metar) + visibility = self._format_visibility(metar) + temperature = self._format_temperature(metar) + altimeter = self._format_altimeter(metar) + clouds = self._format_clouds(metar.get("clouds") or []) + wx_codes = self._format_wx_codes(metar.get("wx_codes") or []) + + embed.add_field(name="Observation", value=f"**Flight Rules:** {rules}\n**Wind:** {wind}\n**Visibility:** {visibility}\n**Altimeter:** {altimeter}", inline=False) + embed.add_field(name="Temperature", value=temperature, inline=True) + embed.add_field(name="Weather", value=wx_codes, inline=True) + embed.add_field(name="Clouds", value=clouds, inline=False) + + report_time = self._short_dt((metar.get("time") or {}).get("dt")) + if report_time: + embed.add_field(name="Observed", value=report_time, inline=True) + + summary = metar.get("summary") + if summary: + embed.add_field(name="Summary", value=self._truncate_report(summary, 1024), inline=False) + + raw = metar.get("raw") or metar.get("sanitized") + if raw: + embed.add_field(name="Raw Report", value=self._truncate_report(raw, 1024), inline=False) + + def _add_taf_fields(self, embed: discord.Embed, taf: dict): + if not taf: + embed.description = "No TAF data available for this station." + return + + start_time = self._short_dt((taf.get("start_time") or {}).get("dt")) + end_time = self._short_dt((taf.get("end_time") or {}).get("dt")) + if start_time or end_time: + embed.add_field(name="Validity", value=f"`{start_time or 'Unknown'}` β†’ `{end_time or 'Unknown'}`", inline=False) + + forecast_periods = taf.get("forecast") or [] + if forecast_periods: + for index, period in enumerate(forecast_periods[:4], start=1): + start = self._short_dt((period.get("start_time") or {}).get("dt")) + end = self._short_dt((period.get("end_time") or {}).get("dt")) + rules = period.get("flight_rules") or "Unknown" + summary = period.get("summary") or self._truncate_report(period.get("raw"), 300) or "No summary available" + embed.add_field( + name=f"Forecast {index}", + value=f"`{start or 'Unknown'}` β†’ `{end or 'Unknown'}`\n**{rules}**\n{self._truncate_report(summary, 900)}", + inline=False, + ) + + raw = taf.get("raw") or taf.get("sanitized") + if raw: + embed.add_field(name="Raw TAF", value=self._truncate_report(raw, 1024), inline=False) + + @staticmethod + def _truncate_report(text, limit): + if not text: + return None + text = str(text) + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + @staticmethod + def _short_dt(value): + if not value: + return None + if "T" in value: + return value.replace("T", " ").replace("+00:00Z", "Z").replace("+00:00", "Z") + return value + + @staticmethod + def _format_visibility(report): + visibility = report.get("visibility") or {} + repr_value = visibility.get("repr") + units = (report.get("units") or {}).get("visibility") or "sm" + if repr_value: + return f"{repr_value} {units}" + return "Unknown" + + @staticmethod + def _format_wind(report): + direction = (report.get("wind_direction") or {}).get("repr") or "VRB" + speed = (report.get("wind_speed") or {}).get("repr") or "0" + gust = (report.get("wind_gust") or {}).get("repr") + units = (report.get("units") or {}).get("wind_speed") or "kt" + base = f"{direction} at {speed}{units}" + if gust: + base += f" gusting {gust}{units}" + return base + + @staticmethod + def _format_temperature(report): + temperature = (report.get("temperature") or {}).get("repr") + dewpoint = (report.get("dewpoint") or {}).get("repr") + units = (report.get("units") or {}).get("temperature") or "C" + if temperature is None and dewpoint is None: + return "Unknown" + return f"{temperature if temperature is not None else '?'}{units} / {dewpoint if dewpoint is not None else '?'}{units}" + + @staticmethod + def _format_altimeter(report): + altimeter = report.get("altimeter") or {} + repr_value = altimeter.get("repr") + value = altimeter.get("value") + units = (report.get("units") or {}).get("altimeter") or "inHg" + if repr_value and value is not None: + return f"{repr_value} ({value} {units})" + if repr_value: + return repr_value + return "Unknown" + + @staticmethod + def _format_clouds(clouds): + if not clouds: + return "None reported" + parts = [] + for cloud in clouds[:5]: + base = cloud.get("base") + cloud_type = cloud.get("type") or cloud.get("repr") or "Cloud" + if base is not None: + parts.append(f"{cloud_type} {base}00ft") + else: + parts.append(cloud_type) + return ", ".join(parts) + + @staticmethod + def _format_wx_codes(wx_codes): + if not wx_codes: + return "None reported" + parts = [] + for code in wx_codes: + if isinstance(code, dict): + parts.append(code.get("value") or code.get("repr") or "Unknown") + else: + parts.append(str(code)) + return ", ".join(parts) + + 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) @@ -254,8 +532,61 @@ def __init__(self, cog): self.api = APIManager(cog) self.helpers = HelperUtils(cog) self.xml_parser = XMLParser() + + async def _avwx_fetch_bundle(self, station_code: str): + """Fetch AVWX summary, METAR, and TAF data for a station.""" + station_code = station_code.upper().strip() + + summary_task = self.cog.api.get_avwx_summary(station_code) + metar_task = self.cog.api.get_avwx_report("metar", station_code) + taf_task = self.cog.api.get_avwx_report("taf", station_code) + summary_result, metar_result, taf_result = await asyncio.gather(summary_task, metar_task, taf_task) + + summary, summary_error = summary_result + metar, metar_error = metar_result + taf, taf_error = taf_result + + if not any((summary, metar, taf)): + errors = [msg for msg in (summary_error, metar_error, taf_error) if msg] + return None, errors[0] if errors else f"No AVWX data available for {station_code}." + + return { + "summary": summary, + "metar": metar, + "taf": taf, + }, None + + async def _send_avwx_view(self, ctx, station_code: str, initial_filter: str = "overview"): + """Fetch AVWX data and send the interactive report view.""" + station_code = station_code.upper().strip() + reports, error = await self._avwx_fetch_bundle(station_code) + if error: + embed = discord.Embed(title="AVWX Unavailable", description=error, color=0xff4545) + await ctx.send(embed=embed) + return + + view = AVWXWeatherView( + station_code=station_code, + report_data=reports, + airport_commands=self, + initial_filter=initial_filter, + ) + embed = view.build_embed(initial_filter) + await ctx.send(embed=embed, view=view) + + async def avwx_conditions(self, ctx, station_code: str): + """Show AVWX aviation weather overview for a station.""" + await self._send_avwx_view(ctx, station_code, initial_filter="overview") + + async def avwx_metar(self, ctx, station_code: str): + """Show AVWX METAR for a station.""" + await self._send_avwx_view(ctx, station_code, initial_filter="metar") + + async def avwx_taf(self, ctx, station_code: str): + """Show AVWX TAF for a station.""" + await self._send_avwx_view(ctx, station_code, initial_filter="taf") - async def _faa_fetch_data(self, airport_code: str = None): + async def _faa_fetch_data(self, airport_code: Optional[str] = None): """Fetch and parse FAA airport status. Returns (ground_delays, arrival_departure_delays, closures, update_time) or None.""" try: headers = {} @@ -708,7 +1039,7 @@ async def clearowmkey(self, ctx): await self.cog.config.openweathermap_api.set(None) await ctx.send("OpenWeatherMap API key cleared.") - async def faa_status(self, ctx, airport_code: str = None): + async def faa_status(self, ctx, airport_code: Optional[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'). diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 4cd3c14..b022dd3 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -47,6 +47,7 @@ def __init__(self, bot): self.config = Config.get_conf(self, identifier=492089091320446976) self.config.register_global(airplanesliveapi=None) # API key for airplanes.live self.config.register_global(openweathermap_api=None) # OWM API key + self.config.register_global(avwx_token=None) # AVWX API token 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 @@ -603,7 +604,7 @@ 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\n`faastatus [code]` - Get FAA National Airspace Status (delays/closures)", 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)\n`avwx ` - Get aviation weather overview\n`metar ` - Get current METAR\n`taf ` - Get current TAF", 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) @@ -629,6 +630,21 @@ 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='avwx', aliases=['wx'], help='Get aviation weather conditions from AVWX for an airport code.') + async def airport_avwx(self, ctx, code: str): + """Get aviation weather conditions from AVWX for an airport code.""" + await self.airport_commands.avwx_conditions(ctx, code) + + @airport_group.command(name='metar', help='Get current METAR from AVWX for an airport code.') + async def airport_metar(self, ctx, code: str): + """Get current METAR from AVWX for an airport code.""" + await self.airport_commands.avwx_metar(ctx, code) + + @airport_group.command(name='taf', help='Get current TAF from AVWX for an airport code.') + async def airport_taf(self, ctx, code: str): + """Get current TAF from AVWX for an airport code.""" + await self.airport_commands.avwx_taf(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).""" @@ -672,6 +688,24 @@ async def airport_clearowmkey(self, ctx): """Clear the OpenWeatherMap API key.""" await self.admin_commands.clear_owm_key(ctx) + @commands.is_owner() + @airport_group.command(name="setavwxtoken") + async def airport_setavwxtoken(self, ctx, token: str): + """Set the AVWX API token.""" + await self.admin_commands.set_avwx_token(ctx, token) + + @commands.is_owner() + @airport_group.command(name="avwxtoken") + async def airport_avwxtoken(self, ctx): + """Show the configured AVWX API token status.""" + await self.admin_commands.check_avwx_token(ctx) + + @commands.is_owner() + @airport_group.command(name="clearavwxtoken") + async def airport_clearavwxtoken(self, ctx): + """Clear the AVWX API token.""" + await self.admin_commands.clear_avwx_token(ctx) + @tasks.loop(minutes=2) async def check_emergency_squawks(self): """Background task to check for emergency squawks.""" diff --git a/skysearch/utils/api.py b/skysearch/utils/api.py index b61af01..639d1af 100644 --- a/skysearch/utils/api.py +++ b/skysearch/utils/api.py @@ -18,6 +18,7 @@ def __init__(self, cog): self.cog = cog self.primary_api_url = "https://rest.api.airplanes.live" self.fallback_api_url = "https://api.airplanes.live" + self.avwx_api_url = "https://avwx.rest/api" self._http_client = None # Request tracking statistics - will be loaded from config @@ -106,6 +107,10 @@ def get_fallback_api_url(self): """Get the fallback API URL.""" return self.fallback_api_url + def get_avwx_api_url(self): + """Get the AVWX API URL.""" + return self.avwx_api_url + async def get_headers(self, url=None, api_mode=None): """Return headers with API key for requests, if available. Only send API key for primary API.""" headers = {} @@ -118,6 +123,18 @@ async def get_headers(self, url=None, api_mode=None): headers['auth'] = api_key return headers + async def get_avwx_headers(self): + """Return headers for AVWX requests.""" + headers = {} + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent + + token = await self.cog.config.avwx_token() + if token: + headers["Authorization"] = f"Token {token}" + return headers + def _update_request_stats(self, api_mode: str, endpoint: str, success: bool, status_code: int = None, response_time: float = 0.0): """Update request statistics.""" @@ -437,4 +454,67 @@ async def get_openweathermap_forecast(self, lat, lon): return None except aiohttp.ClientError as e: print(f"Error fetching OpenWeatherMap forecast: {e}") - return None \ No newline at end of file + return None + + async def get_avwx_report(self, report_type: str, station: str): + """Fetch an AVWX report for a station. + + Returns a tuple of (data, error_message). + """ + token = await self.cog.config.avwx_token() + if not token: + return None, "AVWX token not configured." + + if not self._http_client: + self._http_client = aiohttp.ClientSession() + + report_type = report_type.lower().strip() + station = station.upper().strip() + url = f"{self.avwx_api_url}/{report_type}/{station}?options=info,summary,translate,onfail=cache" + + try: + async with self._http_client.get(url, headers=await self.get_avwx_headers()) as resp: + if resp.status == 200: + return await resp.json(), None + if resp.status == 401: + return None, "AVWX authentication failed. Check the configured token." + if resp.status == 403: + return None, "AVWX token does not have access to this endpoint." + if resp.status == 404: + return None, f"No {report_type.upper()} report found for {station}." + if resp.status == 429: + return None, "AVWX rate limit exceeded. Try again shortly." + return None, f"AVWX returned HTTP {resp.status}." + except aiohttp.ClientError as e: + return None, f"AVWX request failed: {e}" + + async def get_avwx_summary(self, station: str): + """Fetch AVWX summary data for a station. + + Returns a tuple of (data, error_message). + """ + token = await self.cog.config.avwx_token() + if not token: + return None, "AVWX token not configured." + + if not self._http_client: + self._http_client = aiohttp.ClientSession() + + station = station.upper().strip() + url = f"{self.avwx_api_url}/summary/{station}?options=info,onfail=cache" + + try: + async with self._http_client.get(url, headers=await self.get_avwx_headers()) as resp: + if resp.status == 200: + return await resp.json(), None + if resp.status == 401: + return None, "AVWX authentication failed. Check the configured token." + if resp.status == 403: + return None, "AVWX token does not have access to this endpoint." + if resp.status == 404: + return None, f"No AVWX summary found for {station}." + if resp.status == 429: + return None, "AVWX rate limit exceeded. Try again shortly." + return None, f"AVWX returned HTTP {resp.status}." + except aiohttp.ClientError as e: + return None, f"AVWX request failed: {e}" \ No newline at end of file From acd19b425eb44449a3dc45d45faded145bba8887 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:43:01 +0000 Subject: [PATCH 32/56] Update api.py --- skysearch/utils/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skysearch/utils/api.py b/skysearch/utils/api.py index 639d1af..05b8190 100644 --- a/skysearch/utils/api.py +++ b/skysearch/utils/api.py @@ -470,7 +470,7 @@ async def get_avwx_report(self, report_type: str, station: str): report_type = report_type.lower().strip() station = station.upper().strip() - url = f"{self.avwx_api_url}/{report_type}/{station}?options=info,summary,translate,onfail=cache" + url = f"{self.avwx_api_url}/{report_type}/{station}?options=info,summary,translate&onfail=cache" try: async with self._http_client.get(url, headers=await self.get_avwx_headers()) as resp: @@ -501,7 +501,7 @@ async def get_avwx_summary(self, station: str): self._http_client = aiohttp.ClientSession() station = station.upper().strip() - url = f"{self.avwx_api_url}/summary/{station}?options=info,onfail=cache" + url = f"{self.avwx_api_url}/summary/{station}?options=info&onfail=cache" try: async with self._http_client.get(url, headers=await self.get_avwx_headers()) as resp: From e40dd611ff07a8557cd140fe657b53d88aa4c20a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:16:59 +0000 Subject: [PATCH 33/56] Add safe guild locale setter and use it Introduce _set_guild_locales_safe which wraps set_contextual_locales_from_guild with a 2s asyncio.wait_for timeout and catches exceptions, logging warnings and returning False on failure. Replace direct calls to set_contextual_locales_from_guild in several background loops with the safe wrapper and skip guilds where locale setup fails, preventing locale-related delays or errors from breaking background tasks. --- skysearch/skysearch.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index b022dd3..3d78f69 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -103,6 +103,17 @@ async def _refresh_auto_icao_cache(self): if await self.config.guild(guild).auto_icao(): self._auto_icao_enabled_guilds.add(guild.id) self._auto_icao_checked_guilds.add(guild.id) + + async def _set_guild_locales_safe(self, guild) -> bool: + """Set i18n context for a guild without letting failures break background tasks.""" + try: + await asyncio.wait_for(set_contextual_locales_from_guild(self.bot, guild), timeout=2.0) + return True + except asyncio.TimeoutError: + log.warning(f"Timed out setting contextual locales for guild {guild.id}") + except Exception as e: + log.warning(f"Failed to set contextual locales for guild {guild.id}: {e}") + return False async def cog_load(self): """Called when the cog is loaded - refresh cache.""" @@ -726,7 +737,8 @@ async def check_emergency_squawks(self): guilds = self.bot.guilds for guild in guilds: # In non-command contexts set locales explicitly - await set_contextual_locales_from_guild(self.bot, guild) + if not await self._set_guild_locales_safe(guild): + continue guild_config = self.config.guild(guild) alert_channel_id = await guild_config.alert_channel() if alert_channel_id: @@ -889,7 +901,8 @@ async def check_emergency_squawks(self): guilds = self.bot.guilds for guild in guilds: # Set locales once per guild per cycle - await set_contextual_locales_from_guild(self.bot, guild) + if not await self._set_guild_locales_safe(guild): + continue guild_config = self.config.guild(guild) alert_channel_id = await guild_config.alert_channel() custom_alerts = await guild_config.custom_alerts() @@ -949,7 +962,8 @@ async def check_faa_status_changes(self): for guild in self.bot.guilds: try: - await set_contextual_locales_from_guild(self.bot, guild) + if not await self._set_guild_locales_safe(guild): + continue guild_config = self.config.guild(guild) channel_id = await guild_config.faa_alert_channel() if not channel_id: @@ -1014,7 +1028,8 @@ async def check_geofence_alerts(self): api_mode = await self.config.api_mode() key = "aircraft" if api_mode == "primary" else "ac" for guild in self.bot.guilds: - await set_contextual_locales_from_guild(self.bot, guild) + if not await self._set_guild_locales_safe(guild): + continue guild_config = self.config.guild(guild) geofence_alerts = await guild_config.geofence_alerts() if not geofence_alerts: @@ -1350,7 +1365,8 @@ async def check_custom_alerts(self, aircraft_info): try: guilds = self.bot.guilds for guild in guilds: - await set_contextual_locales_from_guild(self.bot, guild) + if not await self._set_guild_locales_safe(guild): + continue guild_config = self.config.guild(guild) alert_channel_id = await guild_config.alert_channel() # Default alert channel may be unset; some alerts might target a custom channel. From 06bdca7c4eff7503db4c1b55b869a29e611461fa Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:43:21 +0000 Subject: [PATCH 34/56] Limit background I/O and add request timeouts Add concurrency limits and timeouts for background network work and hooks, and set HTTP client timeouts. Changes: - Introduced an asyncio.Semaphore and helper _run_background_io to bound concurrent background I/O (default 4). - Added _squawk_hook_timeout and wrapped squawk callback/pre-send/post-send hooks and channel sends with asyncio.wait_for to avoid blocking. - Refactored emergency squawk handling to build per-guild runtime state once per cycle (caches guild, channel, cooldown, last_alerts), enforce cooldowns, batch-update last_alerts only when dirty, and use _run_background_io for photo lookups and sends. - Reduced sleep between emergency checks and moved custom-alerts feed check to once per loop iteration; added dirty flag for custom_alerts to minimize writes. - Use _run_background_io for get_photo_by_aircraft_data and geofence/custom alert sends; ensure landed messages also use the same constrained send path. - APIManager: set aiohttp.ClientTimeout for the session, propagate timeout to ClientSession creation, and handle asyncio.TimeoutError in make_request (log/notify and update request stats). These changes prevent event-loop stalls under load, add resilience against slow external hooks/HTTP calls, and reduce redundant per-aircraft guild work. --- skysearch/skysearch.py | 486 ++++++++++++++++++++++++----------------- skysearch/utils/api.py | 19 +- 2 files changed, 297 insertions(+), 208 deletions(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 3d78f69..d34840f 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -94,6 +94,9 @@ def __init__(self, bot): # Pre-compile regex pattern for ICAO matching self._icao_pattern = re.compile(r'^[a-fA-F0-9]{6}$') + # Limit concurrent background network-heavy operations across tasks. + self._background_io_semaphore = asyncio.Semaphore(4) + self._squawk_hook_timeout = 5.0 async def _refresh_auto_icao_cache(self): """Refresh the cache of guilds with auto_icao enabled.""" @@ -114,6 +117,11 @@ async def _set_guild_locales_safe(self, guild) -> bool: except Exception as e: log.warning(f"Failed to set contextual locales for guild {guild.id}: {e}") return False + + async def _run_background_io(self, awaitable): + """Bound concurrent background I/O to keep event loop responsive under load.""" + async with self._background_io_semaphore: + return await awaitable async def cog_load(self): """Called when the cog is loaded - refresh cache.""" @@ -729,211 +737,261 @@ async def check_emergency_squawks(self): response = await self.api.make_request(url) # No ctx for background task aircraft_count = len(response.get('aircraft', [])) if response else 0 log.debug(f"Checked {squawk_code}: Found {aircraft_count} aircraft") - if response and 'aircraft' in response: - for aircraft_info in response['aircraft']: + aircraft_list = response.get('aircraft', []) if response and 'aircraft' in response else [] + if aircraft_list: + guild_runtime = [] + for guild in self.bot.guilds: + if not await self._set_guild_locales_safe(guild): + continue + guild_config = self.config.guild(guild) + alert_channel_id = await guild_config.alert_channel() + if not alert_channel_id: + continue + alert_channel = self.bot.get_channel(alert_channel_id) + if not alert_channel: + continue + guild_runtime.append({ + "guild": guild, + "guild_config": guild_config, + "alert_channel": alert_channel, + "cooldown_minutes": await guild_config.emergency_cooldown(), + "alert_role_id": await guild_config.alert_role(), + "last_alerts": await guild_config.last_alerts(), + "dirty": False, + }) + + for aircraft_info in aircraft_list: # Ignore aircraft with the hex 00000000 if aircraft_info.get('hex') == '00000000': continue - guilds = self.bot.guilds - for guild in guilds: - # In non-command contexts set locales explicitly - if not await self._set_guild_locales_safe(guild): - continue - guild_config = self.config.guild(guild) - alert_channel_id = await guild_config.alert_channel() - if alert_channel_id: - icao_hex = aircraft_info.get('hex') - if not icao_hex: + icao_hex = aircraft_info.get('hex') + if not icao_hex: + continue + now = datetime.datetime.now(datetime.timezone.utc) + for runtime in guild_runtime: + cooldown_minutes = runtime["cooldown_minutes"] + alert_key = f"{icao_hex}-{squawk_code}" + last_alerts = runtime["last_alerts"] + last_alert_timestamp = last_alerts.get(alert_key) + + if last_alert_timestamp: + last_alert_time = datetime.datetime.fromtimestamp(last_alert_timestamp, tz=datetime.timezone.utc) + time_since_last = (now - last_alert_time).total_seconds() + if time_since_last < cooldown_minutes * 60: + log.debug( + f"Cooldown active for {icao_hex} ({squawk_code}) - {time_since_last:.1f}s " + f"since last alert (cooldown: {cooldown_minutes}m)" + ) continue - - cooldown_minutes = await guild_config.emergency_cooldown() - alert_key = f"{icao_hex}-{squawk_code}" - now = datetime.datetime.now(datetime.timezone.utc) - - last_alerts = await guild_config.last_alerts() - last_alert_timestamp = last_alerts.get(alert_key) - - if last_alert_timestamp: - last_alert_time = datetime.datetime.fromtimestamp(last_alert_timestamp, tz=datetime.timezone.utc) - time_since_last = (now - last_alert_time).total_seconds() - if time_since_last < cooldown_minutes * 60: - log.debug(f"Cooldown active for {icao_hex} ({squawk_code}) - {time_since_last:.1f}s since last alert (cooldown: {cooldown_minutes}m)") - continue # Cooldown active, skip. - - alert_channel = self.bot.get_channel(alert_channel_id) - if alert_channel: - # Update timestamp before sending, to be safe - last_alerts = await guild_config.last_alerts() - last_alerts[alert_key] = now.timestamp() - # Clean up old entries - keys_to_delete = [ - k for k, ts in last_alerts.items() - if (now.timestamp() - ts) > (cooldown_minutes * 60) - ] - for k in keys_to_delete: - if k != alert_key: - del last_alerts[k] - await guild_config.last_alerts.set(last_alerts) - - # Get the alert role - alert_role_id = await guild_config.alert_role() - alert_role_mention = f"<@&{alert_role_id}>" if alert_role_id else "" - - # Debug logging for emergency alerts - log.info(f"EMERGENCY ALERT {icao_hex}: alert_role_id={alert_role_id}, mention='{alert_role_mention}'") - - # Prepare message data for pre-send hooks - message_data = { - 'content': alert_role_mention if alert_role_mention else None, - 'embed': None, - 'view': None, - } - # Compose the embed and view as before - aircraft_data = aircraft_info - image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) - embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) - - # Create buttons for emergency alerts - view = discord.ui.View() - icao = aircraft_data.get('hex', '').upper() - link = f"https://globe.airplanes.live/?icao={icao}" - view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) - - # Social media sharing buttons - import urllib.parse - ground_speed_knots = aircraft_data.get('gs', aircraft_data.get('ground_speed', 'N/A')) + + last_alerts[alert_key] = now.timestamp() + cutoff = now.timestamp() - (cooldown_minutes * 60) + runtime["last_alerts"] = { + k: ts for k, ts in last_alerts.items() if ts >= cutoff or k == alert_key + } + runtime["dirty"] = True + + guild = runtime["guild"] + alert_channel = runtime["alert_channel"] + alert_role_id = runtime["alert_role_id"] + alert_role_mention = f"<@&{alert_role_id}>" if alert_role_id else "" + + # Debug logging for emergency alerts + log.info(f"EMERGENCY ALERT {icao_hex}: alert_role_id={alert_role_id}, mention='{alert_role_mention}'") + + # Prepare message data for pre-send hooks + message_data = { + 'content': alert_role_mention if alert_role_mention else None, + 'embed': None, + 'view': None, + } + # Compose the embed and view as before + aircraft_data = aircraft_info + image_url, photographer = await self._run_background_io( + self.helpers.get_photo_by_aircraft_data(aircraft_data) + ) + embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) + + # Create buttons for emergency alerts + view = discord.ui.View() + icao = aircraft_data.get('hex', '').upper() + link = f"https://globe.airplanes.live/?icao={icao}" + view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) + + # Social media sharing buttons + import urllib.parse + ground_speed_knots = aircraft_data.get('gs', aircraft_data.get('ground_speed', 'N/A')) + ground_speed_mph = 'unknown' + if ground_speed_knots != 'N/A' and ground_speed_knots is not None: + try: + ground_speed_mph = round(float(ground_speed_knots) * 1.15078) + except Exception: ground_speed_mph = 'unknown' - if ground_speed_knots != 'N/A' and ground_speed_knots is not None: - try: - ground_speed_mph = round(float(ground_speed_knots) * 1.15078) - except Exception: - ground_speed_mph = 'unknown' - - lat = aircraft_data.get('lat', 'N/A') - lon = aircraft_data.get('lon', 'N/A') - if lat != 'N/A' and lat is not None: - try: - lat_formatted = round(float(lat), 2) - lat_dir = "N" if lat_formatted >= 0 else "S" - lat = f"{abs(lat_formatted)}{lat_dir}" - except Exception: - pass - if lon != 'N/A' and lon is not None: - try: - lon_formatted = round(float(lon), 2) - lon_dir = "E" if lon_formatted >= 0 else "W" - lon = f"{abs(lon_formatted)}{lon_dir}" - except Exception: - pass - - if squawk_code in ['7500', '7600', '7700']: - tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - discord.gg/WW4eNQj9qr" - else: - tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - discord.gg/WW4eNQj9qr" - - tweet_url = f"https://x.com/intent/tweet?text={urllib.parse.quote_plus(tweet_text)}" - view.add_item(discord.ui.Button(label="Post on X", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) - - whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - whatsapp_url = f"https://api.whatsapp.com/send?text={urllib.parse.quote_plus(whatsapp_text)}" - view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) - - message_data['embed'] = embed - message_data['view'] = view - - # Let other cogs know about the alert first - log.error(f"DEBUG: Calling callbacks for {icao_hex} ({squawk_code}) in {guild.name}") - await self.squawk_api.call_callbacks(guild, aircraft_info, squawk_code) - log.error(f"DEBUG: Finished callbacks for {icao_hex}") - - # Let other cogs modify the message before sending - original_view = message_data.get('view') - original_content = message_data.get('content') - message_data = await self.squawk_api.run_pre_send(guild, aircraft_info, squawk_code, message_data) - - # Ensure buttons are preserved if no other cog modified the view - if message_data.get('view') is None and original_view is not None: - log.warning(f"Pre-send callback removed view for {icao_hex}, restoring buttons") - message_data['view'] = original_view - # Ensure role mention content is preserved if removed by callbacks - if message_data.get('content') is None and original_content is not None: - log.warning(f"Pre-send callback removed content for {icao_hex}, restoring mention content") - message_data['content'] = original_content - - # Debug final content before sending - log.info(f"EMERGENCY ALERT {icao_hex}: Final content before send: '{message_data.get('content')}'") - - # Send the message using the possibly modified data (allow role mentions) - allowed_mentions = None - if alert_role_id: - role_obj = guild.get_role(alert_role_id) - if role_obj: - allowed_mentions = discord.AllowedMentions(roles=[role_obj]) - else: - allowed_mentions = discord.AllowedMentions(roles=True) - sent_message = await alert_channel.send( + + lat = aircraft_data.get('lat', 'N/A') + lon = aircraft_data.get('lon', 'N/A') + if lat != 'N/A' and lat is not None: + try: + lat_formatted = round(float(lat), 2) + lat_dir = "N" if lat_formatted >= 0 else "S" + lat = f"{abs(lat_formatted)}{lat_dir}" + except Exception: + pass + if lon != 'N/A' and lon is not None: + try: + lon_formatted = round(float(lon), 2) + lon_dir = "E" if lon_formatted >= 0 else "W" + lon = f"{abs(lon_formatted)}{lon_dir}" + except Exception: + pass + + if squawk_code in ['7500', '7600', '7700']: + tweet_text = f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #Emergency\n\nJoin via Discord to search and discuss planes with your friends for free - discord.gg/WW4eNQj9qr" + else: + tweet_text = f"Tracking flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph using #SkySearch\n\nJoin via Discord to search and discuss planes with your friends for free - discord.gg/WW4eNQj9qr" + + tweet_url = f"https://x.com/intent/tweet?text={urllib.parse.quote_plus(tweet_text)}" + view.add_item(discord.ui.Button(label="Post on X", emoji="πŸ“£", url=tweet_url, style=discord.ButtonStyle.link)) + + whatsapp_text = f"Check out this aircraft! Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" + whatsapp_url = f"https://api.whatsapp.com/send?text={urllib.parse.quote_plus(whatsapp_text)}" + view.add_item(discord.ui.Button(label="Send on WhatsApp", emoji="πŸ“±", url=whatsapp_url, style=discord.ButtonStyle.link)) + + message_data['embed'] = embed + message_data['view'] = view + + # Let other cogs know about the alert first + log.error(f"DEBUG: Calling callbacks for {icao_hex} ({squawk_code}) in {guild.name}") + try: + await asyncio.wait_for( + self.squawk_api.call_callbacks(guild, aircraft_info, squawk_code), + timeout=self._squawk_hook_timeout, + ) + except asyncio.TimeoutError: + log.warning(f"Squawk callback timeout for {icao_hex} ({squawk_code}) in {guild.name}") + log.error(f"DEBUG: Finished callbacks for {icao_hex}") + + # Let other cogs modify the message before sending + original_view = message_data.get('view') + original_content = message_data.get('content') + try: + message_data = await asyncio.wait_for( + self.squawk_api.run_pre_send(guild, aircraft_info, squawk_code, message_data), + timeout=self._squawk_hook_timeout, + ) + except asyncio.TimeoutError: + log.warning(f"Squawk pre-send timeout for {icao_hex} ({squawk_code}) in {guild.name}") + + # Ensure buttons are preserved if no other cog modified the view + if message_data.get('view') is None and original_view is not None: + log.warning(f"Pre-send callback removed view for {icao_hex}, restoring buttons") + message_data['view'] = original_view + # Ensure role mention content is preserved if removed by callbacks + if message_data.get('content') is None and original_content is not None: + log.warning(f"Pre-send callback removed content for {icao_hex}, restoring mention content") + message_data['content'] = original_content + + # Debug final content before sending + log.info(f"EMERGENCY ALERT {icao_hex}: Final content before send: '{message_data.get('content')}'") + + # Send the message using the possibly modified data (allow role mentions) + allowed_mentions = None + if alert_role_id: + role_obj = guild.get_role(alert_role_id) + if role_obj: + allowed_mentions = discord.AllowedMentions(roles=[role_obj]) + else: + allowed_mentions = discord.AllowedMentions(roles=True) + sent_message = await asyncio.wait_for( + self._run_background_io( + alert_channel.send( content=message_data.get('content'), embed=message_data.get('embed'), view=message_data.get('view'), - allowed_mentions=allowed_mentions + allowed_mentions=allowed_mentions, ) + ), + timeout=10.0, + ) - # Let other cogs react after the message is sent - await self.squawk_api.run_post_send(guild, aircraft_info, squawk_code, sent_message) - - # Check if aircraft has landed - if aircraft_info.get('altitude') is not None and aircraft_info.get('altitude') < 25: - embed = discord.Embed(title=_("Aircraft landed"), description=_("Aircraft {hex} has landed while squawking {squawk}.").format(hex=aircraft_info.get('hex'), squawk=squawk_code), color=0x00ff00) - await alert_channel.send(embed=embed) + # Let other cogs react after the message is sent + try: + await asyncio.wait_for( + self.squawk_api.run_post_send(guild, aircraft_info, squawk_code, sent_message), + timeout=self._squawk_hook_timeout, + ) + except asyncio.TimeoutError: + log.warning(f"Squawk post-send timeout for {icao_hex} ({squawk_code}) in {guild.name}") + + # Check if aircraft has landed + if aircraft_info.get('altitude') is not None and aircraft_info.get('altitude') < 25: + landed_embed = discord.Embed( + title=_("Aircraft landed"), + description=_("Aircraft {hex} has landed while squawking {squawk}.").format( + hex=aircraft_info.get('hex'), squawk=squawk_code + ), + color=0x00ff00, + ) + await asyncio.wait_for( + self._run_background_io(alert_channel.send(embed=landed_embed)), + timeout=10.0, + ) + + for runtime in guild_runtime: + if runtime["dirty"]: + await runtime["guild_config"].last_alerts.set(runtime["last_alerts"]) - # Check custom alerts against the full aircraft feed (not just emergency squawks) - try: - all_url = f"{await self.api.get_api_url()}/?all_with_pos" - all_response = await self.api.make_request(all_url) - # Support both primary ('aircraft') and fallback ('ac') response formats - aircraft_list = [] - if all_response: - if 'aircraft' in all_response: - aircraft_list = all_response['aircraft'] - elif 'ac' in all_response and isinstance(all_response['ac'], list): - aircraft_list = all_response['ac'] - if aircraft_list: - guilds = self.bot.guilds - for guild in guilds: - # Set locales once per guild per cycle - if not await self._set_guild_locales_safe(guild): - continue - guild_config = self.config.guild(guild) - alert_channel_id = await guild_config.alert_channel() - custom_alerts = await guild_config.custom_alerts() - if not custom_alerts: - continue - default_channel = self.bot.get_channel(alert_channel_id) if alert_channel_id else None - for aircraft_info in aircraft_list: - if aircraft_info.get('hex') == '00000000': - continue - for alert_id, alert_data in custom_alerts.items(): - if await self._check_aircraft_matches_alert(aircraft_info, alert_data): - if await self._is_alert_cooldown_active(guild_config, alert_id, alert_data): - continue - # resolve destination channel - destination_channel = default_channel - custom_channel_id = alert_data.get('custom_channel') - if custom_channel_id: - custom_channel = self.bot.get_channel(custom_channel_id) - if custom_channel: - destination_channel = custom_channel - if destination_channel is None: - continue - await self._send_custom_alert(destination_channel, guild_config, aircraft_info, alert_data, alert_id) - # update last triggered (timezone-aware UTC) - custom_alerts[alert_id]['last_triggered'] = datetime.datetime.now(datetime.timezone.utc).isoformat() - await guild_config.custom_alerts.set(custom_alerts) - except Exception as e: - log.error(f"Error checking custom alerts feed: {e}", exc_info=True) - # Removed the "No alert channel set" message - this is normal behavior - await asyncio.sleep(2) + await asyncio.sleep(0.5) + + # Check custom alerts against the full aircraft feed once per loop cycle + try: + all_url = f"{await self.api.get_api_url()}/?all_with_pos" + all_response = await self.api.make_request(all_url) + # Support both primary ('aircraft') and fallback ('ac') response formats + aircraft_list = [] + if all_response: + if 'aircraft' in all_response: + aircraft_list = all_response['aircraft'] + elif 'ac' in all_response and isinstance(all_response['ac'], list): + aircraft_list = all_response['ac'] + if aircraft_list: + guilds = self.bot.guilds + for guild in guilds: + # Set locales once per guild per cycle + if not await self._set_guild_locales_safe(guild): + continue + guild_config = self.config.guild(guild) + alert_channel_id = await guild_config.alert_channel() + custom_alerts = await guild_config.custom_alerts() + if not custom_alerts: + continue + custom_alerts_dirty = False + default_channel = self.bot.get_channel(alert_channel_id) if alert_channel_id else None + for aircraft_info in aircraft_list: + if aircraft_info.get('hex') == '00000000': + continue + for alert_id, alert_data in custom_alerts.items(): + if await self._check_aircraft_matches_alert(aircraft_info, alert_data): + if await self._is_alert_cooldown_active(guild_config, alert_id, alert_data): + continue + # resolve destination channel + destination_channel = default_channel + custom_channel_id = alert_data.get('custom_channel') + if custom_channel_id: + custom_channel = self.bot.get_channel(custom_channel_id) + if custom_channel: + destination_channel = custom_channel + if destination_channel is None: + continue + await self._send_custom_alert(destination_channel, guild_config, aircraft_info, alert_data, alert_id) + # update last triggered (timezone-aware UTC) + custom_alerts[alert_id]['last_triggered'] = datetime.datetime.now(datetime.timezone.utc).isoformat() + custom_alerts_dirty = True + if custom_alerts_dirty: + await guild_config.custom_alerts.set(custom_alerts) + except Exception as e: + log.error(f"Error checking custom alerts feed: {e}", exc_info=True) except Exception as e: log.error(f"Error checking emergency squawks: {e}", exc_info=True) @@ -1099,7 +1157,7 @@ async def before_check_geofence_alerts(self): async def _send_geofence_alert(self, channel, fence, aircraft_info, event_type, role_mention): """Send a geo-fence alert (entry or exit).""" fence_name = fence.get("name", "Unnamed") - image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_info) + image_url, photographer = await self._run_background_io(self.helpers.get_photo_by_aircraft_data(aircraft_info)) embed = self.helpers.create_aircraft_embed(aircraft_info, image_url, photographer) if event_type == "entry": embed.title = f"🟒 Geo-fence: {aircraft_info.get('desc', 'Aircraft')} entered **{fence_name}**" @@ -1112,7 +1170,12 @@ async def _send_geofence_alert(self, channel, fence, aircraft_info, event_type, link = f"https://globe.airplanes.live/?icao={icao}" view.add_item(discord.ui.Button(label="View on airplanes.live", emoji="πŸ—ΊοΈ", url=link, style=discord.ButtonStyle.link)) allowed_mentions = discord.AllowedMentions(roles=True) if role_mention else None - await channel.send(content=role_mention or None, embed=embed, view=view, allowed_mentions=allowed_mentions) + await asyncio.wait_for( + self._run_background_io( + channel.send(content=role_mention or None, embed=embed, view=view, allowed_mentions=allowed_mentions) + ), + timeout=10.0, + ) @tasks.loop(minutes=3) async def check_watched_aircraft(self): @@ -1465,7 +1528,7 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a # Create embed aircraft_data = aircraft_info - image_url, photographer = await self.helpers.get_photo_by_aircraft_data(aircraft_data) + image_url, photographer = await self._run_background_io(self.helpers.get_photo_by_aircraft_data(aircraft_data)) embed = self.helpers.create_aircraft_embed(aircraft_data, image_url, photographer) # Add custom alert header embed.title = f"πŸ”” Custom Alert: {alert_data['type'].upper()} '{alert_data['value']}'" @@ -1519,7 +1582,13 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a # Allow other cogs to modify the message before sending (mirror emergency flow) original_view = message_data.get('view') squawk_code = aircraft_data.get('squawk', 'CUSTOM') - message_data = await self.squawk_api.run_pre_send(alert_channel.guild, aircraft_data, squawk_code, message_data) + try: + message_data = await asyncio.wait_for( + self.squawk_api.run_pre_send(alert_channel.guild, aircraft_data, squawk_code, message_data), + timeout=self._squawk_hook_timeout, + ) + except asyncio.TimeoutError: + log.warning(f"Custom alert pre-send timeout for {alert_id} in {alert_channel.guild.name}") # Ensure buttons are preserved if no other cog modified the view if message_data.get('view') is None and original_view is not None: @@ -1534,15 +1603,26 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a allowed_mentions = discord.AllowedMentions(roles=[role_obj]) else: allowed_mentions = discord.AllowedMentions(roles=True) - sent_message = await alert_channel.send( - content=message_data.get('content'), - embed=message_data.get('embed'), - view=message_data.get('view'), - allowed_mentions=allowed_mentions + sent_message = await asyncio.wait_for( + self._run_background_io( + alert_channel.send( + content=message_data.get('content'), + embed=message_data.get('embed'), + view=message_data.get('view'), + allowed_mentions=allowed_mentions + ) + ), + timeout=10.0, ) # Let other cogs react after the message is sent - await self.squawk_api.run_post_send(alert_channel.guild, aircraft_data, squawk_code, sent_message) + try: + await asyncio.wait_for( + self.squawk_api.run_post_send(alert_channel.guild, aircraft_data, squawk_code, sent_message), + timeout=self._squawk_hook_timeout, + ) + except asyncio.TimeoutError: + log.warning(f"Custom alert post-send timeout for {alert_id} in {alert_channel.guild.name}") log.info(f"Sent custom alert for {alert_id} in {alert_channel.guild.name}") diff --git a/skysearch/utils/api.py b/skysearch/utils/api.py index 05b8190..d871a84 100644 --- a/skysearch/utils/api.py +++ b/skysearch/utils/api.py @@ -19,6 +19,7 @@ def __init__(self, cog): self.primary_api_url = "https://rest.api.airplanes.live" self.fallback_api_url = "https://api.airplanes.live" self.avwx_api_url = "https://avwx.rest/api" + self._http_timeout = aiohttp.ClientTimeout(total=20, connect=5, sock_read=15) self._http_client = None # Request tracking statistics - will be loaded from config @@ -251,7 +252,7 @@ async def _save_stats_to_config(self): async def make_request(self, url, ctx=None): """Make an HTTP request to the selected API (primary or fallback).""" if not self._http_client: - self._http_client = aiohttp.ClientSession() + self._http_client = aiohttp.ClientSession(timeout=self._http_timeout) # Determine which API to use api_mode = await self.cog.config.api_mode() @@ -345,6 +346,14 @@ async def make_request(self, url, ctx=None): self._update_request_stats(api_mode, endpoint, True, status_code, time.time() - start_time) return data + except asyncio.TimeoutError: + error_msg = "Error making request: request timed out" + if ctx: + await ctx.send(f"❌ **Error:** {error_msg}") + else: + print(error_msg) + self._update_request_stats(api_mode, endpoint, False, status_code, time.time() - start_time) + return None except aiohttp.ClientError as e: error_msg = f"Error making request: {e}" if ctx: @@ -427,7 +436,7 @@ async def get_stats(self): """Fetch stats from the airplanes.live API and return the JSON response or None on error.""" url = "https://api.airplanes.live/stats" if not self._http_client: - self._http_client = aiohttp.ClientSession() + self._http_client = aiohttp.ClientSession(timeout=self._http_timeout) try: async with self._http_client.get(url, headers=await self.get_headers(url, api_mode="primary")) as response: if response.status == 200: @@ -445,7 +454,7 @@ async def get_openweathermap_forecast(self, lat, lon): return None url = f"https://api.openweathermap.org/data/2.5/forecast?lat={lat}&lon={lon}&appid={api_key}&units=metric" if not self._http_client: - self._http_client = aiohttp.ClientSession() + self._http_client = aiohttp.ClientSession(timeout=self._http_timeout) try: async with self._http_client.get(url, headers=await self.get_headers(url, api_mode="primary")) as resp: if resp.status == 200: @@ -466,7 +475,7 @@ async def get_avwx_report(self, report_type: str, station: str): return None, "AVWX token not configured." if not self._http_client: - self._http_client = aiohttp.ClientSession() + self._http_client = aiohttp.ClientSession(timeout=self._http_timeout) report_type = report_type.lower().strip() station = station.upper().strip() @@ -498,7 +507,7 @@ async def get_avwx_summary(self, station: str): return None, "AVWX token not configured." if not self._http_client: - self._http_client = aiohttp.ClientSession() + self._http_client = aiohttp.ClientSession(timeout=self._http_timeout) station = station.upper().strip() url = f"{self.avwx_api_url}/summary/{station}?options=info&onfail=cache" From 729571f0741ad2c88976ef6c41b58f4810df0661 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:06:12 +0000 Subject: [PATCH 35/56] caption cog --- image/__init__.py | 5 ++ image/image.py | 158 ++++++++++++++++++++++++++++++++++++++++++++++ image/info.json | 19 ++++++ 3 files changed, 182 insertions(+) create mode 100644 image/__init__.py create mode 100644 image/image.py create mode 100644 image/info.json diff --git a/image/__init__.py b/image/__init__.py new file mode 100644 index 0000000..5c6be34 --- /dev/null +++ b/image/__init__.py @@ -0,0 +1,5 @@ +from .image import ImageTools + + +async def setup(bot): + await bot.add_cog(ImageTools(bot)) diff --git a/image/image.py b/image/image.py new file mode 100644 index 0000000..36dbdeb --- /dev/null +++ b/image/image.py @@ -0,0 +1,158 @@ +import io +from typing import Any, List, Optional + +import aiohttp +import discord +from PIL import Image, ImageDraw, ImageFont +from redbot.core import commands + + +def _is_image_filename(filename: str) -> bool: + lowered = filename.lower() + return lowered.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")) + + +def _pick_font(image_width: int) -> Any: + # Prefer a truetype font for cleaner rendering, but gracefully fall back. + font_size = max(22, min(72, image_width // 12)) + for name in ("arial.ttf", "DejaVuSans-Bold.ttf", "DejaVuSans.ttf"): + try: + return ImageFont.truetype(name, font_size) + except OSError: + continue + return ImageFont.load_default() + + +def _wrap_caption(draw: ImageDraw.ImageDraw, text: str, font: Any, max_width: int) -> List[str]: + words = text.split() + if not words: + return [""] + + lines: List[str] = [] + current = words[0] + + for word in words[1:]: + trial = f"{current} {word}" + trial_width = draw.textbbox((0, 0), trial, font=font)[2] + if trial_width <= max_width: + current = trial + else: + lines.append(current) + current = word + + lines.append(current) + return lines + + +def _build_caption_image(raw_data: bytes, caption: str) -> io.BytesIO: + with Image.open(io.BytesIO(raw_data)) as source: + image = source.convert("RGB") + + width, height = image.size + side_padding = max(12, width // 32) + top_padding = max(10, width // 50) + line_spacing = max(4, width // 120) + + font = _pick_font(width) + drawer = ImageDraw.Draw(image) + lines = _wrap_caption(drawer, caption.strip(), font, width - (side_padding * 2)) + + line_heights = [] + for line in lines: + bbox = drawer.textbbox((0, 0), line, font=font) + line_heights.append(bbox[3] - bbox[1]) + + text_block_height = sum(line_heights) + line_spacing * (len(lines) - 1) + banner_height = text_block_height + (top_padding * 2) + + final = Image.new("RGB", (width, height + banner_height), color=(255, 255, 255)) + final.paste(image, (0, banner_height)) + + final_draw = ImageDraw.Draw(final) + y = top_padding + for idx, line in enumerate(lines): + line_bbox = final_draw.textbbox((0, 0), line, font=font) + line_width = line_bbox[2] - line_bbox[0] + line_height = line_bbox[3] - line_bbox[1] + x = (width - line_width) // 2 + final_draw.text((x, y), line, fill=(0, 0, 0), font=font) + y += line_height + line_spacing + + output = io.BytesIO() + final.save(output, format="PNG") + output.seek(0) + return output + + +class ImageTools(commands.Cog): + """Simple image utilities.""" + + def __init__(self, bot): + self.bot = bot + self.session = aiohttp.ClientSession() + + def cog_unload(self): + self.bot.loop.create_task(self.session.close()) + + async def _get_image_url(self, ctx: commands.Context) -> Optional[str]: + if ctx.message.attachments: + for attachment in ctx.message.attachments: + if (attachment.content_type and attachment.content_type.startswith("image/")) or _is_image_filename(attachment.filename): + return attachment.url + + reference = ctx.message.reference + if reference and reference.resolved and isinstance(reference.resolved, discord.Message): + replied_message = reference.resolved + for attachment in replied_message.attachments: + if (attachment.content_type and attachment.content_type.startswith("image/")) or _is_image_filename(attachment.filename): + return attachment.url + + return None + + async def _download_image(self, url: str) -> bytes: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as resp: + if resp.status != 200: + raise RuntimeError(f"Image download failed with status {resp.status}.") + + content_type = resp.headers.get("Content-Type", "") + if "image" not in content_type.lower(): + raise RuntimeError("That URL does not look like an image.") + + data = await resp.read() + if len(data) > 15 * 1024 * 1024: + raise RuntimeError("Image is too large (max 15MB).") + + return data + + @commands.command(name="caption") + async def caption(self, ctx: commands.Context, *, text: str): + """Add a top caption bar to an image. + + Usage: + - Attach an image and run `[p]caption your text` + - Or reply to an image and run `[p]caption your text` + """ + caption_text = text.strip() + if not caption_text: + await ctx.send("Give me some caption text.") + return + + if len(caption_text) > 180: + await ctx.send("Keep the caption under 180 characters.") + return + + image_url = await self._get_image_url(ctx) + if not image_url: + await ctx.send("Attach an image or reply to an image message, then run the command.") + return + + try: + async with ctx.typing(): + raw = await self._download_image(image_url) + loop = self.bot.loop + output = await loop.run_in_executor(None, _build_caption_image, raw, caption_text) + + file = discord.File(output, filename="caption.png") + await ctx.send(file=file) + except Exception as exc: + await ctx.send(f"Could not caption that image: {exc}") diff --git a/image/info.json b/image/info.json new file mode 100644 index 0000000..624cee4 --- /dev/null +++ b/image/info.json @@ -0,0 +1,19 @@ +{ + "author": [ + "bencos17" + ], + "name": "Image", + "short": "Add simple captions to images.", + "description": "Adds a caption bar to an attached or replied image using the caption command.", + "install_msg": "Use `[p]caption ` with an attachment or by replying to an image.", + "requirements": [ + "Pillow" + ], + "tags": [ + "image", + "caption", + "utility" + ], + "type": "COG", + "end_user_data_statement": "This cog does not persistently store user data." +} \ No newline at end of file From b252c821dd6d9f16fb998890277e10dd274bf718 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:10:39 +0000 Subject: [PATCH 36/56] change name --- {image => imagemanipulation}/__init__.py | 0 image/image.py => imagemanipulation/imagemanipulation.py | 0 {image => imagemanipulation}/info.json | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename {image => imagemanipulation}/__init__.py (100%) rename image/image.py => imagemanipulation/imagemanipulation.py (100%) rename {image => imagemanipulation}/info.json (93%) diff --git a/image/__init__.py b/imagemanipulation/__init__.py similarity index 100% rename from image/__init__.py rename to imagemanipulation/__init__.py diff --git a/image/image.py b/imagemanipulation/imagemanipulation.py similarity index 100% rename from image/image.py rename to imagemanipulation/imagemanipulation.py diff --git a/image/info.json b/imagemanipulation/info.json similarity index 93% rename from image/info.json rename to imagemanipulation/info.json index 624cee4..c8f7e38 100644 --- a/image/info.json +++ b/imagemanipulation/info.json @@ -2,7 +2,7 @@ "author": [ "bencos17" ], - "name": "Image", + "name": "ImageManipulation", "short": "Add simple captions to images.", "description": "Adds a caption bar to an attached or replied image using the caption command.", "install_msg": "Use `[p]caption ` with an attachment or by replying to an image.", From 31c30243887d636c65a8604f3a50bd96054c3ff1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:15:03 +0000 Subject: [PATCH 37/56] test fix --- imagemanipulation/image.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 imagemanipulation/image.py diff --git a/imagemanipulation/image.py b/imagemanipulation/image.py new file mode 100644 index 0000000..9fa7a48 --- /dev/null +++ b/imagemanipulation/image.py @@ -0,0 +1,7 @@ +from .imagemanipulation import ImageManipulation + + +class ImageTools(ImageManipulation): + """Backward-compatible alias for legacy imports.""" + + pass From 189038c95ebb573c41bef570aeaccf2b2040b038 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:36:28 +0000 Subject: [PATCH 38/56] t --- imagemanipulation/__init__.py | 4 ++-- imagemanipulation/imagemanipulation.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/imagemanipulation/__init__.py b/imagemanipulation/__init__.py index 5c6be34..731fb2f 100644 --- a/imagemanipulation/__init__.py +++ b/imagemanipulation/__init__.py @@ -1,5 +1,5 @@ -from .image import ImageTools +from .imagemanipulation import ImageManipulation async def setup(bot): - await bot.add_cog(ImageTools(bot)) + await bot.add_cog(ImageManipulation(bot)) diff --git a/imagemanipulation/imagemanipulation.py b/imagemanipulation/imagemanipulation.py index 36dbdeb..385706b 100644 --- a/imagemanipulation/imagemanipulation.py +++ b/imagemanipulation/imagemanipulation.py @@ -84,7 +84,7 @@ def _build_caption_image(raw_data: bytes, caption: str) -> io.BytesIO: return output -class ImageTools(commands.Cog): +class ImageManipulation(commands.Cog): """Simple image utilities.""" def __init__(self, bot): From f8b021c93478d2e6201d73258446d1196c4ea45a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:47:30 +0000 Subject: [PATCH 39/56] cog --- bloodontheclocktower/__init__.py | 5 + bloodontheclocktower/bloodontheclocktower.py | 400 +++++++++++++++++++ bloodontheclocktower/docs.md | 44 ++ bloodontheclocktower/info.json | 16 + 4 files changed, 465 insertions(+) create mode 100644 bloodontheclocktower/__init__.py create mode 100644 bloodontheclocktower/bloodontheclocktower.py create mode 100644 bloodontheclocktower/docs.md create mode 100644 bloodontheclocktower/info.json diff --git a/bloodontheclocktower/__init__.py b/bloodontheclocktower/__init__.py new file mode 100644 index 0000000..e8bcefd --- /dev/null +++ b/bloodontheclocktower/__init__.py @@ -0,0 +1,5 @@ +from .bloodontheclocktower import BloodOnTheClocktower + + +async def setup(bot): + await bot.add_cog(BloodOnTheClocktower(bot)) diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py new file mode 100644 index 0000000..c2ba913 --- /dev/null +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -0,0 +1,400 @@ +import random +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set, Tuple + +import discord +from redbot.core import commands + + +ROLE_INFO: Dict[str, str] = { + "Chef": "You start knowing how many pairs of evil players there are.", + "Investigator": "You start knowing that 1 of 2 players is a particular Minion.", + "Washerwoman": "You start knowing that 1 of 2 players is a particular Townsfolk.", + "Librarian": "You start knowing that 1 of 2 players is a particular Outsider (or that zero are in play).", + "Empath": "Each night, learn how many of your 2 alive neighbors are evil.", + "Fortune Teller": "Each night, choose 2 players; you learn if either is a Demon.", + "Undertaker": "Each night, learn which character died by execution today.", + "Monk": "Each night, choose a player (not yourself); they are safe from the Demon tonight.", + "Gossip": "Each day, you may make a public statement. Tonight, if true, a player dies.", + "Slayer": "Once per game, during the day, publicly choose a player; if they are the Demon, they die.", + "Soldier": "You are safe from the Demon.", + "Cannibal": "You have the ability of the recently killed executee. If they are evil, you are poisoned until a good player dies by execution.", + "Ravenkeeper": "If you die at night, choose a player; you learn their character.", + "Mayor": "If only 3 players live and no execution occurs, your team wins. If you die at night, another player might die instead.", + "Fool": "The first time you die, you do not.", + "Virgin": "The first time you are nominated, if the nominator is a Townsfolk, they are executed immediately.", + "Butler": "Each night, choose a player (not yourself); tomorrow, you may only vote if they are voting too.", + "Lunatic": "You think you are a Demon, but you are not. The Demon knows who you are.", + "Drunk": "You do not know you are the Drunk. You think you are a Townsfolk character, but you are not.", + "Recluse": "You might register as evil and as a Minion or Demon, even if dead.", + "Klutz": "When you learn that you died, publicly choose 1 alive player; if they are evil, your team loses.", + "Saint": "If you die by execution, your team loses.", + "Mutant": "If you are mad about being an Outsider, you might be executed.", + "Mezepheles": "You start knowing a secret word. The first good player to say this word becomes evil that night.", + "Poisoner": "Each night, choose a player; they are poisoned tonight and tomorrow day.", + "Spy": "Each night, you see the Grimoire. You might register as good and as a Townsfolk or Outsider, even if dead.", + "Marionette": "You think you are a good character, but you are not. The Demon knows who you are. You neighbor the Demon.", + "Wraith": "You may choose to open your eyes at night. You wake when other evil players do.", + "Scarlet Woman": "If there are 5 or more players alive and the Demon dies, you become the Demon.", + "Baron": "There are extra Outsiders in play. [+2 Outsiders]", + "Yaggababble": "You start knowing a secret phrase. For each time you said it publicly today, a player might die.", + "Imp": "Each night, choose a player; they die. If you kill yourself this way, a Minion becomes the Imp.", + "Vortox": "Each night, choose a player; they die. Townsfolk abilities yield false info. Each day, if no one is executed, evil wins.", + "Fang Gu": "Each night, choose a player; they die. The first Outsider this kills becomes an evil Fang Gu and you die instead. [+1 Outsider]", +} + +TOWNSFOLK = [ + "Chef", + "Investigator", + "Washerwoman", + "Librarian", + "Empath", + "Fortune Teller", + "Undertaker", + "Monk", + "Gossip", + "Slayer", + "Soldier", + "Cannibal", + "Ravenkeeper", + "Mayor", + "Fool", + "Virgin", +] + +OUTSIDERS = ["Butler", "Lunatic", "Drunk", "Recluse", "Klutz", "Saint", "Mutant"] +MINIONS = ["Mezepheles", "Poisoner", "Spy", "Marionette", "Wraith", "Scarlet Woman", "Baron"] +DEMONS = ["Yaggababble", "Imp", "Vortox", "Fang Gu"] + +# Player count -> (townsfolk, outsiders, minions, demons) +ROLE_DISTRIBUTION: Dict[int, Tuple[int, int, int, int]] = { + 5: (3, 0, 1, 1), + 6: (3, 1, 1, 1), + 7: (5, 0, 1, 1), + 8: (5, 1, 1, 1), + 9: (5, 2, 1, 1), + 10: (7, 0, 2, 1), + 11: (7, 1, 2, 1), + 12: (7, 2, 2, 1), + 13: (9, 0, 3, 1), + 14: (9, 1, 3, 1), + 15: (9, 2, 3, 1), +} + + +@dataclass +class GameState: + storyteller_id: int + channel_id: int + players: List[int] = field(default_factory=list) + started: bool = False + alive: Set[int] = field(default_factory=set) + roles: Dict[int, str] = field(default_factory=dict) + phase: str = "lobby" + day_number: int = 0 + + +class BloodOnTheClocktower(commands.Cog): + """Lightweight Blood on the Clocktower moderator-assist game cog.""" + + def __init__(self, bot): + self.bot = bot + self.games: Dict[int, GameState] = {} + + def _get_game(self, guild_id: int) -> Optional[GameState]: + return self.games.get(guild_id) + + def _is_storyteller(self, game: GameState, user_id: int) -> bool: + return game.storyteller_id == user_id + + def _assign_roles(self, count: int) -> List[str]: + tf, outs, mins, dems = ROLE_DISTRIBUTION[count] + selected = [] + selected.extend(random.sample(TOWNSFOLK, tf)) + selected.extend(random.sample(OUTSIDERS, outs)) + selected.extend(random.sample(MINIONS, mins)) + selected.extend(random.sample(DEMONS, dems)) + random.shuffle(selected) + return selected + + async def _dm_role(self, member: discord.Member, role_name: str) -> bool: + text = ROLE_INFO.get(role_name, "No description available.") + try: + await member.send(f"Your role is **{role_name}**.\n{text}") + return True + except discord.Forbidden: + return False + + async def _dm_storyteller(self, guild: discord.Guild, storyteller_id: int, message: str) -> bool: + storyteller = guild.get_member(storyteller_id) + if storyteller is None: + return False + try: + await storyteller.send(message) + return True + except discord.Forbidden: + return False + + @commands.group(name="botc") + @commands.guild_only() + async def botc(self, ctx: commands.Context): + """Blood on the Clocktower commands.""" + if ctx.invoked_subcommand is None: + await ctx.send_help() + + @botc.command(name="create") + async def botc_create(self, ctx: commands.Context): + """Create a new game lobby.""" + if ctx.guild.id in self.games: + await ctx.send("A game already exists in this server. Use `[p]botc end` first.") + return + + self.games[ctx.guild.id] = GameState( + storyteller_id=ctx.author.id, + channel_id=ctx.channel.id, + players=[ctx.author.id], + started=False, + phase="lobby", + ) + await ctx.send( + f"Lobby created by {ctx.author.mention}. Use `[p]botc join` to join. " + "Use `[p]botc start` when ready (5-15 players)." + ) + + @botc.command(name="join") + async def botc_join(self, ctx: commands.Context): + """Join the current lobby.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active lobby. Use `[p]botc create` first.") + return + if game.started: + await ctx.send("Game already started.") + return + if ctx.author.id in game.players: + await ctx.send("You are already in the lobby.") + return + + game.players.append(ctx.author.id) + await ctx.send(f"{ctx.author.mention} joined the lobby. Players: {len(game.players)}") + + @botc.command(name="leave") + async def botc_leave(self, ctx: commands.Context): + """Leave the lobby before the game starts.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + if game.started: + await ctx.send("You cannot leave after the game has started.") + return + if ctx.author.id not in game.players: + await ctx.send("You are not in the lobby.") + return + + game.players.remove(ctx.author.id) + if not game.players: + del self.games[ctx.guild.id] + await ctx.send("Lobby is empty. Game removed.") + return + + if game.storyteller_id == ctx.author.id: + game.storyteller_id = game.players[0] + await ctx.send( + f"{ctx.author.mention} left. New storyteller is <@{game.storyteller_id}>." + ) + return + + await ctx.send(f"{ctx.author.mention} left the lobby.") + + @botc.command(name="players") + async def botc_players(self, ctx: commands.Context): + """Show player list and state.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + + lines: List[str] = [] + for uid in game.players: + member = ctx.guild.get_member(uid) + name = member.display_name if member else f"Unknown ({uid})" + state = "alive" if (not game.started or uid in game.alive) else "dead" + tag = " (Storyteller)" if uid == game.storyteller_id else "" + lines.append(f"- {name}: {state}{tag}") + + await ctx.send("Players:\n" + "\n".join(lines)) + + @botc.command(name="start") + async def botc_start(self, ctx: commands.Context): + """Start game and assign roles.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + if game.started: + await ctx.send("Game already started.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can start the game.") + return + + player_count = len(game.players) + if player_count not in ROLE_DISTRIBUTION: + await ctx.send("Player count must be between 5 and 15.") + return + + roles = self._assign_roles(player_count) + game.roles = {uid: roles[idx] for idx, uid in enumerate(game.players)} + game.alive = set(game.players) + game.started = True + game.phase = "night" + game.day_number = 1 + + dm_failed: List[str] = [] + for uid in game.players: + member = ctx.guild.get_member(uid) + if not member: + continue + ok = await self._dm_role(member, game.roles[uid]) + if not ok: + dm_failed.append(member.display_name) + + msg = "Game started. Night 1 begins now. Roles have been sent by DM." + if dm_failed: + msg += "\nCould not DM: " + ", ".join(dm_failed) + msg += "\n`[p]botc reveal` sends assignment summary to storyteller DM only." + await ctx.send(msg) + + @botc.command(name="day") + async def botc_day(self, ctx: commands.Context): + """Switch to day phase.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can change phase.") + return + + game.phase = "day" + await ctx.send(f"It is now **Day {game.day_number}**.") + + @botc.command(name="night") + async def botc_night(self, ctx: commands.Context): + """Switch to night phase and advance day counter.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can change phase.") + return + + game.phase = "night" + game.day_number += 1 + await ctx.send(f"It is now **Night {game.day_number}**.") + + @botc.command(name="execute") + async def botc_execute(self, ctx: commands.Context, member: discord.Member): + """Mark a player dead by execution.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can execute players.") + return + if member.id not in game.players: + await ctx.send("That member is not in this game.") + return + if member.id not in game.alive: + await ctx.send("That player is already dead.") + return + + game.alive.remove(member.id) + role = game.roles.get(member.id, "Unknown") + await ctx.send(f"{member.mention} was executed and died.") + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"Execution result: {member.display_name} was **{role}**.", + ) + + @botc.command(name="kill") + async def botc_kill(self, ctx: commands.Context, member: discord.Member): + """Mark a player dead at night.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can kill players.") + return + if member.id not in game.players: + await ctx.send("That member is not in this game.") + return + if member.id not in game.alive: + await ctx.send("That player is already dead.") + return + + game.alive.remove(member.id) + await ctx.send(f"{member.mention} died in the night.") + + @botc.command(name="info") + async def botc_info(self, ctx: commands.Context, *, role_name: str): + """Show role description.""" + wanted = role_name.strip().lower() + matched = None + for role in ROLE_INFO: + if role.lower() == wanted: + matched = role + break + + if not matched: + await ctx.send("Unknown role name.") + return + + await ctx.send(f"**{matched}**: {ROLE_INFO[matched]}") + + @botc.command(name="reveal") + async def botc_reveal(self, ctx: commands.Context): + """Show storyteller the full assignment list.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can use this.") + return + + lines: List[str] = [] + for uid in game.players: + member = ctx.guild.get_member(uid) + name = member.display_name if member else f"Unknown ({uid})" + role = game.roles.get(uid, "Unknown") + lines.append(f"- {name}: {role}") + + ok = await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + "Assignments:\n" + "\n".join(lines), + ) + if ok: + await ctx.send("Sent assignments to storyteller DM.") + else: + await ctx.send("Could not DM storyteller. Check DM settings.") + + @botc.command(name="end") + async def botc_end(self, ctx: commands.Context): + """End and clear the current game.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can end this game.") + return + + del self.games[ctx.guild.id] + await ctx.send("Game ended and cleared.") diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md new file mode 100644 index 0000000..c9148ab --- /dev/null +++ b/bloodontheclocktower/docs.md @@ -0,0 +1,44 @@ +# bloodontheclocktower + +A Red-DiscordBot cog for running a lightweight Blood on the Clocktower-style game using a custom script. + +## Features + +- Create and manage a single game per guild. +- Players join/leave lobby. +- Start game with automatic role distribution based on player count. +- DM role cards to players. +- Simple day/night tracking. +- Mark players dead by execution or at night. +- Show alive/dead lists. +- Lookup role descriptions. + +## Install + +From your Red bot: + +```text +[p]repo add ben-cogs +[p]cog install ben-cogs bloodontheclocktower +[p]load bloodontheclocktower +``` + +## Commands + +Use the command group: `[p]botc` + +- `[p]botc create` - create a new lobby in the current channel. +- `[p]botc join` - join the current lobby. +- `[p]botc leave` - leave before the game starts. +- `[p]botc players` - show players and alive/dead state. +- `[p]botc start` - assign roles and start night 1. +- `[p]botc day` / `[p]botc night` - switch phase. +- `[p]botc execute @user` - mark a player dead by execution. +- `[p]botc kill @user` - mark a player dead at night. +- `[p]botc info ` - show role text. +- `[p]botc reveal` - storyteller-only assignment dump. +- `[p]botc end` - end and clear the game. + +## Notes + +This is a moderator/storyteller-assist implementation, not a full automation of every character interaction. diff --git a/bloodontheclocktower/info.json b/bloodontheclocktower/info.json new file mode 100644 index 0000000..ec442c7 --- /dev/null +++ b/bloodontheclocktower/info.json @@ -0,0 +1,16 @@ +{ + "author": [ + "benco" + ], + "name": "bloodontheclocktower", + "short": "Run a Blood on the Clocktower style game", + "description": "A Red-DiscordBot cog for running a lightweight Blood on the Clocktower game using a custom script.", + "min_bot_version": "3.5.0", + "requirements": [], + "tags": [ + "game", + "social deduction", + "blood on the clocktower" + ], + "type": "COG" +} \ No newline at end of file From 5d097eedf75f25de396c9ecf0467a8f80bd800c6 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:50:22 +0000 Subject: [PATCH 40/56] fix readme --- bloodontheclocktower/docs.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index c9148ab..46dd6d2 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -18,7 +18,7 @@ A Red-DiscordBot cog for running a lightweight Blood on the Clocktower-style gam From your Red bot: ```text -[p]repo add ben-cogs +[p]repo add ben-cogs https://github.com/BenCos17/ben-cogs [p]cog install ben-cogs bloodontheclocktower [p]load bloodontheclocktower ``` @@ -39,6 +39,4 @@ Use the command group: `[p]botc` - `[p]botc reveal` - storyteller-only assignment dump. - `[p]botc end` - end and clear the game. -## Notes -This is a moderator/storyteller-assist implementation, not a full automation of every character interaction. From 31989a32097f98f9bb6a97b08c7be56c7fe349c8 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:51:37 +0000 Subject: [PATCH 41/56] bot support --- bloodontheclocktower/bloodontheclocktower.py | 223 +++++++++++++++++-- bloodontheclocktower/docs.md | 15 +- 2 files changed, 217 insertions(+), 21 deletions(-) diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py index c2ba913..5bc267a 100644 --- a/bloodontheclocktower/bloodontheclocktower.py +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -90,6 +90,8 @@ class GameState: started: bool = False alive: Set[int] = field(default_factory=set) roles: Dict[int, str] = field(default_factory=dict) + bot_players: Dict[int, str] = field(default_factory=dict) + next_bot_id: int = 1 phase: str = "lobby" day_number: int = 0 @@ -107,6 +109,59 @@ def _get_game(self, guild_id: int) -> Optional[GameState]: def _is_storyteller(self, game: GameState, user_id: int) -> bool: return game.storyteller_id == user_id + def _player_name(self, guild: discord.Guild, game: GameState, uid: int) -> str: + if uid in game.bot_players: + return game.bot_players[uid] + member = guild.get_member(uid) + return member.display_name if member else f"Unknown ({uid})" + + def _new_bot_player(self, game: GameState) -> Tuple[int, str]: + bot_id = -game.next_bot_id + game.next_bot_id += 1 + return bot_id, f"Bot {game.next_bot_id - 1}" + + def _resolve_target(self, guild: discord.Guild, game: GameState, target: str) -> Optional[int]: + cleaned = target.strip() + if cleaned.startswith("<@") and cleaned.endswith(">"): + cleaned = cleaned.replace("<@", "").replace("!", "").replace(">", "") + + if cleaned.lstrip("-").isdigit(): + uid = int(cleaned) + if uid in game.players: + return uid + + lowered = cleaned.lower() + matches: List[int] = [] + for uid in game.players: + if uid in game.bot_players: + name = game.bot_players[uid] + else: + member = guild.get_member(uid) + if member is None: + continue + name = member.display_name + if name.lower() == lowered: + matches.append(uid) + + if len(matches) == 1: + return matches[0] + return None + + def _is_evil(self, role_name: str) -> bool: + return role_name in MINIONS or role_name in DEMONS + + def _check_win_state(self, game: GameState) -> Optional[str]: + alive_roles = [game.roles[uid] for uid in game.alive if uid in game.roles] + alive_demons = [r for r in alive_roles if r in DEMONS] + if not alive_demons: + return "Good wins: all Demons are dead." + + evil_alive = sum(1 for r in alive_roles if self._is_evil(r)) + good_alive = len(alive_roles) - evil_alive + if evil_alive >= good_alive: + return "Evil wins: evil players equal or outnumber good players." + return None + def _assign_roles(self, count: int) -> List[str]: tf, outs, mins, dems = ROLE_DISTRIBUTION[count] selected = [] @@ -217,14 +272,71 @@ async def botc_players(self, ctx: commands.Context): lines: List[str] = [] for uid in game.players: - member = ctx.guild.get_member(uid) - name = member.display_name if member else f"Unknown ({uid})" + name = self._player_name(ctx.guild, game, uid) state = "alive" if (not game.started or uid in game.alive) else "dead" tag = " (Storyteller)" if uid == game.storyteller_id else "" lines.append(f"- {name}: {state}{tag}") await ctx.send("Players:\n" + "\n".join(lines)) + @botc.command(name="addbots") + async def botc_addbots(self, ctx: commands.Context, count: int): + """Add AI bot players to the lobby.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + if game.started: + await ctx.send("Add bots before starting the game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can add bots.") + return + if count <= 0: + await ctx.send("Count must be greater than 0.") + return + + space_left = 15 - len(game.players) + to_add = min(count, space_left) + if to_add <= 0: + await ctx.send("Lobby is already at the 15-player maximum.") + return + + added_names: List[str] = [] + for _ in range(to_add): + uid, name = self._new_bot_player(game) + game.bot_players[uid] = name + game.players.append(uid) + added_names.append(name) + + await ctx.send( + f"Added {to_add} bot player(s): {', '.join(added_names)}. " + f"Total players: {len(game.players)}" + ) + + @botc.command(name="clearbots") + async def botc_clearbots(self, ctx: commands.Context): + """Remove all AI bot players from the lobby.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + if game.started: + await ctx.send("Cannot clear bots after game start.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can clear bots.") + return + + bot_ids = set(game.bot_players.keys()) + if not bot_ids: + await ctx.send("No bot players in the lobby.") + return + + game.players = [uid for uid in game.players if uid not in bot_ids] + game.bot_players.clear() + await ctx.send(f"Removed all bot players. Total players: {len(game.players)}") + @botc.command(name="start") async def botc_start(self, ctx: commands.Context): """Start game and assign roles.""" @@ -252,9 +364,11 @@ async def botc_start(self, ctx: commands.Context): game.day_number = 1 dm_failed: List[str] = [] + bot_assignments: List[str] = [] for uid in game.players: member = ctx.guild.get_member(uid) if not member: + bot_assignments.append(f"- {self._player_name(ctx.guild, game, uid)}: {game.roles[uid]}") continue ok = await self._dm_role(member, game.roles[uid]) if not ok: @@ -266,6 +380,13 @@ async def botc_start(self, ctx: commands.Context): msg += "\n`[p]botc reveal` sends assignment summary to storyteller DM only." await ctx.send(msg) + if bot_assignments: + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + "Bot role assignments:\n" + "\n".join(bot_assignments), + ) + @botc.command(name="day") async def botc_day(self, ctx: commands.Context): """Switch to day phase.""" @@ -296,7 +417,7 @@ async def botc_night(self, ctx: commands.Context): await ctx.send(f"It is now **Night {game.day_number}**.") @botc.command(name="execute") - async def botc_execute(self, ctx: commands.Context, member: discord.Member): + async def botc_execute(self, ctx: commands.Context, *, target: str): """Mark a player dead by execution.""" game = self._get_game(ctx.guild.id) if not game or not game.started: @@ -305,24 +426,30 @@ async def botc_execute(self, ctx: commands.Context, member: discord.Member): if not self._is_storyteller(game, ctx.author.id): await ctx.send("Only the storyteller can execute players.") return - if member.id not in game.players: - await ctx.send("That member is not in this game.") + target_id = self._resolve_target(ctx.guild, game, target) + if target_id is None: + await ctx.send("Could not find that player. Use mention, ID, or exact name (e.g. Bot 1).") return - if member.id not in game.alive: + if target_id not in game.alive: await ctx.send("That player is already dead.") return - game.alive.remove(member.id) - role = game.roles.get(member.id, "Unknown") - await ctx.send(f"{member.mention} was executed and died.") + game.alive.remove(target_id) + role = game.roles.get(target_id, "Unknown") + target_name = self._player_name(ctx.guild, game, target_id) + await ctx.send(f"{target_name} was executed and died.") await self._dm_storyteller( ctx.guild, game.storyteller_id, - f"Execution result: {member.display_name} was **{role}**.", + f"Execution result: {target_name} was **{role}**.", ) + winner = self._check_win_state(game) + if winner: + await ctx.send(winner) + @botc.command(name="kill") - async def botc_kill(self, ctx: commands.Context, member: discord.Member): + async def botc_kill(self, ctx: commands.Context, *, target: str): """Mark a player dead at night.""" game = self._get_game(ctx.guild.id) if not game or not game.started: @@ -331,15 +458,76 @@ async def botc_kill(self, ctx: commands.Context, member: discord.Member): if not self._is_storyteller(game, ctx.author.id): await ctx.send("Only the storyteller can kill players.") return - if member.id not in game.players: - await ctx.send("That member is not in this game.") + target_id = self._resolve_target(ctx.guild, game, target) + if target_id is None: + await ctx.send("Could not find that player. Use mention, ID, or exact name (e.g. Bot 1).") return - if member.id not in game.alive: + if target_id not in game.alive: await ctx.send("That player is already dead.") return - game.alive.remove(member.id) - await ctx.send(f"{member.mention} died in the night.") + game.alive.remove(target_id) + target_name = self._player_name(ctx.guild, game, target_id) + await ctx.send(f"{target_name} died in the night.") + + winner = self._check_win_state(game) + if winner: + await ctx.send(winner) + + @botc.command(name="aisteps") + async def botc_aisteps(self, ctx: commands.Context, steps: int = 1): + """Run AI actions for the current phase.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can run AI actions.") + return + if steps <= 0: + await ctx.send("Steps must be greater than 0.") + return + + max_steps = min(steps, 20) + logs: List[str] = [] + + for _ in range(max_steps): + if game.phase == "day": + candidates = [uid for uid in game.alive if uid != game.storyteller_id] + if not candidates: + logs.append("No valid day execution targets.") + break + target_id = random.choice(candidates) + game.alive.remove(target_id) + target_name = self._player_name(ctx.guild, game, target_id) + logs.append(f"Day AI executes {target_name}.") + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"AI execution role: {target_name} was **{game.roles.get(target_id, 'Unknown')}**.", + ) + else: + demon_ids = [ + uid for uid in game.alive if game.roles.get(uid) in DEMONS + ] + if not demon_ids: + logs.append("No alive Demon to act at night.") + break + candidates = [uid for uid in game.alive if uid not in demon_ids] + if not candidates: + logs.append("No valid night kill targets.") + break + target_id = random.choice(candidates) + game.alive.remove(target_id) + target_name = self._player_name(ctx.guild, game, target_id) + logs.append(f"Night AI kills {target_name}.") + + winner = self._check_win_state(game) + if winner: + logs.append(winner) + break + + await ctx.send("\n".join(logs)) @botc.command(name="info") async def botc_info(self, ctx: commands.Context, *, role_name: str): @@ -370,8 +558,7 @@ async def botc_reveal(self, ctx: commands.Context): lines: List[str] = [] for uid in game.players: - member = ctx.guild.get_member(uid) - name = member.display_name if member else f"Unknown ({uid})" + name = self._player_name(ctx.guild, game, uid) role = game.roles.get(uid, "Unknown") lines.append(f"- {name}: {role}") diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index 46dd6d2..179981a 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -6,10 +6,13 @@ A Red-DiscordBot cog for running a lightweight Blood on the Clocktower-style gam - Create and manage a single game per guild. - Players join/leave lobby. +- Storyteller can add AI bot players to fill seats. - Start game with automatic role distribution based on player count. - DM role cards to players. +- Keep role assignments hidden from public chat. - Simple day/night tracking. - Mark players dead by execution or at night. +- Run simple AI-driven day/night actions. - Show alive/dead lists. - Lookup role descriptions. @@ -31,12 +34,18 @@ Use the command group: `[p]botc` - `[p]botc join` - join the current lobby. - `[p]botc leave` - leave before the game starts. - `[p]botc players` - show players and alive/dead state. +- `[p]botc addbots ` - add AI bot players in lobby (storyteller only). +- `[p]botc clearbots` - remove all AI bot players before start (storyteller only). - `[p]botc start` - assign roles and start night 1. - `[p]botc day` / `[p]botc night` - switch phase. -- `[p]botc execute @user` - mark a player dead by execution. -- `[p]botc kill @user` - mark a player dead at night. +- `[p]botc execute ` - mark a player dead by execution (mention, ID, or exact name like `Bot 1`). +- `[p]botc kill ` - mark a player dead at night (mention, ID, or exact name like `Bot 1`). +- `[p]botc aisteps [count]` - run AI actions for current phase (storyteller only). - `[p]botc info ` - show role text. -- `[p]botc reveal` - storyteller-only assignment dump. +- `[p]botc reveal` - storyteller-only assignment dump to DM. - `[p]botc end` - end and clear the game. +## Notes +This is a moderator/storyteller-assist implementation, not a full automation of every character interaction. +AI actions are intentionally simple and random to support low-player or bot-heavy games. From 8b8de8af230e41343a3d021b6399771e714e871f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:55:18 +0000 Subject: [PATCH 42/56] stuff --- bloodontheclocktower/bloodontheclocktower.py | 192 +++++++++++++++++-- bloodontheclocktower/docs.md | 7 +- 2 files changed, 179 insertions(+), 20 deletions(-) diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py index 5bc267a..56032cb 100644 --- a/bloodontheclocktower/bloodontheclocktower.py +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -92,6 +92,10 @@ class GameState: roles: Dict[int, str] = field(default_factory=dict) bot_players: Dict[int, str] = field(default_factory=dict) next_bot_id: int = 1 + vote_open: bool = False + vote_target: Optional[int] = None + votes_yes: Set[int] = field(default_factory=set) + votes_no: Set[int] = field(default_factory=set) phase: str = "lobby" day_number: int = 0 @@ -150,6 +154,12 @@ def _resolve_target(self, guild: discord.Guild, game: GameState, target: str) -> def _is_evil(self, role_name: str) -> bool: return role_name in MINIONS or role_name in DEMONS + def _reset_vote(self, game: GameState): + game.vote_open = False + game.vote_target = None + game.votes_yes.clear() + game.votes_no.clear() + def _check_win_state(self, game: GameState) -> Optional[str]: alive_roles = [game.roles[uid] for uid in game.alive if uid in game.roles] alive_demons = [r for r in alive_roles if r in DEMONS] @@ -190,6 +200,15 @@ async def _dm_storyteller(self, guild: discord.Guild, storyteller_id: int, messa except discord.Forbidden: return False + async def _announce_cheat(self, guild: discord.Guild, game: GameState, detail: str): + channel = guild.get_channel(game.channel_id) + storyteller_name = self._player_name(guild, game, game.storyteller_id) + if isinstance(channel, (discord.TextChannel, discord.Thread)): + await channel.send( + "Debug cheat notice: " + f"{storyteller_name} used debug role access while being a player. {detail}" + ) + @commands.group(name="botc") @commands.guild_only() async def botc(self, ctx: commands.Context): @@ -399,6 +418,7 @@ async def botc_day(self, ctx: commands.Context): return game.phase = "day" + self._reset_vote(game) await ctx.send(f"It is now **Day {game.day_number}**.") @botc.command(name="night") @@ -414,17 +434,21 @@ async def botc_night(self, ctx: commands.Context): game.phase = "night" game.day_number += 1 + self._reset_vote(game) await ctx.send(f"It is now **Night {game.day_number}**.") @botc.command(name="execute") async def botc_execute(self, ctx: commands.Context, *, target: str): - """Mark a player dead by execution.""" + """Open an execution vote for a nominated player.""" game = self._get_game(ctx.guild.id) if not game or not game.started: await ctx.send("No active started game.") return if not self._is_storyteller(game, ctx.author.id): - await ctx.send("Only the storyteller can execute players.") + await ctx.send("Only the storyteller can open execution votes.") + return + if game.phase != "day": + await ctx.send("Execution votes can only be started during day phase.") return target_id = self._resolve_target(ctx.guild, game, target) if target_id is None: @@ -434,19 +458,101 @@ async def botc_execute(self, ctx: commands.Context, *, target: str): await ctx.send("That player is already dead.") return - game.alive.remove(target_id) - role = game.roles.get(target_id, "Unknown") target_name = self._player_name(ctx.guild, game, target_id) - await ctx.send(f"{target_name} was executed and died.") - await self._dm_storyteller( - ctx.guild, - game.storyteller_id, - f"Execution result: {target_name} was **{role}**.", + self._reset_vote(game) + game.vote_open = True + game.vote_target = target_id + + # Auto-cast votes for alive bot players to support bot-heavy lobbies. + auto_yes = 0 + auto_no = 0 + for uid in list(game.alive): + if uid == target_id or uid not in game.bot_players: + continue + if random.random() < 0.5: + game.votes_yes.add(uid) + auto_yes += 1 + else: + game.votes_no.add(uid) + auto_no += 1 + + await ctx.send( + f"Execution vote opened for **{target_name}**. " + "Alive players use `[p]botc vote yes` or `[p]botc vote no` then storyteller runs `[p]botc tally`." ) + if auto_yes or auto_no: + await ctx.send(f"Auto bot votes applied: {auto_yes} yes, {auto_no} no.") - winner = self._check_win_state(game) - if winner: - await ctx.send(winner) + @botc.command(name="vote") + async def botc_vote(self, ctx: commands.Context, choice: str): + """Cast your vote on the active execution vote.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if game.phase != "day": + await ctx.send("Voting is only available during day phase.") + return + if not game.vote_open or game.vote_target is None: + await ctx.send("No active execution vote. Storyteller can start one with `[p]botc execute `." ) + return + if ctx.author.id not in game.alive: + await ctx.send("Only alive players can vote.") + return + if ctx.author.id == game.vote_target: + await ctx.send("Nominated player cannot vote on their own execution.") + return + + normalized = choice.strip().lower() + if normalized not in {"yes", "no", "y", "n"}: + await ctx.send("Vote must be `yes` or `no`.") + return + + game.votes_yes.discard(ctx.author.id) + game.votes_no.discard(ctx.author.id) + if normalized in {"yes", "y"}: + game.votes_yes.add(ctx.author.id) + await ctx.send(f"{ctx.author.mention} voted **YES**.") + else: + game.votes_no.add(ctx.author.id) + await ctx.send(f"{ctx.author.mention} voted **NO**.") + + @botc.command(name="tally") + async def botc_tally(self, ctx: commands.Context): + """Close and resolve the current execution vote.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can tally votes.") + return + if not game.vote_open or game.vote_target is None: + await ctx.send("No active execution vote.") + return + + target_id = game.vote_target + target_name = self._player_name(ctx.guild, game, target_id) + yes_count = len(game.votes_yes) + no_count = len(game.votes_no) + self._reset_vote(game) + + if yes_count > no_count and target_id in game.alive: + game.alive.remove(target_id) + role = game.roles.get(target_id, "Unknown") + await ctx.send(f"Vote passed ({yes_count}-{no_count}). {target_name} is executed.") + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"Execution result: {target_name} was **{role}**.", + ) + + winner = self._check_win_state(game) + if winner: + await ctx.send(winner) + return + + await ctx.send(f"Vote failed ({yes_count}-{no_count}). No execution.") @botc.command(name="kill") async def botc_kill(self, ctx: commands.Context, *, target: str): @@ -498,14 +604,25 @@ async def botc_aisteps(self, ctx: commands.Context, steps: int = 1): logs.append("No valid day execution targets.") break target_id = random.choice(candidates) - game.alive.remove(target_id) target_name = self._player_name(ctx.guild, game, target_id) - logs.append(f"Day AI executes {target_name}.") - await self._dm_storyteller( - ctx.guild, - game.storyteller_id, - f"AI execution role: {target_name} was **{game.roles.get(target_id, 'Unknown')}**.", - ) + voters = [uid for uid in game.alive if uid != target_id] + yes_votes = 0 + no_votes = 0 + for _uid in voters: + if random.random() < 0.5: + yes_votes += 1 + else: + no_votes += 1 + if yes_votes > no_votes: + game.alive.remove(target_id) + logs.append(f"Day AI vote passes ({yes_votes}-{no_votes}); executes {target_name}.") + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"AI execution role: {target_name} was **{game.roles.get(target_id, 'Unknown')}**.", + ) + else: + logs.append(f"Day AI vote fails ({yes_votes}-{no_votes}); no execution.") else: demon_ids = [ uid for uid in game.alive if game.roles.get(uid) in DEMONS @@ -572,6 +689,43 @@ async def botc_reveal(self, ctx: commands.Context): else: await ctx.send("Could not DM storyteller. Check DM settings.") + @botc.command(name="debugrole") + async def botc_debugrole(self, ctx: commands.Context, *, target: str): + """Debug: storyteller can peek a player's role by target name/id/mention.""" + game = self._get_game(ctx.guild.id) + if not game or not game.started: + await ctx.send("No active started game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can use debug role peek.") + return + + target_id = self._resolve_target(ctx.guild, game, target) + if target_id is None: + await ctx.send("Could not find that player. Use mention, ID, or exact name (e.g. Bot 1).") + return + + role = game.roles.get(target_id, "Unknown") + target_name = self._player_name(ctx.guild, game, target_id) + ok = await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"Debug role peek: {target_name} is **{role}**.", + ) + if not ok: + await ctx.send("Could not DM storyteller. Check DM settings.") + return + + await ctx.send("Debug role sent to storyteller DM.") + + # If storyteller is also in the player list, this is a deliberate cheat disclosure. + if game.storyteller_id in game.players: + await self._announce_cheat( + ctx.guild, + game, + f"Peeked role for {target_name}.", + ) + @botc.command(name="end") async def botc_end(self, ctx: commands.Context): """End and clear the current game.""" diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index 179981a..4a88b02 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -38,14 +38,19 @@ Use the command group: `[p]botc` - `[p]botc clearbots` - remove all AI bot players before start (storyteller only). - `[p]botc start` - assign roles and start night 1. - `[p]botc day` / `[p]botc night` - switch phase. -- `[p]botc execute ` - mark a player dead by execution (mention, ID, or exact name like `Bot 1`). +- `[p]botc execute ` - open an execution vote for a target (day only, storyteller only). +- `[p]botc vote ` - cast your vote on the active execution vote (alive players). +- `[p]botc tally` - close the vote and resolve execution result (storyteller only). - `[p]botc kill ` - mark a player dead at night (mention, ID, or exact name like `Bot 1`). - `[p]botc aisteps [count]` - run AI actions for current phase (storyteller only). - `[p]botc info ` - show role text. - `[p]botc reveal` - storyteller-only assignment dump to DM. +- `[p]botc debugrole ` - storyteller debug role peek to DM. If storyteller is also a player, posts a public cheat notice in the game channel. - `[p]botc end` - end and clear the game. ## Notes This is a moderator/storyteller-assist implementation, not a full automation of every character interaction. AI actions are intentionally simple and random to support low-player or bot-heavy games. +Day AI actions now simulate votes before execution instead of always executing. +The storyteller can still be a player, but using debug role peeks while playing is publicly announced. From 8da98f0ec9d6cd43d8565232216d67919c465047 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:57:54 +0000 Subject: [PATCH 43/56] more tweaks --- bloodontheclocktower/bloodontheclocktower.py | 9 --------- bloodontheclocktower/docs.md | 1 + 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py index 56032cb..865d09a 100644 --- a/bloodontheclocktower/bloodontheclocktower.py +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -383,11 +383,9 @@ async def botc_start(self, ctx: commands.Context): game.day_number = 1 dm_failed: List[str] = [] - bot_assignments: List[str] = [] for uid in game.players: member = ctx.guild.get_member(uid) if not member: - bot_assignments.append(f"- {self._player_name(ctx.guild, game, uid)}: {game.roles[uid]}") continue ok = await self._dm_role(member, game.roles[uid]) if not ok: @@ -399,13 +397,6 @@ async def botc_start(self, ctx: commands.Context): msg += "\n`[p]botc reveal` sends assignment summary to storyteller DM only." await ctx.send(msg) - if bot_assignments: - await self._dm_storyteller( - ctx.guild, - game.storyteller_id, - "Bot role assignments:\n" + "\n".join(bot_assignments), - ) - @botc.command(name="day") async def botc_day(self, ctx: commands.Context): """Switch to day phase.""" diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index 4a88b02..e791657 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -54,3 +54,4 @@ This is a moderator/storyteller-assist implementation, not a full automation of AI actions are intentionally simple and random to support low-player or bot-heavy games. Day AI actions now simulate votes before execution instead of always executing. The storyteller can still be a player, but using debug role peeks while playing is publicly announced. +Bot role assignments are not automatically sent at game start; use reveal/debug commands when needed. (also shows player roles) From 5e9c49010e1390e362eea61f52af316c1a4257fe Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:02:20 +0000 Subject: [PATCH 44/56] more tweaks --- bloodontheclocktower/bloodontheclocktower.py | 228 ++++++++++++++++++- bloodontheclocktower/docs.md | 5 +- 2 files changed, 221 insertions(+), 12 deletions(-) diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py index 865d09a..0599320 100644 --- a/bloodontheclocktower/bloodontheclocktower.py +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -1,4 +1,5 @@ import random +import time from dataclasses import dataclass, field from typing import Dict, List, Optional, Set, Tuple @@ -96,6 +97,10 @@ class GameState: vote_target: Optional[int] = None votes_yes: Set[int] = field(default_factory=set) votes_no: Set[int] = field(default_factory=set) + first_night_no_kill_used: bool = False + ai_chat_enabled: bool = True + suspicion: Dict[int, float] = field(default_factory=dict) + last_ai_chat_ts: float = 0.0 phase: str = "lobby" day_number: int = 0 @@ -168,10 +173,151 @@ def _check_win_state(self, game: GameState) -> Optional[str]: evil_alive = sum(1 for r in alive_roles if self._is_evil(r)) good_alive = len(alive_roles) - evil_alive - if evil_alive >= good_alive: - return "Evil wins: evil players equal or outnumber good players." + if evil_alive > good_alive: + return "Evil wins: evil players outnumber good players." return None + def _bot_vote_yes(self, game: GameState, voter_id: int, target_id: int) -> bool: + voter_role = game.roles.get(voter_id, "") + target_role = game.roles.get(target_id, "") + target_is_evil = self._is_evil(target_role) + voter_is_evil = self._is_evil(voter_role) + suspicion = game.suspicion.get(target_id, 0.0) + + if voter_is_evil: + yes_prob = 0.75 if not target_is_evil else 0.25 + else: + yes_prob = 0.70 if target_is_evil else 0.30 + + # Public suspicion influences votes, but alignment still dominates behavior. + yes_prob += max(-0.2, min(0.2, suspicion * 0.08)) + yes_prob = max(0.05, min(0.95, yes_prob)) + return random.random() < yes_prob + + def _pick_ai_day_target(self, game: GameState) -> Optional[int]: + candidates = [uid for uid in game.alive if uid != game.storyteller_id] + if not candidates: + return None + + # Slightly bias nominations toward evil, with enough noise to stay imperfect. + weights: List[float] = [] + for uid in candidates: + role = game.roles.get(uid, "") + base = 1.6 if self._is_evil(role) else 1.0 + suspicion_boost = max(0.0, game.suspicion.get(uid, 0.0)) + weights.append(base + suspicion_boost) + return random.choices(candidates, weights=weights, k=1)[0] + + def _pick_ai_night_target(self, game: GameState, demon_ids: List[int]) -> Optional[int]: + candidates = [uid for uid in game.alive if uid not in demon_ids] + if not candidates: + return None + + weights: List[float] = [] + for uid in candidates: + role = game.roles.get(uid, "") + target_is_evil = self._is_evil(role) + # Demons prefer good targets and often remove trusted voices. + base = 0.35 if target_is_evil else 1.5 + suspicion = game.suspicion.get(uid, 0.0) + trust_bonus = 0.8 if suspicion < 0 else 0.0 + weights.append(max(0.05, base + trust_bonus - (0.2 * max(0.0, suspicion)))) + + return random.choices(candidates, weights=weights, k=1)[0] + + def _adjust_suspicion(self, game: GameState, uid: int, delta: float): + current = game.suspicion.get(uid, 0.0) + game.suspicion[uid] = max(-2.0, min(4.0, current + delta)) + + def _extract_message_targets( + self, + guild: discord.Guild, + game: GameState, + content: str, + mention_ids: Set[int], + ) -> Set[int]: + targets: Set[int] = set(mention_ids) + lowered = content.lower() + for uid in game.alive: + if uid in game.bot_players: + name = game.bot_players[uid].lower() + else: + member = guild.get_member(uid) + if member is None: + continue + name = member.display_name.lower() + if name and name in lowered: + targets.add(uid) + return targets + + def _apply_message_inference(self, guild: discord.Guild, game: GameState, message: discord.Message): + if message.author.id not in game.players: + return + + text = message.content.lower() + if not text.strip(): + return + + accuse_words = {"evil", "sus", "suspicious", "demon", "minion", "lying", "liar", "execute", "vote"} + defend_words = {"good", "trust", "innocent", "clear", "safe", "town"} + self_claim_words = {"i am", "i'm", "im", "my role", "trust me"} + + mention_ids = {member.id for member in message.mentions if member.id in game.players} + targets = self._extract_message_targets(guild, game, message.content, mention_ids) + targets.discard(message.author.id) + + has_accuse = any(w in text for w in accuse_words) + has_defend = any(w in text for w in defend_words) + + if targets: + delta = 0.0 + if has_accuse: + delta += 0.55 + if has_defend: + delta -= 0.45 + if delta != 0.0: + for uid in targets: + self._adjust_suspicion(game, uid, delta) + + if any(w in text for w in self_claim_words): + # Self-claims slightly increase suspicion to avoid free trust. + self._adjust_suspicion(game, message.author.id, 0.15) + + def _build_ai_chat_line(self, guild: discord.Guild, game: GameState) -> Optional[str]: + alive_bots = [uid for uid in game.alive if uid in game.bot_players] + if not alive_bots: + return None + + speaker_id = random.choice(alive_bots) + speaker_name = self._player_name(guild, game, speaker_id) + candidates = [uid for uid in game.alive if uid != speaker_id] + if not candidates: + return None + + top_target = max(candidates, key=lambda uid: game.suspicion.get(uid, 0.0)) + target_name = self._player_name(guild, game, top_target) + suspicion = game.suspicion.get(top_target, 0.0) + + if suspicion >= 1.0: + templates = [ + f"{speaker_name}: I don't trust {target_name} right now.", + f"{speaker_name}: {target_name} feels like the best execution today.", + f"{speaker_name}: My read is that {target_name} is likely evil.", + ] + elif suspicion <= -0.5: + templates = [ + f"{speaker_name}: I think {target_name} is probably good.", + f"{speaker_name}: I'd rather not execute {target_name} today.", + f"{speaker_name}: {target_name} sounds more trustworthy to me.", + ] + else: + templates = [ + f"{speaker_name}: I'm still unsure. Need more info before voting.", + f"{speaker_name}: Not convinced yet, can we hear more claims?", + f"{speaker_name}: I want to compare stories before we execute.", + ] + return random.choice(templates) + def _assign_roles(self, count: int) -> List[str]: tf, outs, mins, dems = ROLE_DISTRIBUTION[count] selected = [] @@ -216,6 +362,41 @@ async def botc(self, ctx: commands.Context): if ctx.invoked_subcommand is None: await ctx.send_help() + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot or message.guild is None: + return + + game = self._get_game(message.guild.id) + if not game or not game.started: + return + if not game.ai_chat_enabled or message.channel.id != game.channel_id: + return + if message.author.id not in game.players: + return + + content = message.content.strip() + if not content: + return + + valid_prefixes = await self.bot.get_valid_prefixes(message.guild) + if any(content.startswith(prefix) for prefix in valid_prefixes): + return + + self._apply_message_inference(message.guild, game, message) + + if game.phase != "day": + return + if time.time() - game.last_ai_chat_ts < 10: + return + if random.random() >= 0.35: + return + + line = self._build_ai_chat_line(message.guild, game) + if line: + game.last_ai_chat_ts = time.time() + await message.channel.send(line) + @botc.command(name="create") async def botc_create(self, ctx: commands.Context): """Create a new game lobby.""" @@ -381,6 +562,9 @@ async def botc_start(self, ctx: commands.Context): game.started = True game.phase = "night" game.day_number = 1 + game.first_night_no_kill_used = False + game.suspicion = {uid: 0.0 for uid in game.players} + game.last_ai_chat_ts = 0.0 dm_failed: List[str] = [] for uid in game.players: @@ -428,6 +612,21 @@ async def botc_night(self, ctx: commands.Context): self._reset_vote(game) await ctx.send(f"It is now **Night {game.day_number}**.") + @botc.command(name="aichat") + async def botc_aichat(self, ctx: commands.Context, enabled: bool): + """Enable or disable AI chat reactions to player messages.""" + game = self._get_game(ctx.guild.id) + if not game: + await ctx.send("No active game.") + return + if not self._is_storyteller(game, ctx.author.id): + await ctx.send("Only the storyteller can change AI chat settings.") + return + + game.ai_chat_enabled = enabled + state = "enabled" if enabled else "disabled" + await ctx.send(f"AI chat reactions are now {state}.") + @botc.command(name="execute") async def botc_execute(self, ctx: commands.Context, *, target: str): """Open an execution vote for a nominated player.""" @@ -460,7 +659,7 @@ async def botc_execute(self, ctx: commands.Context, *, target: str): for uid in list(game.alive): if uid == target_id or uid not in game.bot_players: continue - if random.random() < 0.5: + if self._bot_vote_yes(game, uid, target_id): game.votes_yes.add(uid) auto_yes += 1 else: @@ -530,6 +729,7 @@ async def botc_tally(self, ctx: commands.Context): if yes_count > no_count and target_id in game.alive: game.alive.remove(target_id) + game.suspicion.pop(target_id, None) role = game.roles.get(target_id, "Unknown") await ctx.send(f"Vote passed ({yes_count}-{no_count}). {target_name} is executed.") await self._dm_storyteller( @@ -564,6 +764,7 @@ async def botc_kill(self, ctx: commands.Context, *, target: str): return game.alive.remove(target_id) + game.suspicion.pop(target_id, None) target_name = self._player_name(ctx.guild, game, target_id) await ctx.send(f"{target_name} died in the night.") @@ -590,22 +791,22 @@ async def botc_aisteps(self, ctx: commands.Context, steps: int = 1): for _ in range(max_steps): if game.phase == "day": - candidates = [uid for uid in game.alive if uid != game.storyteller_id] - if not candidates: + target_id = self._pick_ai_day_target(game) + if target_id is None: logs.append("No valid day execution targets.") break - target_id = random.choice(candidates) target_name = self._player_name(ctx.guild, game, target_id) voters = [uid for uid in game.alive if uid != target_id] yes_votes = 0 no_votes = 0 - for _uid in voters: - if random.random() < 0.5: + for voter_id in voters: + if self._bot_vote_yes(game, voter_id, target_id): yes_votes += 1 else: no_votes += 1 if yes_votes > no_votes: game.alive.remove(target_id) + game.suspicion.pop(target_id, None) logs.append(f"Day AI vote passes ({yes_votes}-{no_votes}); executes {target_name}.") await self._dm_storyteller( ctx.guild, @@ -615,18 +816,23 @@ async def botc_aisteps(self, ctx: commands.Context, steps: int = 1): else: logs.append(f"Day AI vote fails ({yes_votes}-{no_votes}); no execution.") else: + if game.day_number == 1 and not game.first_night_no_kill_used: + game.first_night_no_kill_used = True + logs.append("Night 1 protection: no AI night kill this night.") + continue + demon_ids = [ uid for uid in game.alive if game.roles.get(uid) in DEMONS ] if not demon_ids: logs.append("No alive Demon to act at night.") break - candidates = [uid for uid in game.alive if uid not in demon_ids] - if not candidates: + target_id = self._pick_ai_night_target(game, demon_ids) + if target_id is None: logs.append("No valid night kill targets.") break - target_id = random.choice(candidates) game.alive.remove(target_id) + game.suspicion.pop(target_id, None) target_name = self._player_name(ctx.guild, game, target_id) logs.append(f"Night AI kills {target_name}.") diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index e791657..cba6cc2 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -43,6 +43,7 @@ Use the command group: `[p]botc` - `[p]botc tally` - close the vote and resolve execution result (storyteller only). - `[p]botc kill ` - mark a player dead at night (mention, ID, or exact name like `Bot 1`). - `[p]botc aisteps [count]` - run AI actions for current phase (storyteller only). +- `[p]botc aichat ` - enable or disable AI chat reactions in the game channel (storyteller only). - `[p]botc info ` - show role text. - `[p]botc reveal` - storyteller-only assignment dump to DM. - `[p]botc debugrole ` - storyteller debug role peek to DM. If storyteller is also a player, posts a public cheat notice in the game channel. @@ -54,4 +55,6 @@ This is a moderator/storyteller-assist implementation, not a full automation of AI actions are intentionally simple and random to support low-player or bot-heavy games. Day AI actions now simulate votes before execution instead of always executing. The storyteller can still be a player, but using debug role peeks while playing is publicly announced. -Bot role assignments are not automatically sent at game start; use reveal/debug commands when needed. (also shows player roles) +Bot role assignments are not automatically sent at game start; use reveal/debug commands when needed. +Balance tweaks: evil wins only when evil outnumbers good (not on tie), and AI skips the first-night kill. +AI now tracks suspicion from player chat, uses it for votes and targets, and can post in-channel bot reactions during day phase. From 88453b2a9aae881c1b1fbc31736be64d7022d59b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:06:08 +0000 Subject: [PATCH 45/56] fix MEZEPHELEs --- bloodontheclocktower/bloodontheclocktower.py | 96 ++++++++++++++++++-- bloodontheclocktower/docs.md | 1 + 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py index 0599320..ca2ccb4 100644 --- a/bloodontheclocktower/bloodontheclocktower.py +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -44,6 +44,21 @@ "Fang Gu": "Each night, choose a player; they die. The first Outsider this kills becomes an evil Fang Gu and you die instead. [+1 Outsider]", } +MEZEPHELES_WORDS = [ + "clock", + "tower", + "lantern", + "midnight", + "whisper", + "raven", + "grimoire", + "token", + "fortune", + "candle", + "puzzle", + "echo", +] + TOWNSFOLK = [ "Chef", "Investigator", @@ -101,6 +116,10 @@ class GameState: ai_chat_enabled: bool = True suspicion: Dict[int, float] = field(default_factory=dict) last_ai_chat_ts: float = 0.0 + turned_evil: Set[int] = field(default_factory=set) + mezepheles_word: Optional[str] = None + mezepheles_triggered: bool = False + mezepheles_pending_convert: Optional[int] = None phase: str = "lobby" day_number: int = 0 @@ -159,6 +178,11 @@ def _resolve_target(self, guild: discord.Guild, game: GameState, target: str) -> def _is_evil(self, role_name: str) -> bool: return role_name in MINIONS or role_name in DEMONS + def _is_evil_player(self, game: GameState, uid: int) -> bool: + if uid in game.turned_evil: + return True + return self._is_evil(game.roles.get(uid, "")) + def _reset_vote(self, game: GameState): game.vote_open = False game.vote_target = None @@ -171,17 +195,15 @@ def _check_win_state(self, game: GameState) -> Optional[str]: if not alive_demons: return "Good wins: all Demons are dead." - evil_alive = sum(1 for r in alive_roles if self._is_evil(r)) - good_alive = len(alive_roles) - evil_alive + evil_alive = sum(1 for uid in game.alive if self._is_evil_player(game, uid)) + good_alive = len(game.alive) - evil_alive if evil_alive > good_alive: return "Evil wins: evil players outnumber good players." return None def _bot_vote_yes(self, game: GameState, voter_id: int, target_id: int) -> bool: - voter_role = game.roles.get(voter_id, "") - target_role = game.roles.get(target_id, "") - target_is_evil = self._is_evil(target_role) - voter_is_evil = self._is_evil(voter_role) + target_is_evil = self._is_evil_player(game, target_id) + voter_is_evil = self._is_evil_player(game, voter_id) suspicion = game.suspicion.get(target_id, 0.0) if voter_is_evil: @@ -202,8 +224,7 @@ def _pick_ai_day_target(self, game: GameState) -> Optional[int]: # Slightly bias nominations toward evil, with enough noise to stay imperfect. weights: List[float] = [] for uid in candidates: - role = game.roles.get(uid, "") - base = 1.6 if self._is_evil(role) else 1.0 + base = 1.6 if self._is_evil_player(game, uid) else 1.0 suspicion_boost = max(0.0, game.suspicion.get(uid, 0.0)) weights.append(base + suspicion_boost) return random.choices(candidates, weights=weights, k=1)[0] @@ -215,8 +236,7 @@ def _pick_ai_night_target(self, game: GameState, demon_ids: List[int]) -> Option weights: List[float] = [] for uid in candidates: - role = game.roles.get(uid, "") - target_is_evil = self._is_evil(role) + target_is_evil = self._is_evil_player(game, uid) # Demons prefer good targets and often remove trusted voices. base = 0.35 if target_is_evil else 1.5 suspicion = game.suspicion.get(uid, 0.0) @@ -283,6 +303,16 @@ def _apply_message_inference(self, guild: discord.Guild, game: GameState, messag # Self-claims slightly increase suspicion to avoid free trust. self._adjust_suspicion(game, message.author.id, 0.15) + if ( + game.mezepheles_word + and not game.mezepheles_triggered + and game.mezepheles_pending_convert is None + and game.mezepheles_word.lower() in text + and message.author.id in game.alive + and not self._is_evil_player(game, message.author.id) + ): + game.mezepheles_pending_convert = message.author.id + def _build_ai_chat_line(self, guild: discord.Guild, game: GameState) -> Optional[str]: alive_bots = [uid for uid in game.alive if uid in game.bot_players] if not alive_bots: @@ -385,6 +415,15 @@ async def on_message(self, message: discord.Message): self._apply_message_inference(message.guild, game, message) + if game.mezepheles_pending_convert == message.author.id: + game.mezepheles_triggered = True + player_name = self._player_name(message.guild, game, message.author.id) + await self._dm_storyteller( + message.guild, + game.storyteller_id, + f"Mezepheles trigger: {player_name} said the secret word and will become evil tonight.", + ) + if game.phase != "day": return if time.time() - game.last_ai_chat_ts < 10: @@ -565,6 +604,10 @@ async def botc_start(self, ctx: commands.Context): game.first_night_no_kill_used = False game.suspicion = {uid: 0.0 for uid in game.players} game.last_ai_chat_ts = 0.0 + game.turned_evil.clear() + game.mezepheles_word = None + game.mezepheles_triggered = False + game.mezepheles_pending_convert = None dm_failed: List[str] = [] for uid in game.players: @@ -575,6 +618,20 @@ async def botc_start(self, ctx: commands.Context): if not ok: dm_failed.append(member.display_name) + mez_players = [uid for uid in game.players if game.roles.get(uid) == "Mezepheles"] + if mez_players: + game.mezepheles_word = random.choice(MEZEPHELES_WORDS) + for mez_uid in mez_players: + mez_member = ctx.guild.get_member(mez_uid) + if mez_member: + try: + await mez_member.send( + f"Your Mezepheles secret word is **{game.mezepheles_word}**. " + "The first good player to say it becomes evil tonight." + ) + except discord.Forbidden: + pass + msg = "Game started. Night 1 begins now. Roles have been sent by DM." if dm_failed: msg += "\nCould not DM: " + ", ".join(dm_failed) @@ -610,6 +667,25 @@ async def botc_night(self, ctx: commands.Context): game.phase = "night" game.day_number += 1 self._reset_vote(game) + + if game.mezepheles_pending_convert is not None: + convert_uid = game.mezepheles_pending_convert + game.mezepheles_pending_convert = None + if convert_uid in game.alive and not self._is_evil_player(game, convert_uid): + game.turned_evil.add(convert_uid) + converted_name = self._player_name(ctx.guild, game, convert_uid) + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"Mezepheles effect: {converted_name} has turned evil tonight.", + ) + convert_member = ctx.guild.get_member(convert_uid) + if convert_member: + try: + await convert_member.send("A dark influence takes hold. You are now evil.") + except discord.Forbidden: + pass + await ctx.send(f"It is now **Night {game.day_number}**.") @botc.command(name="aichat") diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index cba6cc2..8be2eb9 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -58,3 +58,4 @@ The storyteller can still be a player, but using debug role peeks while playing Bot role assignments are not automatically sent at game start; use reveal/debug commands when needed. Balance tweaks: evil wins only when evil outnumbers good (not on tie), and AI skips the first-night kill. AI now tracks suspicion from player chat, uses it for votes and targets, and can post in-channel bot reactions during day phase. +Mezepheles now gets a generated secret word by DM at game start; the first good player to say it is turned evil on the next night phase. From a055a6b82fff9faa660ca5e603d1dcedb583e066 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:13:58 +0000 Subject: [PATCH 46/56] more tweaks --- bloodontheclocktower/bloodontheclocktower.py | 161 +++++++----------- bloodontheclocktower/data/__init__.py | 14 ++ bloodontheclocktower/data/mezepheles_words.py | 14 ++ .../data/role_distribution.py | 14 ++ bloodontheclocktower/data/role_groups.py | 22 +++ bloodontheclocktower/data/role_info.py | 36 ++++ bloodontheclocktower/docs.md | 15 +- 7 files changed, 177 insertions(+), 99 deletions(-) create mode 100644 bloodontheclocktower/data/__init__.py create mode 100644 bloodontheclocktower/data/mezepheles_words.py create mode 100644 bloodontheclocktower/data/role_distribution.py create mode 100644 bloodontheclocktower/data/role_groups.py create mode 100644 bloodontheclocktower/data/role_info.py diff --git a/bloodontheclocktower/bloodontheclocktower.py b/bloodontheclocktower/bloodontheclocktower.py index ca2ccb4..6c64442 100644 --- a/bloodontheclocktower/bloodontheclocktower.py +++ b/bloodontheclocktower/bloodontheclocktower.py @@ -6,96 +6,15 @@ import discord from redbot.core import commands - -ROLE_INFO: Dict[str, str] = { - "Chef": "You start knowing how many pairs of evil players there are.", - "Investigator": "You start knowing that 1 of 2 players is a particular Minion.", - "Washerwoman": "You start knowing that 1 of 2 players is a particular Townsfolk.", - "Librarian": "You start knowing that 1 of 2 players is a particular Outsider (or that zero are in play).", - "Empath": "Each night, learn how many of your 2 alive neighbors are evil.", - "Fortune Teller": "Each night, choose 2 players; you learn if either is a Demon.", - "Undertaker": "Each night, learn which character died by execution today.", - "Monk": "Each night, choose a player (not yourself); they are safe from the Demon tonight.", - "Gossip": "Each day, you may make a public statement. Tonight, if true, a player dies.", - "Slayer": "Once per game, during the day, publicly choose a player; if they are the Demon, they die.", - "Soldier": "You are safe from the Demon.", - "Cannibal": "You have the ability of the recently killed executee. If they are evil, you are poisoned until a good player dies by execution.", - "Ravenkeeper": "If you die at night, choose a player; you learn their character.", - "Mayor": "If only 3 players live and no execution occurs, your team wins. If you die at night, another player might die instead.", - "Fool": "The first time you die, you do not.", - "Virgin": "The first time you are nominated, if the nominator is a Townsfolk, they are executed immediately.", - "Butler": "Each night, choose a player (not yourself); tomorrow, you may only vote if they are voting too.", - "Lunatic": "You think you are a Demon, but you are not. The Demon knows who you are.", - "Drunk": "You do not know you are the Drunk. You think you are a Townsfolk character, but you are not.", - "Recluse": "You might register as evil and as a Minion or Demon, even if dead.", - "Klutz": "When you learn that you died, publicly choose 1 alive player; if they are evil, your team loses.", - "Saint": "If you die by execution, your team loses.", - "Mutant": "If you are mad about being an Outsider, you might be executed.", - "Mezepheles": "You start knowing a secret word. The first good player to say this word becomes evil that night.", - "Poisoner": "Each night, choose a player; they are poisoned tonight and tomorrow day.", - "Spy": "Each night, you see the Grimoire. You might register as good and as a Townsfolk or Outsider, even if dead.", - "Marionette": "You think you are a good character, but you are not. The Demon knows who you are. You neighbor the Demon.", - "Wraith": "You may choose to open your eyes at night. You wake when other evil players do.", - "Scarlet Woman": "If there are 5 or more players alive and the Demon dies, you become the Demon.", - "Baron": "There are extra Outsiders in play. [+2 Outsiders]", - "Yaggababble": "You start knowing a secret phrase. For each time you said it publicly today, a player might die.", - "Imp": "Each night, choose a player; they die. If you kill yourself this way, a Minion becomes the Imp.", - "Vortox": "Each night, choose a player; they die. Townsfolk abilities yield false info. Each day, if no one is executed, evil wins.", - "Fang Gu": "Each night, choose a player; they die. The first Outsider this kills becomes an evil Fang Gu and you die instead. [+1 Outsider]", -} - -MEZEPHELES_WORDS = [ - "clock", - "tower", - "lantern", - "midnight", - "whisper", - "raven", - "grimoire", - "token", - "fortune", - "candle", - "puzzle", - "echo", -] - -TOWNSFOLK = [ - "Chef", - "Investigator", - "Washerwoman", - "Librarian", - "Empath", - "Fortune Teller", - "Undertaker", - "Monk", - "Gossip", - "Slayer", - "Soldier", - "Cannibal", - "Ravenkeeper", - "Mayor", - "Fool", - "Virgin", -] - -OUTSIDERS = ["Butler", "Lunatic", "Drunk", "Recluse", "Klutz", "Saint", "Mutant"] -MINIONS = ["Mezepheles", "Poisoner", "Spy", "Marionette", "Wraith", "Scarlet Woman", "Baron"] -DEMONS = ["Yaggababble", "Imp", "Vortox", "Fang Gu"] - -# Player count -> (townsfolk, outsiders, minions, demons) -ROLE_DISTRIBUTION: Dict[int, Tuple[int, int, int, int]] = { - 5: (3, 0, 1, 1), - 6: (3, 1, 1, 1), - 7: (5, 0, 1, 1), - 8: (5, 1, 1, 1), - 9: (5, 2, 1, 1), - 10: (7, 0, 2, 1), - 11: (7, 1, 2, 1), - 12: (7, 2, 2, 1), - 13: (9, 0, 3, 1), - 14: (9, 1, 3, 1), - 15: (9, 2, 3, 1), -} +from .data import ( + DEMONS, + MEZEPHELES_WORDS, + MINIONS, + OUTSIDERS, + ROLE_DISTRIBUTION, + ROLE_INFO, + TOWNSFOLK, +) @dataclass @@ -120,6 +39,7 @@ class GameState: mezepheles_word: Optional[str] = None mezepheles_triggered: bool = False mezepheles_pending_convert: Optional[int] = None + night_deaths: List[int] = field(default_factory=list) phase: str = "lobby" day_number: int = 0 @@ -385,7 +305,13 @@ async def _announce_cheat(self, guild: discord.Guild, game: GameState, detail: s f"{storyteller_name} used debug role access while being a player. {detail}" ) - @commands.group(name="botc") + async def _send_ctx(self, ctx: commands.Context, message: str, *, ephemeral: bool = False): + if ephemeral and getattr(ctx, "interaction", None) is not None: + await ctx.send(message, ephemeral=True) + return + await ctx.send(message) + + @commands.hybrid_group(name="botc") @commands.guild_only() async def botc(self, ctx: commands.Context): """Blood on the Clocktower commands.""" @@ -608,6 +534,7 @@ async def botc_start(self, ctx: commands.Context): game.mezepheles_word = None game.mezepheles_triggered = False game.mezepheles_pending_convert = None + game.night_deaths.clear() dm_failed: List[str] = [] for uid in game.players: @@ -653,6 +580,16 @@ async def botc_day(self, ctx: commands.Context): self._reset_vote(game) await ctx.send(f"It is now **Day {game.day_number}**.") + if game.night_deaths: + names = [self._player_name(ctx.guild, game, uid) for uid in game.night_deaths] + if len(names) == 1: + await ctx.send(f"At dawn, **{names[0]}** died in the night.") + else: + await ctx.send("At dawn, the following players died in the night: " + ", ".join(names)) + game.night_deaths.clear() + else: + await ctx.send("At dawn, nobody died in the night.") + @botc.command(name="night") async def botc_night(self, ctx: commands.Context): """Switch to night phase and advance day counter.""" @@ -823,7 +760,15 @@ async def botc_tally(self, ctx: commands.Context): @botc.command(name="kill") async def botc_kill(self, ctx: commands.Context, *, target: str): - """Mark a player dead at night.""" + """Mark a player dead at night silently (storyteller/private log).""" + await self._kill_player(ctx, target=target, announce=False) + + @botc.command(name="killpublic") + async def botc_killpublic(self, ctx: commands.Context, *, target: str): + """Mark a player dead at night and announce it publicly.""" + await self._kill_player(ctx, target=target, announce=True) + + async def _kill_player(self, ctx: commands.Context, *, target: str, announce: bool): game = self._get_game(ctx.guild.id) if not game or not game.started: await ctx.send("No active started game.") @@ -842,7 +787,20 @@ async def botc_kill(self, ctx: commands.Context, *, target: str): game.alive.remove(target_id) game.suspicion.pop(target_id, None) target_name = self._player_name(ctx.guild, game, target_id) - await ctx.send(f"{target_name} died in the night.") + + role = game.roles.get(target_id, "Unknown") + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"Night kill recorded: {target_name} ({role}).", + ) + + if announce: + await ctx.send(f"{target_name} died in the night.") + else: + if target_id not in game.night_deaths: + game.night_deaths.append(target_id) + await self._send_ctx(ctx, "Night kill recorded.", ephemeral=True) winner = self._check_win_state(game) if winner: @@ -910,7 +868,14 @@ async def botc_aisteps(self, ctx: commands.Context, steps: int = 1): game.alive.remove(target_id) game.suspicion.pop(target_id, None) target_name = self._player_name(ctx.guild, game, target_id) - logs.append(f"Night AI kills {target_name}.") + if target_id not in game.night_deaths: + game.night_deaths.append(target_id) + logs.append("Night AI kill recorded.") + await self._dm_storyteller( + ctx.guild, + game.storyteller_id, + f"AI night kill target: {target_name}.", + ) winner = self._check_win_state(game) if winner: @@ -958,9 +923,9 @@ async def botc_reveal(self, ctx: commands.Context): "Assignments:\n" + "\n".join(lines), ) if ok: - await ctx.send("Sent assignments to storyteller DM.") + await self._send_ctx(ctx, "Sent assignments to storyteller DM.", ephemeral=True) else: - await ctx.send("Could not DM storyteller. Check DM settings.") + await self._send_ctx(ctx, "Could not DM storyteller. Check DM settings.", ephemeral=True) @botc.command(name="debugrole") async def botc_debugrole(self, ctx: commands.Context, *, target: str): @@ -986,10 +951,10 @@ async def botc_debugrole(self, ctx: commands.Context, *, target: str): f"Debug role peek: {target_name} is **{role}**.", ) if not ok: - await ctx.send("Could not DM storyteller. Check DM settings.") + await self._send_ctx(ctx, "Could not DM storyteller. Check DM settings.", ephemeral=True) return - await ctx.send("Debug role sent to storyteller DM.") + await self._send_ctx(ctx, "Debug role sent to storyteller DM.", ephemeral=True) # If storyteller is also in the player list, this is a deliberate cheat disclosure. if game.storyteller_id in game.players: diff --git a/bloodontheclocktower/data/__init__.py b/bloodontheclocktower/data/__init__.py new file mode 100644 index 0000000..309e528 --- /dev/null +++ b/bloodontheclocktower/data/__init__.py @@ -0,0 +1,14 @@ +from .mezepheles_words import MEZEPHELES_WORDS +from .role_distribution import ROLE_DISTRIBUTION +from .role_groups import DEMONS, MINIONS, OUTSIDERS, TOWNSFOLK +from .role_info import ROLE_INFO + +__all__ = [ + "ROLE_INFO", + "MEZEPHELES_WORDS", + "TOWNSFOLK", + "OUTSIDERS", + "MINIONS", + "DEMONS", + "ROLE_DISTRIBUTION", +] diff --git a/bloodontheclocktower/data/mezepheles_words.py b/bloodontheclocktower/data/mezepheles_words.py new file mode 100644 index 0000000..d128d15 --- /dev/null +++ b/bloodontheclocktower/data/mezepheles_words.py @@ -0,0 +1,14 @@ +MEZEPHELES_WORDS = [ + "clock", + "tower", + "lantern", + "midnight", + "whisper", + "raven", + "grimoire", + "token", + "fortune", + "candle", + "puzzle", + "echo", +] diff --git a/bloodontheclocktower/data/role_distribution.py b/bloodontheclocktower/data/role_distribution.py new file mode 100644 index 0000000..1ed4b33 --- /dev/null +++ b/bloodontheclocktower/data/role_distribution.py @@ -0,0 +1,14 @@ +# Player count -> (townsfolk, outsiders, minions, demons) +ROLE_DISTRIBUTION = { + 5: (3, 0, 1, 1), + 6: (3, 1, 1, 1), + 7: (5, 0, 1, 1), + 8: (5, 1, 1, 1), + 9: (5, 2, 1, 1), + 10: (7, 0, 2, 1), + 11: (7, 1, 2, 1), + 12: (7, 2, 2, 1), + 13: (9, 0, 3, 1), + 14: (9, 1, 3, 1), + 15: (9, 2, 3, 1), +} diff --git a/bloodontheclocktower/data/role_groups.py b/bloodontheclocktower/data/role_groups.py new file mode 100644 index 0000000..87da554 --- /dev/null +++ b/bloodontheclocktower/data/role_groups.py @@ -0,0 +1,22 @@ +TOWNSFOLK = [ + "Chef", + "Investigator", + "Washerwoman", + "Librarian", + "Empath", + "Fortune Teller", + "Undertaker", + "Monk", + "Gossip", + "Slayer", + "Soldier", + "Cannibal", + "Ravenkeeper", + "Mayor", + "Fool", + "Virgin", +] + +OUTSIDERS = ["Butler", "Lunatic", "Drunk", "Recluse", "Klutz", "Saint", "Mutant"] +MINIONS = ["Mezepheles", "Poisoner", "Spy", "Marionette", "Wraith", "Scarlet Woman", "Baron"] +DEMONS = ["Yaggababble", "Imp", "Vortox", "Fang Gu"] diff --git a/bloodontheclocktower/data/role_info.py b/bloodontheclocktower/data/role_info.py new file mode 100644 index 0000000..b6c35b4 --- /dev/null +++ b/bloodontheclocktower/data/role_info.py @@ -0,0 +1,36 @@ +ROLE_INFO = { + "Chef": "You start knowing how many pairs of evil players there are.", + "Investigator": "You start knowing that 1 of 2 players is a particular Minion.", + "Washerwoman": "You start knowing that 1 of 2 players is a particular Townsfolk.", + "Librarian": "You start knowing that 1 of 2 players is a particular Outsider (or that zero are in play).", + "Empath": "Each night, learn how many of your 2 alive neighbors are evil.", + "Fortune Teller": "Each night, choose 2 players; you learn if either is a Demon.", + "Undertaker": "Each night, learn which character died by execution today.", + "Monk": "Each night, choose a player (not yourself); they are safe from the Demon tonight.", + "Gossip": "Each day, you may make a public statement. Tonight, if true, a player dies.", + "Slayer": "Once per game, during the day, publicly choose a player; if they are the Demon, they die.", + "Soldier": "You are safe from the Demon.", + "Cannibal": "You have the ability of the recently killed executee. If they are evil, you are poisoned until a good player dies by execution.", + "Ravenkeeper": "If you die at night, choose a player; you learn their character.", + "Mayor": "If only 3 players live and no execution occurs, your team wins. If you die at night, another player might die instead.", + "Fool": "The first time you die, you do not.", + "Virgin": "The first time you are nominated, if the nominator is a Townsfolk, they are executed immediately.", + "Butler": "Each night, choose a player (not yourself); tomorrow, you may only vote if they are voting too.", + "Lunatic": "You think you are a Demon, but you are not. The Demon knows who you are.", + "Drunk": "You do not know you are the Drunk. You think you are a Townsfolk character, but you are not.", + "Recluse": "You might register as evil and as a Minion or Demon, even if dead.", + "Klutz": "When you learn that you died, publicly choose 1 alive player; if they are evil, your team loses.", + "Saint": "If you die by execution, your team loses.", + "Mutant": "If you are mad about being an Outsider, you might be executed.", + "Mezepheles": "You start knowing a secret word. The first good player to say this word becomes evil that night.", + "Poisoner": "Each night, choose a player; they are poisoned tonight and tomorrow day.", + "Spy": "Each night, you see the Grimoire. You might register as good and as a Townsfolk or Outsider, even if dead.", + "Marionette": "You think you are a good character, but you are not. The Demon knows who you are. You neighbor the Demon.", + "Wraith": "You may choose to open your eyes at night. You wake when other evil players do.", + "Scarlet Woman": "If there are 5 or more players alive and the Demon dies, you become the Demon.", + "Baron": "There are extra Outsiders in play. [+2 Outsiders]", + "Yaggababble": "You start knowing a secret phrase. For each time you said it publicly today, a player might die.", + "Imp": "Each night, choose a player; they die. If you kill yourself this way, a Minion becomes the Imp.", + "Vortox": "Each night, choose a player; they die. Townsfolk abilities yield false info. Each day, if no one is executed, evil wins.", + "Fang Gu": "Each night, choose a player; they die. The first Outsider this kills becomes an evil Fang Gu and you die instead. [+1 Outsider]", +} diff --git a/bloodontheclocktower/docs.md b/bloodontheclocktower/docs.md index 8be2eb9..bb49fcb 100644 --- a/bloodontheclocktower/docs.md +++ b/bloodontheclocktower/docs.md @@ -30,6 +30,8 @@ From your Red bot: Use the command group: `[p]botc` +All commands are also available as slash commands under `/botc`. + - `[p]botc create` - create a new lobby in the current channel. - `[p]botc join` - join the current lobby. - `[p]botc leave` - leave before the game starts. @@ -41,7 +43,8 @@ Use the command group: `[p]botc` - `[p]botc execute ` - open an execution vote for a target (day only, storyteller only). - `[p]botc vote ` - cast your vote on the active execution vote (alive players). - `[p]botc tally` - close the vote and resolve execution result (storyteller only). -- `[p]botc kill ` - mark a player dead at night (mention, ID, or exact name like `Bot 1`). +- `[p]botc kill ` - mark a player dead at night silently (private/storyteller logging). Death is announced by name at next day start. +- `[p]botc killpublic ` - mark a player dead at night and announce publicly immediately. - `[p]botc aisteps [count]` - run AI actions for current phase (storyteller only). - `[p]botc aichat ` - enable or disable AI chat reactions in the game channel (storyteller only). - `[p]botc info ` - show role text. @@ -59,3 +62,13 @@ Bot role assignments are not automatically sent at game start; use reveal/debug Balance tweaks: evil wins only when evil outnumbers good (not on tie), and AI skips the first-night kill. AI now tracks suspicion from player chat, uses it for votes and targets, and can post in-channel bot reactions during day phase. Mezepheles now gets a generated secret word by DM at game start; the first good player to say it is turned evil on the next night phase. +Night deaths are buffered and revealed at dawn with player names when day starts. + +## Data Layout + +Static script data is now split into separate files under `data/`: + +- `data/role_info.py` - role descriptions. +- `data/role_groups.py` - Townsfolk/Outsider/Minion/Demon pools. +- `data/role_distribution.py` - player-count distribution table. +- `data/mezepheles_words.py` - Mezepheles secret word list. From eaabea3bd54b752a2ba38cb60bdb223e7898528d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:07:28 +0000 Subject: [PATCH 47/56] gif support --- imagemanipulation/imagemanipulation.py | 47 +++++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/imagemanipulation/imagemanipulation.py b/imagemanipulation/imagemanipulation.py index 385706b..5ce13c2 100644 --- a/imagemanipulation/imagemanipulation.py +++ b/imagemanipulation/imagemanipulation.py @@ -1,9 +1,9 @@ import io -from typing import Any, List, Optional +from typing import Any, List, Optional, Tuple import aiohttp import discord -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw, ImageFont, ImageSequence from redbot.core import commands @@ -44,10 +44,8 @@ def _wrap_caption(draw: ImageDraw.ImageDraw, text: str, font: Any, max_width: in return lines -def _build_caption_image(raw_data: bytes, caption: str) -> io.BytesIO: - with Image.open(io.BytesIO(raw_data)) as source: - image = source.convert("RGB") - +def _add_caption_banner(image: Image.Image, caption: str) -> Image.Image: + image = image.convert("RGB") width, height = image.size side_padding = max(12, width // 32) top_padding = max(10, width // 50) @@ -78,10 +76,41 @@ def _build_caption_image(raw_data: bytes, caption: str) -> io.BytesIO: final_draw.text((x, y), line, fill=(0, 0, 0), font=font) y += line_height + line_spacing + return final + + +def _build_caption_image(raw_data: bytes, caption: str) -> Tuple[io.BytesIO, str]: + with Image.open(io.BytesIO(raw_data)) as source: + is_gif = source.format == "GIF" + + if is_gif: + frames: List[Image.Image] = [] + durations: List[int] = [] + for frame in ImageSequence.Iterator(source): + captioned = _add_caption_banner(frame, caption) + frames.append(captioned.quantize(colors=256, method=Image.Quantize.FASTOCTREE)) + durations.append(frame.info.get("duration", source.info.get("duration", 40))) + + if frames: + output = io.BytesIO() + frames[0].save( + output, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=durations, + loop=source.info.get("loop", 0), + disposal=2, + ) + output.seek(0) + return output, "caption.gif" + + final = _add_caption_banner(source, caption) + output = io.BytesIO() final.save(output, format="PNG") output.seek(0) - return output + return output, "caption.png" class ImageManipulation(commands.Cog): @@ -150,9 +179,9 @@ async def caption(self, ctx: commands.Context, *, text: str): async with ctx.typing(): raw = await self._download_image(image_url) loop = self.bot.loop - output = await loop.run_in_executor(None, _build_caption_image, raw, caption_text) + output, filename = await loop.run_in_executor(None, _build_caption_image, raw, caption_text) - file = discord.File(output, filename="caption.png") + file = discord.File(output, filename=filename) await ctx.send(file=file) except Exception as exc: await ctx.send(f"Could not caption that image: {exc}") From a5072e9a28b6fbf9da509710f56638e9c8b9f5ba Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:09:25 +0000 Subject: [PATCH 48/56] handle an annoying edge case --- imagemanipulation/imagemanipulation.py | 52 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/imagemanipulation/imagemanipulation.py b/imagemanipulation/imagemanipulation.py index 5ce13c2..b4c3cd3 100644 --- a/imagemanipulation/imagemanipulation.py +++ b/imagemanipulation/imagemanipulation.py @@ -1,4 +1,5 @@ import io +import re from typing import Any, List, Optional, Tuple import aiohttp @@ -12,6 +13,13 @@ def _is_image_filename(filename: str) -> bool: return lowered.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")) +def _looks_like_image_url(url: str) -> bool: + base = url.split("?", 1)[0].split("#", 1)[0] + if _is_image_filename(base): + return True + return "media.tenor.com" in url.lower() + + def _pick_font(image_width: int) -> Any: # Prefer a truetype font for cleaner rendering, but gracefully fall back. font_size = max(22, min(72, image_width // 12)) @@ -124,17 +132,49 @@ def cog_unload(self): self.bot.loop.create_task(self.session.close()) async def _get_image_url(self, ctx: commands.Context) -> Optional[str]: - if ctx.message.attachments: - for attachment in ctx.message.attachments: + def _find_attachment_image(message: discord.Message) -> Optional[str]: + for attachment in message.attachments: if (attachment.content_type and attachment.content_type.startswith("image/")) or _is_image_filename(attachment.filename): return attachment.url + for embed in message.embeds: + candidates = [ + getattr(embed.image, "url", None), + getattr(embed.thumbnail, "url", None), + getattr(embed.video, "url", None), + embed.url, + ] + for candidate in candidates: + if candidate and _looks_like_image_url(candidate): + return candidate + + for link in re.findall(r"https?://\S+", message.content): + if _looks_like_image_url(link): + return link + + return None + + from_current = _find_attachment_image(ctx.message) + if from_current: + return from_current + reference = ctx.message.reference - if reference and reference.resolved and isinstance(reference.resolved, discord.Message): + if not reference: + return None + + replied_message: Optional[discord.Message] = None + if reference.resolved and isinstance(reference.resolved, discord.Message): replied_message = reference.resolved - for attachment in replied_message.attachments: - if (attachment.content_type and attachment.content_type.startswith("image/")) or _is_image_filename(attachment.filename): - return attachment.url + elif reference.message_id: + try: + replied_message = await ctx.channel.fetch_message(reference.message_id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + replied_message = None + + if replied_message: + from_reply = _find_attachment_image(replied_message) + if from_reply: + return from_reply return None From 25e6dfb1dfe2b3229875d223ddf87b49b7b2b4fe Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:35:52 +0100 Subject: [PATCH 49/56] Update counter.py --- counter/counter.py | 146 ++++++++++++++++++++++----------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/counter/counter.py b/counter/counter.py index 387d15b..05a92aa 100644 --- a/counter/counter.py +++ b/counter/counter.py @@ -136,79 +136,6 @@ async def _create_pending_request(self, store, name_key: str, initial: int, requ return str(rid) -class OwnerApprovalView(discord.ui.View): - def __init__(self, cog: "Counter", guild_id: int, req_id: str, request_data: dict, *, timeout: Optional[float] = 86400): - super().__init__(timeout=timeout) - self.cog = cog - self.guild_id = guild_id - self.req_id = req_id - self.request_data = request_data - - async def _finalize(self, interaction: discord.Interaction, accepted: bool, message_text: str): - # Attempt to remove pending request and notify requester - guild = self.cog.bot.get_guild(self.guild_id) - store = self.cog.config.guild(guild) - async with store.pending_owner_requests() as reqs: - if self.req_id in reqs: - del reqs[self.req_id] - # Disable buttons - for item in self.children: - try: - item.disabled = True - except Exception: - pass - try: - await interaction.response.edit_message(content=message_text, view=self) - except Exception: - try: - await interaction.response.send_message(message_text, ephemeral=True) - except Exception: - pass - # Notify requester in the original channel if possible - channel_id = self.request_data.get("channel_id") - try: - if channel_id is not None: - channel = self.cog.bot.get_channel(int(channel_id)) - if channel is not None: - try: - await channel.send(f"<@{self.request_data.get('requester')}> Your owner request `{self.req_id}` for counter **{self.request_data.get('name')}** was {'accepted' if accepted else 'declined'}.") - except Exception: - pass - except Exception: - pass - # Also DM the requester if possible - requester_id = self.request_data.get("requester") - try: - requester = self.cog.bot.get_user(int(requester_id)) if requester_id is not None else None - except Exception: - requester = None - if requester: - try: - await requester.send(f"Your owner request `{self.req_id}` for counter **{self.request_data.get('name')}** was {'accepted' if accepted else 'declined'}.") - except Exception: - pass - - @discord.ui.button(label="Accept", style=discord.ButtonStyle.green) - async def accept(self, interaction: discord.Interaction, button: discord.ui.Button): - if interaction.user.id != int(self.request_data.get("owner")): - return await interaction.response.send_message("You are not the requested owner for this request.", ephemeral=True) - # Create the counter - guild = self.cog.bot.get_guild(self.guild_id) - store = self.cog.config.guild(guild) - name = str(self.request_data.get("name") or "") - initial = int(self.request_data.get("initial") or 0) - owner_id = int(self.request_data.get("owner")) - requester_id = int(self.request_data.get("requester") or 0) - nid, data = await self.cog._create_guild_counter(store, name, initial, owner_id, requester_id) - await self._finalize(interaction, True, f"You accepted request `{self.req_id}` β€” created counter **{data['name']}** with id `{nid}` assigned to you.") - - @discord.ui.button(label="Decline", style=discord.ButtonStyle.red) - async def decline(self, interaction: discord.Interaction, button: discord.ui.Button): - if interaction.user.id != int(self.request_data.get("owner")): - return await interaction.response.send_message("You are not the requested owner for this request.", ephemeral=True) - await self._finalize(interaction, False, f"You declined owner request `{self.req_id}` for counter **{self.request_data.get('name')}**.") - - @commands.group(name="counter", invoke_without_command=True) async def counter(self, ctx: commands.Context) -> None: """Counter commands. Use subcommands like `create`, `inc`, `dec`, `set`, `delete`, `show`, `list`.""" @@ -515,3 +442,76 @@ async def owner_decline(self, ctx: commands.Context, request_id: str) -> None: return await ctx.send("You are not the requested owner for that request.") del reqs[request_id] await ctx.send(f"Declined owner request `{request_id}`.") + + +class OwnerApprovalView(discord.ui.View): + def __init__(self, cog: "Counter", guild_id: int, req_id: str, request_data: dict, *, timeout: Optional[float] = 86400): + super().__init__(timeout=timeout) + self.cog = cog + self.guild_id = guild_id + self.req_id = req_id + self.request_data = request_data + + async def _finalize(self, interaction: discord.Interaction, accepted: bool, message_text: str): + # Attempt to remove pending request and notify requester + guild = self.cog.bot.get_guild(self.guild_id) + store = self.cog.config.guild(guild) + async with store.pending_owner_requests() as reqs: + if self.req_id in reqs: + del reqs[self.req_id] + # Disable buttons + for item in self.children: + try: + item.disabled = True + except Exception: + pass + try: + await interaction.response.edit_message(content=message_text, view=self) + except Exception: + try: + await interaction.response.send_message(message_text, ephemeral=True) + except Exception: + pass + # Notify requester in the original channel if possible + channel_id = self.request_data.get("channel_id") + try: + if channel_id is not None: + channel = self.cog.bot.get_channel(int(channel_id)) + if channel is not None: + try: + await channel.send(f"<@{self.request_data.get('requester')}> Your owner request `{self.req_id}` for counter **{self.request_data.get('name')}** was {'accepted' if accepted else 'declined'}.") + except Exception: + pass + except Exception: + pass + # Also DM the requester if possible + requester_id = self.request_data.get("requester") + try: + requester = self.cog.bot.get_user(int(requester_id)) if requester_id is not None else None + except Exception: + requester = None + if requester: + try: + await requester.send(f"Your owner request `{self.req_id}` for counter **{self.request_data.get('name')}** was {'accepted' if accepted else 'declined'}.") + except Exception: + pass + + @discord.ui.button(label="Accept", style=discord.ButtonStyle.green) + async def accept(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != int(self.request_data.get("owner")): + return await interaction.response.send_message("You are not the requested owner for this request.", ephemeral=True) + # Create the counter + guild = self.cog.bot.get_guild(self.guild_id) + store = self.cog.config.guild(guild) + name = str(self.request_data.get("name") or "") + initial = int(self.request_data.get("initial") or 0) + owner_id = int(self.request_data.get("owner")) + requester_id = int(self.request_data.get("requester") or 0) + nid, data = await self.cog._create_guild_counter(store, name, initial, owner_id, requester_id) + await self._finalize(interaction, True, f"You accepted request `{self.req_id}` - created counter **{data['name']}** with id `{nid}` assigned to you.") + + @discord.ui.button(label="Decline", style=discord.ButtonStyle.red) + async def decline(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != int(self.request_data.get("owner")): + return await interaction.response.send_message("You are not the requested owner for this request.", ephemeral=True) + await self._finalize(interaction, False, f"You declined owner request `{self.req_id}` for counter **{self.request_data.get('name')}**.") From 63089d9bfd8d915398b9bc3f96f142ffcb900af9 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:39:31 +0100 Subject: [PATCH 50/56] Update counter.py --- counter/counter.py | 87 +++++++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/counter/counter.py b/counter/counter.py index 05a92aa..429d5be 100644 --- a/counter/counter.py +++ b/counter/counter.py @@ -53,32 +53,67 @@ async def _get_counter_store(self, ctx: commands.Context, scope: str): return self.config, "global" async def _ensure_guild_schema(self, store) -> None: - """Ensure guild store uses the id-based schema; migrate from name->int if needed.""" + """Ensure guild store uses numeric id keys and dict counter entries.""" data = await store.all() - counters = data.get("counters", {}) + counters = data.get("counters", {}) or {} next_id = data.get("next_id") - # If next_id missing, either new guild or legacy format; migrate if needed - if next_id is None: - # Legacy format: counters are name->int - if counters and all(not isinstance(v, dict) for v in counters.values()): - new = {} - nid = 1 - for name, val in counters.items(): - new[str(nid)] = { - "name": name, - "value": int(val), - "owner": None, - "creator": None, - "created_at": None, - } - nid += 1 - async with store.counters() as s: - s.clear() - s.update(new) - await store.next_id.set(nid) + + # Fresh guild with no counters yet. + if not counters and next_id is None: + await store.next_id.set(1) + return + + normalized = {} + needs_new_id = [] + max_id = 0 + + def normalize_entry(raw_key, raw_val): + if isinstance(raw_val, dict): + name = self._clean_name(str(raw_val.get("name") or raw_key)) + value = int(raw_val.get("value") or 0) + return { + "name": name, + "value": value, + "owner": raw_val.get("owner"), + "creator": raw_val.get("creator"), + "created_at": raw_val.get("created_at"), + } + return { + "name": self._clean_name(str(raw_key)), + "value": int(raw_val), + "owner": None, + "creator": None, + "created_at": None, + } + + for raw_key, raw_val in counters.items(): + entry = normalize_entry(raw_key, raw_val) + key = str(raw_key) + if key.isdigit(): + cid = str(int(key)) + if cid in normalized: + needs_new_id.append(entry) + continue + normalized[cid] = entry + max_id = max(max_id, int(cid)) else: - # Fresh schema - await store.next_id.set(1) + needs_new_id.append(entry) + + # Respect existing next_id if it is valid. + if isinstance(next_id, int): + max_id = max(max_id, next_id - 1) + + next_numeric_id = max(max_id + 1, 1) + for entry in needs_new_id: + normalized[str(next_numeric_id)] = entry + next_numeric_id += 1 + + # If anything is not already normalized, write the normalized schema back. + if normalized != counters or next_id != next_numeric_id: + async with store.counters() as s: + s.clear() + s.update(normalized) + await store.next_id.set(next_numeric_id) async def _resolve_guild_counter(self, store, identifier: str): """Resolve an identifier (id or name) to a single (id, counter) tuple. @@ -335,7 +370,11 @@ async def list_(self, ctx: commands.Context, scope: Optional[str] = None) -> Non if not counters: return await ctx.send(f"No counters in {human_scope} scope.") lines = [] - for cid, c in sorted(counters.items(), key=lambda x: int(x[0])): + def sort_key(item): + key = str(item[0]) + return (0, int(key)) if key.isdigit() else (1, key) + + for cid, c in sorted(counters.items(), key=sort_key): owner = f"<@{c['owner']}" + ">" if c.get('owner') else "none" creator = f"<@{c['creator']}" + ">" if c.get('creator') else "unknown" created = c.get('created_at') or 'unknown' From 36f4a94a9f986ded60efbb9c685caabc34532f1b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:22:05 +0100 Subject: [PATCH 51/56] Add star history section to README Added star history section with dynamic chart. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index cedfe80..d91220f 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,13 @@ BenCos17's cogs for Red-DiscordBot. To add the cogs to your instance please do: [p]repo add ben-cogs https://github.com/bencos17/ben-cogs ![GGdtVgzXAAAhYj6](https://github.com/BenCos17/ben-cogs/assets/52817096/4233fcc5-ac77-482f-8375-6c01a48eb553) + +## Star History + + + + + + Star History Chart + + From 28ed3258d1633219bf74de3a3eb8a7978aca0044 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:28:42 +0100 Subject: [PATCH 52/56] Spotify link cleaner thing --- servertools/servertools.py | 78 +++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/servertools/servertools.py b/servertools/servertools.py index 52ad1eb..71910b1 100644 --- a/servertools/servertools.py +++ b/servertools/servertools.py @@ -3,17 +3,57 @@ from redbot.core import commands, Config import asyncio import aiohttp +import re +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from io import BytesIO from PIL import Image, ImageDraw, ImageFont class Servertools(commands.Cog): """Cog providing various server management utilities, such as mod DMs, voice moves, and auto-reactions.""" + + SPOTIFY_URL_RE = re.compile(r'https?://open\.spotify\.com/[^\s<>"]+', re.IGNORECASE) + def __init__(self, bot): self.bot = bot self.config = Config.get_conf(self, identifier=492089091320446976) # Initialize config with a unique identifier - self.config.register_guild(auto_reactions=[]) # Initialize auto_reactions as an empty list + self.config.register_guild( + auto_reactions={}, + spotify_autoclean=False, + ) self.config.register_user(online_notifications=[]) # Add this line to register online notifications + @staticmethod + def _clean_spotify_url(url: str): + """Remove Spotify's `si` query parameter and return a clean URL when possible.""" + try: + parts = urlsplit(url) + except Exception: + return None + + if parts.netloc.lower() != "open.spotify.com": + return None + + if not parts.path: + return None + + params = parse_qsl(parts.query, keep_blank_values=True) + filtered = [(k, v) for k, v in params if k.lower() != "si"] + new_query = urlencode(filtered, doseq=True) + cleaned = urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) + + if cleaned == url: + return None + return cleaned + + def _extract_clean_spotify_urls(self, content: str): + """Find Spotify URLs in message content and return unique cleaned links.""" + cleaned_links = [] + for match in self.SPOTIFY_URL_RE.findall(content): + cleaned = self._clean_spotify_url(match) + if cleaned and cleaned not in cleaned_links: + cleaned_links.append(cleaned) + return cleaned_links + @commands.command() @commands.has_permissions(manage_guild=True) async def moddm(self, ctx, user: discord.User, *, message): @@ -239,6 +279,36 @@ async def list_autoreacts(self, ctx): msg = "\n".join([f"<#{key.split('-')[0]}> - <@{key.split('-')[1]}>: {emoji}" for key, emoji in reactions.items()]) await ctx.send(f"Auto-reactions:\n{msg}") + @commands.group(name="spotifyclean") + @commands.guild_only() + @commands.has_permissions(manage_guild=True) + async def spotifyclean_group(self, ctx): + """Manage automatic Spotify link cleaning for this server.""" + if ctx.invoked_subcommand is None: + enabled = await self.config.guild(ctx.guild).spotify_autoclean() + state = "enabled" if enabled else "disabled" + await ctx.send( + f"Spotify auto-clean is currently **{state}**. Use `{ctx.clean_prefix}spotifyclean on` or `{ctx.clean_prefix}spotifyclean off`." + ) + + @spotifyclean_group.command(name="on") + async def spotifyclean_on(self, ctx): + """Enable Spotify link auto-cleaning in this server.""" + await self.config.guild(ctx.guild).spotify_autoclean.set(True) + await ctx.send("Spotify auto-clean enabled. I will post clean Spotify links without the `si` tracker.") + + @spotifyclean_group.command(name="off") + async def spotifyclean_off(self, ctx): + """Disable Spotify link auto-cleaning in this server.""" + await self.config.guild(ctx.guild).spotify_autoclean.set(False) + await ctx.send("Spotify auto-clean disabled.") + + @spotifyclean_group.command(name="status") + async def spotifyclean_status(self, ctx): + """Show whether Spotify link auto-cleaning is enabled.""" + enabled = await self.config.guild(ctx.guild).spotify_autoclean() + await ctx.send(f"Spotify auto-clean is {'enabled' if enabled else 'disabled'}.") + @commands.Cog.listener() async def on_message(self, message): if message.author.bot: @@ -255,6 +325,12 @@ async def on_message(self, message): if key in reactions: await message.add_reaction(reactions[key]) + # If enabled, post cleaned Spotify links so users can copy a non-tracking URL. + if await self.config.guild(guild).spotify_autoclean(): + cleaned_links = self._extract_clean_spotify_urls(message.content) + if cleaned_links: + await message.channel.send("\n".join(cleaned_links)) + @commands.group() async def notify(self, ctx): """Manage online status notifications""" From c11ffe5b9f8abb24cf1fd99019e951b0a3b5668e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:42:41 +0100 Subject: [PATCH 53/56] docs --- servertools/docs.md | 189 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 servertools/docs.md diff --git a/servertools/docs.md b/servertools/docs.md new file mode 100644 index 0000000..f1f7e3f --- /dev/null +++ b/servertools/docs.md @@ -0,0 +1,189 @@ +# Servertools Cog Documentation + +## Overview +Servertools is a Red-DiscordBot cog that provides server utility and moderation helpers, plus opt-in Spotify link cleaning. + +Main features: +- Moderator DM sender with confirmation +- Voice channel member move command +- Channel lockdown helper +- Bulk message purge +- Audit log viewer +- Server icon updater (URL or attachment) +- Fake Discord ping image generator +- Auto-reactions by user and channel +- User online-status DM notifications +- Opt-in Spotify URL cleaner (removes si tracker parameter) + +## Requirements +- Red-DiscordBot 3.4.0 or newer +- Python packages: + - aiohttp + - Pillow + +## Data Storage +This cog uses Red Config and stores: + +Guild scope: +- auto_reactions: dictionary keyed as channel_id-user_id with emoji value +- spotify_autoclean: boolean, default false + +User scope: +- online_notifications: list of tracked user IDs + +## Commands +Prefix examples use [p] as your bot prefix. + +### 1) moddm +- Name: moddm +- Permission required: Manage Server +- Usage: [p]moddm +- What it does: + - Sends a confirmation prompt in the channel + - Waits up to 30 seconds for yes/y/no/n + - If confirmed, sends the message to the target user in DM as an embed + +Notes: +- Works only in a server +- Target user must be a member of that server + +### 2) voicemove +- Name: voicemove +- Permission required: Move Members +- Usage: [p]voicemove +- What it does: + - Moves the specified member to the specified voice channel + +### 3) ld +- Name: ld +- Permission required: Manage Channels +- Usage: [p]ld +- What it does: + - Locks down the target text channel for @everyone by disabling send_messages + +Important: +- The permissions text argument is currently accepted but not used by the implementation. + +### 4) purge +- Name: purge +- Permission required: Manage Messages +- Usage: [p]purge +- What it does: + - Deletes up to amount recent messages from the current channel + +### 5) auditlog +- Name: auditlog +- Permission required: View Audit Log +- Usage: [p]auditlog +- What it does: + - Sends recent audit log entries as channel messages + +### 6) setservericon +- Name: setservericon +- Scope: Guild only +- Permission required: Manage Server +- Usage: + - [p]setservericon + - [p]setservericon with an attached image +- What it does: + - Updates the guild icon using PNG or WEBP image data + +### 7) fakeping +- Name: fakeping +- Scope: Guild only +- Usage: [p]fakeping +- What it does: + - Downloads the server icon + - Draws a red notification badge with 1 + - Sends the generated image file + +### 8) autoreact command group +- Name: autoreact +- Usage root: [p]autoreact +- What it does: + - Manages automatic reactions for a specific user in a specific channel + +Subcommands: +- [p]autoreact add + - Adds or overwrites an auto-reaction mapping +- [p]autoreact remove + - Removes a mapping +- [p]autoreact list + - Lists all mappings for the server + +Runtime behavior: +- When a non-bot user sends a message, if channel_id-user_id exists in mappings, the bot adds that emoji reaction. + +### 9) notify command group +- Name: notify +- Usage root: [p]notify +- What it does: + - Lets a user track specific members and receive DM alerts when they come online + +Subcommands: +- [p]notify add + - Adds user to your tracking list + - Bots cannot be tracked +- [p]notify remove + - Removes user from your tracking list +- [p]notify list + - Shows users you are currently tracking + +Runtime behavior: +- On member status changes, tracked users are DMd when a member moves from offline or invisible to online. + +### 10) spotifyclean command group +- Name: spotifyclean +- Scope: Guild only +- Permission required: Manage Server +- Usage root: [p]spotifyclean +- What it does: + - Controls opt-in Spotify link cleaning per server + +Subcommands: +- [p]spotifyclean on + - Enables auto-cleaning for this guild +- [p]spotifyclean off + - Disables auto-cleaning +- [p]spotifyclean status + - Shows current enabled or disabled state + +Runtime behavior: +- When enabled, the bot scans each non-bot message for open.spotify.com links. +- It removes only the si query parameter. +- If a cleaned URL differs from original, it posts the cleaned link to the channel. +- Original messages are not edited or deleted. + +## Event Listeners +This cog includes these listeners: + +1. on_message +- Ignores bot messages and DMs +- Handles auto-reactions +- Handles Spotify URL auto-clean posting when enabled + +2. on_member_update +- Checks status transitions +- Sends online notifications to users tracking that member + +## Permissions Summary +- Manage Server: moddm, setservericon, spotifyclean group +- Move Members: voicemove +- Manage Channels: ld +- Manage Messages: purge +- View Audit Log: auditlog +- No explicit command permission decorators: fakeping, autoreact group, notify group + +## Operational Notes +- If the bot lacks Discord permissions for an action, commands return an error message. +- notify alerts are sent via DM and may fail if user DMs are closed. +- Spotify cleaning applies only to links in message text and only for open.spotify.com URLs. + +## Quick Start +1. Load the cog. +2. Optional: enable Spotify cleaner in a server with [p]spotifyclean on. +3. Add auto-reactions with [p]autoreact add. +4. Add online tracking with [p]notify add. + +## End User Data Statement +The cog stores guild and user data for utility features through Red Config and does not share that data with third parties. From 1a1790293eed9658abf4f0964080bdf717d9953e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:44:42 +0100 Subject: [PATCH 54/56] quick bug fix --- servertools/servertools.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/servertools/servertools.py b/servertools/servertools.py index 71910b1..e63dc45 100644 --- a/servertools/servertools.py +++ b/servertools/servertools.py @@ -17,11 +17,21 @@ def __init__(self, bot): self.bot = bot self.config = Config.get_conf(self, identifier=492089091320446976) # Initialize config with a unique identifier self.config.register_guild( - auto_reactions={}, + auto_reactions=[], spotify_autoclean=False, ) self.config.register_user(online_notifications=[]) # Add this line to register online notifications + async def _get_autoreactions_dict(self, guild: discord.Guild): + """Return auto-reactions as a dict and migrate legacy non-dict values.""" + reactions = await self.config.guild(guild).auto_reactions() + if isinstance(reactions, dict): + return reactions + + # Legacy versions stored a non-dict default; normalize to dict. + await self.config.guild(guild).auto_reactions.set({}) + return {} + @staticmethod def _clean_spotify_url(url: str): """Remove Spotify's `si` query parameter and return a clean URL when possible.""" @@ -253,25 +263,27 @@ async def autoreact(self, ctx): @autoreact.command(name="add") async def add_autoreact(self, ctx, user: discord.Member, channel: discord.TextChannel, emoji: str): """Add an auto-reaction for a user in a specific channel""" - async with self.config.guild(ctx.guild).auto_reactions() as reactions: - reactions[f"{channel.id}-{user.id}"] = emoji + reactions = await self._get_autoreactions_dict(ctx.guild) + reactions[f"{channel.id}-{user.id}"] = emoji + await self.config.guild(ctx.guild).auto_reactions.set(reactions) await ctx.send(f"Added auto-reaction with {emoji} for {user.display_name} in {channel.mention}") @autoreact.command(name="remove") async def remove_autoreact(self, ctx, user: discord.Member, channel: discord.TextChannel): """Remove an auto-reaction""" - async with self.config.guild(ctx.guild).auto_reactions() as reactions: - key = f"{channel.id}-{user.id}" - if key in reactions: - del reactions[key] - await ctx.send("Auto-reaction removed.") - else: - await ctx.send("No auto-reaction found for that user in that channel.") + reactions = await self._get_autoreactions_dict(ctx.guild) + key = f"{channel.id}-{user.id}" + if key in reactions: + del reactions[key] + await self.config.guild(ctx.guild).auto_reactions.set(reactions) + await ctx.send("Auto-reaction removed.") + else: + await ctx.send("No auto-reaction found for that user in that channel.") @autoreact.command(name="list") async def list_autoreacts(self, ctx): """List all auto-reactions""" - reactions = await self.config.guild(ctx.guild).auto_reactions() + reactions = await self._get_autoreactions_dict(ctx.guild) if not reactions: await ctx.send("No auto-reactions set.") return @@ -318,9 +330,7 @@ async def on_message(self, message): if not guild: return - reactions = await self.config.guild(guild).auto_reactions() - if reactions is None: # Check if reactions is None - reactions = {} # Initialize as an empty dictionary + reactions = await self._get_autoreactions_dict(guild) key = f"{message.channel.id}-{message.author.id}" if key in reactions: await message.add_reaction(reactions[key]) From 2e843d873b2cc03f46fff6616ff30bb5f53a25e2 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:04:11 +0100 Subject: [PATCH 55/56] dm support --- servertools/docs.md | 31 +++++++++++++++++++++++++++---- servertools/servertools.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/servertools/docs.md b/servertools/docs.md index f1f7e3f..1780b61 100644 --- a/servertools/docs.md +++ b/servertools/docs.md @@ -30,6 +30,7 @@ Guild scope: User scope: - online_notifications: list of tracked user IDs +- spotify_dm_autoclean: boolean, default false ## Commands Prefix examples use [p] as your bot prefix. @@ -154,13 +155,34 @@ Runtime behavior: - If a cleaned URL differs from original, it posts the cleaned link to the channel. - Original messages are not edited or deleted. +### 11) spotifycleandm command group +- Name: spotifycleandm +- Scope: DM or server command context (user-level setting) +- Usage root: [p]spotifycleandm +- What it does: + - Controls Spotify link cleaning for your direct messages with the bot + +Subcommands: +- [p]spotifycleandm on + - Enables DM auto-cleaning for your account +- [p]spotifycleandm off + - Disables DM auto-cleaning for your account +- [p]spotifycleandm status + - Shows current enabled or disabled state for your account + +Runtime behavior: +- When enabled, if you send a Spotify link in a DM with the bot, the bot replies with the cleaned link. +- It removes only the si query parameter. +- Original messages are not edited or deleted. + ## Event Listeners This cog includes these listeners: 1. on_message -- Ignores bot messages and DMs +- Ignores bot messages - Handles auto-reactions - Handles Spotify URL auto-clean posting when enabled +- Handles DM Spotify URL auto-clean posting when user opt-in is enabled 2. on_member_update - Checks status transitions @@ -172,7 +194,7 @@ This cog includes these listeners: - Manage Channels: ld - Manage Messages: purge - View Audit Log: auditlog -- No explicit command permission decorators: fakeping, autoreact group, notify group +- No explicit command permission decorators: fakeping, autoreact group, notify group, spotifycleandm group ## Operational Notes - If the bot lacks Discord permissions for an action, commands return an error message. @@ -182,8 +204,9 @@ This cog includes these listeners: ## Quick Start 1. Load the cog. 2. Optional: enable Spotify cleaner in a server with [p]spotifyclean on. -3. Add auto-reactions with [p]autoreact add. -4. Add online tracking with [p]notify add. +3. Optional: enable DM Spotify cleaner for yourself with [p]spotifycleandm on. +4. Add auto-reactions with [p]autoreact add. +5. Add online tracking with [p]notify add. ## End User Data Statement The cog stores guild and user data for utility features through Red Config and does not share that data with third parties. diff --git a/servertools/servertools.py b/servertools/servertools.py index e63dc45..0b56b9a 100644 --- a/servertools/servertools.py +++ b/servertools/servertools.py @@ -20,7 +20,10 @@ def __init__(self, bot): auto_reactions=[], spotify_autoclean=False, ) - self.config.register_user(online_notifications=[]) # Add this line to register online notifications + self.config.register_user( + online_notifications=[], + spotify_dm_autoclean=False, + ) async def _get_autoreactions_dict(self, guild: discord.Guild): """Return auto-reactions as a dict and migrate legacy non-dict values.""" @@ -321,6 +324,34 @@ async def spotifyclean_status(self, ctx): enabled = await self.config.guild(ctx.guild).spotify_autoclean() await ctx.send(f"Spotify auto-clean is {'enabled' if enabled else 'disabled'}.") + @commands.group(name="spotifycleandm") + async def spotifyclean_dm_group(self, ctx): + """Manage Spotify link cleaning for your direct messages with the bot.""" + if ctx.invoked_subcommand is None: + enabled = await self.config.user(ctx.author).spotify_dm_autoclean() + state = "enabled" if enabled else "disabled" + await ctx.send( + f"DM Spotify auto-clean is currently **{state}**. Use `{ctx.clean_prefix}spotifycleandm on` or `{ctx.clean_prefix}spotifycleandm off`." + ) + + @spotifyclean_dm_group.command(name="on") + async def spotifyclean_dm_on(self, ctx): + """Enable Spotify link auto-cleaning in your DMs with the bot.""" + await self.config.user(ctx.author).spotify_dm_autoclean.set(True) + await ctx.send("DM Spotify auto-clean enabled for your account.") + + @spotifyclean_dm_group.command(name="off") + async def spotifyclean_dm_off(self, ctx): + """Disable Spotify link auto-cleaning in your DMs with the bot.""" + await self.config.user(ctx.author).spotify_dm_autoclean.set(False) + await ctx.send("DM Spotify auto-clean disabled for your account.") + + @spotifyclean_dm_group.command(name="status") + async def spotifyclean_dm_status(self, ctx): + """Show whether DM Spotify link auto-cleaning is enabled for you.""" + enabled = await self.config.user(ctx.author).spotify_dm_autoclean() + await ctx.send(f"DM Spotify auto-clean is {'enabled' if enabled else 'disabled'}.") + @commands.Cog.listener() async def on_message(self, message): if message.author.bot: @@ -328,6 +359,10 @@ async def on_message(self, message): guild = message.guild if not guild: + if await self.config.user(message.author).spotify_dm_autoclean(): + cleaned_links = self._extract_clean_spotify_urls(message.content) + if cleaned_links: + await message.channel.send("\n".join(cleaned_links)) return reactions = await self._get_autoreactions_dict(guild) From 8364e261815508227da668b54d2ac3f484577437 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:10:22 +0100 Subject: [PATCH 56/56] fix fakeping --- servertools/servertools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/servertools/servertools.py b/servertools/servertools.py index 0b56b9a..13beb4c 100644 --- a/servertools/servertools.py +++ b/servertools/servertools.py @@ -246,7 +246,12 @@ async def fake_ping(self, ctx): except Exception: font = ImageFont.load_default() text = "1" - text_width, text_height = draw.textsize(text, font=font) + try: + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + except Exception: + text_width, text_height = font.getmask(text).size text_x = badge_center[0] - text_width // 2 text_y = badge_center[1] - text_height // 2 draw.text((text_x, text_y), text, font=font, fill=(255, 255, 255, 255))