diff --git a/README.md b/README.md index 84d54f8..369c624 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,18 @@ DM-based application system with admin review channels. - `?remindme` - `/timestamp` +### Admin utilities +- `/say` — Make the bot send a message. +- `/edit` — Edit a bot message sent via `/say` (by message ID; opens a modal). +- `/react` — Add a reaction as the bot (emoji + optional message link; defaults to last message in the channel). + +### Moderation +- `?chowkidar` (alias: `?ch`) — Start tracking a user (staff only). +- `?lc` (alias: `?listchowki`) — List currently tracked users (staff only). + +### CodeBuddy practice +- `/question ` — Sends a practice MCQ; reply with `a`/`b`/`c` in that channel to get ✅/❌ feedback (no points). + ### Social - `?quote` - `?meme` diff --git a/bot.py b/bot.py index 35f6aaa..4166ac7 100644 --- a/bot.py +++ b/bot.py @@ -162,7 +162,7 @@ async def on_ready(self): # Set presence await self.change_presence( - activity=discord.Game(name="?helpmenu | Made by YC45") + activity=discord.Game(name="?helpmenu /help | Made by YC45") ) async def on_command_error(self, ctx: commands.Context, error: commands.CommandError): diff --git a/cogs/chowkidar.py b/cogs/chowkidar.py index 0c7188a..338d3de 100644 --- a/cogs/chowkidar.py +++ b/cogs/chowkidar.py @@ -52,7 +52,7 @@ async def setwlchannel(self, ctx): await ctx.send(embed=EmbedBuilder.success_embed("Channel Configured", f"Watchlog channel has been set to {ctx.channel.mention}.")) - @commands.hybrid_command(name="chowkidar", description="Start tracking a user.") + @commands.hybrid_command(name="chowkidar", description="Start tracking a user.", aliases=["ch"]) @is_staff() async def chowkidar(self, ctx, user: discord.Member): if user.id == self.bot.user.id: @@ -70,6 +70,57 @@ async def chowkidar(self, ctx, user: discord.Member): await ctx.send(embed=EmbedBuilder.success_embed("Tracking Initiated", f"Now tracking actions for {user.mention}.")) + @commands.command(name="lc", aliases=["listchowki"]) + @is_staff() + async def list_chowki(self, ctx: commands.Context): + """List all users currently tracked by Chowkidar.""" + if not ctx.guild: + await ctx.send( + embed=EmbedBuilder.error_embed( + "Server Only", + "This command can only be used in a server.", + ) + ) + return + + if not self.watched_users: + await ctx.send( + embed=EmbedBuilder.error_embed( + "No Tracked Users", + "No one is currently being tracked.", + ) + ) + return + + lines: list[str] = [] + for user_id in sorted(self.watched_users): + member = ctx.guild.get_member(user_id) + if member is not None: + display = f"{member}" + else: + user = self.bot.get_user(user_id) + display = f"{user}" if user is not None else "Unknown User" + lines.append(f"- {display} • {user_id}") + + # Discord message limit safety. Keep the output concise. + chunks: list[str] = [] + current = "" + for line in lines: + if len(current) + len(line) + 1 > 3500: + chunks.append(current) + current = "" + current += ("\n" if current else "") + line + if current: + chunks.append(current) + + for i, chunk in enumerate(chunks, 1): + embed = discord.Embed( + title="Chowkidar Tracked Users" if len(chunks) == 1 else f"Chowkidar Tracked Users ({i}/{len(chunks)})", + description=chunk, + color=discord.Color.blurple(), + ) + await ctx.send(embed=embed) + @commands.hybrid_command(name="endwl", description="Stop tracking a user.") @is_staff() async def endwl(self, ctx, user: discord.Member): diff --git a/cogs/codebuddy_leaderboard.py b/cogs/codebuddy_leaderboard.py index 95cfd32..5450655 100644 --- a/cogs/codebuddy_leaderboard.py +++ b/cogs/codebuddy_leaderboard.py @@ -301,7 +301,7 @@ async def codestreak(self, interaction: discord.Interaction): except Exception: pass - @commands.command(name="codestreak", aliases=["cs", "cslb"]) + @commands.command(name="codestreak", aliases=["csl", "cslb"]) async def codestreak_prefix(self, ctx): """Display the streak leaderboard.""" try: diff --git a/cogs/codebuddy_quiz.py b/cogs/codebuddy_quiz.py index 0c1af98..65e345f 100644 --- a/cogs/codebuddy_quiz.py +++ b/cogs/codebuddy_quiz.py @@ -1,7 +1,7 @@ import os import random -from typing import Optional, cast - +from typing import Optional, cast, TypedDict +import time import discord from discord import app_commands from discord.ext import commands, tasks @@ -23,6 +23,11 @@ ) +class _PracticeSession(TypedDict): + created_at: float + correct: str + + class CodeBuddyQuizCog(commands.Cog): def __init__(self, bot: commands.Bot, question_channel_id: int): self.bot = bot @@ -37,6 +42,21 @@ def __init__(self, bot: commands.Bot, question_channel_id: int): self.frequency_minutes = 25 + # Practice question sessions ("knowledge mode") for /question. + # Keyed by (user_id, channel_id) and validated on the user's next a/b/c message. + self._practice_sessions: dict[tuple[int, int], _PracticeSession] = {} + self._practice_timeout_seconds = 120 + + def _cleanup_practice_sessions(self) -> None: + now = time.monotonic() + expired_keys = [ + key + for key, data in self._practice_sessions.items() + if now - data.get("created_at", 0.0) > self._practice_timeout_seconds + ] + for key in expired_keys: + self._practice_sessions.pop(key, None) + async def cog_load(self): self.post_question_loop.change_interval(minutes=self.frequency_minutes) self.post_question_loop.start() @@ -124,141 +144,179 @@ async def before_post_question(self): @commands.Cog.listener() async def on_message(self, message: discord.Message): try: - if ( - message.author.bot - or not self.question_active - or message.channel.id != self.channel_id - ): + if message.author.bot: return - user_id = message.author.id - content = message.content.lower().strip() + # 1) Main scheduled quiz answer checking (points/streaks) in the quiz channel. + if self.question_active and message.channel.id == self.channel_id: + user_id = message.author.id + content = message.content.lower().strip() - if content not in ["a", "b", "c"]: - return + if content not in ["a", "b", "c"]: + return - if user_id in self.ignored_users: - return + if user_id in self.ignored_users: + return - if content == self.current_answer: - try: - await message.add_reaction("✅") - except Exception: - pass + if content == self.current_answer: + try: + await message.add_reaction("✅") + except Exception: + pass - points = 2 if self.bonus_active else 1 - extra_bonus = 0 + points = 2 if self.bonus_active else 1 + extra_bonus = 0 - try: - await increment_user_score(user_id, points) - except Exception as e: - print(f"[Error incrementing user score]: {e}") + try: + await increment_user_score(user_id, points) + except Exception as e: + print(f"[Error incrementing user score]: {e}") - try: - quest_completed = await increment_quest_quiz_count(user_id) - if quest_completed: + try: + quest_completed = await increment_quest_quiz_count(user_id) + if quest_completed: + try: + quest_embed = discord.Embed( + title="Quest Completed!", + description=( + f"{message.author.mention} You completed the **Quiz** quest!\n\n" + "**Rewards Earned:**\n" + "• **0.2** Streak Freeze\n" + "• **0.5** Save\n\n" + "Use `?inventory` to check your items!" + ), + color=0x000000, + ) + await message.channel.send(embed=quest_embed) + except Exception as e: + print(f"[Error sending quest completion message]: {e}") + except Exception as e: + print(f"[Error updating quest progress]: {e}") + + try: + lb = await get_leaderboard(100) + except Exception as e: + print(f"[Error fetching leaderboard]: {e}") + lb = [] + + streak = 0 + for uid, score, s, best in lb: + if uid == user_id: + streak = s + try: + if streak == 3: + extra_bonus = 1 + await increment_user_score(user_id, extra_bonus) + elif streak == 5: + extra_bonus = 2 + await increment_user_score(user_id, extra_bonus) + except Exception as e: + print(f"[Error applying streak bonus]: {e}") + break + + total_points = points + extra_bonus + title = f"{streak}x Streak!" + embed = discord.Embed( + title=title, + description=f"{message.author.mention} answered correctly and earned **{total_points} point(s)**!", + color=discord.Color.green(), + ) + if extra_bonus > 0: + embed.add_field( + name="Streak Bonus", value=f"+{extra_bonus}", inline=True + ) + if self.bonus_active: + embed.set_footer(text="Bonus Question!") + try: + await message.channel.send(embed=embed) + except Exception as e: + print(f"[Error sending success embed]: {e}") + + self._reset_question_state() + else: + self.ignored_users.add(user_id) + + try: + await message.add_reaction("❌") + except Exception: + pass + + freeze_used = False + try: + freeze_used = await use_streak_freeze(user_id) + except Exception as e: + print(f"[Error checking streak freeze]: {e}") + + if freeze_used: try: - quest_embed = discord.Embed( - title="Quest Completed!", + freeze_embed = discord.Embed( + title="Streak Freeze Activated!", description=( - f"{message.author.mention} You completed the **Quiz** quest!\n\n" - "**Rewards Earned:**\n" - "• **0.2** Streak Freeze\n" - "• **0.5** Save\n\n" - "Use `?inventory` to check your items!" + f"{message.author.mention} Wrong answer, but your **Streak Freeze** protected your streak!\n\n" + "Your streak remains intact." ), color=0x000000, ) - await message.channel.send(embed=quest_embed) + freeze_embed.set_footer( + text="Earn more freezes by completing daily quests!" + ) + await message.channel.send(embed=freeze_embed) except Exception as e: - print(f"[Error sending quest completion message]: {e}") - except Exception as e: - print(f"[Error updating quest progress]: {e}") - - try: - lb = await get_leaderboard(100) - except Exception as e: - print(f"[Error fetching leaderboard]: {e}") - lb = [] + print(f"[Error sending freeze message]: {e}") + else: + try: + await reset_user_streak(user_id) + except Exception as e: + print(f"[Error resetting user streak]: {e}") - streak = 0 - for uid, score, s, best in lb: - if uid == user_id: - streak = s try: - if streak == 3: - extra_bonus = 1 - await increment_user_score(user_id, extra_bonus) - elif streak == 5: - extra_bonus = 2 - await increment_user_score(user_id, extra_bonus) + await message.channel.send( + f"{message.author.mention} Wrong answer! Streak reset to 0." + ) + except discord.Forbidden: + pass except Exception as e: - print(f"[Error applying streak bonus]: {e}") - break - - total_points = points + extra_bonus - title = f"{streak}x Streak!" - embed = discord.Embed( - title=title, - description=f"{message.author.mention} answered correctly and earned **{total_points} point(s)**!", - color=discord.Color.green(), - ) - if extra_bonus > 0: - embed.add_field( - name="Streak Bonus", value=f"+{extra_bonus}", inline=True - ) - if self.bonus_active: - embed.set_footer(text="Bonus Question!") - try: - await message.channel.send(embed=embed) - except Exception as e: - print(f"[Error sending success embed]: {e}") + print(f"[Error sending wrong answer message]: {e}") - self._reset_question_state() - else: - self.ignored_users.add(user_id) + return - try: - await message.add_reaction("❌") - except Exception: - pass + # 2) Practice question answer checking (no points) in any channel. + self._cleanup_practice_sessions() + content = message.content.lower().strip() + if content not in ["a", "b", "c"]: + return - freeze_used = False - try: - freeze_used = await use_streak_freeze(user_id) - except Exception as e: - print(f"[Error checking streak freeze]: {e}") + session_key = (message.author.id, message.channel.id) + session = self._practice_sessions.get(session_key) + if not session: + return - if freeze_used: - try: - freeze_embed = discord.Embed( - title="Streak Freeze Activated!", - description=( - f"{message.author.mention} Wrong answer, but your **Streak Freeze** protected your streak!\n\n" - "Your streak remains intact." - ), - color=0x000000, - ) - freeze_embed.set_footer( - text="Earn more freezes by completing daily quests!" - ) - await message.channel.send(embed=freeze_embed) - except Exception as e: - print(f"[Error sending freeze message]: {e}") - else: - try: - await reset_user_streak(user_id) - except Exception as e: - print(f"[Error resetting user streak]: {e}") + created_at = session.get("created_at", 0.0) + if time.monotonic() - created_at > self._practice_timeout_seconds: + self._practice_sessions.pop(session_key, None) + return - try: - await message.channel.send( - f"{message.author.mention} Wrong answer! Streak reset to 0." - ) - except discord.Forbidden: - pass - except Exception as e: - print(f"[Error sending wrong answer message]: {e}") + correct = session.get("correct", "").lower().strip() + try: + if content == correct: + await message.add_reaction("✅") + await message.channel.send( + f"{message.author.mention} Correct! ✅", + allowed_mentions=discord.AllowedMentions(users=True), + delete_after=10, + ) + else: + await message.add_reaction("❌") + await message.channel.send( + f"{message.author.mention} Wrong. Correct answer is **{correct}**.", + allowed_mentions=discord.AllowedMentions(users=True), + delete_after=12, + ) + except Exception: + pass + finally: + # One attempt per practice question. + self._practice_sessions.pop(session_key, None) except Exception as e: print(f"[Unexpected error in on_message]: {e}") @@ -287,6 +345,20 @@ async def question(self, interaction: discord.Interaction, category: str): ) await interaction.response.send_message(embed=embed) + # Store correct answer for this user's next a/b/c message in this channel. + # (Practice questions are not tied to the scheduled quiz channel.) + try: + msg = await interaction.original_response() + channel_id = msg.channel.id + except Exception: + channel_id = interaction.channel_id + + if channel_id is not None: + self._practice_sessions[(interaction.user.id, int(channel_id))] = { + "created_at": time.monotonic(), + "correct": str(q.get("correct", "")).lower().strip(), + } + except Exception as e: print(f"[Unexpected error in /question]: {e}") if not interaction.response.is_done(): @@ -304,7 +376,7 @@ async def question(self, interaction: discord.Interaction, category: str): ) async def frequency(self, interaction: discord.Interaction, minutes: int): try: - if not interaction.user.guild_permissions.manage_guild: + if not isinstance(interaction.user, discord.Member) or not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message( "You need `Manage Server` permission to change quiz frequency.", ephemeral=True, diff --git a/cogs/counting.py b/cogs/counting.py index 063855a..a156dcd 100644 --- a/cogs/counting.py +++ b/cogs/counting.py @@ -587,11 +587,54 @@ async def fail_count(self, message, current_count, reason): except Exception: used_personal = False + # Only spend a server save with explicit confirmation. if not used_personal: + guild_units = 0 try: - used_guild = await try_use_guild_save(guild_id) + guild_units = await get_guild_save_units(guild_id) except Exception: - used_guild = False + guild_units = 0 + + if guild_units >= 10: + prompt_msg: Optional[discord.Message] = None + try: + prompt_msg = await message.channel.send( + f"{message.author.mention} do you want to waste a server save for your foolish ruin?" + ) + except Exception: + prompt_msg = None + + if prompt_msg is not None: + try: + await prompt_msg.add_reaction("✅") + await prompt_msg.add_reaction("❌") + except Exception: + pass + + def _check(reaction: discord.Reaction, user: discord.abc.User) -> bool: + if user.bot: + return False + if user.id != message.author.id: + return False + if reaction.message.id != prompt_msg.id: + return False + return str(reaction.emoji) in {"✅", "❌"} + + try: + reaction, _user = await self.bot.wait_for( + "reaction_add", timeout=20.0, check=_check + ) + if str(reaction.emoji) == "✅": + try: + used_guild = await try_use_guild_save(guild_id) + except Exception: + used_guild = False + else: + used_guild = False + except asyncio.TimeoutError: + used_guild = False + except Exception: + used_guild = False if used_personal or used_guild: try: diff --git a/cogs/help.py b/cogs/help.py index b06284f..8e3c13e 100644 --- a/cogs/help.py +++ b/cogs/help.py @@ -33,7 +33,7 @@ "tags": "Create and manage custom text snippets for your server", "communitycommands": "Engage your community with quotes and memes", "election": "Democratic voting system with weighted votes", - "misc": "Support commands, bug reports, feedback, timestamps, and more", + "misc": "Support commands, timestamps, admin utilities (/say, /edit, /react), and more", "starboardsystem": "Highlight the best messages with stars", "utilityextra": "Extra utility commands like reminders, dice, and emotes", "afksystem": "Away From Keyboard system - Set AFK status with custom reasons, auto-respond to mentions, and track time away", @@ -47,6 +47,7 @@ "staffapplications": "Staff application panel, review buttons, and admin config", "suggestions": "Submit suggestions with voting reactions + discussion threads", "bumpleaderboard": "Track Disboard /bump activity with leaderboards and stats", + "chowkidar": "Watchlist / tracking: track a user and log their actions (staff only)", } diff --git a/cogs/misc.py b/cogs/misc.py index d5e8369..43387d0 100644 --- a/cogs/misc.py +++ b/cogs/misc.py @@ -8,9 +8,52 @@ from typing import Optional, Any, Union from datetime import datetime, timezone, timedelta import calendar +import re +import aiosqlite from better_profanity import profanity from utils.config import Config +from utils.codebuddy_database import DB_PATH + + +class _EditSayModal(discord.ui.Modal, title="Edit Bot Message"): + def __init__(self, *, target_message: discord.Message): + super().__init__(timeout=300) + self._target_message = target_message + default_text = (target_message.content or "")[:2000] + self.message_text = discord.ui.TextInput( + label="Message", + style=discord.TextStyle.paragraph, + required=True, + max_length=2000, + default=default_text, + ) + self.add_item(self.message_text) + + async def on_submit(self, interaction: discord.Interaction) -> None: + new_text = str(self.message_text.value).strip() + if not new_text: + await interaction.response.send_message( + "❌ Message cannot be empty.", + ephemeral=True, + ) + return + try: + await self._target_message.edit(content=new_text) + except discord.Forbidden: + await interaction.response.send_message( + "❌ I don't have permission to edit that message.", + ephemeral=True, + ) + return + except Exception as e: + await interaction.response.send_message( + f"❌ Could not edit message: {e}", + ephemeral=True, + ) + return + + await interaction.response.send_message("✅ Message edited!", ephemeral=True) class Misc(commands.Cog): @@ -20,6 +63,74 @@ def __init__(self, bot: commands.Bot, config: Config): self.bot = bot self.config = config + async def cog_load(self): + # Track which messages were created via /say so only those are editable via /edit. + try: + async with aiosqlite.connect(DB_PATH, timeout=30.0) as db: + await db.execute( + """ + CREATE TABLE IF NOT EXISTS say_messages ( + message_id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + """ + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_say_messages_guild ON say_messages (guild_id)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_say_messages_channel ON say_messages (channel_id)" + ) + await db.commit() + except Exception as e: + print(f"[Misc] Error ensuring say_messages table: {e}") + + async def _is_say_message(self, guild_id: int, message_id: int) -> Optional[tuple[int, int]]: + """Return (guild_id, channel_id) if message is tracked as /say.""" + try: + async with aiosqlite.connect(DB_PATH, timeout=30.0) as db: + async with db.execute( + "SELECT guild_id, channel_id FROM say_messages WHERE message_id = ?", + (message_id,), + ) as cursor: + row = await cursor.fetchone() + if not row: + return None + if int(row[0]) != int(guild_id): + return None + return int(row[0]), int(row[1]) + except Exception: + return None + + async def _record_say_message( + self, + guild_id: int, + channel_id: int, + message_id: int, + author_id: int, + ) -> None: + try: + async with aiosqlite.connect(DB_PATH, timeout=30.0) as db: + await db.execute( + """ + INSERT OR REPLACE INTO say_messages (message_id, guild_id, channel_id, author_id, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + int(message_id), + int(guild_id), + int(channel_id), + int(author_id), + int(datetime.now(timezone.utc).timestamp()), + ), + ) + await db.commit() + except Exception as e: + print(f"[Misc] Error recording /say message: {e}") + @commands.hybrid_command(name='join-vc', description='Join your voice channel for fun') async def join_vc(self, ctx: commands.Context): """Join the invoker's voice channel (only if it is not empty).""" @@ -700,7 +811,18 @@ async def say(self, interaction: discord.Interaction, text: str): return # Send the text in the channel - await interaction.channel.send(text) + sent = await interaction.channel.send(text) + + # Track this message so it can be edited later via /edit. + try: + await self._record_say_message( + interaction.guild.id, + sent.channel.id, + sent.id, + interaction.user.id, + ) + except Exception: + pass # Confirm to admin (ephemeral so only they see it) await interaction.response.send_message( @@ -708,6 +830,196 @@ async def say(self, interaction: discord.Interaction, text: str): ephemeral=True ) + @app_commands.command( + name="edit", + description="Edit a bot message sent via /say (Admin only).", + ) + @app_commands.describe(message_id="Message ID of the /say message to edit") + @app_commands.default_permissions(administrator=True) + async def edit(self, interaction: discord.Interaction, message_id: str): + """Open a modal to edit an existing /say message by ID.""" + if not interaction.guild: + await interaction.response.send_message( + "❌ This command can only be used in a server.", + ephemeral=True, + ) + return + + if not isinstance(interaction.user, discord.Member) or not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "❌ This command is restricted to administrators only.", + ephemeral=True, + ) + return + + try: + mid = int(str(message_id).strip()) + except Exception: + await interaction.response.send_message( + "❌ Invalid message ID.", + ephemeral=True, + ) + return + + say_row = await self._is_say_message(interaction.guild.id, mid) + if not say_row: + await interaction.response.send_message( + "❌ Only messages sent using /say are editable with /edit.", + ephemeral=True, + ) + return + + _guild_id, channel_id = say_row + channel = interaction.guild.get_channel(channel_id) or self.bot.get_channel(channel_id) + if not isinstance(channel, (discord.TextChannel, discord.Thread)): + await interaction.response.send_message( + "❌ Could not find the channel for that message.", + ephemeral=True, + ) + return + + try: + target_message = await channel.fetch_message(mid) + except discord.NotFound: + await interaction.response.send_message( + "❌ Message not found (it may have been deleted).", + ephemeral=True, + ) + return + except Exception as e: + await interaction.response.send_message( + f"❌ Could not fetch message: {e}", + ephemeral=True, + ) + return + + if self.bot.user is None or target_message.author.id != self.bot.user.id: + await interaction.response.send_message( + "❌ That message wasn't sent by me.", + ephemeral=True, + ) + return + + await interaction.response.send_modal(_EditSayModal(target_message=target_message)) + + @app_commands.command( + name="react", + description="React to a message as the bot (Admin only).", + ) + @app_commands.describe( + emoji="Emoji to react with (unicode or custom emoji like <:name:id>)", + message_link="Optional message link to react to; if omitted, reacts to the last message in this channel", + ) + @app_commands.default_permissions(administrator=True) + async def react( + self, + interaction: discord.Interaction, + emoji: str, + message_link: Optional[str] = None, + ): + if not interaction.guild: + await interaction.response.send_message( + "❌ This command can only be used in a server.", + ephemeral=True, + ) + return + + if not isinstance(interaction.user, discord.Member) or not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "❌ This command is restricted to administrators only.", + ephemeral=True, + ) + return + + if not isinstance(interaction.channel, discord.abc.Messageable): + await interaction.response.send_message( + "❌ Cannot use this command in this channel type.", + ephemeral=True, + ) + return + + target_channel: Optional[discord.abc.Messageable] = interaction.channel + target_message: Optional[discord.Message] = None + + if message_link: + raw = message_link.strip().lstrip("<").rstrip(">") + match = re.search(r"channels/(\d+)/(\d+)/(\d+)", raw) + if match: + guild_id = int(match.group(1)) + channel_id = int(match.group(2)) + message_id = int(match.group(3)) + if guild_id != interaction.guild.id: + await interaction.response.send_message( + "❌ That message link is from a different server.", + ephemeral=True, + ) + return + ch = interaction.guild.get_channel(channel_id) or self.bot.get_channel(channel_id) + if not isinstance(ch, (discord.TextChannel, discord.Thread)): + await interaction.response.send_message( + "❌ Could not access that channel.", + ephemeral=True, + ) + return + target_channel = ch + try: + target_message = await ch.fetch_message(message_id) + except Exception: + target_message = None + else: + # Allow providing just a message ID for the current channel. + try: + message_id = int(raw) + if isinstance(interaction.channel, (discord.TextChannel, discord.Thread)): + target_message = await interaction.channel.fetch_message(message_id) + except Exception: + target_message = None + + if target_message is None: + await interaction.response.send_message( + "❌ Could not find that message.", + ephemeral=True, + ) + return + else: + # React to the last message in this channel. + try: + async for m in interaction.channel.history(limit=1): + target_message = m + break + except Exception: + target_message = None + + if target_message is None: + await interaction.response.send_message( + "❌ No messages found in this channel.", + ephemeral=True, + ) + return + + try: + await target_message.add_reaction(emoji) + except discord.Forbidden: + await interaction.response.send_message( + "❌ I don't have permission to add reactions here.", + ephemeral=True, + ) + return + except discord.HTTPException: + await interaction.response.send_message( + "❌ Failed to add that reaction (invalid emoji or missing access).", + ephemeral=True, + ) + return + except Exception as e: + await interaction.response.send_message( + f"❌ Failed to react: {e}", + ephemeral=True, + ) + return + + await interaction.response.send_message("✅ Reaction added!", ephemeral=True) + @commands.command(name='dm', description='Explains why you should not DM members for help') async def dm_command(self, ctx: commands.Context): """Explains why questions should be asked in the server instead of DMs."""