From 424e3fe30b2afc3259c48c69a66506fb5abd3759 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:30:12 +0000 Subject: [PATCH 01/74] Update check-cogs.yml --- .github/workflows/check-cogs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml index 31f5fe0..df941f8 100644 --- a/.github/workflows/check-cogs.yml +++ b/.github/workflows/check-cogs.yml @@ -11,7 +11,7 @@ on: env: BUILD_ARTIFACT_NAME: "my-build-artifact" - COG_PATHS: "dworld" # comma-separated list of cog folder names + 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 RPC_PORT: "6133" jobs: @@ -168,4 +168,4 @@ jobs: description: >- ${{ needs.build-validation-fields.outputs.validation_result == 'success' && format('All cogs ({0}) passed build and RPC validation.', env.COG_PATHS) || format('One or more stages failed for cogs: {0}. Review the workflow logs.', env.COG_PATHS) }} color: ${{ needs.build-validation-fields.outputs.validation_result == 'success' && '3066993' || '16776960' }} - fields: ${{ needs.build-validation-fields.outputs.discord_fields }} \ No newline at end of file + fields: ${{ needs.build-validation-fields.outputs.discord_fields }} From 43b78cad884c6358992b4e792dc574178bbf8738 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:40:12 +0000 Subject: [PATCH 02/74] Update check-cogs.yml --- .github/workflows/check-cogs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml index df941f8..7196146 100644 --- a/.github/workflows/check-cogs.yml +++ b/.github/workflows/check-cogs.yml @@ -53,7 +53,7 @@ jobs: # For dworld cog d-back and wtforms are required - name: Install dependencies run: | - uv pip install d-back wtforms --system + uv pip install d-back wtforms requests --system # Actual magic: test that cogs can be loaded/unloaded via RPC - name: Test cogs via RPC From 65242e3c0c72e06bc377916a2cc14296ae380643 Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:14:48 +0100 Subject: [PATCH 03/74] using feature branch to test automatic installing requirements --- .github/workflows/check-cogs.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml index 7196146..96e38c5 100644 --- a/.github/workflows/check-cogs.yml +++ b/.github/workflows/check-cogs.yml @@ -22,7 +22,7 @@ jobs: # DEMO: how to install Red-DiscordBot, can install from PyPI directly! - name: Build Red-DiscordBot - uses: nntin/d-flows/actions/build-red-discordbot@v1 + uses: nntin/d-flows/actions/build-red-discordbot@feature/improve-check-cog with: red_commit: "" # optional commit SHA artifact_name: ${{ env.BUILD_ARTIFACT_NAME }} # optional artifact name @@ -36,28 +36,23 @@ jobs: # Skip artifact_name if installing from PyPI directly - name: Install Red-DiscordBot - uses: nntin/d-flows/actions/install-red-discordbot@v1 + uses: nntin/d-flows/actions/install-red-discordbot@feature/improve-check-cog with: artifact_name: ${{ env.BUILD_ARTIFACT_NAME }} # same artifact name used for build # Configure Red-DiscordBot with instance name "tinkerer" # todo: add input for skipping --dry-run - name: Configure Red-DiscordBot - uses: nntin/d-flows/actions/setup-red-discordbot@v1 + uses: nntin/d-flows/actions/setup-red-discordbot@feature/improve-check-cog with: token: ${{ secrets.DISCORD_BOT_TOKEN }} optional_args: "--no-cogs" # Example optional argument to run without loading any cogs # --dry-run fails due to bug in Red-DiscordBot https://github.com/Cog-Creators/Red-DiscordBot/issues/6572 continue-on-error: true - - # For dworld cog d-back and wtforms are required - - name: Install dependencies - run: | - uv pip install d-back wtforms requests --system # Actual magic: test that cogs can be loaded/unloaded via RPC - name: Test cogs via RPC - uses: nntin/d-flows/actions/test-red-discordbot@v1 + uses: nntin/d-flows/actions/test-red-discordbot@feature/improve-check-cog with: token: ${{ secrets.DISCORD_BOT_TOKEN }} cog_paths: ${{ env.COG_PATHS }} @@ -133,7 +128,7 @@ jobs: steps: - name: Write Step Summary - uses: nntin/d-flows/actions/step-summary@v1 + uses: nntin/d-flows/actions/step-summary@feature/improve-check-cog with: title: 'Cog Validation Results' markdown: | @@ -160,7 +155,7 @@ jobs: steps: - name: Send Discord Notification - uses: nntin/d-flows/actions/discord-notify@v1 + uses: nntin/d-flows/actions/discord-notify@feature/improve-check-cog with: webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} message_type: 'embed' From f14e653abee8cef505c9a67173f9f57f85da1759 Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:18:55 +0100 Subject: [PATCH 04/74] missing requirements in cog --- emojilink/info.json | 1 + 1 file changed, 1 insertion(+) diff --git a/emojilink/info.json b/emojilink/info.json index 803fa8b..571cd04 100644 --- a/emojilink/info.json +++ b/emojilink/info.json @@ -2,6 +2,7 @@ "author": ["bencos18 (492089091320446976)"], "install_msg": "thank for adding my repo\n feel free to message me on discord or create a github issue if you need support or help with anything.", "short": "some random cogs I made for my own use and decided to make public.", + "requirements": ["Pillow"], "description": "commands to get emoji links and other info.", "tags": ["emoji", "emojis"], "end_user_data_statement": "This cog does not store any End User Data.", From ad08d100e64cd1bb53df75185985612ab792f237 Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:21:47 +0100 Subject: [PATCH 05/74] add yt_dlp dependency --- facebookdownloader/info.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 facebookdownloader/info.json diff --git a/facebookdownloader/info.json b/facebookdownloader/info.json new file mode 100644 index 0000000..4f4eb11 --- /dev/null +++ b/facebookdownloader/info.json @@ -0,0 +1,10 @@ +{ + "author": ["bencos18 (492089091320446976)"], + "install_msg": "thank for adding my repo\n feel free to message me on discord or create a github issue if you need support or help with anything.", + "short": "some random cogs I made for my own use and decided to make public.", + "requirements": ["yt_dlp"], + "description": "-", + "tags": [], + "end_user_data_statement": "This cog does not store any End User Data.", + "type": "COG" +} From bf904180e3ecb126dae8a5a37de2934c93c8eb83 Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:25:39 +0100 Subject: [PATCH 06/74] missing reportlab dependency --- invoice/info.json | 1 + 1 file changed, 1 insertion(+) diff --git a/invoice/info.json b/invoice/info.json index 14aeea2..4176847 100644 --- a/invoice/info.json +++ b/invoice/info.json @@ -2,6 +2,7 @@ "author": ["bencos18"], "install_msg": "thank for adding my repo\n feel free to message me on discord or create a github issue if you need support or help with anything.", "short": "some random cogs I made for my own use and decided to make public.", + "requirements": ["reportlab"], "description": "invoice cog I made written by bencos18, this cog is used to create invoices, This cog is still in development and may have bugs, please report any bugs to me on discord or github.", "tags": [""], "end_user_data_statement": "This cog stores data inputed through the invoice command in a json file in the cog data folder. This data is only used for the invoice command and is not shared with anyone, All data is stored in a temp folder and deleted after the command is ran ", From f814200b1c2c5c34776b313fef24be210428d78c Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:32:14 +0100 Subject: [PATCH 07/74] fix relative import --- skysearch/commands/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/commands/__init__.py b/skysearch/commands/__init__.py index 6117cb8..597cbc6 100644 --- a/skysearch/commands/__init__.py +++ b/skysearch/commands/__init__.py @@ -1,4 +1,4 @@ """ Commands package for SkySearch cog """ -from . import dashboard_integration +from ..dashboard import dashboard_integration From 4cf37d9b805035ac69e4912940920892fccdfd28 Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:34:00 +0100 Subject: [PATCH 08/74] wtforms missing --- skysearch/info.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/info.json b/skysearch/info.json index 5b3f34e..f15a2df 100644 --- a/skysearch/info.json +++ b/skysearch/info.json @@ -6,7 +6,7 @@ "description": "SkySearch is made to let you fetch information about aircraft, and airports. You can query active flights by a selection of variables, or get airport information, runway information, airport forecasts, and more. ", "tags": ["airplanes", "airplaneslive", "aircraft", "aircraft tracking", "ADS-B", "plane spotting", "dashboard"], "end_user_data_statement": "SkySearch stores no user data. Usage of external API integrations provided in SkySearch is subject to the Privacy Policy, and Terms of Service, of the respective service.", - "requirements": ["reportlab"], + "requirements": ["reportlab", "wtforms"], "permissions": [ "embed_links" ], From bfd62c35795ccc8fe7859486a472b79094c483d3 Mon Sep 17 00:00:00 2001 From: NNTin Date: Thu, 20 Nov 2025 23:41:29 +0100 Subject: [PATCH 09/74] new v1 release with the update of automatic requirements installation --- .github/workflows/check-cogs.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml index 96e38c5..ed8c11e 100644 --- a/.github/workflows/check-cogs.yml +++ b/.github/workflows/check-cogs.yml @@ -22,7 +22,7 @@ jobs: # DEMO: how to install Red-DiscordBot, can install from PyPI directly! - name: Build Red-DiscordBot - uses: nntin/d-flows/actions/build-red-discordbot@feature/improve-check-cog + uses: nntin/d-flows/actions/build-red-discordbot@v1 with: red_commit: "" # optional commit SHA artifact_name: ${{ env.BUILD_ARTIFACT_NAME }} # optional artifact name @@ -36,14 +36,14 @@ jobs: # Skip artifact_name if installing from PyPI directly - name: Install Red-DiscordBot - uses: nntin/d-flows/actions/install-red-discordbot@feature/improve-check-cog + uses: nntin/d-flows/actions/install-red-discordbot@v1 with: artifact_name: ${{ env.BUILD_ARTIFACT_NAME }} # same artifact name used for build # Configure Red-DiscordBot with instance name "tinkerer" # todo: add input for skipping --dry-run - name: Configure Red-DiscordBot - uses: nntin/d-flows/actions/setup-red-discordbot@feature/improve-check-cog + uses: nntin/d-flows/actions/setup-red-discordbot@v1 with: token: ${{ secrets.DISCORD_BOT_TOKEN }} optional_args: "--no-cogs" # Example optional argument to run without loading any cogs @@ -52,7 +52,7 @@ jobs: # Actual magic: test that cogs can be loaded/unloaded via RPC - name: Test cogs via RPC - uses: nntin/d-flows/actions/test-red-discordbot@feature/improve-check-cog + uses: nntin/d-flows/actions/test-red-discordbot@v1 with: token: ${{ secrets.DISCORD_BOT_TOKEN }} cog_paths: ${{ env.COG_PATHS }} @@ -128,7 +128,7 @@ jobs: steps: - name: Write Step Summary - uses: nntin/d-flows/actions/step-summary@feature/improve-check-cog + uses: nntin/d-flows/actions/step-summary@v1 with: title: 'Cog Validation Results' markdown: | @@ -155,7 +155,7 @@ jobs: steps: - name: Send Discord Notification - uses: nntin/d-flows/actions/discord-notify@feature/improve-check-cog + uses: nntin/d-flows/actions/discord-notify@v1 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} message_type: 'embed' From ca5284937568d31f7378c438e46e91c5919780b3 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:17:50 +0000 Subject: [PATCH 10/74] stuff --- skysearch/README.md | 7 +- skysearch/commands/__init__.py | 2 +- skysearch/commands/aircraft.py | 83 ++----------------- skysearch/info.json | 2 +- skysearch/skysearch.py | 144 ++++---------------------------- skysearch/utils/helpers.py | 147 +++++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 213 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index 4a042c1..bd06afe 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -77,9 +77,4 @@ Notes: - `[p]skysearch apistats_save` - Manually save API statistics (owner only) ### Dashboard Integration -- `/third-parties/Skysearch` - Web interface for the cog -there is 4 total pages in it - - `Main Page` - shows stats for airplanes.live and tagged aircraft (tags aren't currently updated) - - `Apistats` - shows apistats for the cog itself - - `Guild` - allows you to change cog settings in the dashboard (uses ids, to get them enable developer mode on discord) - - `Lookup` - allows you to lookup data directly in the cog dashboard page +- `/dashboard/apistats` - Web interface for viewing API statistics and performance metrics diff --git a/skysearch/commands/__init__.py b/skysearch/commands/__init__.py index 597cbc6..6117cb8 100644 --- a/skysearch/commands/__init__.py +++ b/skysearch/commands/__init__.py @@ -1,4 +1,4 @@ """ Commands package for SkySearch cog """ -from ..dashboard import dashboard_integration +from . import dashboard_integration diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 5b54ac4..dcf61b1 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -43,49 +43,11 @@ 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() + # Create view with buttons using helper methods 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' + view = self.helpers.create_base_aircraft_view(icao) 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)) + self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code if squawk_code != 'N/A' else None) await ctx.send(embed=embed, view=view) else: @@ -733,43 +695,10 @@ async def _get_aircraft_embed_and_view(self, ctx, response): 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' + # Create view with buttons using helper methods + view = self.helpers.create_base_aircraft_view(icao) 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)) + self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code if squawk_code != 'N/A' else None) return embed, view else: embed = discord.Embed(title='No results found for your query', color=discord.Colour(0xff4545)) diff --git a/skysearch/info.json b/skysearch/info.json index f15a2df..5b3f34e 100644 --- a/skysearch/info.json +++ b/skysearch/info.json @@ -6,7 +6,7 @@ "description": "SkySearch is made to let you fetch information about aircraft, and airports. You can query active flights by a selection of variables, or get airport information, runway information, airport forecasts, and more. ", "tags": ["airplanes", "airplaneslive", "aircraft", "aircraft tracking", "ADS-B", "plane spotting", "dashboard"], "end_user_data_statement": "SkySearch stores no user data. Usage of external API integrations provided in SkySearch is subject to the Privacy Policy, and Terms of Service, of the respective service.", - "requirements": ["reportlab", "wtforms"], + "requirements": ["reportlab"], "permissions": [ "embed_links" ], diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 09a7c72..5f693c4 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -596,50 +596,10 @@ async def check_emergency_squawks(self): 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')) - 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)) + # Create buttons for emergency alerts using helper methods + icao = aircraft_data.get('hex', '') + view = self.helpers.create_base_aircraft_view(icao) + self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code) message_data['embed'] = embed message_data['view'] = view @@ -854,46 +814,14 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a embed.title = f"πŸ”” Custom Alert: {alert_data['type'].upper()} '{alert_data['value']}'" embed.color = 0xffaa00 # Orange color for custom alerts - # Create view with buttons - 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)) - - # Add 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' - - 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 - - tweet_text = f"alert triggered! {alert_data['type'].upper()} '{alert_data['value']}' spotted - Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #discordAlert\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"Custom alert! {alert_data['type'].upper()} '{alert_data['value']}' spotted - 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)) + # Create view with buttons using helper methods + icao = aircraft_data.get('hex', '') + view = self.helpers.create_base_aircraft_view(icao) + self.helpers.add_social_media_buttons( + view, aircraft_data, + alert_type=alert_data['type'], + alert_value=alert_data['value'] + ) # Attach embed and view to message_data message_data['embed'] = embed @@ -1052,50 +980,10 @@ async def simulate_emergency_alert(self, ctx, hex_code: str, squawk_code: str = 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 (same as real 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 (same as real alerts) - 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' - - 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)) + # Create buttons using helper methods (same as real emergency alerts) + icao = aircraft_data.get('hex', '') + view = self.helpers.create_base_aircraft_view(icao) + self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code) message_data['embed'] = embed message_data['view'] = view diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index eafe3ae..2d7d66e 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -616,6 +616,153 @@ async def on_submit(self, interaction: discord.Interaction): await interaction.response.send_message(embed=embed, ephemeral=True) + def convert_speed_knots_to_mph(self, ground_speed_knots): + """ + Convert ground speed from knots to miles per hour. + + Args: + ground_speed_knots: Speed in knots (can be int, float, str, or 'N/A') + + Returns: + int or str: Speed in mph, or 'unknown' if conversion fails + """ + if ground_speed_knots == 'N/A' or ground_speed_knots is None: + return 'unknown' + try: + return round(float(ground_speed_knots) * 1.15078) + except (ValueError, TypeError): + return 'unknown' + + def format_coordinate(self, coord, coord_type='lat'): + """ + Format a coordinate (latitude or longitude) for display. + + Args: + coord: Coordinate value (can be float, int, str, or 'N/A') + coord_type: 'lat' for latitude, 'lon' for longitude + + Returns: + str: Formatted coordinate (e.g., "40.71N" or "-74.01W") or original value if invalid + """ + if coord == 'N/A' or coord is None: + return 'N/A' + try: + coord_float = round(float(coord), 2) + if coord_type == 'lat': + coord_dir = "N" if coord_float >= 0 else "S" + else: # lon + coord_dir = "E" if coord_float >= 0 else "W" + return f"{abs(coord_float)}{coord_dir}" + except (ValueError, TypeError): + return coord + + def create_base_aircraft_view(self, icao_hex): + """ + Create a base Discord view with the airplanes.live link button. + + Args: + icao_hex: ICAO hex code (string) + + Returns: + discord.ui.View: View with airplanes.live button + """ + view = discord.ui.View() + if icao_hex: + icao_hex = icao_hex.upper() + link = f"https://globe.airplanes.live/?icao={icao_hex}" + view.add_item(discord.ui.Button( + label="View on airplanes.live", + emoji="πŸ—ΊοΈ", + url=link, + style=discord.ButtonStyle.link + )) + return view + + def add_social_media_buttons(self, view, aircraft_data, squawk_code=None, alert_type=None, alert_value=None): + """ + Add social media sharing buttons (X/Twitter and WhatsApp) to a view. + + Args: + view: discord.ui.View to add buttons to + aircraft_data: Dictionary containing aircraft information + squawk_code: Optional squawk code (for emergency detection) + alert_type: Optional alert type (for custom alerts) + alert_value: Optional alert value (for custom alerts) + + Returns: + discord.ui.View: View with social media buttons added + """ + from urllib.parse import quote_plus + + # Get formatted data + ground_speed_knots = aircraft_data.get('gs', aircraft_data.get('ground_speed', 'N/A')) + ground_speed_mph = self.convert_speed_knots_to_mph(ground_speed_knots) + + lat = self.format_coordinate(aircraft_data.get('lat', 'N/A'), 'lat') + lon = self.format_coordinate(aircraft_data.get('lon', 'N/A'), 'lon') + + flight = aircraft_data.get('flight', '') + icao = aircraft_data.get('hex', '').upper() + + # Determine tweet text based on context + emergency_squawk_codes = ['7500', '7600', '7700'] + discord_invite = "https://discord.gg/X8huyaeXrA" # Standard Discord invite URL + if squawk_code and squawk_code in emergency_squawk_codes: + tweet_text = ( + f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, " + f"flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " + f"#SkySearch #Emergency\n\n" + f"Join via Discord to search and discuss planes with your friends for free - " + f"{discord_invite}" + ) + elif alert_type and alert_value: + tweet_text = ( + f"alert triggered! {alert_type.upper()} '{alert_value}' spotted - " + f"Flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " + f"#SkySearch #discordAlert\n\n" + f"Join via Discord to search and discuss planes with your friends for free - " + f"{discord_invite}" + ) + else: + tweet_text = ( + f"Tracking flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph " + f"using #SkySearch\n\n" + f"Join via Discord to search and discuss planes with your friends for free - " + f"{discord_invite}" + ) + + tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" + view.add_item(discord.ui.Button( + label="Post on X", + emoji="πŸ“£", + url=tweet_url, + style=discord.ButtonStyle.link + )) + + # WhatsApp sharing + if alert_type and alert_value: + whatsapp_text = ( + f"Custom alert! {alert_type.upper()} '{alert_value}' spotted - " + f"Flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " + f"Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" + ) + else: + whatsapp_text = ( + f"Check out this aircraft! Flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " + f"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 view + + class JSONInputButton(discord.ui.View): """Button view to trigger JSON input modal.""" From d45486e21d20cb4ff1ad98bb94e52ce0ae6f7ce3 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:23:45 +0000 Subject: [PATCH 11/74] Revert "stuff" This reverts commit ca5284937568d31f7378c438e46e91c5919780b3. --- skysearch/README.md | 7 +- skysearch/commands/__init__.py | 2 +- skysearch/commands/aircraft.py | 83 +++++++++++++++++-- skysearch/info.json | 2 +- skysearch/skysearch.py | 144 ++++++++++++++++++++++++++++---- skysearch/utils/helpers.py | 147 --------------------------------- 6 files changed, 213 insertions(+), 172 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index bd06afe..4a042c1 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -77,4 +77,9 @@ Notes: - `[p]skysearch apistats_save` - Manually save API statistics (owner only) ### Dashboard Integration -- `/dashboard/apistats` - Web interface for viewing API statistics and performance metrics +- `/third-parties/Skysearch` - Web interface for the cog +there is 4 total pages in it + - `Main Page` - shows stats for airplanes.live and tagged aircraft (tags aren't currently updated) + - `Apistats` - shows apistats for the cog itself + - `Guild` - allows you to change cog settings in the dashboard (uses ids, to get them enable developer mode on discord) + - `Lookup` - allows you to lookup data directly in the cog dashboard page diff --git a/skysearch/commands/__init__.py b/skysearch/commands/__init__.py index 6117cb8..597cbc6 100644 --- a/skysearch/commands/__init__.py +++ b/skysearch/commands/__init__.py @@ -1,4 +1,4 @@ """ Commands package for SkySearch cog """ -from . import dashboard_integration +from ..dashboard import dashboard_integration diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index dcf61b1..5b54ac4 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -43,11 +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 using helper methods + # Create view with buttons + view = discord.ui.View() icao = aircraft_data.get('hex', '') - view = self.helpers.create_base_aircraft_view(icao) + 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') - self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code if squawk_code != 'N/A' else None) + 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)) await ctx.send(embed=embed, view=view) else: @@ -695,10 +733,43 @@ async def _get_aircraft_embed_and_view(self, ctx, response): 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) - # Create view with buttons using helper methods - view = self.helpers.create_base_aircraft_view(icao) + 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') - self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code if squawk_code != 'N/A' else None) + 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)) diff --git a/skysearch/info.json b/skysearch/info.json index 5b3f34e..f15a2df 100644 --- a/skysearch/info.json +++ b/skysearch/info.json @@ -6,7 +6,7 @@ "description": "SkySearch is made to let you fetch information about aircraft, and airports. You can query active flights by a selection of variables, or get airport information, runway information, airport forecasts, and more. ", "tags": ["airplanes", "airplaneslive", "aircraft", "aircraft tracking", "ADS-B", "plane spotting", "dashboard"], "end_user_data_statement": "SkySearch stores no user data. Usage of external API integrations provided in SkySearch is subject to the Privacy Policy, and Terms of Service, of the respective service.", - "requirements": ["reportlab"], + "requirements": ["reportlab", "wtforms"], "permissions": [ "embed_links" ], diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 5f693c4..09a7c72 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -596,10 +596,50 @@ async def check_emergency_squawks(self): 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 using helper methods - icao = aircraft_data.get('hex', '') - view = self.helpers.create_base_aircraft_view(icao) - self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code) + # 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' + + 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 @@ -814,14 +854,46 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a embed.title = f"πŸ”” Custom Alert: {alert_data['type'].upper()} '{alert_data['value']}'" embed.color = 0xffaa00 # Orange color for custom alerts - # Create view with buttons using helper methods - icao = aircraft_data.get('hex', '') - view = self.helpers.create_base_aircraft_view(icao) - self.helpers.add_social_media_buttons( - view, aircraft_data, - alert_type=alert_data['type'], - alert_value=alert_data['value'] - ) + # Create view with buttons + 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)) + + # Add 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' + + 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 + + tweet_text = f"alert triggered! {alert_data['type'].upper()} '{alert_data['value']}' spotted - Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #discordAlert\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"Custom alert! {alert_data['type'].upper()} '{alert_data['value']}' spotted - 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)) # Attach embed and view to message_data message_data['embed'] = embed @@ -980,10 +1052,50 @@ async def simulate_emergency_alert(self, ctx, hex_code: str, squawk_code: str = 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 using helper methods (same as real emergency alerts) - icao = aircraft_data.get('hex', '') - view = self.helpers.create_base_aircraft_view(icao) - self.helpers.add_social_media_buttons(view, aircraft_data, squawk_code=squawk_code) + # Create buttons (same as real 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 (same as real alerts) + 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' + + 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 diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 2d7d66e..eafe3ae 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -616,153 +616,6 @@ async def on_submit(self, interaction: discord.Interaction): await interaction.response.send_message(embed=embed, ephemeral=True) - def convert_speed_knots_to_mph(self, ground_speed_knots): - """ - Convert ground speed from knots to miles per hour. - - Args: - ground_speed_knots: Speed in knots (can be int, float, str, or 'N/A') - - Returns: - int or str: Speed in mph, or 'unknown' if conversion fails - """ - if ground_speed_knots == 'N/A' or ground_speed_knots is None: - return 'unknown' - try: - return round(float(ground_speed_knots) * 1.15078) - except (ValueError, TypeError): - return 'unknown' - - def format_coordinate(self, coord, coord_type='lat'): - """ - Format a coordinate (latitude or longitude) for display. - - Args: - coord: Coordinate value (can be float, int, str, or 'N/A') - coord_type: 'lat' for latitude, 'lon' for longitude - - Returns: - str: Formatted coordinate (e.g., "40.71N" or "-74.01W") or original value if invalid - """ - if coord == 'N/A' or coord is None: - return 'N/A' - try: - coord_float = round(float(coord), 2) - if coord_type == 'lat': - coord_dir = "N" if coord_float >= 0 else "S" - else: # lon - coord_dir = "E" if coord_float >= 0 else "W" - return f"{abs(coord_float)}{coord_dir}" - except (ValueError, TypeError): - return coord - - def create_base_aircraft_view(self, icao_hex): - """ - Create a base Discord view with the airplanes.live link button. - - Args: - icao_hex: ICAO hex code (string) - - Returns: - discord.ui.View: View with airplanes.live button - """ - view = discord.ui.View() - if icao_hex: - icao_hex = icao_hex.upper() - link = f"https://globe.airplanes.live/?icao={icao_hex}" - view.add_item(discord.ui.Button( - label="View on airplanes.live", - emoji="πŸ—ΊοΈ", - url=link, - style=discord.ButtonStyle.link - )) - return view - - def add_social_media_buttons(self, view, aircraft_data, squawk_code=None, alert_type=None, alert_value=None): - """ - Add social media sharing buttons (X/Twitter and WhatsApp) to a view. - - Args: - view: discord.ui.View to add buttons to - aircraft_data: Dictionary containing aircraft information - squawk_code: Optional squawk code (for emergency detection) - alert_type: Optional alert type (for custom alerts) - alert_value: Optional alert value (for custom alerts) - - Returns: - discord.ui.View: View with social media buttons added - """ - from urllib.parse import quote_plus - - # Get formatted data - ground_speed_knots = aircraft_data.get('gs', aircraft_data.get('ground_speed', 'N/A')) - ground_speed_mph = self.convert_speed_knots_to_mph(ground_speed_knots) - - lat = self.format_coordinate(aircraft_data.get('lat', 'N/A'), 'lat') - lon = self.format_coordinate(aircraft_data.get('lon', 'N/A'), 'lon') - - flight = aircraft_data.get('flight', '') - icao = aircraft_data.get('hex', '').upper() - - # Determine tweet text based on context - emergency_squawk_codes = ['7500', '7600', '7700'] - discord_invite = "https://discord.gg/X8huyaeXrA" # Standard Discord invite URL - if squawk_code and squawk_code in emergency_squawk_codes: - tweet_text = ( - f"Spotted an aircraft declaring an emergency! #Squawk #{squawk_code}, " - f"flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " - f"#SkySearch #Emergency\n\n" - f"Join via Discord to search and discuss planes with your friends for free - " - f"{discord_invite}" - ) - elif alert_type and alert_value: - tweet_text = ( - f"alert triggered! {alert_type.upper()} '{alert_value}' spotted - " - f"Flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " - f"#SkySearch #discordAlert\n\n" - f"Join via Discord to search and discuss planes with your friends for free - " - f"{discord_invite}" - ) - else: - tweet_text = ( - f"Tracking flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph " - f"using #SkySearch\n\n" - f"Join via Discord to search and discuss planes with your friends for free - " - f"{discord_invite}" - ) - - tweet_url = f"https://x.com/intent/tweet?text={quote_plus(tweet_text)}" - view.add_item(discord.ui.Button( - label="Post on X", - emoji="πŸ“£", - url=tweet_url, - style=discord.ButtonStyle.link - )) - - # WhatsApp sharing - if alert_type and alert_value: - whatsapp_text = ( - f"Custom alert! {alert_type.upper()} '{alert_value}' spotted - " - f"Flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " - f"Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" - ) - else: - whatsapp_text = ( - f"Check out this aircraft! Flight {flight} at position {lat}, {lon} with speed {ground_speed_mph} mph. " - f"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 view - - class JSONInputButton(discord.ui.View): """Button view to trigger JSON input modal.""" From 3e6dc599fded02975a5fa77b2a5dc72f167a6880 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 19:59:49 +0000 Subject: [PATCH 12/74] custom watchlist wip --- skysearch/commands/aircraft.py | 256 +++++++++++++++++++++++++++++++++ skysearch/skysearch.py | 164 +++++++++++++++++++++ skysearch/utils/helpers.py | 162 +++++++++++++++++++++ 3 files changed, 582 insertions(+) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 5b54ac4..db64f14 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -827,4 +827,260 @@ async def extract_feeder_url(self, ctx, *, json_input: str = None): ) from ..utils.helpers import JSONInputButton view = JSONInputButton(self.cog) + + async def watchlist_add(self, ctx, icao: str): + """Add an aircraft to the user's watchlist.""" + # Validate ICAO using helper function + is_valid, error_msg = self.helpers.validate_icao(icao) + if not is_valid: + embed = discord.Embed( + title=_("Invalid ICAO Code"), + description=error_msg, + color=0xff4545 + ) + await ctx.send(embed=embed) + return + + icao = icao.upper().strip() + + user_config = self.cog.config.user(ctx.author) + watchlist = await user_config.watchlist() + + if icao in watchlist: + embed = discord.Embed( + title=_("Already in Watchlist"), + description=_("Aircraft {icao} is already in your watchlist.").format(icao=icao), + color=0xffaa00 + ) + await ctx.send(embed=embed) + return + + watchlist.append(icao) + await user_config.watchlist.set(watchlist) + + embed = discord.Embed( + title=_("βœ… Added to Watchlist"), + description=_("Aircraft **{icao}** has been added to your watchlist.\n\nYou will be notified when this aircraft appears online.").format(icao=icao), + color=0x00ff00 + ) + await ctx.send(embed=embed) + + async def watchlist_remove(self, ctx, icao: str): + """Remove an aircraft from the user's watchlist.""" + icao = icao.upper().strip() + + user_config = self.cog.config.user(ctx.author) + watchlist = await user_config.watchlist() + + if icao not in watchlist: + embed = discord.Embed( + title=_("Not in Watchlist"), + description=_("Aircraft {icao} is not in your watchlist.").format(icao=icao), + color=0xff4545 + ) + await ctx.send(embed=embed) + return + + watchlist.remove(icao) + await user_config.watchlist.set(watchlist) + + # Also remove from notifications dict if present + notifications = await user_config.watchlist_notifications() + if icao in notifications: + del notifications[icao] + await user_config.watchlist_notifications.set(notifications) + + embed = discord.Embed( + title=_("βœ… Removed from Watchlist"), + description=_("Aircraft **{icao}** has been removed from your watchlist.").format(icao=icao), + color=0x00ff00 + ) + await ctx.send(embed=embed) + + async def watchlist_list(self, ctx): + """List all aircraft in the user's watchlist.""" + user_config = self.cog.config.user(ctx.author) + watchlist = await user_config.watchlist() + + if not watchlist: + embed = discord.Embed( + title=_("Watchlist Empty"), + description=_("Your watchlist is empty. Use `{prefix}aircraft watchlist add ` to add aircraft.").format(prefix=ctx.prefix), + color=0xffaa00 + ) + await ctx.send(embed=embed) + return + + # Check status of all watched aircraft + embed = discord.Embed( + title=_("Your Watchlist"), + description=_("You are watching **{count}** aircraft:").format(count=len(watchlist)), + color=0xfffffe + ) + + # Check each aircraft's status + online_aircraft = [] + offline_aircraft = [] + + for icao in watchlist: + url = f"/?find_hex={icao}" + response = await self.api.make_request(url, ctx) + api_mode = await self.cog.config.api_mode() + 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: + aircraft_data = aircraft_list[0] + callsign = self.helpers.format_callsign(aircraft_data.get('flight', 'N/A')) + online_aircraft.append(f"**{icao}** - {callsign}") + else: + offline_aircraft.append(f"**{icao}** - Offline") + + if online_aircraft: + embed.add_field( + name=_("🟒 Online ({count})").format(count=len(online_aircraft)), + value="\n".join(online_aircraft[:10]), # Limit to 10 to avoid embed limits + inline=False + ) + if len(online_aircraft) > 10: + embed.add_field( + name=_("..."), + value=_("And {count} more online aircraft").format(count=len(online_aircraft) - 10), + inline=False + ) + + if offline_aircraft: + embed.add_field( + name=_("⚫ Offline ({count})").format(count=len(offline_aircraft)), + value="\n".join(offline_aircraft[:10]), # Limit to 10 + inline=False + ) + if len(offline_aircraft) > 10: + embed.add_field( + name=_("..."), + value=_("And {count} more offline aircraft").format(count=len(offline_aircraft) - 10), + inline=False + ) + + embed.set_footer(text=_("Use `{prefix}aircraft watchlist status` for detailed status of all aircraft.").format(prefix=ctx.prefix)) + await ctx.send(embed=embed) + + async def watchlist_status(self, ctx): + """Get detailed status of all watched aircraft.""" + user_config = self.cog.config.user(ctx.author) + watchlist = await user_config.watchlist() + + if not watchlist: + embed = discord.Embed( + title=_("Watchlist Empty"), + description=_("Your watchlist is empty. Use `{prefix}aircraft watchlist add ` to add aircraft.").format(prefix=ctx.prefix), + color=0xffaa00 + ) + await ctx.send(embed=embed) + return + + await ctx.typing() + + # Check each aircraft + online_count = 0 + offline_count = 0 + aircraft_details = [] + + for icao in watchlist: + url = f"/?find_hex={icao}" + response = await self.api.make_request(url, ctx) + api_mode = await self.cog.config.api_mode() + 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: + aircraft_data = aircraft_list[0] + online_count += 1 + + # Get aircraft details using helper function + status = self.helpers.extract_aircraft_status(aircraft_data) + aircraft_details.append({ + 'icao': icao, + **status, + 'online': True + }) + else: + offline_count += 1 + aircraft_details.append({ + 'icao': icao, + 'callsign': 'N/A', + 'altitude': 'N/A', + 'speed': 'N/A', + 'position': 'N/A', + 'online': False + }) + + # Create embed with details + embed = discord.Embed( + title=_("Watchlist Status"), + description=_("**{total}** aircraft in watchlist | **{online}** online | **{offline}** offline").format( + total=len(watchlist), + online=online_count, + offline=offline_count + ), + color=0xfffffe + ) + + # Add aircraft details (limit to avoid embed limits) + online_details = [a for a in aircraft_details if a['online']] + offline_details = [a for a in aircraft_details if not a['online']] + + if online_details: + online_text = "" + for aircraft in online_details[:5]: # Limit to 5 per field + online_text += f"**{aircraft['icao']}** - {aircraft['callsign']}\n" + online_text += f" Alt: {aircraft['altitude']} | Speed: {aircraft['speed']}\n" + online_text += f" Position: {aircraft['position']}\n\n" + + if len(online_details) > 5: + online_text += _("... and {count} more online aircraft").format(count=len(online_details) - 5) + + embed.add_field( + name=_("🟒 Online Aircraft"), + value=online_text or _("None"), + inline=False + ) + + if offline_details: + offline_text = "\n".join([f"**{a['icao']}**" for a in offline_details[:10]]) + if len(offline_details) > 10: + offline_text += f"\n... and {len(offline_details) - 10} more" + + embed.add_field( + name=_("⚫ Offline Aircraft"), + value=offline_text or _("None"), + inline=False + ) + + await ctx.send(embed=embed) + + async def watchlist_clear(self, ctx): + """Clear the user's entire watchlist.""" + user_config = self.cog.config.user(ctx.author) + watchlist = await user_config.watchlist() + + if not watchlist: + embed = discord.Embed( + title=_("Watchlist Already Empty"), + description=_("Your watchlist is already empty."), + color=0xffaa00 + ) + await ctx.send(embed=embed) + return + + count = len(watchlist) + await user_config.watchlist.set([]) + await user_config.watchlist_notifications.set({}) + + embed = discord.Embed( + title=_("βœ… Watchlist Cleared"), + description=_("Removed **{count}** aircraft from your watchlist.").format(count=count), + color=0x00ff00 + ) + await ctx.send(embed=embed) await ctx.send(embed=embed, view=view) \ No newline at end of file diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 09a7c72..f3c38a1 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -50,6 +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(api_stats=None) # API request statistics for persistence self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}) + self.config.register_user(watchlist=[], watchlist_notifications={}) # User watchlist: list of ICAO codes, and dict of last notification times # Initialize utility managers self.api = APIManager(self) @@ -74,6 +75,7 @@ def __init__(self, bot): # Start background tasks self.check_emergency_squawks.start() + self.check_watched_aircraft.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() @@ -129,6 +131,8 @@ async def _execute_with_hooks(self, ctx, command_name: str, args: list, command_ async def cog_unload(self): """Clean up when the cog is unloaded.""" + self.check_emergency_squawks.cancel() + self.check_watched_aircraft.cancel() await self.api.close() @commands.guild_only() @@ -229,6 +233,7 @@ async def aircraft_group(self, ctx): # Add brief mention of force and cooldown clear for owners 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", 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): @@ -312,6 +317,43 @@ async def aircraft_feeder(self, ctx, *, json_input: str = None): """Extract feeder URL from JSON data or a URL containing feeder data using secure modal.""" await self.aircraft_commands.extract_feeder_url(ctx, json_input=json_input) + # Watchlist commands + @commands.guild_only() + @aircraft_group.group(name='watchlist', invoke_without_command=True) + async def aircraft_watchlist(self, ctx): + """Manage your personal aircraft watchlist.""" + await self.aircraft_commands.watchlist_list(ctx) + + @commands.guild_only() + @aircraft_watchlist.command(name='add') + async def aircraft_watchlist_add(self, ctx, icao: str): + """Add an aircraft to your watchlist by ICAO code.""" + await self.aircraft_commands.watchlist_add(ctx, icao) + + @commands.guild_only() + @aircraft_watchlist.command(name='remove', aliases=['rm', 'del']) + async def aircraft_watchlist_remove(self, ctx, icao: str): + """Remove an aircraft from your watchlist.""" + await self.aircraft_commands.watchlist_remove(ctx, icao) + + @commands.guild_only() + @aircraft_watchlist.command(name='list') + async def aircraft_watchlist_list(self, ctx): + """List all aircraft in your watchlist.""" + await self.aircraft_commands.watchlist_list(ctx) + + @commands.guild_only() + @aircraft_watchlist.command(name='status') + async def aircraft_watchlist_status(self, ctx): + """Get detailed status of all watched aircraft.""" + await self.aircraft_commands.watchlist_status(ctx) + + @commands.guild_only() + @aircraft_watchlist.command(name='clear') + async def aircraft_watchlist_clear(self, ctx): + """Clear your entire watchlist.""" + await self.aircraft_commands.watchlist_clear(ctx) + @@ -744,6 +786,128 @@ async def before_check_emergency_squawks(self): """Wait for bot to be ready before starting the task.""" await self.bot.wait_until_ready() + @tasks.loop(minutes=3) + async def check_watched_aircraft(self): + """Background task to check watched aircraft and notify users when they come online.""" + try: + log.debug("Background task checking watched aircraft...") + + # Get all users with watchlists + # We need to check all users across all guilds + watchlist_map = {} # icao -> list of (user_id, guild) tuples + + for guild in self.bot.guilds: + for member in guild.members: + if member.bot: + continue + try: + user_config = self.config.user(member) + watchlist = await user_config.watchlist() + if watchlist: + for icao in watchlist: + if icao not in watchlist_map: + watchlist_map[icao] = [] + watchlist_map[icao].append((member, guild)) + except Exception as e: + log.debug(f"Error getting watchlist for user {member.id}: {e}") + continue + + if not watchlist_map: + return # No watchlists to check + + # Check all watched aircraft in one API call + icao_list = list(watchlist_map.keys()) + # Batch check - airplanes.live supports multiple hex codes + # We'll check them individually to avoid URL length issues + found_aircraft = {} + + for icao in icao_list: + try: + url = f"{await self.api.get_api_url()}/?find_hex={icao}" + response = await self.api.make_request(url) # No ctx for background task + api_mode = await self.config.api_mode() + 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: + found_aircraft[icao] = aircraft_list[0] + except Exception as e: + log.debug(f"Error checking aircraft {icao}: {e}") + continue + + # Small delay to avoid rate limiting + await asyncio.sleep(0.5) + + # Notify users about aircraft that came online + for icao, aircraft_data in found_aircraft.items(): + if icao not in watchlist_map: + continue + + for user, guild in watchlist_map[icao]: + try: + user_config = self.config.user(user) + notifications = await user_config.watchlist_notifications() + + # Check if we've notified recently (cooldown: 10 minutes) + last_notification = notifications.get(icao, 0) + current_time = datetime.datetime.now(datetime.timezone.utc).timestamp() + cooldown_seconds = 10 * 60 # 10 minutes + + if current_time - last_notification < cooldown_seconds: + continue # Still in cooldown + + # Check if aircraft was offline before (we only notify when it comes online) + # For now, we'll notify every time it's found (user can remove if they don't want notifications) + + # Try to send DM to user + try: + # Create notification embed and view using helper functions + embed = self.helpers.create_watchlist_notification_embed(icao, aircraft_data) + view = self.helpers.create_watchlist_view(icao) + + # Try to send DM + try: + await user.send(embed=embed, view=view) + # Update notification timestamp + notifications[icao] = current_time + await user_config.watchlist_notifications.set(notifications) + log.info(f"Sent watchlist notification to {user.id} for aircraft {icao}") + except discord.Forbidden: + # User has DMs disabled, try to send in a shared guild channel + if guild: + # Try to find a channel we can send to + for channel in guild.text_channels: + if channel.permissions_for(guild.me).send_messages: + try: + await channel.send( + content=_("{user} - Your watched aircraft **{icao}** is online!").format( + user=user.mention, + icao=icao + ), + embed=embed, + view=view + ) + notifications[icao] = current_time + await user_config.watchlist_notifications.set(notifications) + log.info(f"Sent watchlist notification to {user.id} in {guild.name} for aircraft {icao}") + break + except Exception: + continue + except Exception as e: + log.debug(f"Error sending watchlist notification to {user.id}: {e}") + + except Exception as e: + log.debug(f"Error processing watchlist notification for user {user.id}: {e}") + continue + + except Exception as e: + log.error(f"Error checking watched aircraft: {e}", exc_info=True) + + @check_watched_aircraft.before_loop + async def before_check_watched_aircraft(self): + """Wait for bot to be ready before starting the task.""" + await self.bot.wait_until_ready() + async def check_custom_alerts(self, aircraft_info): """Check if aircraft matches any custom alerts for all guilds.""" try: diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index eafe3ae..928660d 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -555,6 +555,168 @@ def create_feeder_view(self, json_input: str, json_data: dict = None): )) return view + + def format_altitude(self, altitude): + """ + Format altitude value for display. + + Args: + altitude: Altitude value (can be 'ground', 'N/A', int, or str) + + Returns: + str: Formatted altitude text + """ + if altitude == 'ground': + return "On ground" + elif altitude != 'N/A' and altitude is not None: + if isinstance(altitude, (int, float)): + return f"{int(altitude):,} ft" + return f"{altitude} ft" + return "N/A" + + def format_speed(self, speed_knots): + """ + Format speed from knots to mph for display. + + Args: + speed_knots: Speed in knots (can be 'N/A', None, int, or float) + + Returns: + str: Formatted speed text in mph + """ + if speed_knots != 'N/A' and speed_knots is not None: + try: + speed_mph = round(float(speed_knots) * 1.15078) + return f"{speed_mph} mph" + except (ValueError, TypeError): + return "N/A" + return "N/A" + + def format_position(self, lat, lon): + """ + Format latitude and longitude for display. + + Args: + lat: Latitude value + lon: Longitude value + + Returns: + str: Formatted position text or "N/A" + """ + if lat != 'N/A' and lat is not None and lon != 'N/A' and lon is not None: + try: + lat_rounded = round(float(lat), 2) + lon_rounded = round(float(lon), 2) + return f"{lat_rounded}, {lon_rounded}" + except (ValueError, TypeError): + return "N/A" + return "N/A" + + def format_callsign(self, callsign): + """ + Format callsign for display (handles blocked/empty callsigns). + + Args: + callsign: Callsign string + + Returns: + str: Formatted callsign or "BLOCKED" + """ + if not callsign or callsign.strip() == '' or callsign == 'N/A': + return 'BLOCKED' + return callsign.strip() + + def validate_icao(self, icao): + """ + Validate ICAO hex code format. + + Args: + icao: ICAO code to validate + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + icao = icao.upper().strip() + if len(icao) != 6: + return False, "ICAO code must be exactly 6 characters." + if not all(c in '0123456789ABCDEF' for c in icao): + return False, "ICAO code must contain only hexadecimal characters (0-9, A-F)." + return True, None + + def create_watchlist_notification_embed(self, icao, aircraft_data): + """ + Create a notification embed for watchlist aircraft coming online. + + Args: + icao: ICAO hex code + aircraft_data: Aircraft data dictionary + + Returns: + discord.Embed: Formatted notification embed + """ + from redbot.core.i18n import Translator + _watchlist = Translator("Skysearch", __file__) + + embed = discord.Embed( + title=_watchlist("🟒 Aircraft Online"), + description=_watchlist("**{icao}** from your watchlist is now online!").format(icao=icao), + color=0x00ff00 + ) + + callsign = self.format_callsign(aircraft_data.get('flight', 'N/A')) + altitude = self.format_altitude(aircraft_data.get('alt_baro', 'N/A')) + speed = self.format_speed(aircraft_data.get('gs', 'N/A')) + position = self.format_position( + aircraft_data.get('lat', 'N/A'), + aircraft_data.get('lon', 'N/A') + ) + + embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) + embed.add_field(name=_watchlist("Altitude"), value=altitude, inline=True) + embed.add_field(name=_watchlist("Speed"), value=speed, inline=True) + embed.add_field(name=_watchlist("Position"), value=position, inline=False) + + return embed + + def create_watchlist_view(self, icao): + """ + Create a view with buttons for watchlist aircraft. + + Args: + icao: ICAO hex code + + Returns: + discord.ui.View: View with link button + """ + 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 + )) + return view + + def extract_aircraft_status(self, aircraft_data): + """ + Extract formatted status information from aircraft data. + + Args: + aircraft_data: Aircraft data dictionary + + Returns: + dict: Dictionary with formatted status fields (callsign, altitude, speed, position) + """ + return { + 'callsign': self.format_callsign(aircraft_data.get('flight', 'N/A')), + 'altitude': self.format_altitude(aircraft_data.get('alt_baro', 'N/A')), + 'speed': self.format_speed(aircraft_data.get('gs', 'N/A')), + 'position': self.format_position( + aircraft_data.get('lat', 'N/A'), + aircraft_data.get('lon', 'N/A') + ) + } class JSONInputModal(discord.ui.Modal): From 48753998eb1e3e35f6b310fb65de69fe16bf061f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:04:02 +0000 Subject: [PATCH 13/74] Update skysearch.py --- skysearch/skysearch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index f3c38a1..f5aa8b5 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -895,6 +895,8 @@ async def check_watched_aircraft(self): continue except Exception as e: log.debug(f"Error sending watchlist notification to {user.id}: {e}") + except Exception as e: + log.debug(f"Error creating notification for user {user.id}: {e}") except Exception as e: log.debug(f"Error processing watchlist notification for user {user.id}: {e}") From 433deb9466bfa8dd9abb6548ca69f4fe1c575c17 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:05:24 +0000 Subject: [PATCH 14/74] Update aircraft.py --- skysearch/commands/aircraft.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index db64f14..3212f7b 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -1082,5 +1082,4 @@ async def watchlist_clear(self, ctx): description=_("Removed **{count}** aircraft from your watchlist.").format(count=count), color=0x00ff00 ) - await ctx.send(embed=embed) - await ctx.send(embed=embed, view=view) \ No newline at end of file + await ctx.send(embed=embed) \ No newline at end of file From 4536caa708cc1fc7ef341697a2fda5e8bf2dc804 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:10:45 +0000 Subject: [PATCH 15/74] docs --- skysearch/README.md | 14 ++++++++++++ skysearch/docs.md | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/skysearch/README.md b/skysearch/README.md index 4a042c1..c1942f0 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -38,6 +38,20 @@ To use the SkySearch cog, follow these steps: - `[p]aircraft export ` - Export data - `[p]aircraft scroll` - Scroll through aircraft +### Watchlist Commands +- `[p]aircraft watchlist` - View your watchlist +- `[p]aircraft watchlist add ` - Add aircraft to your personal watchlist +- `[p]aircraft watchlist remove ` - Remove aircraft from watchlist +- `[p]aircraft watchlist list` - List all watched aircraft with online/offline status +- `[p]aircraft watchlist status` - Get detailed status of all watched aircraft +- `[p]aircraft watchlist clear` - Clear your entire watchlist + +**Features:** +- Personal watchlist per user +- Automatic notifications when watched aircraft come online (via DM or guild channel) +- 10-minute cooldown per aircraft to prevent spam +- Background task checks every 3 minutes + ### Airport Commands - `[p]airport info ` - Get airport information - `[p]airport runway ` - Get runway information diff --git a/skysearch/docs.md b/skysearch/docs.md index dd63634..a00fd05 100644 --- a/skysearch/docs.md +++ b/skysearch/docs.md @@ -196,6 +196,58 @@ Visit `/dashboard/apistats` in your web browser to view API statistics in a web - `*skysearch apistats_reset` - Reset all statistics - `*skysearch apistats_save` - Manually save statistics +## Aircraft Watchlist + +### Personal Watchlist +Create your own personal watchlist of aircraft to monitor. You'll receive notifications when watched aircraft come online. + +### Adding Aircraft to Watchlist +``` +*aircraft watchlist add A03B67 +``` +Adds the aircraft with ICAO code `A03B67` to your watchlist. + +### Viewing Your Watchlist +``` +*aircraft watchlist list +``` +Shows all aircraft in your watchlist with their current online/offline status. + +### Detailed Status +``` +*aircraft watchlist status +``` +Shows detailed information about all watched aircraft including: +- Callsign +- Altitude +- Speed +- Position +- Online/offline status + +### Removing Aircraft +``` +*aircraft watchlist remove A03B67 +``` +Removes the aircraft from your watchlist. + +### Clearing Watchlist +``` +*aircraft watchlist clear +``` +Removes all aircraft from your watchlist. + +### Watchlist Notifications +- **Automatic notifications** when watched aircraft come online +- Notifications sent via **DM** (if enabled) or in a **shared guild channel** +- **10-minute cooldown** per aircraft to prevent spam +- Background task checks every **3 minutes** + +### How It Works +1. Add aircraft to your watchlist using their ICAO hex code +2. The bot automatically checks your watchlist every 3 minutes +3. When a watched aircraft comes online, you receive a notification +4. Notifications include aircraft details and a link to track on airplanes.live + ## Convenience Features ### Auto ICAO Lookup From 2ad5a1e8b363bafaa51154d28ff9e1d7a16ab55a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:42:24 +0000 Subject: [PATCH 16/74] watchlist cooldown adjustment and update docs --- skysearch/README.md | 4 ++- skysearch/commands/aircraft.py | 52 ++++++++++++++++++++++++++++++++++ skysearch/docs.md | 21 +++++++++++++- skysearch/skysearch.py | 21 +++++++++++--- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index c1942f0..46217d7 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -45,11 +45,13 @@ To use the SkySearch cog, follow these steps: - `[p]aircraft watchlist list` - List all watched aircraft with online/offline status - `[p]aircraft watchlist status` - Get detailed status of all watched aircraft - `[p]aircraft watchlist clear` - Clear your entire watchlist +- `[p]aircraft watchlist cooldown [minutes]` - Set or view notification cooldown (default: 10 minutes) **Features:** - Personal watchlist per user - Automatic notifications when watched aircraft come online (via DM or guild channel) -- 10-minute cooldown per aircraft to prevent spam +- If aircraft is already online when added, you'll see its current status immediately +- Configurable cooldown per user (1-1440 minutes, default: 10 minutes) to prevent spam - Background task checks every 3 minutes ### Airport Commands diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 3212f7b..321381e 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -1082,4 +1082,56 @@ async def watchlist_clear(self, ctx): description=_("Removed **{count}** aircraft from your watchlist.").format(count=count), color=0x00ff00 ) + await ctx.send(embed=embed) + + async def watchlist_cooldown(self, ctx, minutes: int = None): + """Set or view the watchlist notification cooldown.""" + user_config = self.cog.config.user(ctx.author) + + if minutes is None: + # Show current cooldown + current_cooldown = await user_config.watchlist_cooldown() + embed = discord.Embed( + title=_("Watchlist Cooldown"), + description=_("Current notification cooldown: **{minutes} minutes**\n\nUse `{prefix}aircraft watchlist cooldown ` to change it.").format( + minutes=current_cooldown, + prefix=ctx.prefix + ), + color=0xfffffe + ) + embed.add_field( + name=_("How it works"), + value=_("After you receive a notification for a watched aircraft, you won't receive another notification for the same aircraft until the cooldown period expires."), + inline=False + ) + await ctx.send(embed=embed) + return + + # Validate cooldown value + if minutes < 1: + embed = discord.Embed( + title=_("Invalid Cooldown"), + description=_("Cooldown must be at least 1 minute."), + color=0xff4545 + ) + await ctx.send(embed=embed) + return + + if minutes > 1440: # 24 hours + embed = discord.Embed( + title=_("Invalid Cooldown"), + description=_("Cooldown cannot exceed 1440 minutes (24 hours)."), + color=0xff4545 + ) + await ctx.send(embed=embed) + return + + # Set cooldown + await user_config.watchlist_cooldown.set(minutes) + + embed = discord.Embed( + title=_("βœ… Cooldown Updated"), + description=_("Watchlist notification cooldown set to **{minutes} minutes**.").format(minutes=minutes), + color=0x00ff00 + ) await ctx.send(embed=embed) \ No newline at end of file diff --git a/skysearch/docs.md b/skysearch/docs.md index a00fd05..c19a95d 100644 --- a/skysearch/docs.md +++ b/skysearch/docs.md @@ -207,6 +207,8 @@ Create your own personal watchlist of aircraft to monitor. You'll receive notifi ``` Adds the aircraft with ICAO code `A03B67` to your watchlist. +**Note:** If the aircraft is already online when you add it, you'll immediately see its current status (callsign, altitude, speed, position) instead of waiting for the next notification. + ### Viewing Your Watchlist ``` *aircraft watchlist list @@ -236,11 +238,28 @@ Removes the aircraft from your watchlist. ``` Removes all aircraft from your watchlist. +### Configuring Notification Cooldown +``` +*aircraft watchlist cooldown # Check current cooldown +*aircraft watchlist cooldown 5 # Set to 5 minutes +*aircraft watchlist cooldown 30 # Set to 30 minutes +*aircraft watchlist cooldown 1440 # Set to 24 hours (maximum) +``` + +**Cooldown Settings:** +- **Default:** 10 minutes +- **Range:** 1-1440 minutes (1 minute to 24 hours) +- **Per-user:** Each user can set their own cooldown preference +- **Purpose:** Prevents spam notifications for the same aircraft + +After receiving a notification for a watched aircraft, you won't receive another notification for the same aircraft until your configured cooldown period expires. + ### Watchlist Notifications - **Automatic notifications** when watched aircraft come online - Notifications sent via **DM** (if enabled) or in a **shared guild channel** -- **10-minute cooldown** per aircraft to prevent spam +- **Configurable cooldown** per user (default: 10 minutes) to prevent spam - Background task checks every **3 minutes** +- If aircraft is **already online** when added, you'll see its status immediately ### How It Works 1. Add aircraft to your watchlist using their ICAO hex code diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index f5aa8b5..741490c 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(api_stats=None) # API request statistics for persistence self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}) - self.config.register_user(watchlist=[], watchlist_notifications={}) # User watchlist: list of ICAO codes, and dict of last notification times + self.config.register_user(watchlist=[], watchlist_notifications={}, watchlist_cooldown=10) # User watchlist: list of ICAO codes, dict of last notification times, and cooldown in minutes (default: 10) # Initialize utility managers self.api = APIManager(self) @@ -233,7 +233,7 @@ async def aircraft_group(self, ctx): # Add brief mention of force and cooldown clear for owners 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", 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=_("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): @@ -353,6 +353,18 @@ async def aircraft_watchlist_status(self, ctx): async def aircraft_watchlist_clear(self, ctx): """Clear your entire watchlist.""" await self.aircraft_commands.watchlist_clear(ctx) + + @commands.guild_only() + @aircraft_watchlist.command(name='cooldown') + async def aircraft_watchlist_cooldown(self, ctx, minutes: int = None): + """Set or view the watchlist notification cooldown (in minutes). Use without a value to check current setting.""" + await self.aircraft_commands.watchlist_cooldown(ctx, minutes) + + @commands.guild_only() + @aircraft_watchlist.command(name='cooldown') + async def aircraft_watchlist_cooldown(self, ctx, minutes: int = None): + """Set or view the watchlist notification cooldown (in minutes). Use without a value to check current setting.""" + await self.aircraft_commands.watchlist_cooldown(ctx, minutes) @@ -848,10 +860,11 @@ async def check_watched_aircraft(self): user_config = self.config.user(user) notifications = await user_config.watchlist_notifications() - # Check if we've notified recently (cooldown: 10 minutes) + # Check if we've notified recently (configurable cooldown) last_notification = notifications.get(icao, 0) current_time = datetime.datetime.now(datetime.timezone.utc).timestamp() - cooldown_seconds = 10 * 60 # 10 minutes + cooldown_minutes = await user_config.watchlist_cooldown() + cooldown_seconds = cooldown_minutes * 60 if current_time - last_notification < cooldown_seconds: continue # Still in cooldown From e52b6b823f48c6d5fcfc82788cc0c97efc08eca6 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:43:55 +0000 Subject: [PATCH 17/74] fix conflict --- skysearch/skysearch.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 741490c..830963e 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -359,12 +359,6 @@ async def aircraft_watchlist_clear(self, ctx): async def aircraft_watchlist_cooldown(self, ctx, minutes: int = None): """Set or view the watchlist notification cooldown (in minutes). Use without a value to check current setting.""" await self.aircraft_commands.watchlist_cooldown(ctx, minutes) - - @commands.guild_only() - @aircraft_watchlist.command(name='cooldown') - async def aircraft_watchlist_cooldown(self, ctx, minutes: int = None): - """Set or view the watchlist notification cooldown (in minutes). Use without a value to check current setting.""" - await self.aircraft_commands.watchlist_cooldown(ctx, minutes) From c02dcd8c245aa317ff3ab02daa151fe14cb85141 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:46:43 +0000 Subject: [PATCH 18/74] tweaks --- skysearch/commands/aircraft.py | 110 ++++++++++++++++++++++++--------- skysearch/skysearch.py | 6 +- 2 files changed, 85 insertions(+), 31 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 321381e..6aad250 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -81,7 +81,7 @@ async def send_aircraft_info(self, ctx, response): 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)}" + 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)}" @@ -1084,17 +1084,30 @@ async def watchlist_clear(self, ctx): ) await ctx.send(embed=embed) - async def watchlist_cooldown(self, ctx, minutes: int = None): - """Set or view the watchlist notification cooldown.""" + async def watchlist_cooldown(self, ctx, duration: str = None): + """Set or view the watchlist notification cooldown. + + Accepts time formats: + - Minutes: "20", "20m", "20.5m" + - Seconds: "30s", "120s" + - Hours: "1h", "2.5h" + """ user_config = self.cog.config.user(ctx.author) - if minutes is None: + if duration is None: # Show current cooldown current_cooldown = await user_config.watchlist_cooldown() + if current_cooldown < 1: + cooldown_text = _("{seconds} seconds").format(seconds=int(current_cooldown * 60)) + elif current_cooldown == int(current_cooldown): + cooldown_text = _("{minutes} minutes").format(minutes=int(current_cooldown)) + else: + cooldown_text = _("{minutes} minutes").format(minutes=current_cooldown) + embed = discord.Embed( title=_("Watchlist Cooldown"), - description=_("Current notification cooldown: **{minutes} minutes**\n\nUse `{prefix}aircraft watchlist cooldown ` to change it.").format( - minutes=current_cooldown, + description=_("Current notification cooldown: **{cooldown}**\n\nUse `{prefix}aircraft watchlist cooldown ` to change it.\n\nExamples: `20m`, `30s`, `1h`, `15.5m`").format( + cooldown=cooldown_text, prefix=ctx.prefix ), color=0xfffffe @@ -1104,34 +1117,75 @@ async def watchlist_cooldown(self, ctx, minutes: int = None): value=_("After you receive a notification for a watched aircraft, you won't receive another notification for the same aircraft until the cooldown period expires."), inline=False ) + embed.add_field( + name=_("Time formats"), + value=_("You can use:\nβ€’ Minutes: `20`, `20m`, `20.5m`\nβ€’ Seconds: `30s`, `120s`\nβ€’ Hours: `1h`, `2.5h`"), + inline=False + ) await ctx.send(embed=embed) return - # Validate cooldown value - if minutes < 1: + # Parse duration string + try: + duration = duration.strip().lower() + minutes = None + + if duration.endswith('s'): + # Convert seconds to minutes + seconds = float(duration[:-1]) + minutes = seconds / 60.0 + elif duration.endswith('m'): + # Minutes + minutes = float(duration[:-1]) + elif duration.endswith('h'): + # Convert hours to minutes + hours = float(duration[:-1]) + minutes = hours * 60.0 + else: + # Assume minutes if no suffix + minutes = float(duration) + + # Validate cooldown value + if minutes < 0.0167: # Less than 1 second + embed = discord.Embed( + title=_("Invalid Cooldown"), + description=_("Cooldown must be at least 1 second."), + color=0xff4545 + ) + await ctx.send(embed=embed) + return + + if minutes > 1440: # 24 hours + embed = discord.Embed( + title=_("Invalid Cooldown"), + description=_("Cooldown cannot exceed 1440 minutes (24 hours)."), + color=0xff4545 + ) + await ctx.send(embed=embed) + return + + # Set cooldown (store as float to support decimals) + await user_config.watchlist_cooldown.set(minutes) + + # Format response message + if minutes < 1: + cooldown_text = _("{seconds} seconds").format(seconds=int(minutes * 60)) + elif minutes == int(minutes): + cooldown_text = _("{minutes} minutes").format(minutes=int(minutes)) + else: + cooldown_text = _("{minutes} minutes").format(minutes=minutes) + embed = discord.Embed( - title=_("Invalid Cooldown"), - description=_("Cooldown must be at least 1 minute."), - color=0xff4545 + title=_("βœ… Cooldown Updated"), + description=_("Watchlist notification cooldown set to **{cooldown}**.").format(cooldown=cooldown_text), + color=0x00ff00 ) await ctx.send(embed=embed) - return - - if minutes > 1440: # 24 hours + + except ValueError: embed = discord.Embed( - title=_("Invalid Cooldown"), - description=_("Cooldown cannot exceed 1440 minutes (24 hours)."), + title=_("Invalid Duration Format"), + 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) - return - - # Set cooldown - await user_config.watchlist_cooldown.set(minutes) - - embed = discord.Embed( - title=_("βœ… Cooldown Updated"), - description=_("Watchlist notification cooldown set to **{minutes} minutes**.").format(minutes=minutes), - color=0x00ff00 - ) - 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 830963e..85b0b93 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -356,9 +356,9 @@ async def aircraft_watchlist_clear(self, ctx): @commands.guild_only() @aircraft_watchlist.command(name='cooldown') - async def aircraft_watchlist_cooldown(self, ctx, minutes: int = None): - """Set or view the watchlist notification cooldown (in minutes). Use without a value to check current setting.""" - await self.aircraft_commands.watchlist_cooldown(ctx, minutes) + 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) From 7cdf2b55d5e8343aaa1743c5e6ba994dfb168b05 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:20:58 +0000 Subject: [PATCH 19/74] take off/landing --- skysearch/commands/aircraft.py | 13 +++- skysearch/skysearch.py | 109 ++++++++++++++++++++++++++++++++- skysearch/utils/helpers.py | 87 ++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 4 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index 6aad250..a2c6949 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -888,7 +888,17 @@ async def watchlist_remove(self, ctx, icao: str): notifications = await user_config.watchlist_notifications() if icao in notifications: del notifications[icao] - await user_config.watchlist_notifications.set(notifications) + if f"{icao}_landing" in notifications: + del notifications[f"{icao}_landing"] + if f"{icao}_takeoff" in notifications: + del notifications[f"{icao}_takeoff"] + await user_config.watchlist_notifications.set(notifications) + + # Remove from aircraft state tracking + aircraft_state = await user_config.watchlist_aircraft_state() + if icao in aircraft_state: + del aircraft_state[icao] + await user_config.watchlist_aircraft_state.set(aircraft_state) embed = discord.Embed( title=_("βœ… Removed from Watchlist"), @@ -1076,6 +1086,7 @@ async def watchlist_clear(self, ctx): count = len(watchlist) await user_config.watchlist.set([]) await user_config.watchlist_notifications.set({}) + await user_config.watchlist_aircraft_state.set({}) embed = discord.Embed( title=_("βœ… Watchlist Cleared"), diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 85b0b93..7eb2049 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(api_stats=None) # API request statistics for persistence self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}) - self.config.register_user(watchlist=[], watchlist_notifications={}, watchlist_cooldown=10) # User watchlist: list of ICAO codes, dict of last notification times, and cooldown in minutes (default: 10) + 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 self.api = APIManager(self) @@ -853,16 +853,119 @@ async def check_watched_aircraft(self): try: user_config = self.config.user(user) notifications = await user_config.watchlist_notifications() + aircraft_state = await user_config.watchlist_aircraft_state() - # Check if we've notified recently (configurable cooldown) - last_notification = notifications.get(icao, 0) current_time = datetime.datetime.now(datetime.timezone.utc).timestamp() cooldown_minutes = await user_config.watchlist_cooldown() cooldown_seconds = cooldown_minutes * 60 + # Check if aircraft has landed + is_landed = self.helpers.is_aircraft_landed(aircraft_data) + last_state = aircraft_state.get(icao, 'unknown') + + # Check for takeoff transition (was landed, now flying) + if last_state == 'landed' and not is_landed: + # Aircraft just took off - send takeoff notification + last_takeoff_notification = notifications.get(f"{icao}_takeoff", 0) + if current_time - last_takeoff_notification >= cooldown_seconds: + try: + embed = self.helpers.create_watchlist_takeoff_embed(icao, aircraft_data) + view = self.helpers.create_watchlist_view(icao) + + try: + await user.send(embed=embed, view=view) + notifications[f"{icao}_takeoff"] = current_time + await user_config.watchlist_notifications.set(notifications) + aircraft_state[icao] = 'flying' + await user_config.watchlist_aircraft_state.set(aircraft_state) + log.info(f"Sent watchlist takeoff notification to {user.id} for aircraft {icao}") + except discord.Forbidden: + if guild: + for channel in guild.text_channels: + if channel.permissions_for(guild.me).send_messages: + try: + await channel.send( + content=_("{user} - Your watched aircraft **{icao}** has taken off!").format( + user=user.mention, + icao=icao + ), + embed=embed, + view=view + ) + notifications[f"{icao}_takeoff"] = current_time + await user_config.watchlist_notifications.set(notifications) + aircraft_state[icao] = 'flying' + await user_config.watchlist_aircraft_state.set(aircraft_state) + log.info(f"Sent watchlist takeoff notification to {user.id} in {guild.name} for aircraft {icao}") + break + except Exception: + continue + except Exception as e: + log.debug(f"Error sending watchlist takeoff notification to {user.id}: {e}") + except Exception as e: + log.debug(f"Error creating takeoff notification for user {user.id}: {e}") + continue # Skip online notification if we just sent takeoff notification + + # Check for landing transition (was flying, now landed) + if last_state == 'flying' and is_landed: + # Aircraft just landed - send landing notification + last_landing_notification = notifications.get(f"{icao}_landing", 0) + if current_time - last_landing_notification >= cooldown_seconds: + try: + embed = self.helpers.create_watchlist_landing_embed(icao, aircraft_data) + view = self.helpers.create_watchlist_view(icao) + + try: + await user.send(embed=embed, view=view) + notifications[f"{icao}_landing"] = current_time + await user_config.watchlist_notifications.set(notifications) + aircraft_state[icao] = 'landed' + await user_config.watchlist_aircraft_state.set(aircraft_state) + log.info(f"Sent watchlist landing notification to {user.id} for aircraft {icao}") + except discord.Forbidden: + if guild: + for channel in guild.text_channels: + if channel.permissions_for(guild.me).send_messages: + try: + await channel.send( + content=_("{user} - Your watched aircraft **{icao}** has landed!").format( + user=user.mention, + icao=icao + ), + embed=embed, + view=view + ) + notifications[f"{icao}_landing"] = current_time + await user_config.watchlist_notifications.set(notifications) + aircraft_state[icao] = 'landed' + await user_config.watchlist_aircraft_state.set(aircraft_state) + log.info(f"Sent watchlist landing notification to {user.id} in {guild.name} for aircraft {icao}") + break + except Exception: + continue + except Exception as e: + log.debug(f"Error sending watchlist landing notification to {user.id}: {e}") + except Exception as e: + log.debug(f"Error creating landing notification for user {user.id}: {e}") + continue # Skip online notification if we just sent landing notification + + # Update aircraft state + if is_landed: + aircraft_state[icao] = 'landed' + else: + aircraft_state[icao] = 'flying' + await user_config.watchlist_aircraft_state.set(aircraft_state) + + # Check if we've notified recently (configurable cooldown) for online notifications + last_notification = notifications.get(icao, 0) + if current_time - last_notification < cooldown_seconds: continue # Still in cooldown + # Only notify if aircraft is flying (not landed) + if is_landed: + continue # Skip online notification if aircraft is on ground + # Check if aircraft was offline before (we only notify when it comes online) # For now, we'll notify every time it's found (user can remove if they don't want notifications) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 928660d..df75efb 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -717,6 +717,93 @@ def extract_aircraft_status(self, aircraft_data): aircraft_data.get('lon', 'N/A') ) } + + def is_aircraft_landed(self, aircraft_data): + """ + Check if aircraft is landed based on altitude. + + Args: + aircraft_data: Aircraft data dictionary + + Returns: + bool: True if aircraft is landed (altitude < 25 or 'ground'), False otherwise + """ + altitude = aircraft_data.get('altitude') or aircraft_data.get('alt_baro') + if altitude == 'ground': + return True + if altitude is not None and altitude != 'N/A': + try: + return float(altitude) < 25 + except (ValueError, TypeError): + return False + return False + + def create_watchlist_landing_embed(self, icao, aircraft_data): + """ + Create a landing notification embed for watchlist aircraft. + + Args: + icao: ICAO hex code + aircraft_data: Aircraft data dictionary + + Returns: + discord.Embed: Formatted landing notification embed + """ + from redbot.core.i18n import Translator + _watchlist = Translator("Skysearch", __file__) + + embed = discord.Embed( + title=_watchlist("πŸ›¬ Aircraft Landed"), + description=_watchlist("**{icao}** from your watchlist has landed!").format(icao=icao), + color=0x00ff00 + ) + + callsign = self.format_callsign(aircraft_data.get('flight', 'N/A')) + position = self.format_position( + aircraft_data.get('lat', 'N/A'), + aircraft_data.get('lon', 'N/A') + ) + + embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) + embed.add_field(name=_watchlist("Status"), value=_watchlist("On ground"), inline=True) + embed.add_field(name=_watchlist("Position"), value=position, inline=False) + + return embed + + def create_watchlist_takeoff_embed(self, icao, aircraft_data): + """ + Create a takeoff notification embed for watchlist aircraft. + + Args: + icao: ICAO hex code + aircraft_data: Aircraft data dictionary + + Returns: + discord.Embed: Formatted takeoff notification embed + """ + from redbot.core.i18n import Translator + _watchlist = Translator("Skysearch", __file__) + + embed = discord.Embed( + title=_watchlist("✈️ Aircraft Took Off"), + description=_watchlist("**{icao}** from your watchlist has taken off!").format(icao=icao), + color=0x0099ff + ) + + callsign = self.format_callsign(aircraft_data.get('flight', 'N/A')) + altitude = self.format_altitude(aircraft_data.get('alt_baro', 'N/A')) + speed = self.format_speed(aircraft_data.get('gs', 'N/A')) + position = self.format_position( + aircraft_data.get('lat', 'N/A'), + aircraft_data.get('lon', 'N/A') + ) + + embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) + embed.add_field(name=_watchlist("Altitude"), value=altitude, inline=True) + embed.add_field(name=_watchlist("Speed"), value=speed, inline=True) + embed.add_field(name=_watchlist("Position"), value=position, inline=False) + + return embed class JSONInputModal(discord.ui.Modal): From a7386a8b9c6f6349984f1a9a93ac2697a0292dcd Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:27:34 +0000 Subject: [PATCH 20/74] fix mistake --- skysearch/commands/aircraft.py | 21 ++++++++++++++++++++- skysearch/skysearch.py | 28 +++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index a2c6949..df4bfda 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -858,9 +858,28 @@ async def watchlist_add(self, ctx, icao: str): watchlist.append(icao) await user_config.watchlist.set(watchlist) + # Initialize aircraft state - check if currently online + aircraft_state = await user_config.watchlist_aircraft_state() + url = f"/?find_hex={icao}" + response = await self.api.make_request(url, ctx) + api_mode = await self.cog.config.api_mode() + 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: + # Aircraft is online - initialize state + aircraft_data = aircraft_list[0] + is_landed = self.helpers.is_aircraft_landed(aircraft_data) + aircraft_state[icao] = 'landed' if is_landed else 'flying' + await user_config.watchlist_aircraft_state.set(aircraft_state) + else: + # Aircraft is offline - set to 'offline' so we can detect when it comes online + aircraft_state[icao] = 'offline' + await user_config.watchlist_aircraft_state.set(aircraft_state) + embed = discord.Embed( title=_("βœ… Added to Watchlist"), - description=_("Aircraft **{icao}** has been added to your watchlist.\n\nYou will be notified when this aircraft appears online.").format(icao=icao), + description=_("Aircraft **{icao}** has been added to your watchlist.\n\nYou will be notified when this aircraft appears online, takes off, or lands.").format(icao=icao), color=0x00ff00 ) await ctx.send(embed=embed) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 7eb2049..44c69f9 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -863,6 +863,16 @@ async def check_watched_aircraft(self): is_landed = self.helpers.is_aircraft_landed(aircraft_data) last_state = aircraft_state.get(icao, 'unknown') + # If state is unknown and aircraft is found, initialize state (but don't notify yet) + if last_state == 'unknown': + if is_landed: + aircraft_state[icao] = 'landed' + else: + aircraft_state[icao] = 'flying' + await user_config.watchlist_aircraft_state.set(aircraft_state) + # Don't send notification on first detection - wait for actual transitions + continue + # Check for takeoff transition (was landed, now flying) if last_state == 'landed' and not is_landed: # Aircraft just took off - send takeoff notification @@ -962,12 +972,20 @@ async def check_watched_aircraft(self): if current_time - last_notification < cooldown_seconds: continue # Still in cooldown - # Only notify if aircraft is flying (not landed) - if is_landed: - continue # Skip online notification if aircraft is on ground + # Only send "online" notification if aircraft was previously offline + # (unknown state is handled above - we initialize it without notifying) + # If it was already flying/landed, we don't need to notify again (takeoff/landing notifications handle those) + if last_state != 'offline': + # Already tracking this aircraft, skip generic online notification + continue - # Check if aircraft was offline before (we only notify when it comes online) - # For now, we'll notify every time it's found (user can remove if they don't want notifications) + # Aircraft was offline and just came online + # Only notify if aircraft is flying (not landed) when coming online + if is_landed: + # Aircraft came online but is on ground - just update state, don't notify + aircraft_state[icao] = 'landed' + await user_config.watchlist_aircraft_state.set(aircraft_state) + continue # Try to send DM to user try: From 4052ae787cf96dd6f82f297eaac27a6113b72e93 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:40:40 +0000 Subject: [PATCH 21/74] Update helpers.py --- skysearch/utils/helpers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index df75efb..5aa2516 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -671,6 +671,11 @@ def create_watchlist_notification_embed(self, icao, aircraft_data): aircraft_data.get('lon', 'N/A') ) + # Determine status + is_landed = self.is_aircraft_landed(aircraft_data) + status = _watchlist("On ground") if is_landed else _watchlist("In flight") + + embed.add_field(name=_watchlist("Status"), value=status, inline=True) embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) embed.add_field(name=_watchlist("Altitude"), value=altitude, inline=True) embed.add_field(name=_watchlist("Speed"), value=speed, inline=True) @@ -764,8 +769,8 @@ def create_watchlist_landing_embed(self, icao, aircraft_data): aircraft_data.get('lon', 'N/A') ) - embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) embed.add_field(name=_watchlist("Status"), value=_watchlist("On ground"), inline=True) + embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) embed.add_field(name=_watchlist("Position"), value=position, inline=False) return embed @@ -798,6 +803,7 @@ def create_watchlist_takeoff_embed(self, icao, aircraft_data): aircraft_data.get('lon', 'N/A') ) + embed.add_field(name=_watchlist("Status"), value=_watchlist("In flight"), inline=True) embed.add_field(name=_watchlist("Callsign"), value=callsign, inline=True) embed.add_field(name=_watchlist("Altitude"), value=altitude, inline=True) embed.add_field(name=_watchlist("Speed"), value=speed, inline=True) From 2743943c2ac418b07e77d532cfb49897410ff1e2 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:49:41 +0000 Subject: [PATCH 22/74] Update info.json --- skysearch/info.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skysearch/info.json b/skysearch/info.json index f15a2df..1b7d969 100644 --- a/skysearch/info.json +++ b/skysearch/info.json @@ -1,10 +1,10 @@ { "author": ["bencos17, adminescalation"], - "install_msg": "## :white_check_mark: Successfully installed SkySearch. **IMPORTANT** - Before you can use this cog, you'll need to sign up for an Airportdb.io account, then add your API token to your Red instance.\n`[p]set api airportdbio api_token XXXXXXXXXXXXXXXXXX`)", + "install_msg": "## :white_check_mark: Successfully installed SkySearch. **IMPORTANT** - Before you can use this cog, you'll need to sign up for an Airportdb.io account, then add your API token to your Red instance.\n`[p]set api airportdbio api_token XXXXXXXXXXXXXXXXXX`, please support the sites that make this possible like airplanes.live by adding a feeder https://airplanes.live/get-started)", "name": "skysearch", - "short": "Get aircraft and airport information thru Discord, enhanced with a variety of consumer APIs", + "short": "Get aircraft and airport information thru Discord, enhanced with a variety of consumer APIs, the maintained version of the one by beehive-cogs and originally written by bencos17", "description": "SkySearch is made to let you fetch information about aircraft, and airports. You can query active flights by a selection of variables, or get airport information, runway information, airport forecasts, and more. ", - "tags": ["airplanes", "airplaneslive", "aircraft", "aircraft tracking", "ADS-B", "plane spotting", "dashboard"], + "tags": ["airplanes", "airplaneslive", "aircraft", "aircraft tracking", "ADS-B", "plane spotting", "dashboard", "planes"], "end_user_data_statement": "SkySearch stores no user data. Usage of external API integrations provided in SkySearch is subject to the Privacy Policy, and Terms of Service, of the respective service.", "requirements": ["reportlab", "wtforms"], "permissions": [ From 00a242f33bc6f321febbdd1c4b745f55b9c2be98 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:59:26 +0000 Subject: [PATCH 23/74] Update README.md --- skysearch/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/README.md b/skysearch/README.md index 46217d7..661634e 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -9,7 +9,7 @@ To use the SkySearch cog, follow these steps: 1. ** Dependencies**: red discord bot: https://docs.discord.red/en/stable/ -2. **Configure API Keys**: +2. **Configure API Keys** : - Set up airplanes.live API key: `[p]setapikey ` - Optional: Configure Google Maps API for airport imagery - Optional: Configure OpenAI API for airport summaries From bc05a00dc8cafc5e4e91a2abcd618382b434eb5d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:59:48 +0000 Subject: [PATCH 24/74] Update README.md --- skysearch/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/README.md b/skysearch/README.md index 661634e..a0856ef 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -6,7 +6,7 @@ A powerful, modular Discord bot cog for tracking aircraft and airport informatio To use the SkySearch cog, follow these steps: -1. ** Dependencies**: +1. **Dependencies**: red discord bot: https://docs.discord.red/en/stable/ 2. **Configure API Keys** : From 844a712ed6bb6b85400fbb4ac5548ac8904dd266 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:02:24 +0000 Subject: [PATCH 25/74] skysearch.skysearch.on_message work --- skysearch/commands/admin.py | 3 ++ skysearch/dashboard/dashboard_integration.py | 5 ++ skysearch/skysearch.py | 56 +++++++++++++++++--- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/skysearch/commands/admin.py b/skysearch/commands/admin.py index e8b0caf..a0c3e47 100644 --- a/skysearch/commands/admin.py +++ b/skysearch/commands/admin.py @@ -119,10 +119,13 @@ async def autoicao(self, ctx, state: bool = None): await ctx.send(embed=embed) else: await self.cog.config.guild(ctx.guild).auto_icao.set(state) + # Update cache when auto_icao is toggled if state: + self.cog._auto_icao_enabled_guilds.add(ctx.guild.id) embed = discord.Embed(title="ICAO Lookup Status", description="Automatic ICAO lookup has been enabled.", color=0x2BBD8E) await ctx.send(embed=embed) else: + self.cog._auto_icao_enabled_guilds.discard(ctx.guild.id) embed = discord.Embed(title="ICAO Lookup Status", description="Automatic ICAO lookup has been disabled.", color=0xff4545) await ctx.send(embed=embed) diff --git a/skysearch/dashboard/dashboard_integration.py b/skysearch/dashboard/dashboard_integration.py index 7a6fd6a..8220a2e 100644 --- a/skysearch/dashboard/dashboard_integration.py +++ b/skysearch/dashboard/dashboard_integration.py @@ -779,6 +779,11 @@ def __init__(self): await config.alert_channel.set(alert_channel_val) await config.alert_role.set(alert_role_val) await config.auto_icao.set(settings_form.auto_icao.data) + # Update cache when auto_icao is toggled via dashboard + if settings_form.auto_icao.data: + cog._auto_icao_enabled_guilds.add(guild.id) + else: + cog._auto_icao_enabled_guilds.discard(guild.id) await config.auto_delete_not_found.set(settings_form.auto_delete.data) # Update the display values to reflect the new settings diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 44c69f9..b139592 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -82,6 +82,27 @@ def __init__(self, bot): # Command execution API self.command_api = CommandAPI() + + # Cache for guilds with auto_icao enabled (optimization to avoid config reads on every message) + self._auto_icao_enabled_guilds = set() + # Track guilds we've checked and confirmed have auto_icao disabled (to avoid repeated checks) + self._auto_icao_checked_guilds = set() + + # Pre-compile regex pattern for ICAO matching + self._icao_pattern = re.compile(r'^[a-fA-F0-9]{6}$') + + async def _refresh_auto_icao_cache(self): + """Refresh the cache of guilds with auto_icao enabled.""" + self._auto_icao_enabled_guilds.clear() + self._auto_icao_checked_guilds.clear() + for guild in self.bot.guilds: + 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 cog_load(self): + """Called when the cog is loaded - refresh cache.""" + await self._refresh_auto_icao_cache() def get_airplane_icon_path(self): """Get the path to the local airplane icon.""" @@ -1229,23 +1250,42 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a @commands.Cog.listener() async def on_message(self, message): """Handle automatic ICAO lookup.""" + # Fast early returns - no async operations if message.author == self.bot.user: return if message.guild is None: return + + guild_id = message.guild.id + + # Fast cache check - avoid expensive config reads if auto_icao is disabled + if guild_id in self._auto_icao_enabled_guilds: + # Guild is known to have auto_icao enabled - proceed with processing + # Double-check config in case cache is stale (should be rare) + auto_icao = await self.config.guild(message.guild).auto_icao() + if not auto_icao: + # Update cache if it was stale + self._auto_icao_enabled_guilds.discard(guild_id) + return + elif guild_id in self._auto_icao_checked_guilds: + # Guild is known to have auto_icao disabled - fast return + return + else: + # First time seeing this guild - do one-time config check + auto_icao = await self.config.guild(message.guild).auto_icao() + self._auto_icao_checked_guilds.add(guild_id) + if auto_icao: + self._auto_icao_enabled_guilds.add(guild_id) + else: + return - # Ensure locales for non-command listener + # Ensure locales for non-command listener (only if auto_icao is enabled) await set_contextual_locales_from_guild(self.bot, message.guild) - auto_icao = await self.config.guild(message.guild).auto_icao() - if not auto_icao: - return - content = message.content - icao_pattern = re.compile(r'^[a-fA-F0-9]{6}$') - - if icao_pattern.match(content): + # Use pre-compiled pattern + if self._icao_pattern.match(content): ctx = await self.bot.get_context(message) await self.aircraft_commands.aircraft_by_icao(ctx, content) From bd591f27da140c21d07f5a06f96d59af5dd92ebe Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:24:15 +0000 Subject: [PATCH 26/74] Update skysearch.py --- skysearch/skysearch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index b139592..810e418 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -199,11 +199,13 @@ async def stats(self, ctx): embed.add_field(name=_("Suspicious aircraft"), value="**{:,}** identifiers".format(len(self.suspicious_icao_set)), inline=True) embed.add_field(name=_("This data appears in the following commands"), value="`callsign` `icao` `reg` `squawk` `type` `radius` `pia` `mil` `ladd`", inline=False) embed.add_field(name=_("Other services"), value=_("Additional data used in this cog is shown below"), inline=False) + embed.add_field(name=_("ADSB-B Data"), value=_("adsb tracking data is powered by [airplanes.live](https://airplanes.live)"), inline=True) embed.add_field(name=_("Photography"), value=_("Photos are powered by community contributions at [planespotters.net](https://www.planespotters.net/)"), inline=True) embed.add_field(name=_("Airport data"), value=_("Airport data is powered by the [airport-data.com](https://airport-data.com/) API service"), inline=True) embed.add_field(name=_("Runway data"), value=_("Runway data is powered by the [airportdb.io](https://airportdb.io) API service"), inline=True) embed.add_field(name=_("Mapping and imagery"), value=_("Mapping and ground imagery powered by [Google Maps](https://maps.google.com) and the [Maps Static API](https://developers.google.com/maps/documentation/maps-static)"), inline=False) + await ctx.send(embed=embed) @commands.guild_only() From 2bf602229ba4e143e4725ef4d5b2328044245b1d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:27:03 +0000 Subject: [PATCH 27/74] Update skysearch.py --- skysearch/skysearch.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 810e418..ce080b1 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -199,13 +199,12 @@ async def stats(self, ctx): embed.add_field(name=_("Suspicious aircraft"), value="**{:,}** identifiers".format(len(self.suspicious_icao_set)), inline=True) embed.add_field(name=_("This data appears in the following commands"), value="`callsign` `icao` `reg` `squawk` `type` `radius` `pia` `mil` `ladd`", inline=False) embed.add_field(name=_("Other services"), value=_("Additional data used in this cog is shown below"), inline=False) - embed.add_field(name=_("ADSB-B Data"), value=_("adsb tracking data is powered by [airplanes.live](https://airplanes.live)"), inline=True) + embed.add_field(name=_("ADSB-B Data"), value=_("ADSB tracking data is powered by [airplanes.live](https://airplanes.live)"), inline=False) embed.add_field(name=_("Photography"), value=_("Photos are powered by community contributions at [planespotters.net](https://www.planespotters.net/)"), inline=True) embed.add_field(name=_("Airport data"), value=_("Airport data is powered by the [airport-data.com](https://airport-data.com/) API service"), inline=True) embed.add_field(name=_("Runway data"), value=_("Runway data is powered by the [airportdb.io](https://airportdb.io) API service"), inline=True) embed.add_field(name=_("Mapping and imagery"), value=_("Mapping and ground imagery powered by [Google Maps](https://maps.google.com) and the [Maps Static API](https://developers.google.com/maps/documentation/maps-static)"), inline=False) - await ctx.send(embed=embed) @commands.guild_only() From b2925ece1efef09a8d87a0958f3b8a001b33b377 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:29:53 +0000 Subject: [PATCH 28/74] Update skysearch.py --- skysearch/skysearch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index ce080b1..01524ff 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -199,7 +199,7 @@ async def stats(self, ctx): embed.add_field(name=_("Suspicious aircraft"), value="**{:,}** identifiers".format(len(self.suspicious_icao_set)), inline=True) embed.add_field(name=_("This data appears in the following commands"), value="`callsign` `icao` `reg` `squawk` `type` `radius` `pia` `mil` `ladd`", inline=False) embed.add_field(name=_("Other services"), value=_("Additional data used in this cog is shown below"), inline=False) - embed.add_field(name=_("ADSB-B Data"), value=_("ADSB tracking data is powered by [airplanes.live](https://airplanes.live)"), inline=False) + embed.add_field(name="ADSB-B Data", value="ADSB tracking data is powered by [airplanes.live](https://airplanes.live)", inline=True) embed.add_field(name=_("Photography"), value=_("Photos are powered by community contributions at [planespotters.net](https://www.planespotters.net/)"), inline=True) embed.add_field(name=_("Airport data"), value=_("Airport data is powered by the [airport-data.com](https://airport-data.com/) API service"), inline=True) embed.add_field(name=_("Runway data"), value=_("Runway data is powered by the [airportdb.io](https://airportdb.io) API service"), inline=True) From dc1b028d9d7afd4359a88a1fc05cf04a6b9efe17 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:32:39 +0000 Subject: [PATCH 29/74] translation stuff --- skysearch/locales/en-US.po | 8 ++++++++ skysearch/locales/ga-IE.po | 8 ++++++++ skysearch/locales/messages.pot | 8 ++++++++ skysearch/skysearch.py | 2 +- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/skysearch/locales/en-US.po b/skysearch/locales/en-US.po index ce4da3b..f16491e 100644 --- a/skysearch/locales/en-US.po +++ b/skysearch/locales/en-US.po @@ -106,6 +106,14 @@ msgstr "" msgid "Additional data used in this cog is shown below" msgstr "" +#: skysearch.py:202 +msgid "ADSB-B Data" +msgstr "ADSB-B Data" + +#: skysearch.py:202 +msgid "ADSB tracking data is powered by [airplanes.live](https://airplanes.live)" +msgstr "ADSB tracking data is powered by [airplanes.live](https://airplanes.live)" + #: skysearch.py:173 msgid "Photography" msgstr "" diff --git a/skysearch/locales/ga-IE.po b/skysearch/locales/ga-IE.po index 042fe2d..01ccc30 100644 --- a/skysearch/locales/ga-IE.po +++ b/skysearch/locales/ga-IE.po @@ -110,6 +110,14 @@ msgstr "SeirbhΓ­sΓ­ eile" msgid "Additional data used in this cog is shown below" msgstr "SonraΓ­ breise a ΓΊsΓ‘idtear sa chrog seo thΓ­os" +#: skysearch.py:202 +msgid "ADSB-B Data" +msgstr "SonraΓ­ ADSB-B" + +#: skysearch.py:202 +msgid "ADSB tracking data is powered by [airplanes.live](https://airplanes.live)" +msgstr "CumhachtaΓ­tear sonraΓ­ rianΓΊ ADSB ag [airplanes.live](https://airplanes.live)" + #: skysearch.py:173 msgid "Photography" msgstr "GrianghrafadΓ³ireacht" diff --git a/skysearch/locales/messages.pot b/skysearch/locales/messages.pot index 054cb8a..1a7a03f 100644 --- a/skysearch/locales/messages.pot +++ b/skysearch/locales/messages.pot @@ -106,6 +106,14 @@ msgstr "" msgid "Additional data used in this cog is shown below" msgstr "" +#: skysearch\skysearch.py:202 +msgid "ADSB-B Data" +msgstr "" + +#: skysearch\skysearch.py:202 +msgid "ADSB tracking data is powered by [airplanes.live](https://airplanes.live)" +msgstr "" + #: skysearch\skysearch.py:173 msgid "Photography" msgstr "" diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 01524ff..c697d12 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -199,7 +199,7 @@ async def stats(self, ctx): embed.add_field(name=_("Suspicious aircraft"), value="**{:,}** identifiers".format(len(self.suspicious_icao_set)), inline=True) embed.add_field(name=_("This data appears in the following commands"), value="`callsign` `icao` `reg` `squawk` `type` `radius` `pia` `mil` `ladd`", inline=False) embed.add_field(name=_("Other services"), value=_("Additional data used in this cog is shown below"), inline=False) - embed.add_field(name="ADSB-B Data", value="ADSB tracking data is powered by [airplanes.live](https://airplanes.live)", inline=True) + embed.add_field(name=_("ADSB-B Data"), value=_("ADSB tracking data is powered by [airplanes.live](https://airplanes.live)"), inline=True) embed.add_field(name=_("Photography"), value=_("Photos are powered by community contributions at [planespotters.net](https://www.planespotters.net/)"), inline=True) embed.add_field(name=_("Airport data"), value=_("Airport data is powered by the [airport-data.com](https://airport-data.com/) API service"), inline=True) embed.add_field(name=_("Runway data"), value=_("Runway data is powered by the [airportdb.io](https://airportdb.io) API service"), inline=True) From f6c0d8ad597af74833ca33459fc6d470bda0dee4 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:43:52 +0000 Subject: [PATCH 30/74] Add Counter and Voice cogs with documentation Introduces a flexible Counter cog supporting per-guild, per-user, and global counters with commands for creation, modification, and listing. Adds a Voice cog that integrates with discord-ext-voice-recv to record and manage per-user audio in voice channels, including commands for joining, recording, and diagnostics. Includes documentation and metadata for both cogs. --- counter/__init__.py | 14 ++ counter/counter.py | 166 +++++++++++++++ counter/docs.md | 20 ++ counter/info.json | 6 + voice/__init__.py | 13 ++ voice/info.json | 10 + voice/voice.py | 509 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 738 insertions(+) create mode 100644 counter/__init__.py create mode 100644 counter/counter.py create mode 100644 counter/docs.md create mode 100644 counter/info.json create mode 100644 voice/__init__.py create mode 100644 voice/info.json create mode 100644 voice/voice.py diff --git a/counter/__init__.py b/counter/__init__.py new file mode 100644 index 0000000..b449fd2 --- /dev/null +++ b/counter/__init__.py @@ -0,0 +1,14 @@ +"""Counter cog package for ben-cogs + +Adds a simple, flexible counter system that supports per-guild, per-user, and global counters. +""" + +from .counter import Counter + +__red_end_user_data_statement__ = ( + "This cog stores counters and their numeric values. It stores guild IDs, user IDs (for user-scoped counters), " + "counter names, and numeric values. It does not store message content or other personal data." +) + +async def setup(bot): + await bot.add_cog(Counter(bot)) diff --git a/counter/counter.py b/counter/counter.py new file mode 100644 index 0000000..d2517fd --- /dev/null +++ b/counter/counter.py @@ -0,0 +1,166 @@ +import re +from typing import Optional + +from redbot.core import commands, Config +from redbot.core.utils.chat_formatting import box + + +class Counter(commands.Cog): + """Flexible counters: per-guild, per-user, and global.""" + + def __init__(self, bot): + self.bot = bot + # Unique identifier for Config. Large random-ish int to avoid collisions. + self.config = Config.get_conf(self, identifier=987654321012345678) + default_guild = {"counters": {}} + default_user = {"counters": {}} + default_global = {"counters": {}} + self.config.register_guild(**default_guild) + self.config.register_user(**default_user) + self.config.register_global(**default_global) + + @staticmethod + def _clean_name(name: str) -> str: + """Normalize counter names to lower-case with underscores for storage.""" + cleaned = re.sub(r"\s+", "_", name.strip().lower()) + # keep alnum and underscores and dashes + cleaned = re.sub(r"[^a-z0-9_\-]", "", cleaned) + return cleaned + + @staticmethod + def _normalize_scope(raw: Optional[str]) -> str: + if not raw: + return "guild" + raw = raw.lower() + if raw in ("g", "guild", "server", "s"): + return "guild" + if raw in ("u", "user", "member"): + return "user" + if raw in ("global", "glo", "gl"): + return "global" + return raw + + async def _get_counter_store(self, ctx: commands.Context, scope: str): + """Return (store_accessor, human_scope) where store_accessor is a context manager for modifying counters.""" + if scope == "guild": + if ctx.guild is None: + raise commands.BadArgument("Guild scope requires a server context.") + return self.config.guild(ctx.guild), "guild" + if scope == "user": + return self.config.user(ctx.author), "user" + return self.config, "global" + + @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`.""" + await ctx.send_help(ctx.command) + + @counter.command(name="create") + async def create(self, ctx: commands.Context, name: str, scope: Optional[str] = None, initial: int = 0) -> None: + """Create a counter. Scope: guild (default), user, global.""" + scope = self._normalize_scope(scope) + if scope == "global" and not await self.bot.is_owner(ctx.author): + return await ctx.send("Global counters can only be created by the bot owner.") + name_key = self._clean_name(name) + store, human_scope = await self._get_counter_store(ctx, scope) + async with store.counters() as counters: + if name_key in counters: + return await ctx.send(f"A counter named **{name_key}** already exists in {human_scope} scope.") + counters[name_key] = int(initial) + await ctx.send(f"Created counter **{name_key}** in {human_scope} scope with value `{initial}`.") + + @counter.command(name="inc") + async def inc(self, ctx: commands.Context, name: str, amount: int = 1, scope: Optional[str] = None) -> None: + """Increment a counter by amount (default 1).""" + scope = self._normalize_scope(scope) + name_key = self._clean_name(name) + store, human_scope = await self._get_counter_store(ctx, scope) + async with store.counters() as counters: + if name_key not in counters: + return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") + counters[name_key] = int(counters[name_key]) + int(amount) + new = counters[name_key] + await ctx.send(f"**{name_key}** in {human_scope} is now `{new}` (+{amount}).") + + @counter.command(name="dec") + async def dec(self, ctx: commands.Context, name: str, amount: int = 1, scope: Optional[str] = None) -> None: + """Decrement a counter by amount (default 1).""" + scope = self._normalize_scope(scope) + name_key = self._clean_name(name) + store, human_scope = await self._get_counter_store(ctx, scope) + async with store.counters() as counters: + if name_key not in counters: + return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") + counters[name_key] = int(counters[name_key]) - int(amount) + new = counters[name_key] + await ctx.send(f"**{name_key}** in {human_scope} is now `{new}` (-{amount}).") + + @counter.command(name="set") + async def set_(self, ctx: commands.Context, name: str, value: int, scope: Optional[str] = None) -> None: + """Set a counter to a specific integer value.""" + scope = self._normalize_scope(scope) + if scope == "global" and not await self.bot.is_owner(ctx.author): + return await ctx.send("Global counters can only be modified by the bot owner.") + name_key = self._clean_name(name) + store, human_scope = await self._get_counter_store(ctx, scope) + async with store.counters() as counters: + if name_key not in counters: + return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") + counters[name_key] = int(value) + await ctx.send(f"Set **{name_key}** in {human_scope} to `{value}`.") + + @counter.command(name="delete") + async def delete(self, ctx: commands.Context, name: str, scope: Optional[str] = None) -> None: + """Delete a counter.""" + scope = self._normalize_scope(scope) + if scope == "global" and not await self.bot.is_owner(ctx.author): + return await ctx.send("Global counters can only be deleted by the bot owner.") + name_key = self._clean_name(name) + store, human_scope = await self._get_counter_store(ctx, scope) + async with store.counters() as counters: + if name_key not in counters: + return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") + del counters[name_key] + await ctx.send(f"Deleted **{name_key}** from {human_scope} scope.") + + @counter.command(name="show") + async def show(self, ctx: commands.Context, name: str, scope: Optional[str] = None) -> None: + """Show the value of a counter.""" + scope = self._normalize_scope(scope) + name_key = self._clean_name(name) + store, human_scope = await self._get_counter_store(ctx, scope) + counters = await store.counters() + if name_key not in counters: + return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") + await ctx.send(f"**{name_key}** in {human_scope} is `{counters[name_key]}`.") + + @counter.command(name="list") + async def list_(self, ctx: commands.Context, scope: Optional[str] = None) -> None: + """List counters in a scope (guild by default).""" + scope = self._normalize_scope(scope) + store, human_scope = await self._get_counter_store(ctx, scope) + counters = await store.counters() + if not counters: + return await ctx.send(f"No counters in {human_scope} scope.") + lines = [f"**{k}**: {v}" for k, v in sorted(counters.items())] + text = "\n".join(lines) + await ctx.send(box(text, lang="ini")) + + @counter.command(name="transfer") + async def transfer(self, ctx: commands.Context, name: str, target_scope: str, scope: Optional[str] = None) -> None: + """Transfer a counter from one scope to another (only bot owner for global target).""" + scope = self._normalize_scope(scope) + target_scope = self._normalize_scope(target_scope) + name_key = self._clean_name(name) + src_store, src_human = await self._get_counter_store(ctx, scope) + dst_store, dst_human = await self._get_counter_store(ctx, target_scope) + if target_scope == "global" and not await self.bot.is_owner(ctx.author): + return await ctx.send("You must be the bot owner to move counters to global scope.") + async with src_store.counters() as src_counters: + if name_key not in src_counters: + return await ctx.send(f"No counter named **{name_key}** in {src_human} scope.") + value = src_counters[name_key] + del src_counters[name_key] + async with dst_store.counters() as dst_counters: + dst_counters[name_key] = value + await ctx.send(f"Moved **{name_key}** ({value}) from {src_human} to {dst_human} scope.") diff --git a/counter/docs.md b/counter/docs.md new file mode 100644 index 0000000..235210b --- /dev/null +++ b/counter/docs.md @@ -0,0 +1,20 @@ +Counter cog + +Commands: +- `counter create [scope] [initial]` - Create a counter. scope is one of `guild` (default), `user`, `global`. +- `counter inc [amount] [scope]` - Increment a counter by amount (default 1). +- `counter dec [amount] [scope]` - Decrement a counter by amount (default 1). +- `counter set [scope]` - Set the counter to a specific integer value. +- `counter delete [scope]` - Delete a counter. +- `counter show [scope]` - Show the value of a counter. +- `counter list [scope]` - List counters in the given scope (default `guild`). + +Notes: +- `guild` scope stores counters per-server. +- `user` scope stores counters per-user (only visible to that user when listed with user scope). +- `global` scope requires the bot owner to create/modify/delete. + +Examples: +- `counter create donuts` -> Creates `donuts` in the current server with value 0. +- `counter inc donuts 2` -> Adds 2 to the `donuts` counter in the server. +- `counter create wins user 0` -> Creates a user-scoped counter for the calling user. diff --git a/counter/info.json b/counter/info.json new file mode 100644 index 0000000..87eee5a --- /dev/null +++ b/counter/info.json @@ -0,0 +1,6 @@ +{ + "name": "Counter", + "author": "bencos17", + "short": "Flexible per-guild/user/global counters", + "description": "Counters that users can create and modify, with server and unique scopes." +} diff --git a/voice/__init__.py b/voice/__init__.py new file mode 100644 index 0000000..c00e93d --- /dev/null +++ b/voice/__init__.py @@ -0,0 +1,13 @@ +"""Voice cog package for ben-cogs + +Exports a setup function for Red and package metadata. +""" + +from .voice import VoiceRecvCog + +__red_end_user_data_statement__ = "This cog does not persist end user data." + + +async def setup(bot): + """Add the cog to the bot.""" + await bot.add_cog(VoiceRecvCog(bot)) diff --git a/voice/info.json b/voice/info.json new file mode 100644 index 0000000..36d98dc --- /dev/null +++ b/voice/info.json @@ -0,0 +1,10 @@ +{ + "name": "Voice", + "author": ["bencos17"], + "description": "Voice receive cog that integrates with discord-ext-voice-recv to record audio per user.", + "install_msg": "This cog requires discord-ext-voice-recv to receive audio. Install with: \"python -m pip install discord-ext-voice-recv\". Use `[p]vjoin` to join voice and `[p]vlisten` to start recording.", + "short": "Voice receive and recording", + "requirements": ["discord-ext-voice-recv"], + "tags": ["voice", "utility", "recording"], + "end_user_data_statement": "This cog may write audio recordings containing user speech to disk. The bot owner controls storage and retention; recordings are not uploaded by default, but can be optionally uploaded to the text channel that initiated the recording (the requesting user will be mentioned)." +} \ No newline at end of file diff --git a/voice/voice.py b/voice/voice.py new file mode 100644 index 0000000..4b975d2 --- /dev/null +++ b/voice/voice.py @@ -0,0 +1,509 @@ +from redbot.core import commands +import discord +import logging +import os +import time +import wave +import asyncio +import sys +from collections import defaultdict + +try: + import voice_recv +except Exception: + # The package installs as `discord.ext.voice_recv`; prefer that if available + try: + from discord.ext import voice_recv # type: ignore + except Exception: + voice_recv = None + + +class RecordingSink: + """A minimal sink compatible with voice_recv.AudioSink API. + + This sink collects PCM chunks per user and writes them to WAV files on cleanup. + Optionally it can upload the resulting files to a text channel and mention the + user who requested the recording. Uploading is performed via the bot's event + loop using a thread-safe scheduling call so cleanup can be called from a + background thread. + """ + + def __init__(self, outdir: str = "voice_records", *, bot=None, text_channel_id: int | None = None, uploader_id: int | None = None, voice_channel_name: str | None = None, upload_on_cleanup: bool = False): + # Keep lazy imports to avoid hard dependency for users who don't have the package + self._has_voice_recv_audio_sink = hasattr(voice_recv, "AudioSink") if voice_recv else False + if self._has_voice_recv_audio_sink: + # Create an adapter subclass that implements the required abstract methods + base = voice_recv.AudioSink + class _Impl(base): + def __init__(self, parent): + # Ensure base initialization runs if required + try: + super().__init__() + except Exception: + pass + self._parent = parent + + def wants_opus(self) -> bool: + return self._parent.wants_opus() + + def write(self, user, data) -> None: + return self._parent.write(user, data) + + def cleanup(self) -> None: + return self._parent.cleanup() + + # instantiate adapter with a reference back to this RecordingSink + self._impl = _Impl(self) + else: + self._impl = None + + self.outdir = outdir + os.makedirs(self.outdir, exist_ok=True) + self.buffers = defaultdict(list) # user_id -> [bytes] + self.sample_rate = 48000 + self.sample_width = 2 + self.channels = 1 + + # Upload-related state + self.bot = bot + self.text_channel_id = text_channel_id + self.uploader_id = uploader_id + self.voice_channel_name = voice_channel_name + self.upload_on_cleanup = bool(upload_on_cleanup) + + # Compatibility wrappers expected by voice_recv + def wants_opus(self) -> bool: + # We expect decoded PCM (so return False) β€” if you want opus, adjust + return False + + def write(self, user, data) -> None: + pcm = getattr(data, "pcm", None) + if not pcm: + # nothing to write (e.g., opus-only sink, or silence) + return + + user_id = getattr(user, "id", "unknown") + self.buffers[user_id].append(pcm) + + def cleanup(self) -> None: + # Write out WAV files for each collected user + written = [] # list of (user_id, filename) + for uid, chunks in list(self.buffers.items()): + if not chunks: + continue + filename = os.path.join(self.outdir, f"{uid}_{int(time.time())}.wav") + with wave.open(filename, "wb") as wf: + wf.setnchannels(self.channels) + wf.setsampwidth(self.sample_width) + wf.setframerate(self.sample_rate) + wf.writeframes(b"".join(chunks)) + written.append((uid, filename)) + + # If configured, schedule an async upload back into the bot's loop + if written and self.upload_on_cleanup and self.bot and self.text_channel_id: + # Use run_coroutine_threadsafe because cleanup may be called from a non-async thread + try: + asyncio.run_coroutine_threadsafe(self._async_upload(written), self.bot.loop) + except Exception: + logging.getLogger("red.voice").exception("Failed to schedule upload of recordings") + + async def _async_upload(self, written: list[tuple[int, str]]) -> None: + """Coroutine to send recordings to the configured text channel and mention the uploader.""" + logger = logging.getLogger("red.voice") + try: + channel = self.bot.get_channel(self.text_channel_id) + if channel is None: + try: + channel = await self.bot.fetch_channel(self.text_channel_id) + except Exception: + channel = None + + if channel is None: + logger.warning("Upload requested but text channel %s could not be found", self.text_channel_id) + return + + mention = f"<@{self.uploader_id}>" if self.uploader_id else None + vc_name = self.voice_channel_name or "(unknown)" + content = f"{mention} recordings from voice channel {vc_name}:" if mention else f"Recordings from voice channel {vc_name}:" + + files = [] + for uid, path in written: + try: + files.append(discord.File(path, filename=os.path.basename(path))) + except Exception: + logger.exception("Failed to open recording file for upload: %s", path) + + if not files: + await channel.send(content + "\nNo files available to upload.") + return + + # Discord limits number/size of files β€” best effort upload + await channel.send(content, files=files) + except Exception: + logger.exception("Unexpected error during upload of voice recordings") + + +class VoiceRecvCog(commands.Cog): + """Cog that provides simple commands to join voice with a VoiceRecv client and record audio. + + Commands: + - vjoin: Join your voice channel using voice_recv.VoiceRecvClient (requires package) + - vleave: Disconnect from voice + - vlisten [outdir]: Start listening and record per-user WAV files to `outdir` (default: voice_records) + - vstop: Stop listening + - vspeaking [member]: Show speaking state for a member + """ + + def __init__(self, bot): + self.bot = bot + self.logger = logging.getLogger("red.voice") + self._sink = None + + @commands.command() + async def vjoin(self, ctx: commands.Context) -> None: + """Join the author's voice channel using VoiceRecvClient (requires discord-ext-voice-recv).""" + if voice_recv is None: + # Try a runtime import in case the package was installed after the cog was loaded + try: + import importlib + try: + _mod = importlib.import_module("voice_recv") + except Exception: + _mod = importlib.import_module("discord.ext.voice_recv") + globals()["voice_recv"] = _mod + except Exception: + await ctx.send("discord-ext-voice-recv is not installed. Install it to use voice receive features (then reload the cog or restart the bot).") + return + + channel = None + if ctx.author and getattr(ctx.author, "voice", None): + channel = ctx.author.voice.channel + if channel is None: + await ctx.send("You must be connected to a voice channel to use this command.") + return + + # Use the provided client class + cls = getattr(voice_recv, "VoiceRecvClient", None) + if cls is None: + await ctx.send("VoiceRecvClient class not found in voice_recv package.") + return + + try: + await channel.connect(cls=cls) + except Exception as exc: + self.logger.exception("Failed to connect to voice: %s", exc) + await ctx.send(f"Failed to connect to voice: {exc}") + return + + await ctx.send(f"Joined voice channel {channel.name}") + + @commands.command() + async def vleave(self, ctx: commands.Context) -> None: + """Disconnect from voice channel.""" + vc: discord.VoiceClient | None = ctx.voice_client + if vc is None: + await ctx.send("Not connected to voice.") + return + + try: + await vc.disconnect() + await ctx.send("Disconnected from voice.") + except Exception as exc: + self.logger.exception("Failed to disconnect: %s", exc) + await ctx.send(f"Failed to disconnect: {exc}") + + @commands.command() + async def vlisten(self, ctx: commands.Context, outdir: str = "voice_records", upload: str = None) -> None: + """Start listening and record per-user WAV files to `outdir` (default: voice_records). + + Pass the literal word `upload` as a second argument to automatically post + the recordings to the text channel that invoked the command when + recording finishes; the requesting user will be mentioned. + """ + vc: discord.VoiceClient | None = ctx.voice_client + if vc is None: + await ctx.send("Bot is not connected to voice.") + return + + if voice_recv is None: + # Try a runtime import in case the package was installed after the cog was loaded + try: + import importlib + try: + _mod = importlib.import_module("voice_recv") + except Exception: + _mod = importlib.import_module("discord.ext.voice_recv") + globals()["voice_recv"] = _mod + except Exception: + await ctx.send("discord-ext-voice-recv is not installed. Install it to use voice receive features (then reload the cog or restart the bot).") + return + + if not hasattr(vc, "listen"): + await ctx.send("This voice client does not support listening β€” make sure discord-ext-voice-recv is installed and used as the voice client (VoiceRecvClient).") + return + + if self._sink is not None: + await ctx.send("Already listening. Use `vstop` to stop the current recording.") + return + + upload_flag = False + if upload: + upload_flag = str(upload).lower() in ("upload", "send", "true", "yes", "1") + + voice_channel_name = None + if ctx.author and getattr(ctx.author, "voice", None): + try: + voice_channel_name = ctx.author.voice.channel.name + except Exception: + voice_channel_name = None + + sink = RecordingSink( + outdir=outdir, + bot=self.bot, + text_channel_id=ctx.channel.id, + uploader_id=getattr(ctx.author, "id", None), + voice_channel_name=voice_channel_name, + upload_on_cleanup=upload_flag, + ) + + def _after(exc): + if exc: + self.logger.exception("Recording finished with error: %s", exc) + else: + self.logger.info("Recording finished cleanly. Writing files.") + try: + sink.cleanup() + except Exception: + self.logger.exception("Error while cleaning up sink") + + try: + to_listen = getattr(sink, "_impl", None) or sink + vc.listen(to_listen, after=_after) + except Exception as exc: + self.logger.exception("Failed to start listening: %s", exc) + await ctx.send(f"Failed to start listening: {exc}") + return + + self._sink = sink + msg = f"Started listening and will save recordings to {outdir}" + if upload_flag: + msg += ". Recordings will be uploaded to this channel when finished." + await ctx.send(msg) + + @commands.command() + async def vstop(self, ctx: commands.Context) -> None: + """Stop listening and finalize recordings.""" + vc: discord.VoiceClient | None = ctx.voice_client + if vc is None: + await ctx.send("Not connected to voice.") + return + + if self._sink is None: + await ctx.send("Not currently listening.") + return + + try: + if hasattr(vc, "stop_listening"): + vc.stop_listening() + else: + # Fallback: attempt to stop by stopping the socket reading + try: + vc.stop() + except Exception: + pass + except Exception as exc: + self.logger.exception("Failed to stop listening: %s", exc) + await ctx.send(f"Failed to stop listening: {exc}") + return + + # Ensure cleanup and write files + try: + self._sink.cleanup() + except Exception: + self.logger.exception("Error while cleaning up sink") + + self._sink = None + await ctx.send("Stopped listening and finalized recordings.") + + @commands.command() + async def vspeaking(self, ctx: commands.Context, member: discord.Member = None) -> None: + """Show speaking (voice activity indicator) state for a member. If no member is given, shows the author's state.""" + member = member or ctx.author + vc: discord.VoiceClient | None = ctx.voice_client + if vc is None: + await ctx.send("Not connected to voice.") + return + + if not hasattr(vc, "get_speaking"): + await ctx.send("This voice client does not expose speaking state (not a VoiceRecvClient).") + return + + try: + state = vc.get_speaking(member) + except Exception as exc: + self.logger.exception("Error checking speaking state: %s", exc) + await ctx.send(f"Error checking speaking state: {exc}") + return + + await ctx.send(f"Speaking state for {member.display_name}: {state}") + + @commands.command() + async def vcheck(self, ctx: commands.Context) -> None: + """Check whether `voice_recv` is importable and show voice client capabilities.""" + try: + import importlib + try: + mod = importlib.import_module("voice_recv") + except Exception: + mod = importlib.import_module("discord.ext.voice_recv") + ver = getattr(mod, "__version__", "unknown") + await ctx.send(f"voice_recv is importable, version {ver}") + except Exception as exc: + await ctx.send(f"voice_recv is not importable: {exc}\nEnsure the package is installed in the same Python interpreter Red is using, then try `[p]vreload` or restart the bot.`") + return + + vc: discord.VoiceClient | None = ctx.voice_client + if vc is None: + await ctx.send("Bot is not connected to voice.") + return + + listen = hasattr(vc, "listen") + get_speaking = hasattr(vc, "get_speaking") + await ctx.send(f"Voice client capabilities: listen={listen}, get_speaking={get_speaking}") + + @commands.command() + async def vreload(self, ctx: commands.Context) -> None: + """Attempt to import or reload the `voice_recv` package at runtime.""" + try: + import importlib, sys + if "voice_recv" in sys.modules: + importlib.reload(sys.modules["voice_recv"]) + else: + try: + importlib.import_module("voice_recv") + except Exception: + importlib.import_module("discord.ext.voice_recv") + # The module may be registered under discord.ext.voice_recv or voice_recv + globals()["voice_recv"] = sys.modules.get("voice_recv") or sys.modules.get("discord.ext.voice_recv") + await ctx.send("Successfully imported/reloaded voice_recv. If voice clients were already connected, you may need to reconnect them.") + except Exception as exc: + await ctx.send(f"Failed to import/reload voice_recv: {exc}\nInstall the package into the environment Red uses, then restart the bot if necessary.") + self.logger.exception("vreload failed: %s", exc) + + @commands.command() + @commands.is_owner() + async def vdiag(self, ctx: commands.Context) -> None: + """Diagnostic information about the bot's Python environment and `voice_recv` availability. + + Owner-only: prints the Python executable, version, site-packages locations, + result of importlib.util.find_spec('voice_recv'), and `pip show` for the + installed package (if available). + """ + try: + import importlib.util, site + info = [] + info.append(("executable", sys.executable)) + info.append(("python_version", sys.version.replace('\n', ' '))) + + try: + specs = importlib.util.find_spec("voice_recv") + info.append(("voice_recv_spec", str(specs))) + except Exception as e: + info.append(("voice_recv_spec", f"error: {e}")) + + info.append(("voice_recv_in_sys_modules", str("voice_recv" in sys.modules))) + + try: + site_pkgs = site.getsitepackages() + except Exception: + site_pkgs = [] + info.append(("site_packages", str(site_pkgs))) + try: + user_site = site.getusersitepackages() + except Exception: + user_site = "" + info.append(("user_site_packages", str(user_site))) + + # Run pip show for more info + pip_out = None + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, "-m", "pip", "show", "discord-ext-voice-recv", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate() + if out: + pip_out = out.decode(errors="replace") + elif err: + pip_out = f"pip show stderr: {err.decode(errors='replace')}" + else: + pip_out = "pip show returned no output" + except Exception as e: + pip_out = f"pip show failed: {e}" + + # Build reply (truncate long sections) + pip_snippet = (pip_out[:1500] + "...") if pip_out and len(pip_out) > 1500 else (pip_out or "") + + lines = [f"{k}: {v}" for k, v in info] + msg = "\n".join(lines) + msg += "\n\npip show:\n" + pip_snippet + # Send in code block if not too long + if len(msg) < 1900: + await ctx.send(f"```\n{msg}\n```") + else: + await ctx.send("Diagnostic output is large; sending top-level info and pip snippet.") + await ctx.send(f"```\n{msg[:1900]}\n```") + except Exception as exc: + await ctx.send(f"vdiag failed: {exc}") + self.logger.exception("vdiag failed: %s", exc) + @commands.command() + @commands.is_owner() + async def vinstall(self, ctx: commands.Context, package: str = "discord-ext-voice-recv") -> None: + """Install a runtime dependency into the bot's Python environment and try importing it. + + This command runs `python -m pip install ` using the Python + interpreter the bot is running under. For safety it's restricted to the + bot owner. + """ + await ctx.send(f"Installing `{package}` into `{sys.executable}`...") + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, "-m", "pip", "install", package, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate() + code = proc.returncode + except Exception as exc: + await ctx.send(f"Failed to start installer: {exc}") + return + + if code != 0: + stderr = err.decode(errors="replace") if err else "" + snippet = stderr[:1900] + # Build content on one physical line so the source string is not split across lines + content = f"Install failed (exit {code}). Stderr:\n```{snippet}```" + await ctx.send(content) + return + + await ctx.send(f"Install finished. Attempting to import `{package}`...") + try: + import importlib, sys as _sys + if "voice_recv" in _sys.modules: + importlib.reload(_sys.modules["voice_recv"]) + mod = _sys.modules["voice_recv"] + else: + try: + mod = importlib.import_module("voice_recv") + except Exception: + mod = importlib.import_module("discord.ext.voice_recv") + globals()["voice_recv"] = mod + ver = getattr(mod, "__version__", "unknown") + await ctx.send(f"Successfully installed and imported `voice_recv` (version {ver}).") + except Exception as exc: + await ctx.send(f"Install succeeded but import failed: {exc}\nYou may need to restart the bot.") + self.logger.exception("Post-install import failed: %s", exc) + + + From 709b47d5e6684d80bfd0da23a0d716699100ea94 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:47:35 +0000 Subject: [PATCH 31/74] fix copypaste id mistake --- counter/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/counter/counter.py b/counter/counter.py index d2517fd..6e6915f 100644 --- a/counter/counter.py +++ b/counter/counter.py @@ -11,7 +11,7 @@ class Counter(commands.Cog): def __init__(self, bot): self.bot = bot # Unique identifier for Config. Large random-ish int to avoid collisions. - self.config = Config.get_conf(self, identifier=987654321012345678) + self.config = Config.get_conf(self, identifier=492089091320446976) default_guild = {"counters": {}} default_user = {"counters": {}} default_global = {"counters": {}} From b2029a095140b81a00498ae02f5b06196c6bec4d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:47:51 +0000 Subject: [PATCH 32/74] Update counter.py --- counter/counter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/counter/counter.py b/counter/counter.py index 6e6915f..6e1c0a8 100644 --- a/counter/counter.py +++ b/counter/counter.py @@ -10,7 +10,6 @@ class Counter(commands.Cog): def __init__(self, bot): self.bot = bot - # Unique identifier for Config. Large random-ish int to avoid collisions. self.config = Config.get_conf(self, identifier=492089091320446976) default_guild = {"counters": {}} default_user = {"counters": {}} From f1b3858b31cddcecb3285384f2f662ff69c50f77 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:57:29 +0000 Subject: [PATCH 33/74] Add multi-counter support and owner requests for guild counters Guild counters now support multiple counters with the same name, each assigned a unique numeric id. Added support for assigning counters to owners, including an approval workflow with pending owner requests and interactive Discord UI. Updated all relevant commands to handle id-based guild counters, and extended documentation to describe the new features and usage. --- counter/counter.py | 386 +++++++++++++++++++++++++++++++++++++++++++-- counter/docs.md | 12 +- 2 files changed, 378 insertions(+), 20 deletions(-) diff --git a/counter/counter.py b/counter/counter.py index 6e1c0a8..387d15b 100644 --- a/counter/counter.py +++ b/counter/counter.py @@ -1,5 +1,7 @@ import re -from typing import Optional +from typing import Optional, Union, List +import datetime +import discord from redbot.core import commands, Config from redbot.core.utils.chat_formatting import box @@ -11,7 +13,8 @@ class Counter(commands.Cog): def __init__(self, bot): self.bot = bot self.config = Config.get_conf(self, identifier=492089091320446976) - default_guild = {"counters": {}} + # Guild counters now support multiple counters with unique IDs per guild. + default_guild = {"counters": {}, "next_id": 1, "pending_owner_requests": {}, "next_req_id": 1} default_user = {"counters": {}} default_global = {"counters": {}} self.config.register_guild(**default_guild) @@ -49,19 +52,213 @@ async def _get_counter_store(self, ctx: commands.Context, scope: str): return self.config.user(ctx.author), "user" 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.""" + data = await store.all() + counters = data.get("counters", {}) + 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) + else: + # Fresh schema + await store.next_id.set(1) + + async def _resolve_guild_counter(self, store, identifier: str): + """Resolve an identifier (id or name) to a single (id, counter) tuple. + + Returns: + - (id, counter) if unique match + - None if none found + - list of (id, counter) if multiple matches + """ + counters = await store.counters() + if not counters: + return None + if identifier.isdigit(): + cid = str(int(identifier)) + if cid in counters: + return cid, counters[cid] + return None + name_key = self._clean_name(identifier) + matches = [(cid, c) for cid, c in counters.items() if c.get("name") == name_key] + if not matches: + return None + if len(matches) == 1: + return matches[0] + return matches + + async def _create_guild_counter(self, store, name_key: str, initial: int, owner_id: Optional[int], creator_id: int): + """Create a guild counter and return (id, data).""" + nid = await store.next_id() + data = { + "name": name_key, + "value": int(initial), + "owner": owner_id, + "creator": creator_id, + "created_at": datetime.datetime.utcnow().isoformat(), + } + async with store.counters() as counters: + counters[str(nid)] = data + await store.next_id.set(nid + 1) + return str(nid), data + + async def _create_pending_request(self, store, name_key: str, initial: int, requester_id: int, owner_id: int, channel_id: int): + """Create a pending owner-request and return its request id.""" + rid = await store.next_req_id() + data = { + "name": name_key, + "initial": int(initial), + "requester": requester_id, + "owner": owner_id, + "channel_id": channel_id, + "requested_at": datetime.datetime.utcnow().isoformat(), + } + async with store.pending_owner_requests() as reqs: + reqs[str(rid)] = data + await store.next_req_id.set(rid + 1) + 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`.""" await ctx.send_help(ctx.command) @counter.command(name="create") - async def create(self, ctx: commands.Context, name: str, scope: Optional[str] = None, initial: int = 0) -> None: - """Create a counter. Scope: guild (default), user, global.""" + async def create(self, ctx: commands.Context, name: str, scope: Optional[str] = None, initial: int = 0, owner: Optional[discord.Member] = None) -> None: + """Create a counter. Scope: guild (default), user, global. + + Guild scope supports multiple counters with the same name; each counter receives a unique id. + Optionally provide `owner` (mention or id) to associate the counter with a member. + """ scope = self._normalize_scope(scope) if scope == "global" and not await self.bot.is_owner(ctx.author): return await ctx.send("Global counters can only be created by the bot owner.") name_key = self._clean_name(name) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + # if owner omitted or owner is the requester, create immediately + if not owner or owner.id == ctx.author.id: + nid, data = await self._create_guild_counter(store, name_key, initial, owner.id if owner else None, ctx.author.id) + owner_text = f" (owner <@{data['owner']}>)" if data['owner'] else "" + await ctx.send(f"Created counter **{name_key}** with id `{nid}` in {human_scope} scope{owner_text} with value `{initial}`.") + return + # Prevent duplicate pending requests for same name-owner + existing = await store.pending_owner_requests() + for k, v in existing.items(): + if v.get("name") == name_key and v.get("owner") == owner.id: + return await ctx.send(f"There is already a pending owner request `{k}` for **{name_key}** assigned to {owner.mention}.") + # Create a pending owner request and notify the owner in the server channel (ping) + rid = await self._create_pending_request(store, name_key, initial, ctx.author.id, owner.id, ctx.channel.id) + owner_user = owner + view = OwnerApprovalView(self, ctx.guild.id, rid, { + "name": name_key, + "initial": int(initial), + "requester": ctx.author.id, + "owner": owner.id, + "channel_id": ctx.channel.id, + "requested_at": datetime.datetime.utcnow().isoformat(), + }) + try: + await ctx.send(f"{owner_user.mention}, {ctx.author.mention} has requested you be the owner of counter **{name_key}** in this guild. You can Accept or Decline below.", view=view) + except Exception: + # If we can't send in the channel (rare), still register request but inform the requester + await ctx.send(f"Owner request `{rid}` registered but I couldn't notify {owner_user.mention} in this channel. They will need to accept via `counter owner accept {rid}`.") + else: + await ctx.send(f"Owner request `{rid}` sent to {owner_user.mention} for counter **{name_key}** β€” waiting for their approval.") + return + # user/global legacy behavior async with store.counters() as counters: if name_key in counters: return await ctx.send(f"A counter named **{name_key}** already exists in {human_scope} scope.") @@ -72,8 +269,23 @@ async def create(self, ctx: commands.Context, name: str, scope: Optional[str] = async def inc(self, ctx: commands.Context, name: str, amount: int = 1, scope: Optional[str] = None) -> None: """Increment a counter by amount (default 1).""" scope = self._normalize_scope(scope) - name_key = self._clean_name(name) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + res = await self._resolve_guild_counter(store, name) + if res is None: + return await ctx.send(f"No counter named **{name}** found in {human_scope} scope.") + if isinstance(res, list): + lines = [f"`{cid}`: **{c.get('name')}** val={c.get('value')} owner={('<@%d>'%c['owner']) if c.get('owner') else 'none'}" for cid, c in res] + return await ctx.send("Multiple counters match that name. Use the id to disambiguate:\n" + "\n".join(lines)) + cid, c = res + async with store.counters() as counters: + counters[cid]['value'] = int(counters[cid]['value']) + int(amount) + new = counters[cid]['value'] + await ctx.send(f"**{c['name']}** (id `{cid}`) in {human_scope} is now `{new}` (+{amount}).") + return + # user/global legacy behavior + name_key = self._clean_name(name) async with store.counters() as counters: if name_key not in counters: return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") @@ -85,8 +297,23 @@ async def inc(self, ctx: commands.Context, name: str, amount: int = 1, scope: Op async def dec(self, ctx: commands.Context, name: str, amount: int = 1, scope: Optional[str] = None) -> None: """Decrement a counter by amount (default 1).""" scope = self._normalize_scope(scope) - name_key = self._clean_name(name) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + res = await self._resolve_guild_counter(store, name) + if res is None: + return await ctx.send(f"No counter named **{name}** found in {human_scope} scope.") + if isinstance(res, list): + lines = [f"`{cid}`: **{c.get('name')}** val={c.get('value')} owner={('<@%d>'%c['owner']) if c.get('owner') else 'none'}" for cid, c in res] + return await ctx.send("Multiple counters match that name. Use the id to disambiguate:\n" + "\n".join(lines)) + cid, c = res + async with store.counters() as counters: + counters[cid]['value'] = int(counters[cid]['value']) - int(amount) + new = counters[cid]['value'] + await ctx.send(f"**{c['name']}** (id `{cid}`) in {human_scope} is now `{new}` (-{amount}).") + return + # user/global legacy behavior + name_key = self._clean_name(name) async with store.counters() as counters: if name_key not in counters: return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") @@ -100,8 +327,21 @@ async def set_(self, ctx: commands.Context, name: str, value: int, scope: Option scope = self._normalize_scope(scope) if scope == "global" and not await self.bot.is_owner(ctx.author): return await ctx.send("Global counters can only be modified by the bot owner.") - name_key = self._clean_name(name) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + res = await self._resolve_guild_counter(store, name) + if res is None: + return await ctx.send(f"No counter named **{name}** found in {human_scope} scope.") + if isinstance(res, list): + lines = [f"`{cid}`: **{c.get('name')}** val={c.get('value')} owner={('<@%d>'%c['owner']) if c.get('owner') else 'none'}" for cid, c in res] + return await ctx.send("Multiple counters match that name. Use the id to disambiguate:\n" + "\n".join(lines)) + cid, c = res + async with store.counters() as counters: + counters[cid]['value'] = int(value) + await ctx.send(f"Set **{c['name']}** (id `{cid}`) in {human_scope} to `{value}`.") + return + name_key = self._clean_name(name) async with store.counters() as counters: if name_key not in counters: return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") @@ -114,8 +354,21 @@ async def delete(self, ctx: commands.Context, name: str, scope: Optional[str] = scope = self._normalize_scope(scope) if scope == "global" and not await self.bot.is_owner(ctx.author): return await ctx.send("Global counters can only be deleted by the bot owner.") - name_key = self._clean_name(name) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + res = await self._resolve_guild_counter(store, name) + if res is None: + return await ctx.send(f"No counter named **{name}** found in {human_scope} scope.") + if isinstance(res, list): + lines = [f"`{cid}`: **{c.get('name')}** val={c.get('value')} owner={('<@%d>'%c['owner']) if c.get('owner') else 'none'}" for cid, c in res] + return await ctx.send("Multiple counters match that name. Use the id to disambiguate:\n" + "\n".join(lines)) + cid, c = res + async with store.counters() as counters: + del counters[cid] + await ctx.send(f"Deleted **{c['name']}** (id `{cid}`) from {human_scope} scope.") + return + name_key = self._clean_name(name) async with store.counters() as counters: if name_key not in counters: return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") @@ -126,8 +379,19 @@ async def delete(self, ctx: commands.Context, name: str, scope: Optional[str] = async def show(self, ctx: commands.Context, name: str, scope: Optional[str] = None) -> None: """Show the value of a counter.""" scope = self._normalize_scope(scope) - name_key = self._clean_name(name) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + res = await self._resolve_guild_counter(store, name) + if res is None: + return await ctx.send(f"No counter named **{name}** found in {human_scope} scope.") + if isinstance(res, list): + lines = [f"`{cid}`: **{c.get('name')}** val={c.get('value')} owner={('<@%d>'%c['owner']) if c.get('owner') else 'none'}" for cid, c in res] + return await ctx.send("Multiple counters match that name. Use the id to disambiguate:\n" + "\n".join(lines)) + cid, c = res + await ctx.send(f"**{c['name']}** (id `{cid}`) in {human_scope} is `{c['value']}`.") + return + name_key = self._clean_name(name) counters = await store.counters() if name_key not in counters: return await ctx.send(f"No counter named **{name_key}** found in {human_scope} scope.") @@ -138,6 +402,20 @@ async def list_(self, ctx: commands.Context, scope: Optional[str] = None) -> Non """List counters in a scope (guild by default).""" scope = self._normalize_scope(scope) store, human_scope = await self._get_counter_store(ctx, scope) + if scope == "guild": + await self._ensure_guild_schema(store) + counters = await store.counters() + 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])): + 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' + lines.append(f"`{cid}` **{c['name']}**: {c['value']} owner:{owner} creator:{creator} created:{created}") + text = "\n".join(lines) + await ctx.send(box(text, lang="ini")) + return counters = await store.counters() if not counters: return await ctx.send(f"No counters in {human_scope} scope.") @@ -150,16 +428,90 @@ async def transfer(self, ctx: commands.Context, name: str, target_scope: str, sc """Transfer a counter from one scope to another (only bot owner for global target).""" scope = self._normalize_scope(scope) target_scope = self._normalize_scope(target_scope) - name_key = self._clean_name(name) src_store, src_human = await self._get_counter_store(ctx, scope) dst_store, dst_human = await self._get_counter_store(ctx, target_scope) if target_scope == "global" and not await self.bot.is_owner(ctx.author): return await ctx.send("You must be the bot owner to move counters to global scope.") - async with src_store.counters() as src_counters: - if name_key not in src_counters: - return await ctx.send(f"No counter named **{name_key}** in {src_human} scope.") - value = src_counters[name_key] - del src_counters[name_key] + # Guild source: resolve id/name + if scope == "guild": + await self._ensure_guild_schema(src_store) + res = await self._resolve_guild_counter(src_store, name) + if res is None: + return await ctx.send(f"No counter named **{name}** in {src_human} scope.") + if isinstance(res, list): + lines = [f"`{cid}`: **{c.get('name')}** val={c.get('value')} owner={('<@%d>'%c['owner']) if c.get('owner') else 'none'}" for cid, c in res] + return await ctx.send("Multiple counters match that name. Use the id to disambiguate:\n" + "\n".join(lines)) + cid, c = res + value = c['value'] + async with src_store.counters() as src_counters: + del src_counters[cid] + else: + name_key = self._clean_name(name) + async with src_store.counters() as src_counters: + if name_key not in src_counters: + return await ctx.send(f"No counter named **{name_key}** in {src_human} scope.") + value = src_counters[name_key] + del src_counters[name_key] + # Destination: store value (convert guild->legacy formats) + if target_scope == "guild": + await self._ensure_guild_schema(dst_store) + nid = await dst_store.next_id() + data = {"name": self._clean_name(name), "value": int(value), "owner": None, "creator": ctx.author.id, "created_at": datetime.datetime.utcnow().isoformat()} + async with dst_store.counters() as dst_counters: + dst_counters[str(nid)] = data + await dst_store.next_id.set(nid + 1) + await ctx.send(f"Moved **{data['name']}** ({value}) from {src_human} to {dst_human} scope as id `{nid}`.") + return async with dst_store.counters() as dst_counters: - dst_counters[name_key] = value - await ctx.send(f"Moved **{name_key}** ({value}) from {src_human} to {dst_human} scope.") + dst_counters[self._clean_name(name)] = int(value) + await ctx.send(f"Moved **{self._clean_name(name)}** ({value}) from {src_human} to {dst_human} scope.") + + @counter.group(name="owner", invoke_without_command=True) + async def owner_group(self, ctx: commands.Context) -> None: + """Owner-related subcommands for pending owner requests (list/accept/decline).""" + await ctx.send_help(ctx.command) + + @owner_group.command(name="list") + async def owner_list(self, ctx: commands.Context) -> None: + """List pending owner requests addressed to you in this guild.""" + if ctx.guild is None: + return await ctx.send("This command must be used in a guild.") + store = self.config.guild(ctx.guild) + data = await store.pending_owner_requests() + found = [(rid, r) for rid, r in data.items() if r.get("owner") == ctx.author.id] + if not found: + return await ctx.send("You have no pending owner requests in this guild.") + lines = [f"`{rid}`: counter **{r.get('name')}** requested by <@{r.get('requester')}> at {r.get('requested_at')}" for rid, r in found] + await ctx.send(box("\n".join(lines), lang="ini")) + + @owner_group.command(name="accept") + async def owner_accept(self, ctx: commands.Context, request_id: str) -> None: + """Accept a pending owner request by id.""" + if ctx.guild is None: + return await ctx.send("This command must be used in a guild.") + store = self.config.guild(ctx.guild) + async with store.pending_owner_requests() as reqs: + if request_id not in reqs: + return await ctx.send("No such owner request id.") + req = reqs[request_id] + if req.get("owner") != ctx.author.id: + return await ctx.send("You are not the requested owner for that request.") + # create counter + nid, data = await self._create_guild_counter(store, req.get("name"), req.get("initial"), req.get("owner"), req.get("requester")) + del reqs[request_id] + await ctx.send(f"Accepted owner request `{request_id}` β€” created counter **{data['name']}** with id `{nid}` and assigned to you.") + + @owner_group.command(name="decline") + async def owner_decline(self, ctx: commands.Context, request_id: str) -> None: + """Decline a pending owner request by id.""" + if ctx.guild is None: + return await ctx.send("This command must be used in a guild.") + store = self.config.guild(ctx.guild) + async with store.pending_owner_requests() as reqs: + if request_id not in reqs: + return await ctx.send("No such owner request id.") + req = reqs[request_id] + if req.get("owner") != ctx.author.id: + 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}`.") diff --git a/counter/docs.md b/counter/docs.md index 235210b..9c34d1e 100644 --- a/counter/docs.md +++ b/counter/docs.md @@ -10,11 +10,17 @@ Commands: - `counter list [scope]` - List counters in the given scope (default `guild`). Notes: -- `guild` scope stores counters per-server. +- `guild` scope stores counters per-server and now allows multiple counters with the same name; each guild counter has a unique numeric id. +- When multiple guild counters share the same name, use the id to disambiguate (e.g., `counter inc 23 1`). - `user` scope stores counters per-user (only visible to that user when listed with user scope). - `global` scope requires the bot owner to create/modify/delete. Examples: -- `counter create donuts` -> Creates `donuts` in the current server with value 0. -- `counter inc donuts 2` -> Adds 2 to the `donuts` counter in the server. +- `counter create donuts` -> Creates `donuts` in the current server with value 0 and returns an id. +- `counter create coins` -> Creates a guild counter named `coins` (id shows in the response) β€” you can create another `coins` for another user. +- `counter create coins owner:@member` -> Create `coins` associated with a member (use a mention or ID); the requested owner will be asked for permission **in the server channel** (they will be pinged). If they accept via the Accept button or `counter owner accept `, the counter is created and assigned to them; if they decline the request is removed. +- `counter owner list` -> Show pending owner requests addressed to you in this guild. +- `counter owner accept ` -> Accept a pending owner request (alternatively use the Accept button in the server message). +- `counter owner decline ` -> Decline a pending owner request. +- `counter inc 23 2` -> Add 2 to the counter with id `23`. - `counter create wins user 0` -> Creates a user-scoped counter for the calling user. From cf032a031756b20a455a67ae14a09d500b17586b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:44:01 +0000 Subject: [PATCH 34/74] repo cleanup clean up old cogs that are non functional/half-finished or just a bad idea all round --- {facebookdownloader => bible}/info.json | 2 +- earthquake/__init__.py | 6 - earthquake/earthquake.py | 204 ------- earthquake/info.json | 17 - facebookdownloader/README.md | 38 -- facebookdownloader/__init__.py | 4 - .../facebook_video_downloader.py | 73 --- facebookdownloader/requirements.txt | 3 - imgen/Imgen.py | 24 - imgen/__init__.py | 8 - imgen/info.json | 10 - imgen/readme | 2 - voice/__init__.py | 13 - voice/info.json | 10 - voice/voice.py | 509 ------------------ 15 files changed, 1 insertion(+), 922 deletions(-) rename {facebookdownloader => bible}/info.json (92%) delete mode 100644 earthquake/__init__.py delete mode 100644 earthquake/earthquake.py delete mode 100644 earthquake/info.json delete mode 100644 facebookdownloader/README.md delete mode 100644 facebookdownloader/__init__.py delete mode 100644 facebookdownloader/facebook_video_downloader.py delete mode 100644 facebookdownloader/requirements.txt delete mode 100644 imgen/Imgen.py delete mode 100644 imgen/__init__.py delete mode 100644 imgen/info.json delete mode 100644 imgen/readme delete mode 100644 voice/__init__.py delete mode 100644 voice/info.json delete mode 100644 voice/voice.py diff --git a/facebookdownloader/info.json b/bible/info.json similarity index 92% rename from facebookdownloader/info.json rename to bible/info.json index 4f4eb11..5e4609f 100644 --- a/facebookdownloader/info.json +++ b/bible/info.json @@ -2,7 +2,7 @@ "author": ["bencos18 (492089091320446976)"], "install_msg": "thank for adding my repo\n feel free to message me on discord or create a github issue if you need support or help with anything.", "short": "some random cogs I made for my own use and decided to make public.", - "requirements": ["yt_dlp"], + "requirements": ["bible"], "description": "-", "tags": [], "end_user_data_statement": "This cog does not store any End User Data.", diff --git a/earthquake/__init__.py b/earthquake/__init__.py deleted file mode 100644 index 525788d..0000000 --- a/earthquake/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .earthquake import Earthquake - -__red_end_user_data_statement__ = "This cog does not store any end user data." - -async def setup(bot): - await bot.add_cog(Earthquake(bot)) diff --git a/earthquake/earthquake.py b/earthquake/earthquake.py deleted file mode 100644 index 81a0004..0000000 --- a/earthquake/earthquake.py +++ /dev/null @@ -1,204 +0,0 @@ -import discord -from redbot.core import commands, Config -from discord.ext import tasks -import aiohttp -import json -import datetime -import logging - -class Earthquake(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.stop_messages = False - self.config = Config.get_conf(self, identifier=492089091320446976) - default_guild = { - "alert_channel_id": None, - "min_magnitude": 5, - "announced_earthquake_ids": [] # Track announced earthquake IDs per guild - } - self.config.register_guild(**default_guild) - logging.basicConfig(level=logging.INFO) - self.check_earthquakes.start() - - @commands.command(name='setalertchannel', help='Set the channel for earthquake alerts. Usage: !setalertchannel ') - async def set_alert_channel(self, ctx, channel: discord.TextChannel): - await self.config.guild(ctx.guild).alert_channel_id.set(channel.id) # Set to the specified channel - logging.info(f"Alert channel set to {channel.id} ({channel.name}) for guild {ctx.guild.id}.") # Added logging - await ctx.send(f"Alert channel set to {channel.name}.") - - @commands.command(name='setminmagnitude', help='Set the minimum magnitude for alerts') - async def set_min_magnitude(self, ctx, magnitude: float): - await self.config.guild(ctx.guild).min_magnitude.set(magnitude) - await ctx.send(f"Minimum magnitude for alerts set to {magnitude}.") - - @tasks.loop(minutes=10) - async def check_earthquakes(self): - guilds = self.bot.guilds - for guild in guilds: - alert_channel_id = await self.config.guild(guild).alert_channel_id() - min_magnitude = await self.config.guild(guild).min_magnitude() - if alert_channel_id is None: # Check if alert channel is not set - continue # Do not check if alert channel is not set - if self.stop_messages: # Check if messages should be stopped - continue # Do not check if messages are stopped - - url = 'https://earthquake.usgs.gov/fdsnws/event/1/query' - params = { - 'format': 'geojson', - 'orderby': 'time', - 'minmagnitude': min_magnitude # Use the configured minimum magnitude - } - async with aiohttp.ClientSession() as session: - try: - async with session.get(url, params=params) as response: - response.raise_for_status() - data = await response.json() - if data['metadata']['count'] > 0: - # Get announced earthquake IDs from config - announced_ids = set(await self.config.guild(guild).announced_earthquake_ids()) - new_announced_ids = set(announced_ids) - for feature in data['features']: - eq_id = feature.get('id') - if eq_id in announced_ids: - continue # Already announced - if self.stop_messages: # Check here before sending - break - # Send alert for each new earthquake - await self.send_earthquake_embed(self.bot.get_channel(alert_channel_id), feature) - new_announced_ids.add(eq_id) - # Save updated announced IDs (keep only the most recent 100 to avoid bloat) - if new_announced_ids != announced_ids: - # Sort by most recent in data['features'] order - recent_ids = [f.get('id') for f in data['features'] if f.get('id') in new_announced_ids] - # Add any old IDs not in this batch - for old_id in announced_ids: - if old_id not in recent_ids: - recent_ids.append(old_id) - await self.config.guild(guild).announced_earthquake_ids.set(recent_ids[:100]) - except Exception as e: - logging.error(f"Error fetching earthquake data: {e}") - - @check_earthquakes.before_loop - async def before_check_earthquakes(self): - await self.bot.wait_until_ready() - - async def send_earthquake_embed(self, ctx, feature, webhook=None): - utc_time = datetime.datetime.utcfromtimestamp(feature['properties']['time'] / 1000) - embed = discord.Embed(title="Earthquake Alert", description=f"Location: {feature['properties']['place']}", color=0x00ff00, timestamp=utc_time) - embed.add_field(name="Magnitude", value=feature['properties']['mag'], inline=False) - embed.add_field(name="Time", value=discord.utils.format_dt(utc_time, style='F'), inline=False) - embed.add_field(name="Depth", value=feature['geometry']['coordinates'][2], inline=False) - embed.add_field(name="More Info", value=f"[USGS Info Page]({feature['properties']['url']})", inline=False) - - if webhook: - await webhook.send(embed=embed) - else: - await ctx.send(embed=embed) - - @commands.command(name='earthquake', help='Get the latest earthquake information. Use !earthquake . Type can be "rectangle" or "circle".') - async def earthquake(self, ctx, search_type: str, *, params: str): - if self.stop_messages: # Check if messages should be stopped - await ctx.send("Earthquake messages are currently stopped.") - return - - url = 'https://earthquake.usgs.gov/fdsnws/event/1/query' - params_dict = { - 'format': 'geojson', - 'orderby': 'time', - } - - if search_type.lower() == "rectangle": - try: - # Expecting params in the format: "minlat,maxlat,minlon,maxlon" - minlat, maxlat, minlon, maxlon = map(float, params.split(',')) - params_dict['minlatitude'] = minlat - params_dict['maxlatitude'] = maxlat - params_dict['minlongitude'] = minlon - params_dict['maxlongitude'] = maxlon - except ValueError: - await ctx.send("Invalid parameters for rectangle. Use: minlat,maxlat,minlon,maxlon") - return - - elif search_type.lower() == "circle": - try: - # Expecting params in the format: "latitude,longitude,maxradiuskm" - latitude, longitude, maxradiuskm = map(float, params.split(',')) - params_dict['latitude'] = latitude - params_dict['longitude'] = longitude - params_dict['maxradiuskm'] = maxradiuskm - except ValueError: - await ctx.send("Invalid parameters for circle. Use: latitude,longitude,maxradiuskm") - return - - else: - await ctx.send("Invalid search type. Use 'rectangle' or 'circle'.") - return - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url, params=params_dict) as response: - response.raise_for_status() # Raise an error for bad responses - data = await response.json() - except Exception as e: - logging.error(f"Error fetching earthquake data: {e}") - await ctx.send(f"Failed to fetch earthquake data: {str(e)}") - return - - if data['metadata']['count'] > 0: - try: - avatar_bytes = await self.bot.user.avatar.read() - webhook = await ctx.channel.create_webhook(name="Earthquake Alert Webhook", avatar=avatar_bytes) - for feature in data['features']: - if self.stop_messages: - break - await self.send_earthquake_embed(ctx, feature, webhook) - await webhook.delete() - except discord.Forbidden: - for feature in data['features']: - if self.stop_messages: - break - await self.send_earthquake_embed(ctx, feature) - else: - await ctx.send("No earthquakes found in the given parameters.") - - @commands.command(name='eqstop', help='Stop all earthquake messages and tasks') - async def stop_messages(self, ctx): - self.stop_messages = True # Set the flag to stop messages - self.check_earthquakes.stop() # Stop the task loop - await ctx.send("All earthquake messages and tasks have been stopped.") - - @commands.command(name='eqstart', help='Restart earthquake messages and tasks') - async def start_messages(self, ctx): - if self.check_earthquakes.is_running(): # Check if the task is already running - await ctx.send("Earthquake messages are already running.") - return - self.stop_messages = False # Reset the flag to allow messages - self.check_earthquakes.start() # Restart the task loop - await ctx.send("Earthquake messages and tasks have been restarted.") - - @commands.command(name='testalert', help='Test the earthquake alert system') - async def test_alert(self, ctx): - alert_channel_id = await self.config.guild(ctx.guild).alert_channel_id() - if alert_channel_id is None: - await ctx.send("Alert channel is not set. Use `!setalertchannel` to set it.") - return - test_feature = { - 'properties': { - 'place': 'Test Location', - 'mag': 5.0, - 'time': datetime.datetime.now().timestamp() * 1000, - 'url': 'https://earthquake.usgs.gov/' - }, - 'geometry': { - 'coordinates': [0, 0, 10] - } - } - await self.send_earthquake_embed(ctx, test_feature) - - @commands.command(name='forceupdate', help='Force an update for earthquake alerts') - async def force_update(self, ctx): - alert_channel_id = await self.config.guild(ctx.guild).alert_channel_id() - if alert_channel_id is None: - await ctx.send("Alert channel is not set. Use `!setalertchannel` to set it.") - return - await self.check_earthquakes() # Manually trigger the check for earthquakes \ No newline at end of file diff --git a/earthquake/info.json b/earthquake/info.json deleted file mode 100644 index e950782..0000000 --- a/earthquake/info.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "earthquake", - "short": "earthquake stuff", - "description": "DO NOT USE SPAMS channel and bot Get the latest earthquake information", - "end_user_data_statement": "This cog does not record end user data.", - "install_msg": "Do not use atm", - "author": [ - "bencos18"], - - "tags": [ - "api", - "earthquake" - ], - "hidden": true, - "disabled": true, - "type": "COG" -} diff --git a/facebookdownloader/README.md b/facebookdownloader/README.md deleted file mode 100644 index c89eaaa..0000000 --- a/facebookdownloader/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Facebook Video Downloader Cog for Redbot - -This cog allows you to download videos from public Facebook posts directly into your Discord server using Redbot. - -## Features -- Download Facebook videos by providing a public post URL. -- Uploads the video directly to the Discord channel. - -## Installation -1. Install dependencies: - ``` -pip install -r requirements.txt - ``` -2. Place `facebook_video_downloader.py` in your Redbot's `cogs` directory or load as a custom cog. - -## Loading the Cog -In your Discord server, use: -``` -[p]load facebook_video_downloader -``` -Replace `[p]` with your bot's prefix. - -## Usage -``` -[p]fbvideo -``` -Example: -``` -[p]fbvideo https://www.facebook.com/watch/?v=1234567890 -``` - -## Notes -- Only works with public Facebook videos. -- If the video cannot be downloaded, the bot will notify you. -- Facebook may change their page structure, which could break this cog. If it stops working, check for updates or open an issue. - -## Disclaimer -This cog is for educational purposes. Downloading videos from Facebook may violate their terms of service. Use responsibly. \ No newline at end of file diff --git a/facebookdownloader/__init__.py b/facebookdownloader/__init__.py deleted file mode 100644 index 185cbfc..0000000 --- a/facebookdownloader/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .facebook_video_downloader import FacebookVideoDownloader - -async def setup(bot): - await bot.add_cog(FacebookVideoDownloader(bot)) \ No newline at end of file diff --git a/facebookdownloader/facebook_video_downloader.py b/facebookdownloader/facebook_video_downloader.py deleted file mode 100644 index 012b753..0000000 --- a/facebookdownloader/facebook_video_downloader.py +++ /dev/null @@ -1,73 +0,0 @@ -import discord -from redbot.core import commands -import yt_dlp -import os -import tempfile - -class FacebookVideoDownloader(commands.Cog): - """Download Facebook videos via a command.""" - - def __init__(self, bot): - self.bot = bot - - @commands.command() - async def fbvideo(self, ctx, url: str): - """ - Download a Facebook video from a public URL and upload it here. - - **Usage:** - `[p]fbvideo ` - Example: `[p]fbvideo https://www.facebook.com/watch/?v=1234567890` - """ - # (6) Check for permissions to send files - if not ctx.channel.permissions_for(ctx.me).send_messages or not ctx.channel.permissions_for(ctx.me).attach_files: - await ctx.send("I don't have permission to send files in this channel.") - return - async with ctx.typing(): - # (5) User feedback: status message - status_msg = await ctx.send("Starting download... This may take a moment.") - ydl_opts = { - 'format': 'bestvideo+bestaudio/best', - 'merge_output_format': 'mp4', - 'quiet': True, - 'noplaylist': True, - } - # Cookie support: look for a cookies file - cookies_path = None - for candidate in ["facebook_cookies.txt", "instagram_cookies.txt", "cookies.txt"]: - if os.path.exists(candidate): - cookies_path = candidate - break - if cookies_path: - ydl_opts['cookiefile'] = cookies_path - try: - # (4) Use tempfile for safe file handling - with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as tmpfile: - ydl_opts['outtmpl'] = tmpfile.name - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - await status_msg.edit(content="Checking file size...") - if not os.path.exists(tmpfile.name): - await status_msg.edit(content="Could not download the video. Please check the URL or try again later.") - return - file_size = os.path.getsize(tmpfile.name) - max_size = 8 * 1024 * 1024 # 8MB default Discord limit - file_size_mb = file_size / (1024 * 1024) - if file_size == 0: - await status_msg.edit(content="The downloaded file is empty. This usually means the video is private, requires login, or yt-dlp was blocked. The bot owner can provide cookies for authentication. See: https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp") - os.remove(tmpfile.name) - return - if file_size > max_size: - await status_msg.edit(content=f"The downloaded video is too large to upload to Discord.\nFile size: {file_size_mb:.2f} MB (limit: {max_size // (1024 * 1024)} MB).\nUpload failed because the file exceeds Discord's upload limit.") - os.remove(tmpfile.name) - return - await status_msg.edit(content="Uploading video to Discord...") - await ctx.send(file=discord.File(tmpfile.name)) - os.remove(tmpfile.name) - await status_msg.delete() - except Exception as e: - err_str = str(e).lower() - if 'login required' in err_str or 'cookies' in err_str or 'private' in err_str or 'not available' in err_str: - await status_msg.edit(content="Download failed: Login or cookies required. The bot owner can provide cookies for authentication. See: https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp") - else: - await status_msg.edit(content=f"Error: {e}") \ No newline at end of file diff --git a/facebookdownloader/requirements.txt b/facebookdownloader/requirements.txt deleted file mode 100644 index 420f301..0000000 --- a/facebookdownloader/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests -pytube -yt-dlp \ No newline at end of file diff --git a/imgen/Imgen.py b/imgen/Imgen.py deleted file mode 100644 index dbd6228..0000000 --- a/imgen/Imgen.py +++ /dev/null @@ -1,24 +0,0 @@ -import discord -from redbot.core import commands, Config -import aiohttp - -class Imgen(commands.Cog): - """Cog for interacting with the Imgen API""" - - def __init__(self, bot): - self.bot = bot - self.config = Config.get_conf(self, identifier=492089091320446976, force_registration=True) - default_global = {} - self.config.register_global(**default_global) - - @commands.command() - async def memes(self, ctx, top_text: str, bottom_text: str, color: str = None, font: str = None): - """Generate a meme with top and bottom text""" - async with aiohttp.ClientSession() as session: - async with session.get('https://imgen.red/api/meme_v2', params={'top_text': top_text, 'bottom_text': bottom_text, 'color': color, 'font': font}) as response: - if response.status == 200: - meme_url = await response.text() - await ctx.send(meme_url) - else: - await ctx.send("Error generating meme") - diff --git a/imgen/__init__.py b/imgen/__init__.py deleted file mode 100644 index 3275d4f..0000000 --- a/imgen/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from redbot.core.bot import Red - -from .Imgen import Imgen - -__red_end_user_data_statement__ = "This cog does not store any end user data." - -async def setup(bot: Red): - await bot.add_cog(Imgen(bot)) \ No newline at end of file diff --git a/imgen/info.json b/imgen/info.json deleted file mode 100644 index 44cbce5..0000000 --- a/imgen/info.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "author": ["bencos18 (492089091320446976)"], - "install_msg": "thank for adding my repo\n feel free to message me on discord or create a github issue if you need support or help with anything.", - "short": "some random cogs I made for my own use and decided to make public.", - "description": "Imgen cog.", - "tags": ["emoji", "emojis"], - "end_user_data_statement": "This cog does not store any End User Data.", - "type": "COG", - "hidden" : true -} diff --git a/imgen/readme b/imgen/readme deleted file mode 100644 index 1f33eae..0000000 --- a/imgen/readme +++ /dev/null @@ -1,2 +0,0 @@ -this is a WIP cog -not reasy for use yet diff --git a/voice/__init__.py b/voice/__init__.py deleted file mode 100644 index c00e93d..0000000 --- a/voice/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Voice cog package for ben-cogs - -Exports a setup function for Red and package metadata. -""" - -from .voice import VoiceRecvCog - -__red_end_user_data_statement__ = "This cog does not persist end user data." - - -async def setup(bot): - """Add the cog to the bot.""" - await bot.add_cog(VoiceRecvCog(bot)) diff --git a/voice/info.json b/voice/info.json deleted file mode 100644 index 36d98dc..0000000 --- a/voice/info.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Voice", - "author": ["bencos17"], - "description": "Voice receive cog that integrates with discord-ext-voice-recv to record audio per user.", - "install_msg": "This cog requires discord-ext-voice-recv to receive audio. Install with: \"python -m pip install discord-ext-voice-recv\". Use `[p]vjoin` to join voice and `[p]vlisten` to start recording.", - "short": "Voice receive and recording", - "requirements": ["discord-ext-voice-recv"], - "tags": ["voice", "utility", "recording"], - "end_user_data_statement": "This cog may write audio recordings containing user speech to disk. The bot owner controls storage and retention; recordings are not uploaded by default, but can be optionally uploaded to the text channel that initiated the recording (the requesting user will be mentioned)." -} \ No newline at end of file diff --git a/voice/voice.py b/voice/voice.py deleted file mode 100644 index 4b975d2..0000000 --- a/voice/voice.py +++ /dev/null @@ -1,509 +0,0 @@ -from redbot.core import commands -import discord -import logging -import os -import time -import wave -import asyncio -import sys -from collections import defaultdict - -try: - import voice_recv -except Exception: - # The package installs as `discord.ext.voice_recv`; prefer that if available - try: - from discord.ext import voice_recv # type: ignore - except Exception: - voice_recv = None - - -class RecordingSink: - """A minimal sink compatible with voice_recv.AudioSink API. - - This sink collects PCM chunks per user and writes them to WAV files on cleanup. - Optionally it can upload the resulting files to a text channel and mention the - user who requested the recording. Uploading is performed via the bot's event - loop using a thread-safe scheduling call so cleanup can be called from a - background thread. - """ - - def __init__(self, outdir: str = "voice_records", *, bot=None, text_channel_id: int | None = None, uploader_id: int | None = None, voice_channel_name: str | None = None, upload_on_cleanup: bool = False): - # Keep lazy imports to avoid hard dependency for users who don't have the package - self._has_voice_recv_audio_sink = hasattr(voice_recv, "AudioSink") if voice_recv else False - if self._has_voice_recv_audio_sink: - # Create an adapter subclass that implements the required abstract methods - base = voice_recv.AudioSink - class _Impl(base): - def __init__(self, parent): - # Ensure base initialization runs if required - try: - super().__init__() - except Exception: - pass - self._parent = parent - - def wants_opus(self) -> bool: - return self._parent.wants_opus() - - def write(self, user, data) -> None: - return self._parent.write(user, data) - - def cleanup(self) -> None: - return self._parent.cleanup() - - # instantiate adapter with a reference back to this RecordingSink - self._impl = _Impl(self) - else: - self._impl = None - - self.outdir = outdir - os.makedirs(self.outdir, exist_ok=True) - self.buffers = defaultdict(list) # user_id -> [bytes] - self.sample_rate = 48000 - self.sample_width = 2 - self.channels = 1 - - # Upload-related state - self.bot = bot - self.text_channel_id = text_channel_id - self.uploader_id = uploader_id - self.voice_channel_name = voice_channel_name - self.upload_on_cleanup = bool(upload_on_cleanup) - - # Compatibility wrappers expected by voice_recv - def wants_opus(self) -> bool: - # We expect decoded PCM (so return False) β€” if you want opus, adjust - return False - - def write(self, user, data) -> None: - pcm = getattr(data, "pcm", None) - if not pcm: - # nothing to write (e.g., opus-only sink, or silence) - return - - user_id = getattr(user, "id", "unknown") - self.buffers[user_id].append(pcm) - - def cleanup(self) -> None: - # Write out WAV files for each collected user - written = [] # list of (user_id, filename) - for uid, chunks in list(self.buffers.items()): - if not chunks: - continue - filename = os.path.join(self.outdir, f"{uid}_{int(time.time())}.wav") - with wave.open(filename, "wb") as wf: - wf.setnchannels(self.channels) - wf.setsampwidth(self.sample_width) - wf.setframerate(self.sample_rate) - wf.writeframes(b"".join(chunks)) - written.append((uid, filename)) - - # If configured, schedule an async upload back into the bot's loop - if written and self.upload_on_cleanup and self.bot and self.text_channel_id: - # Use run_coroutine_threadsafe because cleanup may be called from a non-async thread - try: - asyncio.run_coroutine_threadsafe(self._async_upload(written), self.bot.loop) - except Exception: - logging.getLogger("red.voice").exception("Failed to schedule upload of recordings") - - async def _async_upload(self, written: list[tuple[int, str]]) -> None: - """Coroutine to send recordings to the configured text channel and mention the uploader.""" - logger = logging.getLogger("red.voice") - try: - channel = self.bot.get_channel(self.text_channel_id) - if channel is None: - try: - channel = await self.bot.fetch_channel(self.text_channel_id) - except Exception: - channel = None - - if channel is None: - logger.warning("Upload requested but text channel %s could not be found", self.text_channel_id) - return - - mention = f"<@{self.uploader_id}>" if self.uploader_id else None - vc_name = self.voice_channel_name or "(unknown)" - content = f"{mention} recordings from voice channel {vc_name}:" if mention else f"Recordings from voice channel {vc_name}:" - - files = [] - for uid, path in written: - try: - files.append(discord.File(path, filename=os.path.basename(path))) - except Exception: - logger.exception("Failed to open recording file for upload: %s", path) - - if not files: - await channel.send(content + "\nNo files available to upload.") - return - - # Discord limits number/size of files β€” best effort upload - await channel.send(content, files=files) - except Exception: - logger.exception("Unexpected error during upload of voice recordings") - - -class VoiceRecvCog(commands.Cog): - """Cog that provides simple commands to join voice with a VoiceRecv client and record audio. - - Commands: - - vjoin: Join your voice channel using voice_recv.VoiceRecvClient (requires package) - - vleave: Disconnect from voice - - vlisten [outdir]: Start listening and record per-user WAV files to `outdir` (default: voice_records) - - vstop: Stop listening - - vspeaking [member]: Show speaking state for a member - """ - - def __init__(self, bot): - self.bot = bot - self.logger = logging.getLogger("red.voice") - self._sink = None - - @commands.command() - async def vjoin(self, ctx: commands.Context) -> None: - """Join the author's voice channel using VoiceRecvClient (requires discord-ext-voice-recv).""" - if voice_recv is None: - # Try a runtime import in case the package was installed after the cog was loaded - try: - import importlib - try: - _mod = importlib.import_module("voice_recv") - except Exception: - _mod = importlib.import_module("discord.ext.voice_recv") - globals()["voice_recv"] = _mod - except Exception: - await ctx.send("discord-ext-voice-recv is not installed. Install it to use voice receive features (then reload the cog or restart the bot).") - return - - channel = None - if ctx.author and getattr(ctx.author, "voice", None): - channel = ctx.author.voice.channel - if channel is None: - await ctx.send("You must be connected to a voice channel to use this command.") - return - - # Use the provided client class - cls = getattr(voice_recv, "VoiceRecvClient", None) - if cls is None: - await ctx.send("VoiceRecvClient class not found in voice_recv package.") - return - - try: - await channel.connect(cls=cls) - except Exception as exc: - self.logger.exception("Failed to connect to voice: %s", exc) - await ctx.send(f"Failed to connect to voice: {exc}") - return - - await ctx.send(f"Joined voice channel {channel.name}") - - @commands.command() - async def vleave(self, ctx: commands.Context) -> None: - """Disconnect from voice channel.""" - vc: discord.VoiceClient | None = ctx.voice_client - if vc is None: - await ctx.send("Not connected to voice.") - return - - try: - await vc.disconnect() - await ctx.send("Disconnected from voice.") - except Exception as exc: - self.logger.exception("Failed to disconnect: %s", exc) - await ctx.send(f"Failed to disconnect: {exc}") - - @commands.command() - async def vlisten(self, ctx: commands.Context, outdir: str = "voice_records", upload: str = None) -> None: - """Start listening and record per-user WAV files to `outdir` (default: voice_records). - - Pass the literal word `upload` as a second argument to automatically post - the recordings to the text channel that invoked the command when - recording finishes; the requesting user will be mentioned. - """ - vc: discord.VoiceClient | None = ctx.voice_client - if vc is None: - await ctx.send("Bot is not connected to voice.") - return - - if voice_recv is None: - # Try a runtime import in case the package was installed after the cog was loaded - try: - import importlib - try: - _mod = importlib.import_module("voice_recv") - except Exception: - _mod = importlib.import_module("discord.ext.voice_recv") - globals()["voice_recv"] = _mod - except Exception: - await ctx.send("discord-ext-voice-recv is not installed. Install it to use voice receive features (then reload the cog or restart the bot).") - return - - if not hasattr(vc, "listen"): - await ctx.send("This voice client does not support listening β€” make sure discord-ext-voice-recv is installed and used as the voice client (VoiceRecvClient).") - return - - if self._sink is not None: - await ctx.send("Already listening. Use `vstop` to stop the current recording.") - return - - upload_flag = False - if upload: - upload_flag = str(upload).lower() in ("upload", "send", "true", "yes", "1") - - voice_channel_name = None - if ctx.author and getattr(ctx.author, "voice", None): - try: - voice_channel_name = ctx.author.voice.channel.name - except Exception: - voice_channel_name = None - - sink = RecordingSink( - outdir=outdir, - bot=self.bot, - text_channel_id=ctx.channel.id, - uploader_id=getattr(ctx.author, "id", None), - voice_channel_name=voice_channel_name, - upload_on_cleanup=upload_flag, - ) - - def _after(exc): - if exc: - self.logger.exception("Recording finished with error: %s", exc) - else: - self.logger.info("Recording finished cleanly. Writing files.") - try: - sink.cleanup() - except Exception: - self.logger.exception("Error while cleaning up sink") - - try: - to_listen = getattr(sink, "_impl", None) or sink - vc.listen(to_listen, after=_after) - except Exception as exc: - self.logger.exception("Failed to start listening: %s", exc) - await ctx.send(f"Failed to start listening: {exc}") - return - - self._sink = sink - msg = f"Started listening and will save recordings to {outdir}" - if upload_flag: - msg += ". Recordings will be uploaded to this channel when finished." - await ctx.send(msg) - - @commands.command() - async def vstop(self, ctx: commands.Context) -> None: - """Stop listening and finalize recordings.""" - vc: discord.VoiceClient | None = ctx.voice_client - if vc is None: - await ctx.send("Not connected to voice.") - return - - if self._sink is None: - await ctx.send("Not currently listening.") - return - - try: - if hasattr(vc, "stop_listening"): - vc.stop_listening() - else: - # Fallback: attempt to stop by stopping the socket reading - try: - vc.stop() - except Exception: - pass - except Exception as exc: - self.logger.exception("Failed to stop listening: %s", exc) - await ctx.send(f"Failed to stop listening: {exc}") - return - - # Ensure cleanup and write files - try: - self._sink.cleanup() - except Exception: - self.logger.exception("Error while cleaning up sink") - - self._sink = None - await ctx.send("Stopped listening and finalized recordings.") - - @commands.command() - async def vspeaking(self, ctx: commands.Context, member: discord.Member = None) -> None: - """Show speaking (voice activity indicator) state for a member. If no member is given, shows the author's state.""" - member = member or ctx.author - vc: discord.VoiceClient | None = ctx.voice_client - if vc is None: - await ctx.send("Not connected to voice.") - return - - if not hasattr(vc, "get_speaking"): - await ctx.send("This voice client does not expose speaking state (not a VoiceRecvClient).") - return - - try: - state = vc.get_speaking(member) - except Exception as exc: - self.logger.exception("Error checking speaking state: %s", exc) - await ctx.send(f"Error checking speaking state: {exc}") - return - - await ctx.send(f"Speaking state for {member.display_name}: {state}") - - @commands.command() - async def vcheck(self, ctx: commands.Context) -> None: - """Check whether `voice_recv` is importable and show voice client capabilities.""" - try: - import importlib - try: - mod = importlib.import_module("voice_recv") - except Exception: - mod = importlib.import_module("discord.ext.voice_recv") - ver = getattr(mod, "__version__", "unknown") - await ctx.send(f"voice_recv is importable, version {ver}") - except Exception as exc: - await ctx.send(f"voice_recv is not importable: {exc}\nEnsure the package is installed in the same Python interpreter Red is using, then try `[p]vreload` or restart the bot.`") - return - - vc: discord.VoiceClient | None = ctx.voice_client - if vc is None: - await ctx.send("Bot is not connected to voice.") - return - - listen = hasattr(vc, "listen") - get_speaking = hasattr(vc, "get_speaking") - await ctx.send(f"Voice client capabilities: listen={listen}, get_speaking={get_speaking}") - - @commands.command() - async def vreload(self, ctx: commands.Context) -> None: - """Attempt to import or reload the `voice_recv` package at runtime.""" - try: - import importlib, sys - if "voice_recv" in sys.modules: - importlib.reload(sys.modules["voice_recv"]) - else: - try: - importlib.import_module("voice_recv") - except Exception: - importlib.import_module("discord.ext.voice_recv") - # The module may be registered under discord.ext.voice_recv or voice_recv - globals()["voice_recv"] = sys.modules.get("voice_recv") or sys.modules.get("discord.ext.voice_recv") - await ctx.send("Successfully imported/reloaded voice_recv. If voice clients were already connected, you may need to reconnect them.") - except Exception as exc: - await ctx.send(f"Failed to import/reload voice_recv: {exc}\nInstall the package into the environment Red uses, then restart the bot if necessary.") - self.logger.exception("vreload failed: %s", exc) - - @commands.command() - @commands.is_owner() - async def vdiag(self, ctx: commands.Context) -> None: - """Diagnostic information about the bot's Python environment and `voice_recv` availability. - - Owner-only: prints the Python executable, version, site-packages locations, - result of importlib.util.find_spec('voice_recv'), and `pip show` for the - installed package (if available). - """ - try: - import importlib.util, site - info = [] - info.append(("executable", sys.executable)) - info.append(("python_version", sys.version.replace('\n', ' '))) - - try: - specs = importlib.util.find_spec("voice_recv") - info.append(("voice_recv_spec", str(specs))) - except Exception as e: - info.append(("voice_recv_spec", f"error: {e}")) - - info.append(("voice_recv_in_sys_modules", str("voice_recv" in sys.modules))) - - try: - site_pkgs = site.getsitepackages() - except Exception: - site_pkgs = [] - info.append(("site_packages", str(site_pkgs))) - try: - user_site = site.getusersitepackages() - except Exception: - user_site = "" - info.append(("user_site_packages", str(user_site))) - - # Run pip show for more info - pip_out = None - try: - proc = await asyncio.create_subprocess_exec( - sys.executable, "-m", "pip", "show", "discord-ext-voice-recv", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, err = await proc.communicate() - if out: - pip_out = out.decode(errors="replace") - elif err: - pip_out = f"pip show stderr: {err.decode(errors='replace')}" - else: - pip_out = "pip show returned no output" - except Exception as e: - pip_out = f"pip show failed: {e}" - - # Build reply (truncate long sections) - pip_snippet = (pip_out[:1500] + "...") if pip_out and len(pip_out) > 1500 else (pip_out or "") - - lines = [f"{k}: {v}" for k, v in info] - msg = "\n".join(lines) - msg += "\n\npip show:\n" + pip_snippet - # Send in code block if not too long - if len(msg) < 1900: - await ctx.send(f"```\n{msg}\n```") - else: - await ctx.send("Diagnostic output is large; sending top-level info and pip snippet.") - await ctx.send(f"```\n{msg[:1900]}\n```") - except Exception as exc: - await ctx.send(f"vdiag failed: {exc}") - self.logger.exception("vdiag failed: %s", exc) - @commands.command() - @commands.is_owner() - async def vinstall(self, ctx: commands.Context, package: str = "discord-ext-voice-recv") -> None: - """Install a runtime dependency into the bot's Python environment and try importing it. - - This command runs `python -m pip install ` using the Python - interpreter the bot is running under. For safety it's restricted to the - bot owner. - """ - await ctx.send(f"Installing `{package}` into `{sys.executable}`...") - try: - proc = await asyncio.create_subprocess_exec( - sys.executable, "-m", "pip", "install", package, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, err = await proc.communicate() - code = proc.returncode - except Exception as exc: - await ctx.send(f"Failed to start installer: {exc}") - return - - if code != 0: - stderr = err.decode(errors="replace") if err else "" - snippet = stderr[:1900] - # Build content on one physical line so the source string is not split across lines - content = f"Install failed (exit {code}). Stderr:\n```{snippet}```" - await ctx.send(content) - return - - await ctx.send(f"Install finished. Attempting to import `{package}`...") - try: - import importlib, sys as _sys - if "voice_recv" in _sys.modules: - importlib.reload(_sys.modules["voice_recv"]) - mod = _sys.modules["voice_recv"] - else: - try: - mod = importlib.import_module("voice_recv") - except Exception: - mod = importlib.import_module("discord.ext.voice_recv") - globals()["voice_recv"] = mod - ver = getattr(mod, "__version__", "unknown") - await ctx.send(f"Successfully installed and imported `voice_recv` (version {ver}).") - except Exception as exc: - await ctx.send(f"Install succeeded but import failed: {exc}\nYou may need to restart the bot.") - self.logger.exception("Post-install import failed: %s", exc) - - - From 9929d2e54ed1c74167aa14ba37eeb25e941acef9 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:49:31 +0000 Subject: [PATCH 35/74] fix broken author key --- counter/info.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/counter/info.json b/counter/info.json index 87eee5a..f238dbd 100644 --- a/counter/info.json +++ b/counter/info.json @@ -1,6 +1,6 @@ { "name": "Counter", - "author": "bencos17", + "author": ["bencos17"], "short": "Flexible per-guild/user/global counters", "description": "Counters that users can create and modify, with server and unique scopes." } From 016227fece2f268670377c9d8180b04c4c0cbc50 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:24:40 +0000 Subject: [PATCH 36/74] shard info cog --- clusters/__init__.py | 4 +++ clusters/clusters.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ clusters/info.json | 7 ++++++ 3 files changed, 69 insertions(+) create mode 100644 clusters/__init__.py create mode 100644 clusters/clusters.py create mode 100644 clusters/info.json diff --git a/clusters/__init__.py b/clusters/__init__.py new file mode 100644 index 0000000..cf19814 --- /dev/null +++ b/clusters/__init__.py @@ -0,0 +1,4 @@ +from .clusters import Clusters + +async def setup(bot): + await bot.add_cog(Clusters(bot)) diff --git a/clusters/clusters.py b/clusters/clusters.py new file mode 100644 index 0000000..b3d84a6 --- /dev/null +++ b/clusters/clusters.py @@ -0,0 +1,58 @@ +import discord +from redbot.core import commands +import psutil, time, random + +MARVEL_NAMES = [ + "IronMan", "Thor", "Hulk", "BlackWidow", "CaptainAmerica", "Loki", + "DoctorStrange", "SpiderMan", "BlackPanther", "ScarletWitch" +] + +class Clusters(commands.Cog): + """Shows dynamic Marvel-themed cluster status.""" + + def __init__(self, bot): + self.bot = bot + self.start_time = time.time() + self.shard_names = {} + self._assign_shard_names() + + def _assign_shard_names(self): + shard_ids = list(self.bot.shards.keys()) + shard_count = len(shard_ids) + names = random.sample(MARVEL_NAMES, shard_count) + self.shard_names = dict(zip(shard_ids, names)) + + def uptime(self): + seconds = int(time.time() - self.start_time) + weeks, seconds = divmod(seconds, 604800) + days, seconds = divmod(seconds, 86400) + hours, _ = divmod(seconds, 3600) + return f"{weeks} weeks and {days} days and {hours} hours ago" + + @commands.command() + async def clusters(self, ctx): + """Shows the status of all clusters.""" + lines = [] + + for shard_id, name in self.shard_names.items(): + cpu = psutil.cpu_percent(interval=None) + ram = psutil.virtual_memory().used / 1024**3 + latency = round(self.bot.shards[shard_id].latency * 1000) + + # Get all guilds on this shard + guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] + servers = len(guilds) + users = sum(g.member_count or 0 for g in guilds) + + lines.append( + f"Cluster#{name} Alive Running\n" + f"CPU : {cpu:.1f}%\n" + f"RAM : {ram:.1f} GiB\n" + f"Latency: {latency}ms\n" + f"Servers: {servers}\n" + f"Users : {users}\n" + f"Shards : [{shard_id}]\n" + f"Started: {self.uptime()}\n" + ) + + await ctx.send("```\n" + "\n".join(lines) + "```") diff --git a/clusters/info.json b/clusters/info.json new file mode 100644 index 0000000..822d56e --- /dev/null +++ b/clusters/info.json @@ -0,0 +1,7 @@ +{ + "name": "Clusters", + "author": "bencos17", + "description": "Shows dynamic Marvel-themed cluster statuses for your bot.", + "requirements": [], + "tags": ["clusters", "shards", "status"] +} From 0abab7861747e2a471a12045595559d2303f80a7 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:27:39 +0000 Subject: [PATCH 37/74] Update clusters.py --- clusters/clusters.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index b3d84a6..d28d01f 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -31,8 +31,12 @@ def uptime(self): @commands.command() async def clusters(self, ctx): - """Shows the status of all clusters.""" - lines = [] + """Shows the status of all clusters using an embed.""" + embed = discord.Embed( + title="Cluster Status", + description=f"Bot started: {self.uptime()}", + color=discord.Color.blue() + ) for shard_id, name in self.shard_names.items(): cpu = psutil.cpu_percent(interval=None) @@ -44,15 +48,16 @@ async def clusters(self, ctx): servers = len(guilds) users = sum(g.member_count or 0 for g in guilds) - lines.append( - f"Cluster#{name} Alive Running\n" - f"CPU : {cpu:.1f}%\n" - f"RAM : {ram:.1f} GiB\n" - f"Latency: {latency}ms\n" - f"Servers: {servers}\n" - f"Users : {users}\n" - f"Shards : [{shard_id}]\n" - f"Started: {self.uptime()}\n" + value = ( + f"**Status:** Alive Running\n" + f"**CPU:** {cpu:.1f}%\n" + f"**RAM:** {ram:.1f} GiB\n" + f"**Latency:** {latency} ms\n" + f"**Servers:** {servers}\n" + f"**Users:** {users}\n" + f"**Shards:** [{shard_id}]" ) - await ctx.send("```\n" + "\n".join(lines) + "```") + embed.add_field(name=f"Cluster #{name}", value=value, inline=False) + + await ctx.send(embed=embed) From c45aba549c0413b5335bb2ba934c4cbec129545e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:29:31 +0000 Subject: [PATCH 38/74] Update clusters.py --- clusters/clusters.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index d28d01f..4882ffc 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -1,7 +1,8 @@ import discord from redbot.core import commands -import psutil, time, random +import psutil, time +# Fixed Marvel-themed names, mapped by shard index MARVEL_NAMES = [ "IronMan", "Thor", "Hulk", "BlackWidow", "CaptainAmerica", "Loki", "DoctorStrange", "SpiderMan", "BlackPanther", "ScarletWitch" @@ -17,10 +18,11 @@ def __init__(self, bot): self._assign_shard_names() def _assign_shard_names(self): - shard_ids = list(self.bot.shards.keys()) - shard_count = len(shard_ids) - names = random.sample(MARVEL_NAMES, shard_count) - self.shard_names = dict(zip(shard_ids, names)) + """Assign a persistent cluster name to each shard based on its ID.""" + for shard_id in self.bot.shards.keys(): + # Cycle through MARVEL_NAMES if more shards than names + name = MARVEL_NAMES[shard_id % len(MARVEL_NAMES)] + self.shard_names[shard_id] = name def uptime(self): seconds = int(time.time() - self.start_time) @@ -43,7 +45,6 @@ async def clusters(self, ctx): ram = psutil.virtual_memory().used / 1024**3 latency = round(self.bot.shards[shard_id].latency * 1000) - # Get all guilds on this shard guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] servers = len(guilds) users = sum(g.member_count or 0 for g in guilds) From 1456e3204aab455e28ab16eca0f888cd55fd24eb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:30:48 +0000 Subject: [PATCH 39/74] Update clusters.py --- clusters/clusters.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index 4882ffc..5b370c6 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -1,28 +1,31 @@ import discord -from redbot.core import commands +from redbot.core import commands, Config import psutil, time -# Fixed Marvel-themed names, mapped by shard index MARVEL_NAMES = [ "IronMan", "Thor", "Hulk", "BlackWidow", "CaptainAmerica", "Loki", "DoctorStrange", "SpiderMan", "BlackPanther", "ScarletWitch" ] class Clusters(commands.Cog): - """Shows dynamic Marvel-themed cluster status.""" + """Shows dynamic Marvel-themed cluster status with customizable names.""" def __init__(self, bot): self.bot = bot self.start_time = time.time() + self.config = Config.get_conf(self, identifier=1234567890) + self.config.register_global(custom_names={}) self.shard_names = {} - self._assign_shard_names() - def _assign_shard_names(self): - """Assign a persistent cluster name to each shard based on its ID.""" + async def initialize_shard_names(self): + """Load names from config or assign defaults based on shard ID.""" + custom_names = await self.config.custom_names() for shard_id in self.bot.shards.keys(): - # Cycle through MARVEL_NAMES if more shards than names - name = MARVEL_NAMES[shard_id % len(MARVEL_NAMES)] - self.shard_names[shard_id] = name + if str(shard_id) in custom_names: + self.shard_names[shard_id] = custom_names[str(shard_id)] + else: + # Default persistent name + self.shard_names[shard_id] = MARVEL_NAMES[shard_id % len(MARVEL_NAMES)] def uptime(self): seconds = int(time.time() - self.start_time) @@ -34,6 +37,8 @@ def uptime(self): @commands.command() async def clusters(self, ctx): """Shows the status of all clusters using an embed.""" + await self.initialize_shard_names() + embed = discord.Embed( title="Cluster Status", description=f"Bot started: {self.uptime()}", @@ -62,3 +67,21 @@ async def clusters(self, ctx): embed.add_field(name=f"Cluster #{name}", value=value, inline=False) await ctx.send(embed=embed) + + @commands.is_owner() + @commands.command() + async def renamecluster(self, ctx, shard_id: int, *, new_name: str): + """Rename a cluster persistently. Owner only.""" + if shard_id not in self.bot.shards: + await ctx.send(f"Shard ID {shard_id} does not exist.") + return + + # Update config + custom_names = await self.config.custom_names() + custom_names[str(shard_id)] = new_name + await self.config.custom_names.set(custom_names) + + # Update in-memory name + self.shard_names[shard_id] = new_name + + await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") From e5b3e9f874a7d48c538b2ad261b839008421de73 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:33:46 +0000 Subject: [PATCH 40/74] Update clusters.py --- clusters/clusters.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index 5b370c6..75791a7 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -1,6 +1,6 @@ import discord from redbot.core import commands, Config -import psutil, time +import psutil, datetime MARVEL_NAMES = [ "IronMan", "Thor", "Hulk", "BlackWidow", "CaptainAmerica", "Loki", @@ -8,11 +8,10 @@ ] class Clusters(commands.Cog): - """Shows dynamic Marvel-themed cluster status with customizable names.""" + """Shows dynamic Marvel-themed cluster status with customizable names and uptime.""" def __init__(self, bot): self.bot = bot - self.start_time = time.time() self.config = Config.get_conf(self, identifier=1234567890) self.config.register_global(custom_names={}) self.shard_names = {} @@ -24,24 +23,39 @@ async def initialize_shard_names(self): if str(shard_id) in custom_names: self.shard_names[shard_id] = custom_names[str(shard_id)] else: - # Default persistent name self.shard_names[shard_id] = MARVEL_NAMES[shard_id % len(MARVEL_NAMES)] - def uptime(self): - seconds = int(time.time() - self.start_time) - weeks, seconds = divmod(seconds, 604800) - days, seconds = divmod(seconds, 86400) - hours, _ = divmod(seconds, 3600) + def format_timedelta(self, td: datetime.timedelta): + """Format a timedelta into weeks, days, hours.""" + total_seconds = int(td.total_seconds()) + weeks, remainder = divmod(total_seconds, 604800) + days, remainder = divmod(remainder, 86400) + hours, _ = divmod(remainder, 3600) return f"{weeks} weeks and {days} days and {hours} hours ago" + def get_server_uptime(self): + """Return server uptime as timedelta.""" + boot_timestamp = psutil.boot_time() + return datetime.datetime.utcnow() - datetime.datetime.utcfromtimestamp(boot_timestamp) + @commands.command() async def clusters(self, ctx): """Shows the status of all clusters using an embed.""" await self.initialize_shard_names() + # Bot uptime (Red tracks this as bot.uptime) + bot_uptime = getattr(self.bot, "uptime", None) + if bot_uptime is None: + bot_uptime_str = "Unknown" + else: + bot_uptime_str = self.format_timedelta(bot_uptime) + + # Server uptime + server_uptime = self.format_timedelta(self.get_server_uptime()) + embed = discord.Embed( title="Cluster Status", - description=f"Bot started: {self.uptime()}", + description=f"**Bot uptime:** {bot_uptime_str}\n**Server uptime:** {server_uptime}", color=discord.Color.blue() ) @@ -76,12 +90,9 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): await ctx.send(f"Shard ID {shard_id} does not exist.") return - # Update config custom_names = await self.config.custom_names() custom_names[str(shard_id)] = new_name await self.config.custom_names.set(custom_names) - - # Update in-memory name self.shard_names[shard_id] = new_name await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") From f44ac6748a4a3d16f545d94f82da027ed7c6cbcb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:35:57 +0000 Subject: [PATCH 41/74] fix uptime I broke --- clusters/clusters.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index 75791a7..c710b45 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -44,11 +44,16 @@ async def clusters(self, ctx): await self.initialize_shard_names() # Bot uptime (Red tracks this as bot.uptime) - bot_uptime = getattr(self.bot, "uptime", None) - if bot_uptime is None: + bot_start_time = getattr(self.bot, "uptime", None) + if bot_start_time is None: bot_uptime_str = "Unknown" else: - bot_uptime_str = self.format_timedelta(bot_uptime) + # If it's a datetime, compute timedelta from now + if isinstance(bot_start_time, datetime.datetime): + td = datetime.datetime.utcnow() - bot_start_time + else: + td = bot_start_time # already a timedelta + bot_uptime_str = self.format_timedelta(td) # Server uptime server_uptime = self.format_timedelta(self.get_server_uptime()) From fcd465a3784f08c9e8a7092131667df123771f97 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:38:07 +0000 Subject: [PATCH 42/74] Update clusters.py --- clusters/clusters.py | 64 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index c710b45..42a4c4f 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -1,6 +1,7 @@ import discord from redbot.core import commands, Config -import psutil, datetime +import psutil, datetime, json, aiohttp +from aiohttp import web MARVEL_NAMES = [ "IronMan", "Thor", "Hulk", "BlackWidow", "CaptainAmerica", "Loki", @@ -8,7 +9,7 @@ ] class Clusters(commands.Cog): - """Shows dynamic Marvel-themed cluster status with customizable names and uptime.""" + """Shows dynamic Marvel-themed cluster status with customizable names and uptime, plus a web endpoint.""" def __init__(self, bot): self.bot = bot @@ -16,6 +17,17 @@ def __init__(self, bot): self.config.register_global(custom_names={}) self.shard_names = {} + # Start aiohttp web server + self.app = web.Application() + self.app.add_routes([web.get('/clusters', self.web_clusters)]) + self.runner = web.AppRunner(self.app) + self.bot.loop.create_task(self.start_webserver()) + + async def start_webserver(self): + await self.runner.setup() + self.site = web.TCPSite(self.runner, '0.0.0.0', 8080) # Change IP/port if needed + await self.site.start() + async def initialize_shard_names(self): """Load names from config or assign defaults based on shard ID.""" custom_names = await self.config.custom_names() @@ -48,14 +60,12 @@ async def clusters(self, ctx): if bot_start_time is None: bot_uptime_str = "Unknown" else: - # If it's a datetime, compute timedelta from now if isinstance(bot_start_time, datetime.datetime): td = datetime.datetime.utcnow() - bot_start_time else: - td = bot_start_time # already a timedelta + td = bot_start_time bot_uptime_str = self.format_timedelta(td) - # Server uptime server_uptime = self.format_timedelta(self.get_server_uptime()) embed = discord.Embed( @@ -101,3 +111,47 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): self.shard_names[shard_id] = new_name await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") + + async def web_clusters(self, request): + """Return cluster data as JSON for web endpoint.""" + await self.initialize_shard_names() + + # Bot uptime + bot_start_time = getattr(self.bot, "uptime", None) + if bot_start_time is None: + bot_uptime_str = "Unknown" + else: + if isinstance(bot_start_time, datetime.datetime): + td = datetime.datetime.utcnow() - bot_start_time + else: + td = bot_start_time + bot_uptime_str = self.format_timedelta(td) + + server_uptime_str = self.format_timedelta(self.get_server_uptime()) + + data = { + "bot_uptime": bot_uptime_str, + "server_uptime": server_uptime_str, + "clusters": [] + } + + for shard_id, name in self.shard_names.items(): + guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] + servers = len(guilds) + users = sum(g.member_count or 0 for g in guilds) + latency = round(self.bot.shards[shard_id].latency * 1000) + cpu = psutil.cpu_percent(interval=None) + ram = psutil.virtual_memory().used / 1024**3 + + cluster_data = { + "shard_id": shard_id, + "name": name, + "servers": servers, + "users": users, + "latency_ms": latency, + "cpu_percent": cpu, + "ram_gib": ram + } + data["clusters"].append(cluster_data) + + return web.Response(text=json.dumps(data, indent=2), content_type="application/json") From 15528c97bba0d0471fadb46c764ac323e2696a1c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:29:05 +0000 Subject: [PATCH 43/74] Create docs.md --- clusters/docs.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 clusters/docs.md diff --git a/clusters/docs.md b/clusters/docs.md new file mode 100644 index 0000000..bbdda80 --- /dev/null +++ b/clusters/docs.md @@ -0,0 +1,46 @@ +\# How to Use SkySearch + + + +\## Getting Started + + + +\### First Time Setup + +1\. \*\*Load the cog\*\* in your Red-DiscordBot instance + +\### Basic Commands + +\- `\*clusters` - main cluster info command + +\- `\*renamecluster ` - allows the bot owner to override the default cluster names + + + +\## Usage + +\[p]clusters + + + +prints out the bots current clusters + + + + + +\[p]renamecluster + + + + + + is a number between 0 and your mac amount of shards + + what you want the cluter to be called from now on + + + + + From aea2d2821dd4c7b4a2f65158a6308c2697b1ffe8 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:29:37 +0000 Subject: [PATCH 44/74] Update docs.md --- clusters/docs.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/clusters/docs.md b/clusters/docs.md index bbdda80..e07a085 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -10,11 +10,11 @@ 1\. \*\*Load the cog\*\* in your Red-DiscordBot instance -\### Basic Commands +### Basic Commands -\- `\*clusters` - main cluster info command +- `\*clusters` - main cluster info command -\- `\*renamecluster ` - allows the bot owner to override the default cluster names +- `\*renamecluster ` - allows the bot owner to override the default cluster names From a65950953544b82e93f58648f30268173c51dfcb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:30:05 +0000 Subject: [PATCH 45/74] Update docs.md --- clusters/docs.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/clusters/docs.md b/clusters/docs.md index e07a085..9ba72e0 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -12,9 +12,12 @@ ### Basic Commands -- `\*clusters` - main cluster info command +- `*clusters` - main cluster info command +for example this is my current output on my bot +image -- `\*renamecluster ` - allows the bot owner to override the default cluster names + +- `*renamecluster ` - allows the bot owner to override the default cluster names From 9417ce3b0d8b9d6bf68908505f8597f89f29fc53 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:30:31 +0000 Subject: [PATCH 46/74] Update docs.md --- clusters/docs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clusters/docs.md b/clusters/docs.md index 9ba72e0..1ceb282 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -12,12 +12,12 @@ ### Basic Commands -- `*clusters` - main cluster info command +- `[p]clusters` - main cluster info command for example this is my current output on my bot image -- `*renamecluster ` - allows the bot owner to override the default cluster names +- `[p]renamecluster ` - allows the bot owner to override the default cluster names From 1ed577e099ecabae904a98668d954f28db4f600f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:30:54 +0000 Subject: [PATCH 47/74] Update docs.md --- clusters/docs.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/clusters/docs.md b/clusters/docs.md index 1ceb282..fea2d32 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -13,10 +13,6 @@ ### Basic Commands - `[p]clusters` - main cluster info command -for example this is my current output on my bot -image - - - `[p]renamecluster ` - allows the bot owner to override the default cluster names @@ -24,7 +20,8 @@ for example this is my current output on my bot \## Usage \[p]clusters - +for example this is my current output on my bot +image prints out the bots current clusters From db80e70a7301be29adfa12e69adf265d54821430 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:31:02 +0000 Subject: [PATCH 48/74] Update docs.md --- clusters/docs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/clusters/docs.md b/clusters/docs.md index fea2d32..d953761 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -21,6 +21,7 @@ \[p]clusters for example this is my current output on my bot + image From 34d54ca5bdaf29ca15b1c42519d5859c86b82eb4 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:31:13 +0000 Subject: [PATCH 49/74] Update docs.md --- clusters/docs.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clusters/docs.md b/clusters/docs.md index d953761..64d2f7c 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -20,12 +20,13 @@ \## Usage \[p]clusters +prints out the bots current clusters + for example this is my current output on my bot image -prints out the bots current clusters From 12ea0e61bc69d1541cd992e8a19d65b34fa32db1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:32:01 +0000 Subject: [PATCH 50/74] Update docs.md --- clusters/docs.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/clusters/docs.md b/clusters/docs.md index 64d2f7c..1dd8342 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -42,6 +42,11 @@ for example this is my current output on my bot what you want the cluter to be called from now on +this is how it's used + +image + + From 36f97569ab25ad3ddb888d93735c23dbdd2c027f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:32:59 +0000 Subject: [PATCH 51/74] Update docs.md --- clusters/docs.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/clusters/docs.md b/clusters/docs.md index 1dd8342..85c44b5 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -1,12 +1,11 @@ -\# How to Use SkySearch +# How to Use Clusters +## Getting Started -\## Getting Started - -\### First Time Setup +### First Time Setup 1\. \*\*Load the cog\*\* in your Red-DiscordBot instance From 6140af5b28de9a81ad0428c1e1fd29521609e68b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:33:45 +0000 Subject: [PATCH 52/74] Update docs.md --- clusters/docs.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clusters/docs.md b/clusters/docs.md index 85c44b5..66087a0 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -9,6 +9,9 @@ 1\. \*\*Load the cog\*\* in your Red-DiscordBot instance +[p]repo add ben-cogs https://github.com/bencos17/ben-cogs +[p]cog install ben-cogs clusters + ### Basic Commands - `[p]clusters` - main cluster info command From 19d2d9cbfe6989e008bcbb0075a882bc88d8c38a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:34:01 +0000 Subject: [PATCH 53/74] Update docs.md --- clusters/docs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clusters/docs.md b/clusters/docs.md index 66087a0..8717382 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -19,7 +19,7 @@ -\## Usage +## Usage \[p]clusters prints out the bots current clusters @@ -34,7 +34,7 @@ for example this is my current output on my bot -\[p]renamecluster +[p]renamecluster From cfe5418cfb23e679bf3b06a59af55b305f465754 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:34:24 +0000 Subject: [PATCH 54/74] Update docs.md --- clusters/docs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clusters/docs.md b/clusters/docs.md index 8717382..dc1f6a3 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -7,7 +7,7 @@ ### First Time Setup -1\. \*\*Load the cog\*\* in your Red-DiscordBot instance +1\. **Load the cog** in your Red-DiscordBot instance [p]repo add ben-cogs https://github.com/bencos17/ben-cogs [p]cog install ben-cogs clusters From caa8f95dbd6c27e1f55b1cc45775a3877096ac58 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:34:42 +0000 Subject: [PATCH 55/74] Update docs.md --- clusters/docs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/clusters/docs.md b/clusters/docs.md index dc1f6a3..e118a0a 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -10,6 +10,7 @@ 1\. **Load the cog** in your Red-DiscordBot instance [p]repo add ben-cogs https://github.com/bencos17/ben-cogs + [p]cog install ben-cogs clusters ### Basic Commands From fc99e059abae91991d8a01635b9a5114f6c44e7e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:22:14 +0000 Subject: [PATCH 56/74] . --- .github/martineimages/__init__.py | 7 +++++++ .github/martineimages/docs.md | 22 ++++++++++++++++++++++ .github/martineimages/info.json | 13 +++++++++++++ .github/martineimages/martineimages.py | 0 4 files changed, 42 insertions(+) create mode 100644 .github/martineimages/__init__.py create mode 100644 .github/martineimages/docs.md create mode 100644 .github/martineimages/info.json create mode 100644 .github/martineimages/martineimages.py diff --git a/.github/martineimages/__init__.py b/.github/martineimages/__init__.py new file mode 100644 index 0000000..48a5c9d --- /dev/null +++ b/.github/martineimages/__init__.py @@ -0,0 +1,7 @@ +from .martineimages import MartineImages + +__red_end_user_data_statement__ = "This cog does not store any end user data." + +async def setup(bot): + await bot.add_cog(MartineImages(bot)) + diff --git a/.github/martineimages/docs.md b/.github/martineimages/docs.md new file mode 100644 index 0000000..be059e6 --- /dev/null +++ b/.github/martineimages/docs.md @@ -0,0 +1,22 @@ +A cog for fetching images from the Martine API, including memes, wallpapers, subreddit images, ship images, and osu! profile cards. + +# [p]martinememe +Get a random meme from Martine API.
+ - Usage: `[p]martinememe` + +# [p]martinewallpaper +Get a random wallpaper from Martine API.
+ - Usage: `[p]martinewallpaper` + +# [p]martinesubreddit +Get a random image from a specified subreddit using Martine API.
+ - Usage: `[p]martinesubreddit ` + +# [p]martineship +Generate a ship image with two Discord users using Martine API.
+ - Usage: `[p]martineship ` + +# [p]martineosuprofile +Generate an osu! profile card using Martine API.
+ - Usage: `[p]martineosuprofile ` + diff --git a/.github/martineimages/info.json b/.github/martineimages/info.json new file mode 100644 index 0000000..0e42946 --- /dev/null +++ b/.github/martineimages/info.json @@ -0,0 +1,13 @@ +{ + "name": "MartineImages", + "author": ["bencos17"], + "description": "A cog for fetching images from the Martine API, including memes, wallpapers, subreddit images, ship images, and osu! profile cards.", + "install_msg": "Thank you for installing the MartineImages cog! Use [p]help MartineImages to see all available commands.", + "requirements": [], + "tags": ["images", "memes", "wallpapers", "api", "fun", "osu"], + "min_bot_version": "3.5.0", + "hidden": false, + "disabled": false, + "type": "COG" +} + diff --git a/.github/martineimages/martineimages.py b/.github/martineimages/martineimages.py new file mode 100644 index 0000000..e69de29 From 09540b90cbf8d841aa3fb0a0899a1ffc8b0ce33b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:24:58 +0000 Subject: [PATCH 57/74] =?UTF-8?q?fix=20it=20accidently=20ending=20up=20in?= =?UTF-8?q?=20.github=20=F0=9F=A4=A6=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {.github/martineimages => martineimages}/__init__.py | 0 {.github/martineimages => martineimages}/docs.md | 0 {.github/martineimages => martineimages}/info.json | 0 {.github/martineimages => martineimages}/martineimages.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {.github/martineimages => martineimages}/__init__.py (100%) rename {.github/martineimages => martineimages}/docs.md (100%) rename {.github/martineimages => martineimages}/info.json (100%) rename {.github/martineimages => martineimages}/martineimages.py (100%) diff --git a/.github/martineimages/__init__.py b/martineimages/__init__.py similarity index 100% rename from .github/martineimages/__init__.py rename to martineimages/__init__.py diff --git a/.github/martineimages/docs.md b/martineimages/docs.md similarity index 100% rename from .github/martineimages/docs.md rename to martineimages/docs.md diff --git a/.github/martineimages/info.json b/martineimages/info.json similarity index 100% rename from .github/martineimages/info.json rename to martineimages/info.json diff --git a/.github/martineimages/martineimages.py b/martineimages/martineimages.py similarity index 100% rename from .github/martineimages/martineimages.py rename to martineimages/martineimages.py From 4565bfd89093d01e82112527f742447d35b98b5c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:26:54 +0000 Subject: [PATCH 58/74] how was it blank when I moved it....wth --- martineimages/martineimages.py | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/martineimages/martineimages.py b/martineimages/martineimages.py index e69de29..d427524 100644 --- a/martineimages/martineimages.py +++ b/martineimages/martineimages.py @@ -0,0 +1,74 @@ +from discord.ext import commands +import discord +from typing import Optional + +class MartineImages(commands.Cog): + """Cog for Martine Images API.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.base_url = "https://api.martinebot.com/v1" + + async def fetch_image(self, endpoint: str, params: Optional[dict] = None) -> Optional[str]: + headers = {"User-Agent": "Red-MartineImages/1.1.4"} + params = params or {} + async with self.bot.session.get( + f"{self.base_url}{endpoint}", headers=headers, params=params + ) as resp: + if resp.status == 200: + json = await resp.json() + # If endpoint returns plain string, not JSON + if isinstance(json, str): + return json + return json.get("url") + return None + + @commands.command(name="martinememe") + async def meme(self, ctx: commands.Context): + """Get a random meme from Martine API.""" + url = await self.fetch_image("/images/memes") + if url: + await ctx.send(url) + else: + await ctx.send("Couldn't fetch a meme at this time.") + + @commands.command(name="martinewallpaper") + async def wallpaper(self, ctx: commands.Context): + """Get a random wallpaper from Martine API.""" + url = await self.fetch_image("/images/wallpaper") + if url: + await ctx.send(url) + else: + await ctx.send("Couldn't fetch a wallpaper at this time.") + + @commands.command(name="martinesubreddit") + async def subreddit(self, ctx: commands.Context, subreddit: str): + """Get a random image from a specified subreddit using Martine API.""" + url = await self.fetch_image("/images/subreddit", {"subreddit": subreddit}) + if url: + await ctx.send(url) + else: + await ctx.send(f"Couldn't fetch an image from r/{subreddit}.") + + @commands.command(name="martineship") + async def ship(self, ctx: commands.Context, user1: discord.User, user2: discord.User): + """Generate a ship image with two Discord users using Martine API.""" + url = await self.fetch_image( + "/imagesgen/ship", {"user1": str(user1.id), "user2": str(user2.id)} + ) + if url: + await ctx.send(url) + else: + await ctx.send("Couldn't generate a ship image at this time.") + + @commands.command(name="martineosuprofile") + async def osuprofile(self, ctx: commands.Context, username: str): + """Generate an osu! profile card using Martine API.""" + url = await self.fetch_image("/imagesgen/osuprofile", {"username": username}) + if url: + await ctx.send(url) + else: + await ctx.send(f"Couldn't fetch osu! profile for **{username}**.") + + + From 5b5995ac3030abbb56fb7fd0ee96b7aa406a9202 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:27:11 +0000 Subject: [PATCH 59/74] Update martineimages.py --- martineimages/martineimages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/martineimages/martineimages.py b/martineimages/martineimages.py index d427524..148abea 100644 --- a/martineimages/martineimages.py +++ b/martineimages/martineimages.py @@ -1,11 +1,11 @@ -from discord.ext import commands +from redbot.core import commands import discord from typing import Optional class MartineImages(commands.Cog): """Cog for Martine Images API.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot): self.bot = bot self.base_url = "https://api.martinebot.com/v1" From 4d70fbcf3ee6fb27ca4081ca6a6e1a0503ded591 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:30:08 +0000 Subject: [PATCH 60/74] Update martineimages.py --- martineimages/martineimages.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/martineimages/martineimages.py b/martineimages/martineimages.py index 148abea..9d35f4f 100644 --- a/martineimages/martineimages.py +++ b/martineimages/martineimages.py @@ -1,5 +1,6 @@ from redbot.core import commands import discord +import aiohttp from typing import Optional class MartineImages(commands.Cog): @@ -8,11 +9,29 @@ class MartineImages(commands.Cog): def __init__(self, bot): self.bot = bot self.base_url = "https://api.martinebot.com/v1" + self.session = None + + async def cog_load(self): + """Initialize the cog and create aiohttp session""" + timeout = aiohttp.ClientTimeout(total=10) + self.session = aiohttp.ClientSession(timeout=timeout) + + async def cog_unload(self): + """Clean up the cog and close aiohttp session""" + if self.session: + await self.session.close() + + async def _ensure_session(self): + """Ensure aiohttp session exists""" + if self.session is None or self.session.closed: + timeout = aiohttp.ClientTimeout(total=10) + self.session = aiohttp.ClientSession(timeout=timeout) async def fetch_image(self, endpoint: str, params: Optional[dict] = None) -> Optional[str]: - headers = {"User-Agent": "Red-MartineImages/1.1.4"} + await self._ensure_session() + headers = {"User-Agent": "Red-MartineImages/ben-cogs/v1.1.4"} params = params or {} - async with self.bot.session.get( + async with self.session.get( f"{self.base_url}{endpoint}", headers=headers, params=params ) as resp: if resp.status == 200: From 6f0f1394cd0d2a52e6cb38ce27fc90134d8aaee1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:33:57 +0000 Subject: [PATCH 61/74] Update martineimages.py --- martineimages/martineimages.py | 89 ++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/martineimages/martineimages.py b/martineimages/martineimages.py index 9d35f4f..b424af2 100644 --- a/martineimages/martineimages.py +++ b/martineimages/martineimages.py @@ -1,8 +1,13 @@ from redbot.core import commands +from redbot.core.utils.chat_formatting import pagify import discord import aiohttp +import json +import logging from typing import Optional +log = logging.getLogger("red.cogs.martineimages") + class MartineImages(commands.Cog): """Cog for Martine Images API.""" @@ -31,15 +36,34 @@ async def fetch_image(self, endpoint: str, params: Optional[dict] = None) -> Opt await self._ensure_session() headers = {"User-Agent": "Red-MartineImages/ben-cogs/v1.1.4"} params = params or {} - async with self.session.get( - f"{self.base_url}{endpoint}", headers=headers, params=params - ) as resp: - if resp.status == 200: - json = await resp.json() - # If endpoint returns plain string, not JSON - if isinstance(json, str): - return json - return json.get("url") + try: + async with self.session.get( + f"{self.base_url}{endpoint}", headers=headers, params=params + ) as resp: + if resp.status == 200: + try: + json_data = await resp.json() + # If endpoint returns plain string, not JSON + if isinstance(json_data, str): + return json_data + # Try different possible response formats + if isinstance(json_data, dict): + return json_data.get("url") or json_data.get("image") or json_data.get("link") + return None + except aiohttp.ContentTypeError: + # If response is not JSON, try reading as text + text = await resp.text() + if text: + return text + return None + else: + log.warning(f"Martine API returned status {resp.status} for {endpoint}") + return None + except aiohttp.ClientError as e: + log.error(f"Error fetching from Martine API: {e}") + return None + except Exception as e: + log.error(f"Unexpected error in fetch_image: {e}", exc_info=True) return None @commands.command(name="martinememe") @@ -89,5 +113,52 @@ async def osuprofile(self, ctx: commands.Context, username: str): else: await ctx.send(f"Couldn't fetch osu! profile for **{username}**.") + @commands.command(name="martinedebug") + @commands.is_owner() + async def debug_api(self, ctx: commands.Context, endpoint: str = "/images/memes"): + """Debug command to inspect API responses. Owner only.""" + await self._ensure_session() + headers = {"User-Agent": "Red-MartineImages/ben-cogs/v1.1.4"} + full_url = f"{self.base_url}{endpoint}" + + try: + async with self.session.get(full_url, headers=headers) as resp: + status = resp.status + content_type = resp.headers.get("Content-Type", "unknown") + + # Try to get response as text first + try: + text_response = await resp.text() + except: + text_response = "Could not read as text" + + # Try to get as JSON + json_response = None + try: + await resp.rewind() # Reset response stream + json_response = await resp.json() + except: + pass + + # Build debug message + debug_msg = f"**API Debug Info**\n" + debug_msg += f"URL: `{full_url}`\n" + debug_msg += f"Status: `{status}`\n" + debug_msg += f"Content-Type: `{content_type}`\n\n" + + if json_response: + debug_msg += f"**JSON Response:**\n```json\n{json.dumps(json_response, indent=2)[:1500]}\n```\n" + + if text_response and not json_response: + debug_msg += f"**Text Response (first 500 chars):**\n```\n{text_response[:500]}\n```\n" + + # Split into multiple messages if too long + for page in pagify(debug_msg): + await ctx.send(page) + + except Exception as e: + await ctx.send(f"**Error:** {type(e).__name__}: {str(e)}") + log.error(f"Debug command error: {e}", exc_info=True) + From fdafd852e0e7ac44216bfab79f64026401ff0a7c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:35:35 +0000 Subject: [PATCH 62/74] Update martineimages.py --- martineimages/martineimages.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/martineimages/martineimages.py b/martineimages/martineimages.py index b424af2..001fda7 100644 --- a/martineimages/martineimages.py +++ b/martineimages/martineimages.py @@ -48,7 +48,11 @@ async def fetch_image(self, endpoint: str, params: Optional[dict] = None) -> Opt return json_data # Try different possible response formats if isinstance(json_data, dict): - return json_data.get("url") or json_data.get("image") or json_data.get("link") + # Martine API returns data in data.image_url + if "data" in json_data and isinstance(json_data["data"], dict): + return json_data["data"].get("image_url") + # Fallback to other possible formats + return json_data.get("url") or json_data.get("image") or json_data.get("image_url") or json_data.get("link") return None except aiohttp.ContentTypeError: # If response is not JSON, try reading as text From a0c9520161d6972f1e71df0218484c4393ef75e1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:41:17 +0000 Subject: [PATCH 63/74] hmm --- skysearch/commands/aircraft.py | 1 + 1 file changed, 1 insertion(+) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index df4bfda..8c7224d 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -827,6 +827,7 @@ async def extract_feeder_url(self, ctx, *, json_input: str = None): ) from ..utils.helpers import JSONInputButton view = JSONInputButton(self.cog) + await ctx.send(embed=embed, view=view) async def watchlist_add(self, ctx, icao: str): """Add an aircraft to the user's watchlist.""" From 4f5b935947cf83c7141ed527c2f8b47743ead27f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:43:57 +0000 Subject: [PATCH 64/74] fix aircraft feeder command private submit system not working --- skysearch/commands/aircraft.py | 1 + 1 file changed, 1 insertion(+) diff --git a/skysearch/commands/aircraft.py b/skysearch/commands/aircraft.py index df4bfda..8c7224d 100644 --- a/skysearch/commands/aircraft.py +++ b/skysearch/commands/aircraft.py @@ -827,6 +827,7 @@ async def extract_feeder_url(self, ctx, *, json_input: str = None): ) from ..utils.helpers import JSONInputButton view = JSONInputButton(self.cog) + await ctx.send(embed=embed, view=view) async def watchlist_add(self, ctx, icao: str): """Add an aircraft to the user's watchlist.""" From 4797eda3c2fd9d780aa20462195c89bc03b42f4f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:08:52 +0000 Subject: [PATCH 65/74] Update emojilink.py --- emojilink/emojilink.py | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/emojilink/emojilink.py b/emojilink/emojilink.py index 8988a53..33862c5 100644 --- a/emojilink/emojilink.py +++ b/emojilink/emojilink.py @@ -163,6 +163,37 @@ def get_all_emojis(self, emojis): all_emojis.append((emoji, None)) return all_emojis + async def resolve_emoji(self, ctx: commands.Context, emoji_input: typing.Union[discord.PartialEmoji, str, int]): + """Resolve an emoji from PartialEmoji, emoji ID (int/str), or emoji string.""" + if isinstance(emoji_input, discord.PartialEmoji): + return emoji_input + + # Try to parse as emoji ID + emoji_id = None + if isinstance(emoji_input, int): + emoji_id = emoji_input + elif isinstance(emoji_input, str): + # Try to parse as integer ID first + try: + emoji_id = int(emoji_input) + except ValueError: + # Not a numeric ID, try to parse as emoji string using discord.py converter + try: + return await commands.PartialEmojiConverter().convert(ctx, emoji_input) + except commands.BadArgument: + raise commands.BadArgument(f"Couldn't convert \"{emoji_input}\" to PartialEmoji or emoji ID.") + + if emoji_id is not None: + # Look up emoji by ID in the guild + if ctx.guild: + guild_emoji = discord.utils.get(ctx.guild.emojis, id=emoji_id) + if guild_emoji: + return guild_emoji + # If not found in guild, raise an error + raise commands.BadArgument(f"Emoji with ID {emoji_id} not found in this server.") + + raise commands.BadArgument(f"Couldn't convert \"{emoji_input}\" to PartialEmoji or emoji ID.") + # ----------------------------- # Add / Copy / Delete / Rename # ----------------------------- @@ -222,7 +253,7 @@ async def add_emoji(self, ctx: commands.Context, name: str, source: typing.Union @emojilink.command(name="copy") @commands.has_permissions(manage_emojis=True) - async def copy_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): + async def copy_emoji(self, ctx: commands.Context, emoji_input: typing.Union[discord.PartialEmoji, str, int]): """Copy a custom emoji with automatic background removal.""" if not ctx.author.guild_permissions.manage_emojis: return await ctx.send("You do not have permission to manage emojis.") @@ -230,6 +261,7 @@ async def copy_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): return await ctx.send("I do not have permissions to manage emojis in this server.") try: + emoji = await self.resolve_emoji(ctx, emoji_input) async with ctx.typing(): emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{ 'gif' if emoji.animated else 'png'}" async with aiohttp.ClientSession() as session: @@ -265,9 +297,10 @@ async def copy_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): @emojilink.command(name="delete") @commands.has_permissions(manage_emojis=True) - async def delete_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): + async def delete_emoji(self, ctx: commands.Context, emoji_input: typing.Union[discord.PartialEmoji, str, int]): """Delete a custom emoji from the server.""" try: + emoji = await self.resolve_emoji(ctx, emoji_input) guild_emoji = discord.utils.get(ctx.guild.emojis, id=emoji.id) if guild_emoji is None: return await ctx.send("This emoji doesn't exist in this server.") @@ -302,13 +335,14 @@ async def cancel_callback(interaction: discord.Interaction): @emojilink.command(name="rename", aliases=["edit"]) @commands.has_permissions(manage_emojis=True) - async def rename_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji, new_name: str): + async def rename_emoji(self, ctx: commands.Context, emoji_input: typing.Union[discord.PartialEmoji, str, int], new_name: str): """Rename a custom emoji in the server.""" if not new_name.isalnum() and "_" not in new_name: return await ctx.send("Emoji name must contain only letters, numbers, and underscores.") if len(new_name) < 2 or len(new_name) > 32: return await ctx.send("Emoji name must be between 2 and 32 characters long.") + emoji = await self.resolve_emoji(ctx, emoji_input) guild_emoji = discord.utils.get(ctx.guild.emojis, id=emoji.id) if guild_emoji is None: return await ctx.send("This emoji doesn't exist in this server.") From 451f465695412c666d5f26dc90d01e63f0564bd3 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:39:36 +0000 Subject: [PATCH 66/74] add a way to set the user agent --- skysearch/README.md | 4 +++ skysearch/commands/admin.py | 50 +++++++++++++++++++++++++++++++++++ skysearch/commands/airport.py | 18 ++++++++++--- skysearch/docs.md | 15 +++++++++++ skysearch/skysearch.py | 19 +++++++++++++ skysearch/utils/api.py | 8 ++++-- skysearch/utils/helpers.py | 35 +++++++++++++++++++----- 7 files changed, 137 insertions(+), 12 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index a0856ef..22c51cd 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 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 - Optional: Configure airportdb.io API for runway data @@ -85,6 +86,9 @@ Notes: - `[p]apikey` - Check API key status - `[p]clearapikey` - Clear API key - `[p]debugapi` - Debug API issues +- `[p]setuseragent ` - Set a custom User-Agent header for outbound HTTP requests +- `[p]useragent` - Show current User-Agent setting +- `[p]clearuseragent` - Clear User-Agent setting (use aiohttp default) ### 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 a0c3e47..a28831c 100644 --- a/skysearch/commands/admin.py +++ b/skysearch/commands/admin.py @@ -226,6 +226,56 @@ async def clear_api_key(self, ctx): embed.add_field(name="Note", value="Some features may be limited without an API key", inline=True) await ctx.send(embed=embed) + async def set_user_agent(self, ctx, user_agent: str): + """Set the User-Agent header used for outbound HTTP requests.""" + user_agent = (user_agent or "").strip() + if not user_agent: + embed = discord.Embed( + title="User-Agent Error", + description="User-Agent cannot be empty. Use `clearuseragent` to clear it.", + color=0xff4545, + ) + await ctx.send(embed=embed) + return + + await self.cog.config.user_agent.set(user_agent) + embed = discord.Embed( + title="User-Agent Updated", + description="SkySearch will include this User-Agent on outbound HTTP requests.", + color=0x2BBD8E, + ) + embed.add_field(name="User-Agent", value=f"`{user_agent}`", inline=False) + await ctx.send(embed=embed) + + async def check_user_agent(self, ctx): + """Show the currently configured User-Agent header.""" + user_agent = await self.cog.config.user_agent() + if user_agent: + embed = discord.Embed( + title="User-Agent Status", + description="βœ… A custom User-Agent is configured.", + color=0x2BBD8E, + ) + embed.add_field(name="User-Agent", value=f"`{user_agent}`", inline=False) + else: + embed = discord.Embed( + title="User-Agent Status", + description="ℹ️ No custom User-Agent is configured (aiohttp default will be used).", + color=0xfffffe, + ) + embed.add_field(name="Set", value="Use `*aircraft setuseragent `", inline=False) + await ctx.send(embed=embed) + + async def clear_user_agent(self, ctx): + """Clear the configured User-Agent header.""" + await self.cog.config.user_agent.clear() + embed = discord.Embed( + title="User-Agent Cleared", + description="Custom User-Agent cleared. aiohttp default will be used.", + color=0xff4545, + ) + await ctx.send(embed=embed) + async def debug_api(self, ctx): """Debug API key and connection issues - sends detailed info via DM.""" try: diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 5d917c0..f80266b 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -195,8 +195,17 @@ async def forecast(self, ctx, code: str): return try: + # Include optional custom User-Agent (some APIs like api.weather.gov may require it) + headers = {} + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent + async with aiohttp.ClientSession() as session: - async with session.get(f"https://airport-data.com/api/ap_info.json?{code_type}={code}") as response1: + async with session.get( + f"https://airport-data.com/api/ap_info.json?{code_type}={code}", + headers=headers if headers else None, + ) as response1: data1 = await response1.json() latitude, longitude = data1.get('latitude'), data1.get('longitude') country_code = data1.get('country_code') @@ -207,14 +216,17 @@ async def forecast(self, ctx, code: str): if country_code == 'US': # US logic (NOAA/NWS) - async with session.get(f"https://api.weather.gov/points/{latitude},{longitude}") as response2: + async with session.get( + f"https://api.weather.gov/points/{latitude},{longitude}", + headers=headers if headers else None, + ) as response2: data2 = await response2.json() forecast_url = data2.get('properties', {}).get('forecast') if not forecast_url: await ctx.send(embed=discord.Embed(title="Error", description="Could not fetch forecast URL.", color=0xff4545)) return - async with session.get(forecast_url) as response3: + async with session.get(forecast_url, headers=headers if headers else None) as response3: data3 = await response3.json() periods = data3.get('properties', {}).get('periods') if not periods: diff --git a/skysearch/docs.md b/skysearch/docs.md index c19a95d..206935e 100644 --- a/skysearch/docs.md +++ b/skysearch/docs.md @@ -9,6 +9,10 @@ ``` *aircraft setapikey YOUR_API_KEY_HERE ``` + - (Optional) Set a custom User-Agent (recommended for some APIs like `api.weather.gov`): + ``` + *aircraft setuseragent SkySearch/1.0 (+https://github.com/bencos17/ben-cogs) + ``` - For OpenWeatherMap (for weather/forecast): ``` *airport setowmkey YOUR_OWM_API_KEY_HERE @@ -196,6 +200,17 @@ Visit `/dashboard/apistats` in your web browser to view API statistics in a web - `*skysearch apistats_reset` - Reset all statistics - `*skysearch apistats_save` - Manually save statistics +## User-Agent (Owner) + +Some upstream APIs may require a valid **User-Agent** header. SkySearch can be configured to send one for all outbound HTTP requests. + +Commands: +``` +*aircraft setuseragent +*aircraft useragent +*aircraft clearuseragent +``` + ## Aircraft Watchlist ### Personal Watchlist diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index c697d12..e377817 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -48,6 +48,7 @@ def __init__(self, bot): 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(api_mode="primary") # API mode: 'primary' or 'fallback (going to remove this when airplanes.live removes the public api because of companies abusing it...when that happens you'll need an api key for it)' + self.config.register_global(user_agent=None) # Optional custom User-Agent header for all outbound HTTP requests self.config.register_global(api_stats=None) # API request statistics for persistence self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}) self.config.register_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) @@ -541,6 +542,24 @@ async def aircraft_clearapikey(self, ctx): """Clear the airplanes.live API key.""" await self.admin_commands.clear_api_key(ctx) + @commands.is_owner() + @aircraft_group.command(name="setuseragent") + async def aircraft_setuseragent(self, ctx, *, user_agent: str): + """Set the User-Agent header SkySearch uses for outbound HTTP requests.""" + await self.admin_commands.set_user_agent(ctx, user_agent) + + @commands.is_owner() + @aircraft_group.command(name="useragent") + async def aircraft_useragent(self, ctx): + """Show the configured User-Agent header (if any).""" + await self.admin_commands.check_user_agent(ctx) + + @commands.is_owner() + @aircraft_group.command(name="clearuseragent") + async def aircraft_clearuseragent(self, ctx): + """Clear the configured User-Agent header.""" + await self.admin_commands.clear_user_agent(ctx) + # Airport commands @commands.guild_only() @commands.group(name='airport', help='Command center for airport related commands', invoke_without_command=True) diff --git a/skysearch/utils/api.py b/skysearch/utils/api.py index b5fb679..b61af01 100644 --- a/skysearch/utils/api.py +++ b/skysearch/utils/api.py @@ -109,6 +109,10 @@ def get_fallback_api_url(self): 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 = {} + # Optional custom User-Agent for all outbound HTTP requests (useful for APIs that require it) + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent api_key = await self.cog.config.airplanesliveapi() if api_mode == "primary" and api_key: headers['auth'] = api_key @@ -408,7 +412,7 @@ async def get_stats(self): if not self._http_client: self._http_client = aiohttp.ClientSession() try: - async with self._http_client.get(url) as response: + async with self._http_client.get(url, headers=await self.get_headers(url, api_mode="primary")) as response: if response.status == 200: return await response.json() else: @@ -426,7 +430,7 @@ async def get_openweathermap_forecast(self, lat, lon): if not self._http_client: self._http_client = aiohttp.ClientSession() try: - async with self._http_client.get(url) as resp: + async with self._http_client.get(url, headers=await self.get_headers(url, api_mode="primary")) as resp: if resp.status == 200: return await resp.json() else: diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 5aa2516..0e01a6a 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -18,6 +18,18 @@ def _ensure_http_client(self): """Ensure HTTP client is initialized.""" if not hasattr(self.cog, '_http_client'): self.cog._http_client = aiohttp.ClientSession() + + async def _get_http_headers(self) -> dict: + """Get outbound HTTP headers (includes configured User-Agent if set).""" + headers = {} + try: + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent + except Exception: + # In case config isn't available for some reason, fall back to aiohttp defaults. + pass + return headers async def get_photo_by_hex(self, hex_id, registration=None): """ @@ -35,7 +47,10 @@ async def get_photo_by_hex(self, hex_id, registration=None): # First try to get photo by hex ICAO directly if hex_id: try: - async with self.cog._http_client.get(f'https://api.planespotters.net/pub/photos/hex/{hex_id}') as response: + async with self.cog._http_client.get( + f'https://api.planespotters.net/pub/photos/hex/{hex_id}', + headers=await self._get_http_headers(), + ) as response: if response.status == 200: json_out = await response.json() if 'photos' in json_out and json_out['photos']: @@ -50,7 +65,10 @@ async def get_photo_by_hex(self, hex_id, registration=None): # If no photo found by hex, try by registration if provided if registration: try: - async with self.cog._http_client.get(f'https://api.planespotters.net/pub/photos/reg/{registration}') as response: + async with self.cog._http_client.get( + f'https://api.planespotters.net/pub/photos/reg/{registration}', + headers=await self._get_http_headers(), + ) as response: if response.status == 200: json_out = await response.json() if 'photos' in json_out and json_out['photos']: @@ -77,7 +95,10 @@ async def get_photo_by_hex(self, hex_id, registration=None): if reg and reg != registration: # Only try if we haven't already tried this registration # try to get photo using the registration try: - async with self.cog._http_client.get(f'https://api.planespotters.net/pub/photos/reg/{reg}') as response: + async with self.cog._http_client.get( + f'https://api.planespotters.net/pub/photos/reg/{reg}', + headers=await self._get_http_headers(), + ) as response: if response.status == 200: json_out = await response.json() if 'photos' in json_out and json_out['photos']: @@ -320,7 +341,7 @@ async def get_airport_data(self, airport_code: str): try: # Try airport-data.com API url = f"https://airport-data.com/api/ap_info.json?icao={airport_code}" - async with self.cog._http_client.get(url) as response: + 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 not isinstance(data, list): # Valid airport data @@ -360,7 +381,7 @@ async def get_runway_data(self, airport_code: str): try: # Try airportdb.io API url = f"https://airportdb.io/api/v1/airports/{airport_code}" - async with self.cog._http_client.get(url) as response: + 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: @@ -379,7 +400,7 @@ async def get_navaid_data(self, airport_code: str): 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) as response: + 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: @@ -412,7 +433,7 @@ async def parse_json_input(self, json_input: str): # Fetch the JSON data from the URL self._ensure_http_client() - async with self.cog._http_client.get(json_input) as response: + async with self.cog._http_client.get(json_input, headers=await self._get_http_headers()) as response: if response.status != 200: raise ValueError(f"Failed to fetch JSON data. Status: {response.status}") From a18e65c27844463143be9cf99de2d89aa2ed7737 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:32:21 +0000 Subject: [PATCH 67/74] Update README.md --- skysearch/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index 22c51cd..4094349 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -86,9 +86,9 @@ Notes: - `[p]apikey` - Check API key status - `[p]clearapikey` - Clear API key - `[p]debugapi` - Debug API issues -- `[p]setuseragent ` - Set a custom User-Agent header for outbound HTTP requests -- `[p]useragent` - Show current User-Agent setting -- `[p]clearuseragent` - Clear User-Agent setting (use aiohttp default) +- `[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) ### API Monitoring Commands - `[p]skysearch apistats` - View comprehensive API request statistics and performance metrics From 38aaadd11248464cb76ebb98f811a90cc0c040a1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:07:59 +0000 Subject: [PATCH 68/74] Add radiosonde cog for SondeHub tracking Add a new Red-DiscordBot cog package `radiosonde` that tracks radiosondes using the SondeHub API. Implements a background updater (aiohttp session) that fetches latest sondes and posts updates to a configured guild channel, with per-guild config keys: tracked_sondes, update_channel, and update_interval (default 300s). Exposes commands to manage tracking (sonde add/remove/list) and configuration (setchannel, interval), and includes an end-user data statement and cleanup of the aiohttp session on cog unload. --- radiosonde/__init__.py | 7 +++ radiosonde/radiosonde.py | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 radiosonde/__init__.py create mode 100644 radiosonde/radiosonde.py diff --git a/radiosonde/__init__.py b/radiosonde/__init__.py new file mode 100644 index 0000000..375356a --- /dev/null +++ b/radiosonde/__init__.py @@ -0,0 +1,7 @@ +from .radiosonde import Radiosonde + +__red_end_user_data_statement__ = "This cog does not store any end user data." + +async def setup(bot): + await bot.add_cog(Radiosonde(bot)) + diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py new file mode 100644 index 0000000..a68cd96 --- /dev/null +++ b/radiosonde/radiosonde.py @@ -0,0 +1,113 @@ + +import discord +from redbot.core import commands, Config, checks +import aiohttp +import asyncio + +class SondeTracker(commands.Cog): + """Track radiosondes using the SondeHub API.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf( + self, identifier=492089091320446976, force_registration=True + ) + # Per guild configuration + self.config.register_guild( + tracked_sondes=[], + update_channel=None, + update_interval=300 # default 5 minutes + ) + + self.session = aiohttp.ClientSession() + self.bg_task = self.bot.loop.create_task(self.update_sondes()) + + def cog_unload(self): + self.bg_task.cancel() + asyncio.create_task(self.session.close()) + + async def fetch_sondes(self): + url = "https://api.sondehub.org/sondes/latest.json" + async with self.session.get(url) as resp: + if resp.status != 200: + return [] + return await resp.json() + + async def update_sondes(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + for guild in self.bot.guilds: + guild_config = await self.config.guild(guild).all() + tracked = guild_config.get("tracked_sondes", []) + channel_id = guild_config.get("update_channel") + interval = guild_config.get("update_interval", 300) + + if tracked and channel_id: + sondes_data = await self.fetch_sondes() + channel = self.bot.get_channel(channel_id) + if not channel: + continue + for sonde_id in tracked: + for sonde in sondes_data: + if str(sonde.get("id")) == str(sonde_id): + msg = ( + f"**Sonde {sonde_id} Update**\n" + f"Lat: {sonde.get('lat')}\n" + f"Lon: {sonde.get('lon')}\n" + f"Alt: {sonde.get('alt'):.1f} m\n" + f"Speed: {sonde.get('vel'):.1f} m/s\n" + ) + await channel.send(msg) + await asyncio.sleep(1) # small delay between guilds + await asyncio.sleep(60) # wait 1 minute before next batch + + @commands.group() + async def sonde(self, ctx): + """Manage sonde tracking.""" + pass + + @sonde.command() + async def add(self, ctx, sonde_id: str): + """Add a sonde to track.""" + tracked = await self.config.guild(ctx.guild).tracked_sondes() + if sonde_id in tracked: + await ctx.send(f"Sonde {sonde_id} is already tracked.") + return + tracked.append(sonde_id) + await self.config.guild(ctx.guild).tracked_sondes.set(tracked) + await ctx.send(f"Now tracking sonde {sonde_id}.") + + @sonde.command() + async def remove(self, ctx, sonde_id: str): + """Stop tracking a sonde.""" + tracked = await self.config.guild(ctx.guild).tracked_sondes() + if sonde_id not in tracked: + await ctx.send(f"Sonde {sonde_id} is not being tracked.") + return + tracked.remove(sonde_id) + await self.config.guild(ctx.guild).tracked_sondes.set(tracked) + await ctx.send(f"Stopped tracking sonde {sonde_id}.") + + @sonde.command() + async def list(self, ctx): + """List all tracked sondes.""" + tracked = await self.config.guild(ctx.guild).tracked_sondes() + if not tracked: + await ctx.send("No sondes are being tracked in this server.") + return + await ctx.send("Tracked sondes: " + ", ".join(tracked)) + + @sonde.command() + async def setchannel(self, ctx, channel: discord.TextChannel): + """Set the channel for sonde updates.""" + await self.config.guild(ctx.guild).update_channel.set(channel.id) + await ctx.send(f"Sonde updates will be sent to {channel.mention}.") + + @sonde.command() + async def interval(self, ctx, seconds: int): + """Set the update interval in seconds.""" + if seconds < 30: + await ctx.send("Interval must be at least 30 seconds.") + return + await self.config.guild(ctx.guild).update_interval.set(seconds) + await ctx.send(f"Update interval set to {seconds} seconds.") From ae31c6ba5f04c1715a61603802db92f115fc4b81 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:09:38 +0000 Subject: [PATCH 69/74] fix --- radiosonde/radiosonde.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index a68cd96..452bfc2 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -4,7 +4,7 @@ import aiohttp import asyncio -class SondeTracker(commands.Cog): +class Radiosonde(commands.Cog): """Track radiosondes using the SondeHub API.""" def __init__(self, bot): From 68c64c35a677aa5ca5dd0988e502426d3c4e40e4 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:13:33 +0000 Subject: [PATCH 70/74] stuff --- radiosonde/docs.md | 138 +++++++++++++++++++++++++++++++++++++++ radiosonde/radiosonde.py | 30 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 radiosonde/docs.md diff --git a/radiosonde/docs.md b/radiosonde/docs.md new file mode 100644 index 0000000..042f35b --- /dev/null +++ b/radiosonde/docs.md @@ -0,0 +1,138 @@ +# Radiosonde Cog Documentation + +## Overview + +The `Radiosonde` cog allows you to track radiosondes (weather balloons) using the SondeHub API. It automatically monitors specified sondes and sends periodic updates to a configured channel with their current location, altitude, and speed. + +## Installation + +1. Ensure you have [Red DiscordBot](https://docs.discord.red/en/stable/) installed and running. +2. Add this cog to your bot: + ``` + [p]repo add ben-cogs https://github.com/bencos18/ben-cogs + [p]cog install ben-cogs radiosonde + ``` + Replace `[p]` with your bot's prefix. + +--- + +## Commands + +### Main Command Group +- **`[p]sonde`** + - Shows help or usage info for the sonde tracking commands. + +#### Subcommands + +- **`[p]sonde add `** + - Add a sonde to track in this server. + - The sonde ID should match the ID from the SondeHub API. + - Example: `[p]sonde add U1234567` + +- **`[p]sonde remove `** + - Stop tracking a specific sonde. + - Example: `[p]sonde remove U1234567` + +- **`[p]sonde list`** + - List all sondes currently being tracked in this server. + - Example: `[p]sonde list` + +- **`[p]sonde status`** + - List the current status of all tracked sondes (latitude, longitude, altitude, speed). Fetches live data from the SondeHub API. + - Example: `[p]sonde status` + +- **`[p]sonde setchannel `** + - Set the channel where sonde updates will be sent. + - You can mention the channel or use the channel ID. + - Example: `[p]sonde setchannel #weather-updates` or `[p]sonde setchannel 123456789012345678` + +- **`[p]sonde interval `** + - Set how often (in seconds) the bot checks for sonde updates. + - Minimum interval is 30 seconds. + - Default is 300 seconds (5 minutes). + - Example: `[p]sonde interval 60` (check every minute) + +--- + +## Setup Guide + +### How to Set Up Sonde Tracking + +1. **Set the update channel:** + ``` + [p]sonde setchannel #your-channel + ``` + This tells the bot where to send sonde updates. + +2. **Add a sonde to track:** + ``` + [p]sonde add + ``` + Replace `` with the actual sonde ID you want to track. You can find sonde IDs from the [SondeHub website](https://sondehub.org/) or API. + +3. **(Optional) Adjust the update interval:** + ``` + [p]sonde interval 120 + ``` + This sets the bot to check for updates every 120 seconds (2 minutes). + +4. **View tracked sondes:** + ``` + [p]sonde list + ``` + This shows all sondes currently being tracked. + +### Example Setup Flow + +``` +[p]sonde setchannel #weather-data +[p]sonde add U1234567 +[p]sonde add U7654321 +[p]sonde interval 180 +[p]sonde list +``` + +--- + +## Update Format + +When a tracked sonde is updated, the bot sends a message with the following information: +- **Sonde ID**: The identifier of the sonde +- **Lat**: Latitude coordinate +- **Lon**: Longitude coordinate +- **Alt**: Altitude in meters +- **Speed**: Velocity in meters per second + +Example update message: +``` +**Sonde U1234567 Update** +Lat: 40.7128 +Lon: -74.0060 +Alt: 1234.5 m +Speed: 5.2 m/s +``` + +--- + +## Features & Notes + +- **Per-server configuration**: Each server can track different sondes and have different update channels. +- **Automatic updates**: The bot continuously monitors tracked sondes and sends updates automatically. +- **API Source**: Uses the [SondeHub API](https://api.sondehub.org/sondes/latest.json) for real-time sonde data. +- **Update frequency**: The bot checks for updates every minute, but only sends messages based on your configured interval. +- **Minimum interval**: Update intervals must be at least 30 seconds to prevent API abuse. + +--- + +## Troubleshooting + +- **No updates being sent**: Make sure you've set a channel with `[p]sonde setchannel` and added at least one sonde with `[p]sonde add`. +- **Sonde not found**: Verify the sonde ID is correct. The sonde must be active and present in the SondeHub API. +- **Updates too frequent/infrequent**: Adjust the interval using `[p]sonde interval `. + +--- + +## Permissions + +- Users need permission to send messages in the channel where commands are used. +- The bot needs permission to send messages in the configured update channel. diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 452bfc2..0d64d47 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -97,6 +97,36 @@ async def list(self, ctx): return await ctx.send("Tracked sondes: " + ", ".join(tracked)) + @sonde.command() + async def status(self, ctx): + """List current status of all tracked sondes (lat, lon, alt, speed).""" + tracked = await self.config.guild(ctx.guild).tracked_sondes() + if not tracked: + await ctx.send("No sondes are being tracked in this server.") + return + async with ctx.typing(): + sondes_data = await self.fetch_sondes() + if not sondes_data: + await ctx.send("Could not fetch sonde data from the API. Try again later.") + return + by_id = {str(s.get("id")): s for s in sondes_data} + lines = [] + for sonde_id in tracked: + s = by_id.get(sonde_id) + if s is None: + lines.append(f"**{sonde_id}** β€” No current data (not in latest API)") + continue + lat = s.get("lat", "β€”") + lon = s.get("lon", "β€”") + alt = s.get("alt") + vel = s.get("vel") + alt_str = f"{alt:.1f} m" if alt is not None else "β€”" + vel_str = f"{vel:.1f} m/s" if vel is not None else "β€”" + lines.append( + f"**{sonde_id}** β€” Lat: {lat} | Lon: {lon} | Alt: {alt_str} | Speed: {vel_str}" + ) + await ctx.send("**Tracked sondes status**\n" + "\n".join(lines)) + @sonde.command() async def setchannel(self, ctx, channel: discord.TextChannel): """Set the channel for sonde updates.""" From 14242350d48b5cf4ee123567d4fee70f6fb8d783 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:16:52 +0000 Subject: [PATCH 71/74] more --- radiosonde/docs.md | 2 +- radiosonde/radiosonde.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/radiosonde/docs.md b/radiosonde/docs.md index 042f35b..cdeabb9 100644 --- a/radiosonde/docs.md +++ b/radiosonde/docs.md @@ -118,7 +118,7 @@ Speed: 5.2 m/s - **Per-server configuration**: Each server can track different sondes and have different update channels. - **Automatic updates**: The bot continuously monitors tracked sondes and sends updates automatically. -- **API Source**: Uses the [SondeHub API](https://api.sondehub.org/sondes/latest.json) for real-time sonde data. +- **API Source**: Uses the [SondeHub API](https://api.v2.sondehub.org/sondes/latest.json) for real-time sonde data. - **Update frequency**: The bot checks for updates every minute, but only sends messages based on your configured interval. - **Minimum interval**: Update intervals must be at least 30 seconds to prevent API abuse. diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 0d64d47..459a9ff 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -27,11 +27,14 @@ def cog_unload(self): asyncio.create_task(self.session.close()) async def fetch_sondes(self): - url = "https://api.sondehub.org/sondes/latest.json" - async with self.session.get(url) as resp: - if resp.status != 200: - return [] - return await resp.json() + url = "https://api.v2.sondehub.org/sondes/latest.json" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return [] + return await resp.json() + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return [] async def update_sondes(self): await self.bot.wait_until_ready() @@ -107,7 +110,9 @@ async def status(self, ctx): async with ctx.typing(): sondes_data = await self.fetch_sondes() if not sondes_data: - await ctx.send("Could not fetch sonde data from the API. Try again later.") + await ctx.send( + "Could not fetch sonde data from the API (unreachable or error). Try again later." + ) return by_id = {str(s.get("id")): s for s in sondes_data} lines = [] From dd352a747b386ac504878b2ddb06d2fa806d27fe Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:20:10 +0000 Subject: [PATCH 72/74] vcvc --- radiosonde/docs.md | 2 +- radiosonde/radiosonde.py | 74 +++++++++++++++++++++++++++------------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/radiosonde/docs.md b/radiosonde/docs.md index cdeabb9..5902586 100644 --- a/radiosonde/docs.md +++ b/radiosonde/docs.md @@ -118,7 +118,7 @@ Speed: 5.2 m/s - **Per-server configuration**: Each server can track different sondes and have different update channels. - **Automatic updates**: The bot continuously monitors tracked sondes and sends updates automatically. -- **API Source**: Uses the [SondeHub API](https://api.v2.sondehub.org/sondes/latest.json) for real-time sonde data. +- **API Source**: Uses the [SondeHub v2 API](https://api.v2.sondehub.org/sondes) for real-time sonde data. See the [API documentation](https://github.com/projecthorus/sondehub-infra/blob/main/swagger.yaml) for details. - **Update frequency**: The bot checks for updates every minute, but only sends messages based on your configured interval. - **Minimum interval**: Update intervals must be at least 30 seconds to prevent API abuse. diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 459a9ff..dbcab0f 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -27,14 +27,24 @@ def cog_unload(self): asyncio.create_task(self.session.close()) async def fetch_sondes(self): - url = "https://api.v2.sondehub.org/sondes/latest.json" + """Fetch latest sonde data. Returns (data_dict, error_message). + data_dict is a dictionary keyed by serial number. error_message is None on success.""" + url = "https://api.v2.sondehub.org/sondes" try: async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status != 200: - return [] - return await resp.json() - except (aiohttp.ClientError, asyncio.TimeoutError, OSError): - return [] + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + # API returns dict keyed by serial number + return data if isinstance(data, dict) else {}, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" async def update_sondes(self): await self.bot.wait_until_ready() @@ -46,21 +56,31 @@ async def update_sondes(self): interval = guild_config.get("update_interval", 300) if tracked and channel_id: - sondes_data = await self.fetch_sondes() + sondes_data, _ = await self.fetch_sondes() channel = self.bot.get_channel(channel_id) if not channel: continue for sonde_id in tracked: - for sonde in sondes_data: - if str(sonde.get("id")) == str(sonde_id): - msg = ( - f"**Sonde {sonde_id} Update**\n" - f"Lat: {sonde.get('lat')}\n" - f"Lon: {sonde.get('lon')}\n" - f"Alt: {sonde.get('alt'):.1f} m\n" - f"Speed: {sonde.get('vel'):.1f} m/s\n" - ) - await channel.send(msg) + sonde = sondes_data.get(sonde_id) + if sonde: + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + # Calculate speed from horizontal and vertical velocity + if vel_h is not None and vel_v is not None: + speed = (vel_h**2 + vel_v**2)**0.5 + elif vel_h is not None: + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "β€”" + msg = ( + f"**Sonde {sonde_id} Update**\n" + f"Lat: {sonde.get('lat')}\n" + f"Lon: {sonde.get('lon')}\n" + f"Alt: {sonde.get('alt'):.1f} m\n" + f"Speed: {speed_str}\n" + ) + await channel.send(msg) await asyncio.sleep(1) # small delay between guilds await asyncio.sleep(60) # wait 1 minute before next batch @@ -108,25 +128,33 @@ async def status(self, ctx): await ctx.send("No sondes are being tracked in this server.") return async with ctx.typing(): - sondes_data = await self.fetch_sondes() - if not sondes_data: + sondes_data, error = await self.fetch_sondes() + if error or not sondes_data: + detail = f" {error}" if error else "" await ctx.send( - "Could not fetch sonde data from the API (unreachable or error). Try again later." + f"Could not fetch sonde data from the API.{detail} Try again later." ) return - by_id = {str(s.get("id")): s for s in sondes_data} lines = [] for sonde_id in tracked: - s = by_id.get(sonde_id) + s = sondes_data.get(sonde_id) if s is None: lines.append(f"**{sonde_id}** β€” No current data (not in latest API)") continue lat = s.get("lat", "β€”") lon = s.get("lon", "β€”") alt = s.get("alt") - vel = s.get("vel") + vel_h = s.get("vel_h") + vel_v = s.get("vel_v") + # Calculate speed from horizontal and vertical velocity + if vel_h is not None and vel_v is not None: + speed = (vel_h**2 + vel_v**2)**0.5 + elif vel_h is not None: + speed = vel_h + else: + speed = None alt_str = f"{alt:.1f} m" if alt is not None else "β€”" - vel_str = f"{vel:.1f} m/s" if vel is not None else "β€”" + vel_str = f"{speed:.1f} m/s" if speed is not None else "β€”" lines.append( f"**{sonde_id}** β€” Lat: {lat} | Lon: {lon} | Alt: {alt_str} | Speed: {vel_str}" ) From ebbc2c021cb6d23b6e2985648f81e9fa2ae904b7 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:23:21 +0000 Subject: [PATCH 73/74] radiosonde sites system --- radiosonde/radiosonde.py | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index dbcab0f..84b18cc 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -46,6 +46,25 @@ async def fetch_sondes(self): except OSError as e: return {}, f"Network/OS error: {type(e).__name__}: {e}" + async def fetch_sites(self): + """Fetch launch sites data. Returns (data_dict, error_message). + data_dict is keyed by station ID. error_message is None on success.""" + url = "https://api.v2.sondehub.org/sites" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + return data if isinstance(data, dict) else {}, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" + async def update_sondes(self): await self.bot.wait_until_ready() while not self.bot.is_closed(): @@ -174,3 +193,47 @@ async def interval(self, ctx, seconds: int): return await self.config.guild(ctx.guild).update_interval.set(seconds) await ctx.send(f"Update interval set to {seconds} seconds.") + + @sonde.command() + async def site(self, ctx, station_id: str): + """Look up a radiosonde launch site by station ID (e.g. 03953, 94767).""" + async with ctx.typing(): + sites_data, error = await self.fetch_sites() + if error or not sites_data: + detail = f" {error}" if error else "" + await ctx.send( + f"Could not fetch sites from the API.{detail} Try again later." + ) + return + site = sites_data.get(station_id) + if site is None: + await ctx.send(f"No site found with station ID `{station_id}`.") + return + name = site.get("station_name", "β€”") + pos = site.get("position") + if isinstance(pos, (list, tuple)) and len(pos) >= 2: + lon, lat = pos[0], pos[1] + pos_str = f"Lat: {lat}, Lon: {lon}" + else: + pos_str = "β€”" + alt = site.get("alt") + alt_str = f"{alt} m" if alt is not None else "β€”" + rs = site.get("rs_types", []) + rs_str = ", ".join(str(r) for r in rs[:10]) if rs else "β€”" + if rs and len(rs) > 10: + rs_str += f" (+{len(rs) - 10} more)" + times = site.get("times", []) + times_str = ", ".join(str(t) for t in times[:6]) if times else "β€”" + if times and len(times) > 6: + times_str += f" (+{len(times) - 6} more)" + notes = site.get("notes", "").strip() + msg = ( + f"**{name}** (station `{station_id}`)\n" + f"Position: {pos_str}\n" + f"Altitude: {alt_str}\n" + f"Radiosonde types: {rs_str}\n" + f"Launch times (UTC): {times_str}" + ) + if notes: + msg += f"\n*{notes[:200]}{'…' if len(notes) > 200 else ''}*" + await ctx.send(msg) From 5aa8b787f074a7d85983230ce0ac27527e1fdfdb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:25:49 +0000 Subject: [PATCH 74/74] tweaks to sites command to allow names --- radiosonde/docs.md | 8 +++++- radiosonde/radiosonde.py | 60 ++++++++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/radiosonde/docs.md b/radiosonde/docs.md index 5902586..f2b8812 100644 --- a/radiosonde/docs.md +++ b/radiosonde/docs.md @@ -52,6 +52,10 @@ The `Radiosonde` cog allows you to track radiosondes (weather balloons) using th - Default is 300 seconds (5 minutes). - Example: `[p]sonde interval 60` (check every minute) +- **`[p]sonde site `** + - Look up a radiosonde launch site by station ID (from the [SondeHub sites](https://api.v2.sondehub.org/sites) list). Shows name, position, altitude, sonde types, and launch times. + - Example: `[p]sonde site 03953` or `[p]sonde site 94767` + --- ## Setup Guide @@ -118,7 +122,9 @@ Speed: 5.2 m/s - **Per-server configuration**: Each server can track different sondes and have different update channels. - **Automatic updates**: The bot continuously monitors tracked sondes and sends updates automatically. -- **API Source**: Uses the [SondeHub v2 API](https://api.v2.sondehub.org/sondes) for real-time sonde data. See the [API documentation](https://github.com/projecthorus/sondehub-infra/blob/main/swagger.yaml) for details. +- **API sources**: Uses the [SondeHub v2 API](https://github.com/projecthorus/sondehub-infra/blob/main/swagger.yaml): + - [Sondes](https://api.v2.sondehub.org/sondes) β€” latest sonde telemetry (keyed by serial number). + - [Sites](https://api.v2.sondehub.org/sites) β€” launch sites (keyed by station ID), used by `[p]sonde site`. - **Update frequency**: The bot checks for updates every minute, but only sends messages based on your configured interval. - **Minimum interval**: Update intervals must be at least 30 seconds to prevent API abuse. diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 84b18cc..a5b19a9 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -194,21 +194,8 @@ async def interval(self, ctx, seconds: int): await self.config.guild(ctx.guild).update_interval.set(seconds) await ctx.send(f"Update interval set to {seconds} seconds.") - @sonde.command() - async def site(self, ctx, station_id: str): - """Look up a radiosonde launch site by station ID (e.g. 03953, 94767).""" - async with ctx.typing(): - sites_data, error = await self.fetch_sites() - if error or not sites_data: - detail = f" {error}" if error else "" - await ctx.send( - f"Could not fetch sites from the API.{detail} Try again later." - ) - return - site = sites_data.get(station_id) - if site is None: - await ctx.send(f"No site found with station ID `{station_id}`.") - return + def _format_site_message(self, site_id: str, site: dict) -> str: + """Build the display message for a single site.""" name = site.get("station_name", "β€”") pos = site.get("position") if isinstance(pos, (list, tuple)) and len(pos) >= 2: @@ -228,7 +215,7 @@ async def site(self, ctx, station_id: str): times_str += f" (+{len(times) - 6} more)" notes = site.get("notes", "").strip() msg = ( - f"**{name}** (station `{station_id}`)\n" + f"**{name}** (station `{site_id}`)\n" f"Position: {pos_str}\n" f"Altitude: {alt_str}\n" f"Radiosonde types: {rs_str}\n" @@ -236,4 +223,43 @@ async def site(self, ctx, station_id: str): ) if notes: msg += f"\n*{notes[:200]}{'…' if len(notes) > 200 else ''}*" - await ctx.send(msg) + return msg + + @sonde.command() + async def site(self, ctx, query: str): + """Look up a radiosonde launch site by station ID or by name (e.g. 10238, Bergen-Hohne).""" + async with ctx.typing(): + sites_data, error = await self.fetch_sites() + if error or not sites_data: + detail = f" {error}" if error else "" + await ctx.send( + f"Could not fetch sites from the API.{detail} Try again later." + ) + return + # Try exact match by station ID first + site = sites_data.get(query) + if site is not None: + await ctx.send(self._format_site_message(query, site)) + return + # Search by station name (case-insensitive, substring) + query_lower = query.lower() + matches = [ + (sid, s) + for sid, s in sites_data.items() + if query_lower in (s.get("station_name") or "").lower() + ] + if not matches: + await ctx.send(f"No site found for `{query}` (try station ID or part of the site name).") + return + if len(matches) == 1: + sid, s = matches[0] + await ctx.send(self._format_site_message(sid, s)) + return + # Multiple matches: list them (up to 15) + lines = [f"**Multiple sites matching \"{query}\"** β€” use station ID for one:\n"] + for sid, s in sorted(matches, key=lambda x: (x[1].get("station_name") or ""))[:15]: + name = s.get("station_name", "β€”") + lines.append(f"β€’ `{sid}` β€” {name}") + if len(matches) > 15: + lines.append(f"*… and {len(matches) - 15} more. Narrow your search.*") + await ctx.send("\n".join(lines))