diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml index 31f5fe0..ed8c11e 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: @@ -49,11 +49,6 @@ jobs: 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 --system # Actual magic: test that cogs can be loaded/unloaded via RPC - name: Test cogs via RPC @@ -168,4 +163,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 }} diff --git a/imgen/info.json b/bible/info.json similarity index 77% rename from imgen/info.json rename to bible/info.json index 44cbce5..5e4609f 100644 --- a/imgen/info.json +++ b/bible/info.json @@ -2,9 +2,9 @@ "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"], + "requirements": ["bible"], + "description": "-", + "tags": [], "end_user_data_statement": "This cog does not store any End User Data.", - "type": "COG", - "hidden" : true + "type": "COG" } 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..42a4c4f --- /dev/null +++ b/clusters/clusters.py @@ -0,0 +1,157 @@ +import discord +from redbot.core import commands, Config +import psutil, datetime, json, aiohttp +from aiohttp import web + +MARVEL_NAMES = [ + "IronMan", "Thor", "Hulk", "BlackWidow", "CaptainAmerica", "Loki", + "DoctorStrange", "SpiderMan", "BlackPanther", "ScarletWitch" +] + +class Clusters(commands.Cog): + """Shows dynamic Marvel-themed cluster status with customizable names and uptime, plus a web endpoint.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567890) + 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() + for shard_id in self.bot.shards.keys(): + if str(shard_id) in custom_names: + self.shard_names[shard_id] = custom_names[str(shard_id)] + else: + self.shard_names[shard_id] = MARVEL_NAMES[shard_id % len(MARVEL_NAMES)] + + 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_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 = self.format_timedelta(self.get_server_uptime()) + + embed = discord.Embed( + title="Cluster Status", + description=f"**Bot uptime:** {bot_uptime_str}\n**Server uptime:** {server_uptime}", + color=discord.Color.blue() + ) + + for shard_id, name in self.shard_names.items(): + cpu = psutil.cpu_percent(interval=None) + ram = psutil.virtual_memory().used / 1024**3 + latency = round(self.bot.shards[shard_id].latency * 1000) + + guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] + servers = len(guilds) + users = sum(g.member_count or 0 for g in guilds) + + value = ( + f"**Status:** Alive Running\n" + f"**CPU:** {cpu:.1f}%\n" + f"**RAM:** {ram:.1f} GiB\n" + f"**Latency:** {latency} ms\n" + f"**Servers:** {servers}\n" + f"**Users:** {users}\n" + f"**Shards:** [{shard_id}]" + ) + + 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 + + custom_names = await self.config.custom_names() + custom_names[str(shard_id)] = new_name + await self.config.custom_names.set(custom_names) + self.shard_names[shard_id] = new_name + + await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") + + async def web_clusters(self, request): + """Return cluster data as JSON for web endpoint.""" + await self.initialize_shard_names() + + # 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") diff --git a/clusters/docs.md b/clusters/docs.md new file mode 100644 index 0000000..e118a0a --- /dev/null +++ b/clusters/docs.md @@ -0,0 +1,56 @@ +# How to Use Clusters + + +## Getting Started + + + +### First Time Setup + +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 +- `[p]renamecluster ` - allows the bot owner to override the default cluster names + + + +## Usage + +\[p]clusters +prints out the bots current clusters + +for example this is my current output on my bot + +image + + + + + + + +[p]renamecluster + + + + + + is a number between 0 and your mac amount of shards + + what you want the cluter to be called from now on + +this is how it's used + +image + + + + + + 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"] +} 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..387d15b --- /dev/null +++ b/counter/counter.py @@ -0,0 +1,517 @@ +import re +from typing import Optional, Union, List +import datetime +import discord + +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 + self.config = Config.get_conf(self, identifier=492089091320446976) + # 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) + 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" + + 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, 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.") + 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) + 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.") + 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) + 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.") + 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.") + 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.") + 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.") + 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.") + 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) + 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.") + 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) + 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.") + 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) + 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.") + # 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[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 new file mode 100644 index 0000000..9c34d1e --- /dev/null +++ b/counter/docs.md @@ -0,0 +1,26 @@ +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 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 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. diff --git a/counter/info.json b/counter/info.json new file mode 100644 index 0000000..f238dbd --- /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/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/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.") 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.", 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/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/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 ", diff --git a/martineimages/__init__.py b/martineimages/__init__.py new file mode 100644 index 0000000..48a5c9d --- /dev/null +++ b/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/martineimages/docs.md b/martineimages/docs.md new file mode 100644 index 0000000..be059e6 --- /dev/null +++ b/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/martineimages/info.json b/martineimages/info.json new file mode 100644 index 0000000..0e42946 --- /dev/null +++ b/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/martineimages/martineimages.py b/martineimages/martineimages.py new file mode 100644 index 0000000..001fda7 --- /dev/null +++ b/martineimages/martineimages.py @@ -0,0 +1,168 @@ +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.""" + + 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]: + await self._ensure_session() + headers = {"User-Agent": "Red-MartineImages/ben-cogs/v1.1.4"} + params = params or {} + 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): + # 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 + 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") + 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}**.") + + @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) + + + diff --git a/earthquake/__init__.py b/radiosonde/__init__.py similarity index 57% rename from earthquake/__init__.py rename to radiosonde/__init__.py index 525788d..375356a 100644 --- a/earthquake/__init__.py +++ b/radiosonde/__init__.py @@ -1,6 +1,7 @@ -from .earthquake import Earthquake +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(Earthquake(bot)) + await bot.add_cog(Radiosonde(bot)) + diff --git a/radiosonde/docs.md b/radiosonde/docs.md new file mode 100644 index 0000000..f2b8812 --- /dev/null +++ b/radiosonde/docs.md @@ -0,0 +1,144 @@ +# 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) + +- **`[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 + +### 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 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. + +--- + +## 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 new file mode 100644 index 0000000..a5b19a9 --- /dev/null +++ b/radiosonde/radiosonde.py @@ -0,0 +1,265 @@ + +import discord +from redbot.core import commands, Config, checks +import aiohttp +import asyncio + +class Radiosonde(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): + """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 {}, 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 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(): + 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: + 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 + + @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 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, error = await self.fetch_sondes() + if error or not sondes_data: + detail = f" {error}" if error else "" + await ctx.send( + f"Could not fetch sonde data from the API.{detail} Try again later." + ) + return + lines = [] + for sonde_id in tracked: + s = sondes_data.get(sonde_id) + if s is None: + lines.append(f"**{sonde_id}** — No current data (not in latest API)") + continue + lat = s.get("lat", "—") + lon = s.get("lon", "—") + alt = s.get("alt") + vel_h = s.get("vel_h") + vel_v = s.get("vel_v") + # Calculate speed from horizontal and vertical velocity + if vel_h is not None and vel_v is not None: + speed = (vel_h**2 + vel_v**2)**0.5 + elif vel_h is not None: + speed = vel_h + else: + speed = None + alt_str = f"{alt:.1f} m" if alt is not None else "—" + vel_str = f"{speed:.1f} m/s" if speed is not None else "—" + lines.append( + f"**{sonde_id}** — Lat: {lat} | Lon: {lon} | Alt: {alt_str} | Speed: {vel_str}" + ) + await ctx.send("**Tracked sondes status**\n" + "\n".join(lines)) + + @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.") + + 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: + 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 `{site_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 ''}*" + 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)) diff --git a/skysearch/README.md b/skysearch/README.md index 4a042c1..4094349 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -6,11 +6,12 @@ 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**: +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 @@ -38,6 +39,22 @@ 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 +- `[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) +- 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 - `[p]airport info ` - Get airport information - `[p]airport runway ` - Get runway information @@ -69,6 +86,9 @@ Notes: - `[p]apikey` - Check API key status - `[p]clearapikey` - Clear API key - `[p]debugapi` - Debug API issues +- `[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 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/admin.py b/skysearch/commands/admin.py index e8b0caf..a28831c 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) @@ -223,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/aircraft.py b/skysearch/commands/aircraft.py index 5b54ac4..8c7224d 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)}" @@ -827,4 +827,396 @@ 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) \ No newline at end of file + await ctx.send(embed=embed, view=view) + + 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) + + # 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, takes off, or lands.").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] + 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"), + 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({}) + await user_config.watchlist_aircraft_state.set({}) + + embed = discord.Embed( + title=_("✅ Watchlist Cleared"), + description=_("Removed **{count}** aircraft from your watchlist.").format(count=count), + color=0x00ff00 + ) + await ctx.send(embed=embed) + + 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 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: **{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 + ) + 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 + ) + 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 + + # 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=_("✅ Cooldown Updated"), + description=_("Watchlist notification cooldown set to **{cooldown}**.").format(cooldown=cooldown_text), + color=0x00ff00 + ) + await ctx.send(embed=embed) + + except ValueError: + embed = discord.Embed( + 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) \ No newline at end of file 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/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/docs.md b/skysearch/docs.md index dd63634..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,88 @@ 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 +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. + +**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 +``` +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. + +### 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** +- **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 +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 diff --git a/skysearch/info.json b/skysearch/info.json index 5b3f34e..1b7d969 100644 --- a/skysearch/info.json +++ b/skysearch/info.json @@ -1,12 +1,12 @@ { "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"], + "requirements": ["reportlab", "wtforms"], "permissions": [ "embed_links" ], 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 09a7c72..e377817 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -48,8 +48,10 @@ 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) # Initialize utility managers self.api = APIManager(self) @@ -74,12 +76,34 @@ def __init__(self, bot): # Start background tasks self.check_emergency_squawks.start() + self.check_watched_aircraft.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() # 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.""" @@ -129,6 +153,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() @@ -174,6 +200,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=_("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) @@ -229,6 +256,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\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): @@ -312,6 +340,49 @@ 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) + + @commands.guild_only() + @aircraft_watchlist.command(name='cooldown') + 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) + @@ -471,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) @@ -744,6 +833,252 @@ 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() + aircraft_state = await user_config.watchlist_aircraft_state() + + 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') + + # 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 + 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 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 + + # 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: + # 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 creating notification for user {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: @@ -935,23 +1270,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) 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 eafe3ae..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}") @@ -555,6 +576,261 @@ 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') + ) + + # 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) + 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') + ) + } + + 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("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 + + 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("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) + embed.add_field(name=_watchlist("Position"), value=position, inline=False) + + return embed class JSONInputModal(discord.ui.Modal):