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 ![GGdtVgzXAAAhYj6](https://github.com/BenCos17/ben-cogs/assets/52817096/4233fcc5-ac77-482f-8375-6c01a48eb553) + +## Star History + + + + + + Star History Chart + + 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): """