diff --git a/.github/workflows/check-cogs.yml b/.github/workflows/check-cogs.yml new file mode 100644 index 0000000..31f5fe0 --- /dev/null +++ b/.github/workflows/check-cogs.yml @@ -0,0 +1,171 @@ + +# For tests with nektos/act use: +# act -e .\.github\workflows\fixtures\push.json -W .\.github\workflows\check-cogs.yml --container-options "-v /c/privat/codex/d-cogs/.artifacts/dist:/tmp/dist:ro" --secret-file .secrets + +# To test with local Red-DiscordBot build artifact, mount the host folder containing the built wheels to /tmp/dist in the container. +# docker run -it --rm -v /c/privat/codex/d-cogs/.artifacts/dist:/tmp/dist python:3.11 bash +name: "Check Cogs" + +on: + pull_request: + +env: + BUILD_ARTIFACT_NAME: "my-build-artifact" + COG_PATHS: "dworld" # comma-separated list of cog folder names + RPC_PORT: "6133" + +jobs: + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # DEMO: how to install Red-DiscordBot, can install from PyPI directly! + - name: Build Red-DiscordBot + uses: nntin/d-flows/actions/build-red-discordbot@v1 + with: + red_commit: "" # optional commit SHA + artifact_name: ${{ env.BUILD_ARTIFACT_NAME }} # optional artifact name + + test-cogs: + runs-on: ubuntu-latest + needs: install + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Skip artifact_name if installing from PyPI directly + - name: Install Red-DiscordBot + uses: nntin/d-flows/actions/install-red-discordbot@v1 + with: + artifact_name: ${{ env.BUILD_ARTIFACT_NAME }} # same artifact name used for build + + # Configure Red-DiscordBot with instance name "tinkerer" + # todo: add input for skipping --dry-run + - name: Configure Red-DiscordBot + uses: nntin/d-flows/actions/setup-red-discordbot@v1 + with: + token: ${{ secrets.DISCORD_BOT_TOKEN }} + optional_args: "--no-cogs" # Example optional argument to run without loading any cogs + # --dry-run fails due to bug in Red-DiscordBot https://github.com/Cog-Creators/Red-DiscordBot/issues/6572 + continue-on-error: true + + # For dworld cog d-back and wtforms are required + - name: Install dependencies + run: | + uv pip install d-back wtforms --system + + # Actual magic: test that cogs can be loaded/unloaded via RPC + - name: Test cogs via RPC + uses: nntin/d-flows/actions/test-red-discordbot@v1 + with: + token: ${{ secrets.DISCORD_BOT_TOKEN }} + cog_paths: ${{ env.COG_PATHS }} + rpc_port: ${{ env.RPC_PORT }} + + ################################################################## + #### Build validation results for Discord notification #### + ################################################################## + build-validation-fields: + name: Build Validation Fields + runs-on: ubuntu-latest + needs: [install, test-cogs] + if: always() + outputs: + discord_fields: ${{ steps.discord_fields.outputs.fields }} + validation_result: ${{ steps.validation_result.outputs.result }} + + steps: + - name: Determine validation result + id: validation_result + run: | + BUILD_STATUS="${{ needs.install.result }}" + TEST_STATUS="${{ needs['test-cogs'].result }}" + + if [[ "$BUILD_STATUS" == "success" && "$TEST_STATUS" == "success" ]]; then + echo "result=success" >> "$GITHUB_OUTPUT" + else + echo "result=failed" >> "$GITHUB_OUTPUT" + fi + + - name: Build Discord Fields + id: discord_fields + run: | + BUILD_STATUS="${{ needs.install.result }}" + TEST_STATUS="${{ needs['test-cogs'].result }}" + + BUILD_EMOJI=$([[ "$BUILD_STATUS" == "success" ]] && echo "✅" || echo "❌") + TEST_EMOJI=$([[ "$TEST_STATUS" == "success" ]] && echo "✅" || echo "❌") + + FIELDS=$(jq -n \ + --arg build_status "$BUILD_EMOJI $BUILD_STATUS" \ + --arg test_status "$TEST_EMOJI $TEST_STATUS" \ + --arg cogs "${{ env.COG_PATHS }}" \ + --arg artifact "${{ env.BUILD_ARTIFACT_NAME }}" \ + --arg actor "${{ github.actor }}" \ + --arg pr_number "${{ github.event.pull_request.number }}" \ + --arg pr_url "${{ github.event.pull_request.html_url }}" \ + --arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + '[ + {"name": "Build Red-DiscordBot", "value": $build_status, "inline": true}, + {"name": "Cog Tests", "value": $test_status, "inline": true}, + {"name": "Cogs Tested", "value": $cogs, "inline": true}, + {"name": "Artifact", "value": $artifact, "inline": true}, + {"name": "PR Details", "value": ("[#" + $pr_number + "](" + $pr_url + ") by @" + $actor), "inline": false}, + {"name": "Run Details", "value": ("[View Full Run](" + $run_url + ")"), "inline": false} + ]') + + delimiter="$(openssl rand -hex 8)" + { + echo "fields<<${delimiter}" + echo "$FIELDS" + echo "${delimiter}" + } >> "$GITHUB_OUTPUT" + + ################################################################## + #### Display validation results via Step Summary #### + ################################################################## + display-validation-summary: + name: Display Validation Summary + runs-on: ubuntu-latest + needs: [install, test-cogs, build-validation-fields] + if: always() + + steps: + - name: Write Step Summary + uses: nntin/d-flows/actions/step-summary@v1 + with: + title: 'Cog Validation Results' + markdown: | + | Stage | Status | + |-------|--------| + | Build Red-DiscordBot | ${{ needs.install.result == 'success' && '✅ Passed' || '❌ Failed' }} | + | Cog Tests | ${{ needs['test-cogs'].result == 'success' && '✅ Passed' || '❌ Failed' }} | + + **Artifact**: `${{ env.BUILD_ARTIFACT_NAME }}` + + **Cogs Tested**: `${{ env.COG_PATHS }}` + + **Overall Result**: ${{ needs.build-validation-fields.outputs.validation_result == 'success' && '✅ All checks passed' || '⚠️ Some checks failed or were skipped' }} + overwrite: false + + ################################################################## + #### Display validation results via Discord #### + ################################################################## + notify-cog-validation: + name: Notify Validation Results + runs-on: ubuntu-latest + needs: [build-validation-fields, display-validation-summary] + if: always() + + steps: + - name: Send Discord Notification + uses: nntin/d-flows/actions/discord-notify@v1 + with: + webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} + message_type: 'embed' + title: ${{ needs.build-validation-fields.outputs.validation_result == 'success' && '✅ Cog Validation Completed Successfully' || '⚠️ Cog Validation Completed with Issues' }} + description: >- + ${{ needs.build-validation-fields.outputs.validation_result == 'success' && format('All cogs ({0}) passed build and RPC validation.', env.COG_PATHS) || format('One or more stages failed for cogs: {0}. Review the workflow logs.', env.COG_PATHS) }} + color: ${{ needs.build-validation-fields.outputs.validation_result == 'success' && '3066993' || '16776960' }} + fields: ${{ needs.build-validation-fields.outputs.discord_fields }} \ No newline at end of file diff --git a/ampremover/ampremover.py b/ampremover/ampremover.py index 4ff289a..ee51903 100644 --- a/ampremover/ampremover.py +++ b/ampremover/ampremover.py @@ -5,6 +5,7 @@ import aiohttp import asyncio from .dashboard_integration import DashboardIntegration +import time class AmputatorBot(DashboardIntegration, commands.Cog): """Cog to convert AMP URLs to canonical forms using the AmputatorBot API. @@ -14,8 +15,17 @@ class AmputatorBot(DashboardIntegration, commands.Cog): def __init__(self, bot): self.bot = bot - self.config = Config.get_conf(self, identifier=492089091320446976) # Use a unique identifier for your cog - self.config.register_guild(opted_in=False) # Register a guild-specific variable for opted-in status + self.config = Config.get_conf(self, identifier=492089091320446976) + # Register a guild-specific variable for opted-in status and basic stats + self.config.register_guild( + opted_in=False, + stats={ + "total_conversions": 0, + "total_urls_detected": 0, + "total_canonical_returned": 0, + "last_conversion_ts": 0, + }, + ) self.opted_in_users = set() async def initialize_config(self, guild): @@ -54,6 +64,9 @@ async def convert_amp(self, ctx, *, message: str): return canonical_links = await self.fetch_canonical_links(urls) + # Update stats if invoked in a guild context + if ctx.guild is not None: + await self._update_guild_stats(ctx.guild, urls_detected=len(urls), canonical_returned=len(canonical_links)) if canonical_links: if ctx.guild: # If in a server, respond in the channel await ctx.send(f"Canonical URL(s): {'; '.join(canonical_links)}") @@ -91,6 +104,8 @@ async def on_message(self, message): urls = self.extract_urls(message.content) if urls: canonical_links = await self.fetch_canonical_links(urls) + # Update stats for guild automatic conversion checks + await self._update_guild_stats(message.guild, urls_detected=len(urls), canonical_returned=len(canonical_links)) if canonical_links: await message.channel.send(f"Canonical URL(s): {'; '.join(canonical_links)}") else: # DM context @@ -101,6 +116,17 @@ async def on_message(self, message): if canonical_links: await message.author.send(f"Canonical URL(s): {'; '.join(canonical_links)}") + async def _update_guild_stats(self, guild, *, urls_detected: int, canonical_returned: int) -> None: + """Update guild-level stats used by the dashboard. + + Increments total conversion events, accumulates counts, and records the last conversion timestamp. + """ + async with self.config.guild(guild).stats() as stats: + stats["total_conversions"] = int(stats.get("total_conversions", 0)) + 1 + stats["total_urls_detected"] = int(stats.get("total_urls_detected", 0)) + int(urls_detected) + stats["total_canonical_returned"] = int(stats.get("total_canonical_returned", 0)) + int(canonical_returned) + stats["last_conversion_ts"] = int(time.time()) + @amputator.command(name='settings') async def show_settings(self, ctx): """Display the current configuration settings for the AmputatorBot in this guild.""" diff --git a/ampremover/dashboard_integration.py b/ampremover/dashboard_integration.py index bed1af0..abb9dfc 100644 --- a/ampremover/dashboard_integration.py +++ b/ampremover/dashboard_integration.py @@ -197,24 +197,37 @@ def __init__(self): @dashboard_page(name="stats", description="View AMP URL conversion statistics", methods=("GET",)) async def stats_page(self, user: discord.User, guild: discord.Guild, **kwargs) -> typing.Dict[str, typing.Any]: """Dashboard page for viewing conversion statistics.""" - # Get guild settings + # Get guild settings and stats opted_in = await self.config.guild(guild).opted_in() - - # For now, we'll show basic stats. You can expand this later with actual conversion tracking + stats = await self.config.guild(guild).stats() + + total_conversions = int(stats.get("total_conversions", 0)) + total_urls_detected = int(stats.get("total_urls_detected", 0)) + total_canonical_returned = int(stats.get("total_canonical_returned", 0)) + last_ts = int(stats.get("last_conversion_ts", 0)) + + # Compute simple rate + success_rate = 0.0 + if total_urls_detected > 0: + success_rate = (total_canonical_returned / total_urls_detected) * 100.0 + + # Format last conversion time (Dashboard templates can format raw epoch too; keep simple here) + last_conversion = "Never" if last_ts == 0 else f"{last_ts}" + stats_html = f""" -
+

