diff --git a/wormhole/commands/reports.py b/wormhole/commands/reports.py index 76fd587..5ee34f0 100644 --- a/wormhole/commands/reports.py +++ b/wormhole/commands/reports.py @@ -21,23 +21,41 @@ class ReportCommands(WormholeBase): net_name = await self._net_for_ch(ctx.channel.id) if not net_name: return await ctx.send(embed=err_embed("This channel isn't in a network.")) - nd = await self._net(net_name) - report_id = nd.get("report_counter", 0) + 1 - report = { - "id": report_id, - "reporter_id": ctx.author.id, - "message_id": message_id, - "channel_id": ctx.channel.id, - "guild_id": ctx.guild.id if ctx.guild else None, - "reason": reason, - "timestamp": datetime.now(timezone.utc).isoformat(), - "resolved": False, - "resolved_by": None, - "resolution": None, - } + + # Fetch the message so the report actually records who wrote it and + # what it said, instead of just a bare, unverified id. + target_message = None + try: + target_message = await ctx.channel.fetch_message(message_id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + pass + + # report_id must be computed and written inside the same locked + # section โ€” reading report_counter beforehand let two concurrent + # reports land on the same id and left the counter not advanced. async with self.config.networks() as ns: - ns[net_name].setdefault("reports", []).append(report) - ns[net_name]["report_counter"] = report_id + nd = ns.get(net_name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{net_name}` not found.")) + report_id = nd.get("report_counter", 0) + 1 + report = { + "id": report_id, + "reporter_id": ctx.author.id, + "author_id": target_message.author.id if target_message else None, + "message_id": message_id, + "channel_id": ctx.channel.id, + "guild_id": ctx.guild.id if ctx.guild else None, + "content_preview": truncate(target_message.content or "*[no text]*", 300) if target_message else None, + "jump_url": target_message.jump_url if target_message else None, + "reason": reason, + "timestamp": datetime.now(timezone.utc).isoformat(), + "resolved": False, + "resolved_by": None, + "resolution": None, + } + nd.setdefault("reports", []).append(report) + nd["report_counter"] = report_id + await ctx.send(embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."), delete_after=10) await self._log(nd, warn_embed( f"๐Ÿšฉ **Report #{report_id}**\n" @@ -87,11 +105,19 @@ class ReportCommands(WormholeBase): reporter = self.bot.get_user(report.get("reporter_id")) em = discord.Embed(title=f"๐Ÿšฉ Report #{report_id}", colour=COLOUR_INFO) em.add_field(name="Reporter", value=str(reporter or report.get("reporter_id")), inline=True) + if report.get("author_id"): + author = self.bot.get_user(report["author_id"]) + em.add_field(name="Message Author", value=str(author or report["author_id"]), inline=True) em.add_field(name="Status", value="Resolved" if report.get("resolved") else "Open", inline=True) em.add_field(name="Reason", value=report.get("reason", "N/A"), inline=False) + if report.get("content_preview"): + em.add_field(name="Message Content", value=truncate(report["content_preview"], 1024), inline=False) em.add_field(name="Timestamp", value=report.get("timestamp", "?")[:16], inline=True) if report.get("message_id"): - em.add_field(name="Message ID", value=str(report["message_id"]), inline=True) + id_value = f"`{report['message_id']}`" + if report.get("jump_url"): + id_value += f" โ€” [Jump]({report['jump_url']})" + em.add_field(name="Message ID", value=id_value, inline=True) if report.get("resolved_by"): resolver = self.bot.get_user(report["resolved_by"]) em.add_field(name="Resolved By", value=str(resolver or report["resolved_by"]), inline=True) diff --git a/wormhole/core.py b/wormhole/core.py index ae79685..23cd367 100644 --- a/wormhole/core.py +++ b/wormhole/core.py @@ -158,8 +158,7 @@ class WormholeV4( self._bg_tasks.append(asyncio.create_task(self._scheduled_msg_loop())) self._bg_tasks.append(asyncio.create_task(self._health_check_loop())) self._bg_tasks.append(asyncio.create_task(self._poll_expiry_loop())) - # Context menus disabled - eliminates registration complexity - # self._register_context_menus() + self._register_context_menus() self._register_find_origin_context_menu() self._ready.set() log.info("Wormhole v%s ready โ€” %d networks loaded", self.__version__, len(nets)) @@ -173,8 +172,7 @@ class WormholeV4( decorator on the mixin classes โ€” do NOT manually register duplicates. """ # Remove any existing context menus first (idempotent registration) - menu_names = ("Report to Wormhole", "Bookmark (Wormhole)", - "Delete from Network", "Wormhole Profile") + menu_names = ("Report to Wormhole", "Delete from Network", "Wormhole Profile") for name in menu_names: try: self.bot.tree.remove_command(name, type=discord.AppCommandType.message) @@ -183,11 +181,9 @@ class WormholeV4( # Register context menus ctx_report = app_commands.ContextMenu(name="Report to Wormhole", callback=self._ctx_report_message) - ctx_bookmark = app_commands.ContextMenu(name="Bookmark (Wormhole)", callback=self._ctx_bookmark_message) ctx_delete = app_commands.ContextMenu(name="Delete from Network", callback=self._ctx_delete_message) ctx_profile = app_commands.ContextMenu(name="Wormhole Profile", callback=self._ctx_view_profile) self.bot.tree.add_command(ctx_report) - self.bot.tree.add_command(ctx_bookmark) self.bot.tree.add_command(ctx_delete) self.bot.tree.add_command(ctx_profile) @@ -195,8 +191,7 @@ class WormholeV4( for task in self._bg_tasks: task.cancel() # Remove context menus (must specify type=discord.AppCommandType.message) - for cmd_name in ("Report to Wormhole", "Bookmark (Wormhole)", - "Delete from Network", "Wormhole Profile", "Find Origin"): + for cmd_name in ("Report to Wormhole", "Delete from Network", "Wormhole Profile", "Find Origin"): self.bot.tree.remove_command(cmd_name, type=discord.AppCommandType.message) # โ”€โ”€ Background task loops โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -662,25 +657,6 @@ class WormholeV4( modal = ReportModal(self, net_name, message) await interaction.response.send_modal(modal) - async def _ctx_bookmark_message(self, interaction: discord.Interaction, message: discord.Message) -> None: - uid_str = str(interaction.user.id) - bookmark = { - "content": truncate(message.content or "*[no text]*", 500), - "author": str(message.author), - "server": message.guild.name if message.guild else "DM", - "channel": message.channel.name if hasattr(message.channel, "name") else "DM", - "timestamp": datetime.now(timezone.utc).isoformat(), - "jump_url": message.jump_url, - } - async with self.config.bookmarks() as bm: - bm.setdefault(uid_str, []).append(bookmark) - if len(bm[uid_str]) > 50: - bm[uid_str] = bm[uid_str][-50:] - await interaction.response.send_message( - embed=ok_embed("Message bookmarked! Use `wh bm list` to view."), - ephemeral=True, - ) - async def _ctx_delete_message(self, interaction: discord.Interaction, message: discord.Message) -> None: net_name = await self._net_for_ch(message.channel.id) if not net_name: @@ -716,30 +692,59 @@ class WormholeV4( async def _ctx_view_profile(self, interaction: discord.Interaction, user: discord.User) -> None: nets = await self.config.networks() + global_banned = await self.config.global_banned_users() + profiles_found = [] + total_messages = 0 for name, nd in nets.items(): p = nd.get("user_profiles", {}).get(str(user.id)) - if p: - role = get_role(nd, user.id) - profiles_found.append((name, p, role)) + if not p: + continue + role = get_role(nd, user.id) + karma = nd.get("karma_scores", {}).get(str(user.id), 0) + is_banned = user.id in nd.get("banned_users", []) + is_muted = user.id in nd.get("muted_users", []) + profiles_found.append((name, p, role, karma, is_banned, is_muted)) + total_messages += p.get("message_count", 0) + if not profiles_found: await interaction.response.send_message("No wormhole profile found for this user.", ephemeral=True) return - em = discord.Embed( - title=f"๐ŸŒ€ Wormhole Profile โ€” {user.display_name}", - colour=COLOUR_INFO, - ) + + # Busiest networks first โ€” most relevant at a glance + profiles_found.sort(key=lambda t: t[1].get("message_count", 0), reverse=True) + + em = discord.Embed(title=f"๐ŸŒ€ Wormhole Profile โ€” {user.display_name}", colour=COLOUR_INFO) em.set_thumbnail(url=user.display_avatar.url) - for name, p, role in profiles_found[:10]: + em.add_field(name="User", value=f"{user}\n`{user.id}`", inline=True) + em.add_field(name="Account Created", value=discord.utils.format_dt(user.created_at, "R"), inline=True) + em.add_field(name="Networks Active In", value=str(len(profiles_found)), inline=True) + em.add_field(name="Total Messages (all networks)", value=f"{total_messages:,}", inline=True) + + if user.id in global_banned: + em.add_field(name="โš ๏ธ Global Status", value="**Banned from Wormhole network-wide**", inline=False) + + for name, p, role, karma, is_banned, is_muted in profiles_found[:10]: + status_flags = [] + if is_banned: + status_flags.append("๐Ÿšซ Banned") + if is_muted: + status_flags.append("๐Ÿ”‡ Muted") + status_str = " ยท ".join(status_flags) if status_flags else "โœ… Active" em.add_field( name=f"Network: {name}", value=( f"Role: **{role_name(role)}**\n" f"Messages: {p.get('message_count', 0):,}\n" - f"First seen: {p.get('first_seen', 'Unknown')[:10]}" + f"Karma: {karma}\n" + f"Servers seen in: {len(p.get('servers', []))}\n" + f"First seen: {p.get('first_seen', 'Unknown')[:10]}\n" + f"Status: {status_str}" ), inline=True, ) + if len(profiles_found) > 10: + em.set_footer(text=f"Showing 10 of {len(profiles_found)} networks (busiest first)") await interaction.response.send_message(embed=em, ephemeral=True) async def _ctx_find_origin(self, interaction: discord.Interaction, message: discord.Message) -> None: diff --git a/wormhole/ui/modals.py b/wormhole/ui/modals.py index 49348c9..975a767 100644 --- a/wormhole/ui/modals.py +++ b/wormhole/ui/modals.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING import discord -from ..utils import ok_embed, warn_embed +from ..utils import ok_embed, truncate, warn_embed if TYPE_CHECKING: from ..core import Wormhole @@ -29,30 +29,33 @@ class ReportModal(discord.ui.Modal, title="Report Message"): self.message = message async def on_submit(self, interaction: discord.Interaction) -> None: - nets = await self.cog.config.networks() - nd = nets.get(self.net_name) - if not nd: - await interaction.response.send_message("Network not found.", ephemeral=True) - return - - report_id = nd.get("report_counter", 0) + 1 - report = { - "id": report_id, - "reporter_id": interaction.user.id, - "author_id": self.message.author.id, - "channel_id": self.message.channel.id, - "guild_id": self.message.guild.id if self.message.guild else None, - "message_content_hash": str(hash(self.message.content or "")), - "reason": self.reason.value, - "timestamp": datetime.now(timezone.utc).isoformat(), - "resolved": False, - "resolved_by": None, - } - + # report_id must be computed and written inside the same locked + # section โ€” reading report_counter beforehand let two concurrent + # reports land on the same id and left the counter not advanced. async with self.cog.config.networks() as ns: - if self.net_name in ns: - ns[self.net_name].setdefault("reports", []).append(report) - ns[self.net_name]["report_counter"] = report_id + nd = ns.get(self.net_name) + if not nd: + await interaction.response.send_message("Network not found.", ephemeral=True) + return + + report_id = nd.get("report_counter", 0) + 1 + report = { + "id": report_id, + "reporter_id": interaction.user.id, + "author_id": self.message.author.id, + "message_id": self.message.id, + "channel_id": self.message.channel.id, + "guild_id": self.message.guild.id if self.message.guild else None, + "content_preview": truncate(self.message.content or "*[no text]*", 300), + "jump_url": self.message.jump_url, + "reason": self.reason.value, + "timestamp": datetime.now(timezone.utc).isoformat(), + "resolved": False, + "resolved_by": None, + "resolution": None, + } + nd.setdefault("reports", []).append(report) + nd["report_counter"] = report_id owner = self.cog.bot.get_user(nd.get("owner_id")) if owner: