diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml
index ed8c11e..bbb5e6c 100644
--- a/.github/workflows/check-cogs.yml
+++ b/.github/workflows/check-cogs.yml
@@ -9,6 +9,9 @@ name: "Check Cogs"
on:
pull_request:
+permissions:
+ contents: read
+
env:
BUILD_ARTIFACT_NAME: "my-build-artifact"
COG_PATHS: "ampremover,bell,bible,currency,dank,earthquake,emojilink,enumbers,example,facebookdownloader,imgen,invoice,loan,scp,seasons,servertools,skysearch,spamatron,talknotifier,xkcd" # comma-separated list of cog folder names
diff --git a/README.md b/README.md
index cedfe80..d91220f 100644
--- a/README.md
+++ b/README.md
@@ -6,3 +6,13 @@ BenCos17's cogs for Red-DiscordBot.
To add the cogs to your instance please do: [p]repo add ben-cogs https://github.com/bencos17/ben-cogs

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