📊 AMP Remover Statistics

Statistics for {guild.name}

- -
-
-
-
+ +
+
+
+
Guild Settings
-
-

Automatic Conversion: - +

+

Automatic Conversion: + {'Enabled' if opted_in else 'Disabled'}

@@ -223,34 +236,24 @@ async def stats_page(self, user: discord.User, guild: discord.Guild, **kwargs) -
- -
-
-
-
Bot Information
+ +
+
+
+
Conversion Stats
-
-

Bot Name: {self.bot.user.display_name}

-

API Used: AmputatorBot API

-

Commands Available:

-
    -
  • [p]amputator convert - Manual conversion
  • -
  • [p]amputator optin - Enable auto-conversion
  • -
  • [p]amputator optout - Disable auto-conversion
  • -
  • [p]amputator settings - View settings
  • +
    +
      +
    • Total conversion events: {total_conversions}
    • +
    • Total URLs detected: {total_urls_detected}
    • +
    • Total canonical returned: {total_canonical_returned}
    • +
    • Success rate: {success_rate:.1f}%
    • +
    • Last conversion (epoch): {last_conversion}
- -
-
-
💡 Tip
-

To track conversion statistics, you would need to add logging functionality to the main cog. - This could include tracking the number of URLs converted, success rates, and user activity.

-
-
""" diff --git a/dank/info.json b/dank/info.json index f32e31f..bcbf22d 100644 --- a/dank/info.json +++ b/dank/info.json @@ -1,6 +1,6 @@ { "name": "Dank", - "author": ["yourname"], + "author": ["bencos17"], "description": "Gay and Simp rating machine, Dank Memer style.", "requirements": [], "min_bot_version": "3.0.0", @@ -8,4 +8,4 @@ "hidden": false, "disabled": false, "type": "COG" -} \ No newline at end of file +} diff --git a/emojilink/emojilink.py b/emojilink/emojilink.py index e8e53a7..8988a53 100644 --- a/emojilink/emojilink.py +++ b/emojilink/emojilink.py @@ -4,8 +4,12 @@ import random import typing import aiohttp +from PIL import Image +import io class EmojiLink(commands.Cog): + """Emoji management commands with automatic background removal.""" + def __init__(self, bot: Red): self.bot = bot @@ -14,65 +18,52 @@ async def emojilink(self, ctx: commands.Context): """Emoji related commands.""" await ctx.send_help(str(ctx.command)) + # ----------------------------- + # Subcommands + # ----------------------------- + @emojilink.command(name="getlink") async def get_emoji_link(self, ctx: commands.Context, emoji: typing.Union[discord.PartialEmoji, str]): - """ - Get the link for a Discord emoji. - - Parameters: - - emoji: The Discord emoji (custom emoji or Unicode emoji). - """ - # Determine if the provided emoji is a custom emoji or a Unicode emoji + """Get the link for a Discord emoji.""" if isinstance(emoji, discord.PartialEmoji): emoji_str = str(emoji) - emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{emoji.animated and 'gif' or 'png'}" + emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{ 'gif' if emoji.animated else 'png'}" elif isinstance(emoji, str): emoji_str = emoji - # Generate a link using Emojipedia (replace '+' with the Unicode emoji) emoji_url = f"https://emojipedia.org/{'+'.join(emoji.encode('unicode-escape').decode('utf-8').split())}/" else: raise commands.BadArgument("Invalid emoji provided.") - - # Send the emoji and the emoji link await ctx.send(f"Emoji: {emoji_str}") await ctx.send(f"Emoji link: {emoji_url}") @emojilink.command(name="list", aliases=["all"]) async def list_emojis(self, ctx: commands.Context): - """List all custom emojis in the server along with their names and links.""" + """List all custom emojis in the server with names and links.""" if not ctx.guild.emojis: await ctx.send("No custom emojis found in this server.") return - # Create embed pages with 3 emojis per page emojis = ctx.guild.emojis pages = [] for i in range(0, len(emojis), 3): embed = discord.Embed(title="Server Emojis", color=discord.Color.blue()) chunk = emojis[i:i + 3] - - # Add each emoji as a large image for emoji in chunk: - emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{emoji.animated and 'gif' or 'png'}" - # Create a clickable image with name and download text underneath + emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{ 'gif' if emoji.animated else 'png'}" embed.add_field( name=f":{emoji.name}:", value=f"[⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀]({emoji_url})\n{emoji} • [Download]({emoji_url})", inline=True ) - - # Add empty fields to maintain grid layout if needed remaining = 3 - len(chunk) for _ in range(remaining): embed.add_field(name="⠀", value="⠀", inline=True) - embed.set_footer(text=f"Page {i//3 + 1}/{-(-len(emojis)//3)} • Total emojis: {len(emojis)}") pages.append(embed) if not pages: return await ctx.send("No emojis to display.") - # Create custom view with buttons class PaginationView(discord.ui.View): def __init__(self): super().__init__(timeout=60) @@ -82,24 +73,18 @@ def __init__(self): async def previous_button(self, interaction: discord.Interaction, button: discord.ui.Button): if interaction.user != ctx.author: return await interaction.response.send_message("You cannot use these buttons.", ephemeral=True) - self.current_page -= 1 - # Update button states button.disabled = self.current_page == 0 self.children[1].disabled = self.current_page == len(pages) - 1 - await interaction.response.edit_message(embed=pages[self.current_page], view=self) @discord.ui.button(label="Next ▶️", style=discord.ButtonStyle.secondary) async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): if interaction.user != ctx.author: return await interaction.response.send_message("You cannot use these buttons.", ephemeral=True) - self.current_page += 1 - # Update button states self.children[0].disabled = self.current_page == 0 button.disabled = self.current_page == len(pages) - 1 - await interaction.response.edit_message(embed=pages[self.current_page], view=self) async def on_timeout(self): @@ -110,35 +95,28 @@ async def on_timeout(self): except: pass - # Send the initial message with the view view = PaginationView() view.message = await ctx.send(embed=pages[0], view=view) @emojilink.command(name="info") async def emoji_info(self, ctx: commands.Context, emoji: typing.Union[discord.PartialEmoji, str]): - """ - Get information about a specific custom emoji, including its name, ID, and creation date. - - Parameters: - - emoji: The Discord emoji (custom emoji or Unicode emoji). - """ - # Determine if the provided emoji is a custom emoji or a Unicode emoji + """Get information about a specific emoji.""" if isinstance(emoji, discord.PartialEmoji): emoji_str = str(emoji) - emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{emoji.animated and 'gif' or 'png'}" + emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{ 'gif' if emoji.animated else 'png'}" emoji_name = emoji.name emoji_id = emoji.id emoji_created_at = emoji.created_at elif isinstance(emoji, str): emoji_str = emoji - emoji_url = None # Unicode emojis don't have a direct image link - emoji_name = None # Unicode emojis don't have a name - emoji_id = None # Unicode emojis don't have an ID - emoji_created_at = None # Unicode emojis don't have a creation date + emoji_url = None + emoji_name = None + emoji_id = None + emoji_created_at = None else: raise commands.BadArgument("Invalid emoji provided.") - if emoji_name is not None: + if emoji_name: await ctx.send(f"Emoji: {emoji_str}\nName: {emoji_name}\nID: {emoji_id}\nCreation Date: {emoji_created_at}") else: await ctx.send(f"Emoji: {emoji_str}") @@ -148,14 +126,11 @@ async def emoji_info(self, ctx: commands.Context, emoji: typing.Union[discord.Pa @emojilink.command(name="random") async def random_emoji(self, ctx: commands.Context): - """ - Get a link for a random custom emoji in the server. - """ + """Get a random custom emoji.""" emojis = ctx.guild.emojis if emojis: random_emoji = random.choice(emojis) - emoji_url = f"https://cdn.discordapp.com/emojis/{random_emoji.id}.{random_emoji.animated and 'gif' or 'png'}" - # Send the emoji and the emoji link + emoji_url = f"https://cdn.discordapp.com/emojis/{random_emoji.id}.{ 'gif' if random_emoji.animated else 'png'}" await ctx.send(f"Random Emoji: {random_emoji}") await ctx.send(f"Emoji link: {emoji_url}") else: @@ -163,73 +138,81 @@ async def random_emoji(self, ctx: commands.Context): @emojilink.command(name="search") async def emoji_search(self, ctx: commands.Context, keyword: str): - """ - Search for custom emojis based on their names or keywords. - - Parameters: - - keyword: The search keyword. - """ + """Search custom emojis by name.""" matching_emojis = [ - f"{emoji}: [Link]({emoji_url})" - for emoji, emoji_url in self.get_all_emojis(ctx.guild.emojis) - if keyword.lower() in emoji.name.lower() # Compare against emoji name + f"{emoji}: [Link]({emoji_url})" + for emoji, emoji_url in self.get_all_emojis(ctx.guild.emojis) + if hasattr(emoji, "name") and keyword.lower() in emoji.name.lower() ] if matching_emojis: await ctx.send("\n".join(matching_emojis)) else: - await ctx.send(f"No custom emojis found matching the keyword '{keyword}'.") + await ctx.send(f"No custom emojis found matching '{keyword}'.") + + # ----------------------------- + # Helpers + # ----------------------------- def get_all_emojis(self, emojis): - """ - Helper function to extract all emojis and their URLs from a list of emojis. - """ all_emojis = [] for emoji in emojis: if isinstance(emoji, discord.PartialEmoji): - emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{emoji.animated and 'gif' or 'png'}" - all_emojis.append((str(emoji), emoji_url)) + emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{ 'gif' if emoji.animated else 'png'}" + all_emojis.append((emoji, emoji_url)) elif isinstance(emoji, str): - # Unicode emojis don't have a direct image link all_emojis.append((emoji, None)) return all_emojis + # ----------------------------- + # Add / Copy / Delete / Rename + # ----------------------------- + @emojilink.command(name="add", aliases=["create"]) @commands.has_permissions(manage_emojis=True) async def add_emoji(self, ctx: commands.Context, name: str, source: typing.Union[discord.PartialEmoji, str] = None): - """Add a custom emoji to the server from a URL, attachment, or existing emoji.""" - # Validate emoji name - if not name.isalnum() and not '_' in name: + """Add a custom emoji with automatic background removal.""" + if not name.isalnum() and "_" not in name: return await ctx.send("Emoji name must contain only letters, numbers, and underscores.") - if len(name) < 2 or len(name) > 32: return await ctx.send("Emoji name must be between 2 and 32 characters long.") - - # Check for attachment if no source is provided if not source and not ctx.message.attachments: - return await ctx.send("Please provide either an existing emoji, URL, or attach an image file.") - + return await ctx.send("Provide an existing emoji, URL, or attach an image file.") + try: async with ctx.typing(): async with aiohttp.ClientSession() as session: - # Handle different source types if isinstance(source, discord.PartialEmoji): - # Copy existing emoji - url = f"https://cdn.discordapp.com/emojis/{source.id}.{source.animated and 'gif' or 'png'}" + url = f"https://cdn.discordapp.com/emojis/{source.id}.{ 'gif' if source.animated else 'png'}" elif isinstance(source, str) and source.startswith(('http://', 'https://')): - # Direct URL url = source elif ctx.message.attachments: - # Attachment url = ctx.message.attachments[0].url else: - return await ctx.send("Invalid source. Please provide an existing emoji, valid URL, or attach an image file.") - + return await ctx.send("Invalid source.") + async with session.get(url) as response: if response.status != 200: return await ctx.send("Failed to fetch image.") image_data = await response.read() - await ctx.guild.create_custom_emoji(name=name, image=image_data) - await ctx.send(f"Emoji '{name}' added successfully.") + + # === Background removal === + image = Image.open(io.BytesIO(image_data)).convert("RGBA") + datas = image.getdata() + new_data = [] + for item in datas: + if item[0] > 240 and item[1] > 240 and item[2] > 240: + new_data.append((255, 255, 255, 0)) + else: + new_data.append(item) + image.putdata(new_data) + buffered = io.BytesIO() + image.save(buffered, format="PNG") + buffered.seek(0) + image_data = buffered.read() + # === End background removal === + + await ctx.guild.create_custom_emoji(name=name, image=image_data) + await ctx.send(f"Emoji '{name}' added successfully.") except discord.HTTPException as e: await ctx.send(f"Failed to add emoji: {e.text}") except discord.Forbidden: @@ -237,32 +220,42 @@ async def add_emoji(self, ctx: commands.Context, name: str, source: typing.Union except Exception as e: await ctx.send(f"An unexpected error occurred: {e}") - @emojilink.command(name="copy", require_var_positional=True) + @emojilink.command(name="copy") @commands.has_permissions(manage_emojis=True) async def copy_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): - """ - Copy a custom emoji from one server to another. - - Parameters: - - emoji: The custom emoji to copy. - """ - # Check if the user has permission to manage emojis + """Copy a custom emoji with automatic background removal.""" if not ctx.author.guild_permissions.manage_emojis: return await ctx.send("You do not have permission to manage emojis.") - if not ctx.guild.me.guild_permissions.manage_emojis: return await ctx.send("I do not have permissions to manage emojis in this server.") try: async with ctx.typing(): - emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{emoji.animated and 'gif' or 'png'}" + emoji_url = f"https://cdn.discordapp.com/emojis/{emoji.id}.{ 'gif' if emoji.animated else 'png'}" async with aiohttp.ClientSession() as session: async with session.get(emoji_url) as response: if response.status != 200: return await ctx.send("Failed to fetch emoji image.") image_data = await response.read() - await ctx.guild.create_custom_emoji(name=emoji.name, image=image_data) - await ctx.send(f"Emoji '{emoji.name}' copied successfully.") + + # === Background removal === + image = Image.open(io.BytesIO(image_data)).convert("RGBA") + datas = image.getdata() + new_data = [] + for item in datas: + if item[0] > 240 and item[1] > 240 and item[2] > 240: + new_data.append((255, 255, 255, 0)) + else: + new_data.append(item) + image.putdata(new_data) + buffered = io.BytesIO() + image.save(buffered, format="PNG") + buffered.seek(0) + image_data = buffered.read() + # === End background removal === + + await ctx.guild.create_custom_emoji(name=emoji.name, image=image_data) + await ctx.send(f"Emoji '{emoji.name}' copied successfully.") except discord.HTTPException as e: await ctx.send(f"Failed to copy emoji: {e.text}") except discord.Forbidden: @@ -270,54 +263,40 @@ async def copy_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): except Exception as e: await ctx.send(f"An unexpected error occurred: {e}") - @emojilink.command(name="delete", require_var_positional=True) + @emojilink.command(name="delete") @commands.has_permissions(manage_emojis=True) async def delete_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji): - """ - Delete a custom emoji from the server. - - Parameters: - - emoji: The custom emoji to delete. - """ + """Delete a custom emoji from the server.""" try: - # Find the emoji in the guild's emoji list guild_emoji = discord.utils.get(ctx.guild.emojis, id=emoji.id) if guild_emoji is None: return await ctx.send("This emoji doesn't exist in this server.") - - # Create confirmation view + view = discord.ui.View(timeout=30) confirm_button = discord.ui.Button(label="Confirm", style=discord.ButtonStyle.danger) cancel_button = discord.ui.Button(label="Cancel", style=discord.ButtonStyle.secondary) - + async def confirm_callback(interaction: discord.Interaction): if interaction.user != ctx.author: return await interaction.response.send_message("You cannot use this button.", ephemeral=True) - try: await guild_emoji.delete() await interaction.response.edit_message(content=f"Emoji '{emoji.name}' deleted successfully.", view=None) except Exception as e: await interaction.response.edit_message(content=f"Failed to delete emoji: {e}", view=None) - + async def cancel_callback(interaction: discord.Interaction): if interaction.user != ctx.author: return await interaction.response.send_message("You cannot use this button.", ephemeral=True) - await interaction.response.edit_message(content="Emoji deletion cancelled.", view=None) - + confirm_button.callback = confirm_callback cancel_button.callback = cancel_callback - + view.add_item(confirm_button) view.add_item(cancel_button) - - # Send confirmation message - await ctx.send( - f"Are you sure you want to delete the emoji {emoji}?", - view=view - ) - + + await ctx.send(f"Are you sure you want to delete the emoji {emoji}?", view=view) except Exception as e: await ctx.send(f"An unexpected error occurred: {e}") @@ -325,14 +304,11 @@ async def cancel_callback(interaction: discord.Interaction): @commands.has_permissions(manage_emojis=True) async def rename_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji, new_name: str): """Rename a custom emoji in the server.""" - # Validate new name - if not new_name.isalnum() and not '_' in new_name: + if not new_name.isalnum() and "_" not in new_name: return await ctx.send("Emoji name must contain only letters, numbers, and underscores.") - if len(new_name) < 2 or len(new_name) > 32: return await ctx.send("Emoji name must be between 2 and 32 characters long.") - # Find the emoji in the guild's emoji list guild_emoji = discord.utils.get(ctx.guild.emojis, id=emoji.id) if guild_emoji is None: return await ctx.send("This emoji doesn't exist in this server.") @@ -345,4 +321,4 @@ async def rename_emoji(self, ctx: commands.Context, emoji: discord.PartialEmoji, except discord.Forbidden: await ctx.send("I do not have permissions to rename emojis.") except Exception as e: - await ctx.send(f"An unexpected error occurred: {e}") \ No newline at end of file + await ctx.send(f"An unexpected error occurred: {e}") diff --git a/skysearch/README.md b/skysearch/README.md index bd06afe..4a042c1 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -77,4 +77,9 @@ Notes: - `[p]skysearch apistats_save` - Manually save API statistics (owner only) ### Dashboard Integration -- `/dashboard/apistats` - Web interface for viewing API statistics and performance metrics +- `/third-parties/Skysearch` - Web interface for the cog +there is 4 total pages in it + - `Main Page` - shows stats for airplanes.live and tagged aircraft (tags aren't currently updated) + - `Apistats` - shows apistats for the cog itself + - `Guild` - allows you to change cog settings in the dashboard (uses ids, to get them enable developer mode on discord) + - `Lookup` - allows you to lookup data directly in the cog dashboard page diff --git a/skysearch/commands/admin.py b/skysearch/commands/admin.py index c74a075..e8b0caf 100644 --- a/skysearch/commands/admin.py +++ b/skysearch/commands/admin.py @@ -160,6 +160,9 @@ async def list_alert_channels(self, ctx): next_iteration = self.cog.check_emergency_squawks.next_iteration now = datetime.datetime.now(datetime.timezone.utc) if next_iteration: + # Ensure timezone-aware arithmetic by coercing naive datetime to UTC + if next_iteration.tzinfo is None: + next_iteration = next_iteration.replace(tzinfo=datetime.timezone.utc) time_remaining = (next_iteration - now).total_seconds() if time_remaining > 0: time_remaining_formatted = f"" @@ -607,7 +610,7 @@ async def apistats_debug(self, ctx): ) await ctx.send(embed=embed) - async def add_custom_alert(self, ctx, alert_type: str, value: str, cooldown: int = 5, channel: discord.TextChannel = None): + async def add_custom_alert(self, ctx, alert_type: str, value: str, cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): """Add a custom alert for specific aircraft or squawks. Alert types: @@ -657,6 +660,7 @@ async def add_custom_alert(self, ctx, alert_type: str, value: str, cooldown: int 'value': value, 'cooldown': cooldown, 'custom_channel': channel.id if channel else None, + 'custom_role': role.id if role else None, 'created_by': ctx.author.id, 'created_at': datetime.datetime.utcnow().isoformat(), 'last_triggered': None @@ -665,9 +669,10 @@ async def add_custom_alert(self, ctx, alert_type: str, value: str, cooldown: int await guild_config.custom_alerts.set(custom_alerts) channel_info = f" to {channel.mention}" if channel else " to default alert channel" + role_info = f" and will mention {role.mention}" if role else "" embed = discord.Embed( title="✅ Custom Alert Added", - description=f"Added alert for {alert_type} '{value}' with {cooldown} minute cooldown{channel_info}", + description=f"Added alert for {alert_type} '{value}' with {cooldown} minute cooldown{channel_info}{role_info}", color=0x00ff00 ) await ctx.send(embed=embed) @@ -741,7 +746,7 @@ async def list_custom_alerts(self, ctx): if alert_info['last_triggered']: last_triggered = datetime.datetime.fromisoformat(alert_info['last_triggered']).strftime("%Y-%m-%d %H:%M UTC") - # Get channel information + # Get channel and role information custom_channel_id = alert_info.get('custom_channel') channel_info = "" if custom_channel_id: @@ -750,6 +755,13 @@ async def list_custom_alerts(self, ctx): channel_info = f"**Channel:** {channel_name}\n" else: channel_info = "**Channel:** Default\n" + + custom_role_id = alert_info.get('custom_role') + role_info = "" + if custom_role_id: + role_obj = ctx.guild.get_role(custom_role_id) + role_name = role_obj.mention if role_obj else f"<@&{custom_role_id}>" + role_info = f"**Role:** {role_name}\n" embed.add_field( name=f"🔔 {alert_id}", @@ -757,6 +769,7 @@ async def list_custom_alerts(self, ctx): f"**Value:** {alert_info['value']}\n" f"**Cooldown:** {alert_info['cooldown']} minutes\n" f"{channel_info}" + f"{role_info}" f"**Created:** {created_at.strftime('%Y-%m-%d %H:%M UTC')}\n" f"**Last Triggered:** {last_triggered}", inline=False diff --git a/skysearch/dashboard/dashboard_integration.py b/skysearch/dashboard/dashboard_integration.py index 5157eff..7a6fd6a 100644 --- a/skysearch/dashboard/dashboard_integration.py +++ b/skysearch/dashboard/dashboard_integration.py @@ -752,6 +752,7 @@ def __init__(self): alert_value = wtforms.StringField("Alert Value", render_kw={"class": "form-field", "placeholder": "Enter value to monitor..."}) cooldown = wtforms.IntegerField("Cooldown (minutes)", render_kw={"class": "form-field", "placeholder": "5", "min": "1", "max": "1440"}) custom_channel = wtforms.StringField("Custom Channel ID (optional)", render_kw={"class": "form-field", "placeholder": "Leave empty to use default alert channel"}) + custom_role = wtforms.StringField("Custom Role ID (optional)", render_kw={"class": "form-field", "placeholder": "Leave empty to use default alert role"}) submit_alert = wtforms.SubmitField("Add Alert", render_kw={"class": "form-submit"}) # Lightweight CSRF-only form for remove action @@ -816,6 +817,7 @@ def __init__(self): alert_value = alert_form.alert_value.data.strip() cooldown = alert_form.cooldown.data or 5 custom_channel_id = alert_form.custom_channel.data.strip() if alert_form.custom_channel.data else None + custom_role_id = alert_form.custom_role.data.strip() if alert_form.custom_role.data else None if not alert_value: result_html = ''' @@ -841,6 +843,18 @@ def __init__(self): Error: Custom channel not found. Please enter a valid channel ID.
''' + elif custom_role_id and not custom_role_id.isdigit(): + result_html = ''' +
+ Error: Custom role ID must be a valid numeric ID. +
+ ''' + elif custom_role_id and not guild.get_role(int(custom_role_id)): + result_html = ''' +
+ Error: Custom role not found. Please enter a valid role ID. +
+ ''' else: alert_id = f"{alert_type}_{alert_value.lower()}" @@ -856,6 +870,7 @@ def __init__(self): 'value': alert_value, 'cooldown': cooldown, 'custom_channel': int(custom_channel_id) if custom_channel_id else None, + 'custom_role': int(custom_role_id) if custom_role_id else None, 'created_by': 'dashboard_user', 'created_at': datetime.datetime.utcnow().isoformat(), 'last_triggered': None @@ -869,10 +884,15 @@ def __init__(self): channel_info = f" to #{channel_name}" else: channel_info = " to default alert channel" + role_info = "" + if custom_role_id: + role_obj = guild.get_role(int(custom_role_id)) + role_name = f"@{role_obj.name}" if role_obj else f"<@&{custom_role_id}>" + role_info = f" and will mention {role_name}" result_html = f'''
- Success: Added alert for {alert_type} '{alert_value}' with {cooldown} minute cooldown{channel_info}. + Success: Added alert for {alert_type} '{alert_value}' with {cooldown} minute cooldown{channel_info}{role_info}.
''' @@ -921,7 +941,7 @@ def __init__(self): if alert_data['last_triggered']: last_triggered = datetime.datetime.fromisoformat(alert_data['last_triggered']).strftime("%Y-%m-%d %H:%M UTC") - # Get channel information + # Get channel and role information custom_channel_id = alert_data.get('custom_channel') channel_info = "" if custom_channel_id: @@ -930,13 +950,19 @@ def __init__(self): channel_info = f" | Channel: #{channel_name}" else: channel_info = " | Channel: Default" + role_info = "" + custom_role_id = alert_data.get('custom_role') + if custom_role_id: + role_obj = guild.get_role(custom_role_id) + role_name = f"@{role_obj.name}" if role_obj else f"<@&{custom_role_id}>" + role_info = f" | Role: {role_name}" alerts_html += f'''
🔔 {alert_id}
- Type: {alert_data['type']} | Value: {alert_data['value']} | Cooldown: {alert_data['cooldown']} min{channel_info}
+ Type: {alert_data['type']} | Value: {alert_data['value']} | Cooldown: {alert_data['cooldown']} min{channel_info}{role_info}
Created: {created_at.strftime('%Y-%m-%d %H:%M UTC')} | Last Triggered: {last_triggered}
diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index 538175d..09a7c72 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -225,7 +225,7 @@ async def aircraft_group(self, ctx): embed.add_field(name=_("Special Aircraft"), value="`military` `ladd` `pia`", inline=False) embed.add_field(name=_("Export"), value=_("`export` - Export aircraft data to CSV, PDF, TXT, or HTML"), inline=False) embed.add_field(name=_("Configuration"), value="`alertchannel` `alertrole` `autoicao` `autodelete` `showalertchannel` `setapimode` `apimode`", inline=False) - embed.add_field(name=_("Custom Alerts"), value="`addalert` `removealert` `listalerts` `clearalerts`\n*Use `addalert` with optional channel parameter*", inline=False) + embed.add_field(name=_("Custom Alerts"), value="`addalert` `removealert` `listalerts` `clearalerts`\n*Use `addalert` with optional channel and role parameters*", inline=False) # Add brief mention of force and cooldown clear for owners if await ctx.bot.is_owner(ctx.author): embed.add_field(name=_("Custom Alert Admin"), value="`forcealert` (owner) `clearalertcooldown`", inline=False) @@ -312,6 +312,10 @@ async def aircraft_feeder(self, ctx, *, json_input: str = None): """Extract feeder URL from JSON data or a URL containing feeder data using secure modal.""" await self.aircraft_commands.extract_feeder_url(ctx, json_input=json_input) + + + + # Admin commands @aircraft_group.command(name='alertchannel') async def aircraft_alertchannel(self, ctx, channel: discord.TextChannel = None): @@ -370,17 +374,23 @@ async def aircraft_set_api_mode(self, ctx, mode: str): await self.config.api_mode.set(mode) await ctx.send(f"✅ API mode set to **{mode}**.") + + + + + # Custom Alerts Commands @commands.guild_only() @aircraft_group.command(name='addalert') - async def aircraft_add_alert(self, ctx, alert_type: str, value: str, cooldown: int = 5, channel: discord.TextChannel = None): + async def aircraft_add_alert(self, ctx, alert_type: str, value: str, cooldown: int = 5, channel: discord.TextChannel = None, role: discord.Role = None): """Add a custom alert for specific aircraft or squawks. Alert types: icao, callsign, squawk, type, reg Cooldown: 1-1440 minutes (default: 5) Channel: Optional channel to send alerts to (default: uses alert channel) + Role: Optional role to ping for this alert (overrides default alert role) """ - await self.admin_commands.add_custom_alert(ctx, alert_type, value, cooldown, channel) + await self.admin_commands.add_custom_alert(ctx, alert_type, value, cooldown, channel, role) @commands.guild_only() @aircraft_group.command(name='removealert') @@ -572,6 +582,9 @@ async def check_emergency_squawks(self): 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, @@ -617,11 +630,11 @@ async def check_emergency_squawks(self): 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 - https://discord.gg/X8huyaeXrA" + 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 - https://discord.gg/X8huyaeXrA" + 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://twitter.com/intent/tweet?text={urllib.parse.quote_plus(tweet_text)}" + 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" @@ -638,18 +651,34 @@ async def check_emergency_squawks(self): # 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 - - # Send the message using the possibly modified data + # 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( content=message_data.get('content'), embed=message_data.get('embed'), - view=message_data.get('view') + view=message_data.get('view'), + allowed_mentions=allowed_mentions ) # Let other cogs react after the message is sent @@ -805,8 +834,9 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a else: log.warning(f"Custom channel {custom_channel_id} not found for alert {alert_id}, using default channel") - # Get the alert role - alert_role_id = await guild_config.alert_role() + # Get the alert role (prefer per-alert custom_role, else guild default) + custom_role_id = alert_data.get('custom_role') + alert_role_id = custom_role_id if custom_role_id else await guild_config.alert_role() alert_role_mention = f"<@&{alert_role_id}>" if alert_role_id else "" # Prepare message data to mirror emergency alert style (pre/post hooks support) @@ -857,8 +887,8 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a except Exception: pass - tweet_text = f"Custom alert triggered! {alert_data['type'].upper()} '{alert_data['value']}' spotted - Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #CustomAlert\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={urllib.parse.quote_plus(tweet_text)}" + tweet_text = f"alert triggered! {alert_data['type'].upper()} '{alert_data['value']}' spotted - Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. #SkySearch #discordAlert\n\nJoin via Discord to search and discuss planes with your friends for free - discord.gg/WW4eNQj9qr" + tweet_url = f"https://x.com/intent/tweet?text={urllib.parse.quote_plus(tweet_text)}" view.add_item(discord.ui.Button(label="Post on X", emoji="📣", url=tweet_url, style=discord.ButtonStyle.link)) whatsapp_text = f"Custom alert! {alert_data['type'].upper()} '{alert_data['value']}' spotted - Flight {aircraft_data.get('flight', '')} at position {lat}, {lon} with speed {ground_speed_mph} mph. Track live @ https://globe.airplanes.live/?icao={icao} #SkySearch" @@ -879,11 +909,19 @@ async def _send_custom_alert(self, alert_channel, guild_config, aircraft_info, a log.warning(f"Pre-send callback removed view for custom alert {alert_id}, restoring buttons") message_data['view'] = original_view - # Send the message using the possibly modified data + # Send the message using the possibly modified data (allow role mentions) + allowed_mentions = None + if alert_role_id: + role_obj = alert_channel.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( content=message_data.get('content'), embed=message_data.get('embed'), - view=message_data.get('view') + view=message_data.get('view'), + allowed_mentions=allowed_mentions ) # Let other cogs react after the message is sent @@ -1048,11 +1086,11 @@ async def simulate_emergency_alert(self, ctx, hex_code: str, squawk_code: str = 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 - https://discord.gg/X8huyaeXrA" + 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 - https://discord.gg/X8huyaeXrA" + 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://twitter.com/intent/tweet?text={urllib.parse.quote_plus(tweet_text)}" + 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" @@ -1079,11 +1117,19 @@ async def simulate_emergency_alert(self, ctx, hex_code: str, squawk_code: str = log.warning(f"Pre-send callback removed view for {hex_code}, restoring buttons") message_data['view'] = original_view - # Send the message using the possibly modified data + # 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( content=message_data.get('content'), embed=message_data.get('embed'), - view=message_data.get('view') + view=message_data.get('view'), + allowed_mentions=allowed_mentions ) # Let other cogs react after the message is sent