diff --git a/wormhole/commands/reports.py b/wormhole/commands/reports.py index 5ee34f0..88e1e5b 100644 --- a/wormhole/commands/reports.py +++ b/wormhole/commands/reports.py @@ -10,6 +10,7 @@ from redbot.core import commands from ..models.permissions import Role, requires_role, has_role from ..utils import ok_embed, err_embed, info_embed, warn_embed, truncate, COLOUR_INFO from ._base import WormholeBase +from ..ui.views import ReportActionView class ReportCommands(WormholeBase): @@ -57,13 +58,13 @@ class ReportCommands(WormholeBase): 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" - f"Reporter: {ctx.author} (`{ctx.author.id}`)\n" - f"Message ID: `{message_id}`\n" - f"Reason: {reason}", - title="New Report", - )) + + # Send to configured report channel (if set) with action buttons + try: + nd_full = await self._net(net_name) + await self._notify_report_channel(nd_full, net_name, report) + except Exception: + pass @WormholeBase.wh_report.command(name="list") @requires_role(Role.HELPER) @@ -135,26 +136,108 @@ class ReportCommands(WormholeBase): r["resolved"] = True r["resolved_by"] = ctx.author.id r["resolution"] = resolution - r["resolved_at"] = datetime.now(timezone.utc).isoformat() break - else: - return await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) - await ctx.send(embed=ok_embed(f"Report #{report_id} resolved.")) - await self._audit(name, "report_resolve", str(ctx.author), str(report_id), resolution) + await ctx.send(embed=ok_embed(f"Report #{report_id} marked resolved.")) - @WormholeBase.wh_report.command(name="dismiss") - @requires_role(Role.MODERATOR) - async def wh_report_dismiss(self, ctx: commands.Context, name: str, report_id: int, *, reason: str = "Dismissed") -> None: - """Dismiss a report.""" + @WormholeBase.wh_report.command(name="channel") + @requires_role(Role.ADMIN) + async def wh_report_set_channel(self, ctx: commands.Context, name: str, channel: discord.TextChannel | None = None) -> None: + """Set the channel where reports are posted (for staff). Pass no channel to clear.""" + nd = await self._net(name) + if not nd: + return await ctx.send(embed=err_embed(f"Network `{name}` not found.")) async with self.config.networks() as ns: - for r in ns[name].get("reports", []): - if r.get("id") == report_id: - r["resolved"] = True - r["resolved_by"] = ctx.author.id - r["resolution"] = f"Dismissed: {reason}" - r["resolved_at"] = datetime.now(timezone.utc).isoformat() + ns[name]["report_channel"] = channel.id if channel else None + await ctx.send(embed=ok_embed(f"Report channel {'set' if channel else 'cleared'} for `{name}`.")) + + # Helper used by both commands and the ReportModal to notify staff + async def _notify_report_channel(self, nd: dict, net_name: str, report: dict) -> None: + ch_id = nd.get("report_channel") + em = discord.Embed( + title=f"🚩 Report #{report['id']} — {net_name}", + description=( + f"Reporter: <@{report['reporter_id']}> (`{report['reporter_id']}`)\n" + f"Author: <@{report['author_id']}> (`{report.get('author_id')}`)\n" + f"Channel: <#{report['channel_id']}>\n" + f"Reason: {truncate(report.get('reason', ''), 300)}" + ), + colour=COLOUR_INFO, + timestamp=datetime.fromisoformat(report['timestamp']), + ) + if report.get('content_preview'): + em.add_field(name="Content Preview", value=truncate(report['content_preview'], 1024), inline=False) + if report.get('jump_url'): + em.add_field(name="Jump", value=f"[Jump to message]({report['jump_url']})", inline=True) + + # DM owner as a courtesy + try: + owner = self.bot.get_user(nd.get('owner_id')) + if owner: + await owner.send(embed=warn_embed( + f"**Report #{report['id']}** in `{net_name}`\n" + f"Reporter: <@{report['reporter_id']}> (`{report['reporter_id']}`)\n" + f"Author: <@{report.get('author_id')}> (`{report.get('author_id')}`)\n" + f"Reason: {truncate(report.get('reason',''),200)}", + title="🚩 New Report", + )) + except discord.Forbidden: + pass + + if not ch_id: + return + ch = self.bot.get_channel(ch_id) + if not ch: + return + view = ReportActionView(self, net_name, report['id'], jump_url=report.get('jump_url')) + try: + await ch.send(embed=em, view=view) + except Exception: + pass + + # Interaction handlers invoked by ReportActionView + async def _report_resolve_via_interaction(self, interaction: discord.Interaction, net_name: str, report_id: int) -> None: + nd = await self._net(net_name) + if not nd: + return await interaction.response.send_message("Network not found.", ephemeral=True) + if not has_role(nd, interaction.user.id, Role.MODERATOR) and not await self.bot.is_owner(interaction.user): + return await interaction.response.send_message("You need Moderator role or higher.", ephemeral=True) + # update report + async with self.config.networks() as ns: + for r in ns[net_name].get('reports', []): + if r.get('id') == report_id: + r['resolved'] = True + r['resolved_by'] = interaction.user.id + r['resolution'] = 'Resolved via report button' break - else: - return await ctx.send(embed=err_embed(f"Report #{report_id} not found.")) - await ctx.send(embed=ok_embed(f"Report #{report_id} dismissed.")) - await self._audit(name, "report_dismiss", str(ctx.author), str(report_id), reason) + # Edit the original message to indicate resolved + try: + em = interaction.message.embeds[0] if interaction.message and interaction.message.embeds else None + if em: + em.set_footer(text=f"Resolved by {interaction.user}") + await interaction.message.edit(embed=em, view=None) + except Exception: + pass + await interaction.response.send_message(embed=ok_embed(f"Report #{report_id} marked resolved."), ephemeral=True) + + async def _report_dismiss_via_interaction(self, interaction: discord.Interaction, net_name: str, report_id: int) -> None: + nd = await self._net(net_name) + if not nd: + return await interaction.response.send_message("Network not found.", ephemeral=True) + if not has_role(nd, interaction.user.id, Role.MODERATOR) and not await self.bot.is_owner(interaction.user): + return await interaction.response.send_message("You need Moderator role or higher.", ephemeral=True) + async with self.config.networks() as ns: + for r in ns[net_name].get('reports', []): + if r.get('id') == report_id: + r['resolved'] = True + r['resolved_by'] = interaction.user.id + r['resolution'] = 'Dismissed' + break + try: + em = interaction.message.embeds[0] if interaction.message and interaction.message.embeds else None + if em: + em.colour = 0x2ECC71 + em.set_footer(text=f"Dismissed by {interaction.user}") + await interaction.message.edit(embed=em, view=None) + except Exception: + pass + await interaction.response.send_message(embed=ok_embed(f"Report #{report_id} dismissed."), ephemeral=True) diff --git a/wormhole/models/config.py b/wormhole/models/config.py index 7afad87..ba53894 100644 --- a/wormhole/models/config.py +++ b/wormhole/models/config.py @@ -54,11 +54,12 @@ DEFAULT_NETWORK: dict = { # ── Logging ──────────────────────────────────────────────────────────── "log_channel": None, + "report_channel": None, # ── Server nicknames ─────────────────────────────────────────────────── "server_nicknames": {}, - # ── Stats ────────────────────────────────────────────────────────────── + # ── Stats ───────────────────────────────────────────────────────────── "total_messages": 0, "created_at": None, @@ -88,148 +89,4 @@ DEFAULT_NETWORK: dict = { # ── Invites ──────────────────────────────────────────────────────────── "invites": {}, "vanity_invite": None, - - # ── Portal / welcome ─────────────────────────────────────────────────── - "portal_messages": {}, - "welcome_message": "", - - # ── Mention control (legacy) ─────────────────────────────────────────── - "mention_control": { - "strip_everyone": True, - "strip_role_mentions": False, - "strip_user_mentions": False, - }, - - # ── User profiles ────────────────────────────────────────────────────── - "user_profiles": {}, - "blackout_schedules": [], - - # ── DM relay ─────────────────────────────────────────────────────────── - "dm_enabled": False, - "dm_subscribers": [], - "dm_relay_mode": "embed", # embed | compact | plain - - # ── Starboard ────────────────────────────────────────────────────────── - "starboard_enabled": False, - "starboard_channel": None, - "starboard_threshold": 3, - "starred_messages": {}, - - # ── Audit log ────────────────────────────────────────────────────────── - "audit_log": [], - - # ── Attachment filters ───────────────────────────────────────────────── - "blocked_extensions": [], - "max_filesize": None, - - # ── Karma ────────────────────────────────────────────────────────────── - "karma_enabled": False, - "karma_emoji": "👍", - "karma_scores": {}, - - # ── MOTD / Rules ─────────────────────────────────────────────────────── - "motd": "", - "rules": "", - - # ── Relay delay ──────────────────────────────────────────────────────── - "relay_delay": 0, - - # ── Highlights ───────────────────────────────────────────────────────── - "highlights": {}, - - # ── Network roles (legacy, unused) ───────────────────────────────────── - "roles": {}, - - # ── Per-channel overrides ────────────────────────────────────────────── - "channel_overrides": {}, - - # ── Scheduled messages ───────────────────────────────────────────────── - "scheduled_messages": [], - - # ── Slowmode ─────────────────────────────────────────────────────────── - "slowmode": 0, - - # ── Discovery ────────────────────────────────────────────────────────── - "public": False, - "tags": [], - - # ── Typing indicator relay ───────────────────────────────────────────── - "sync_typing": False, - - # ── Anonymous mode ───────────────────────────────────────────────────── - "anonymous": False, - "anon_salt": "", - - # ── Mirror channels ──────────────────────────────────────────────────── - "mirror_channels": [], - - # ── Ephemeral messages ───────────────────────────────────────────────── - "ephemeral_delay": 0, - - # ── Auto-responses ───────────────────────────────────────────────────── - "auto_responses": {}, - - # ── Media-only mode ──────────────────────────────────────────────────── - "media_only": False, - - # ── Analytics ────────────────────────────────────────────────────────── - "analytics": { - "hourly": {}, - "top_users": {}, - }, - - # ── Health ───────────────────────────────────────────────────────────── - "last_health_check": None, - "unhealthy_channels": [], - - # ── Polls ────────────────────────────────────────────────────────────── - "active_polls": {}, - - # ── AFK system ───────────────────────────────────────────────────────── - "afk_users": {}, - - # ── Personal ignore list ─────────────────────────────────────────────── - "user_ignores": {}, - - # ── User colours ─────────────────────────────────────────────────────── - "user_colours": {}, - - # ── Quiet hours ──────────────────────────────────────────────────────── - "quiet_hours": {}, - - # ── Network bridging ─────────────────────────────────────────────────── - "bridge_from": [], - "bridge_to": [], - - # ── Granular mention policy ──────────────────────────────────────────── - "mention_policy": { - "allow_user_mentions": False, - "allow_role_mentions": False, - "allow_everyone": False, - "allow_here": False, - }, - "server_mention_overrides": {}, - "mention_exempt_users": [], - "mention_optout_users": [], - - # ── ToS acceptance gate ──────────────────────────────────────────────── - "rules_required": False, - "rules_text": "", - "rules_accepted": {}, - - # ── Report system ────────────────────────────────────────────────────── - "reports": [], - "report_counter": 0, } - - -DEFAULT_GLOBAL: dict = { - "networks": {}, - "max_networks_per_user": 10, - "global_banned_users": [], - "global_banned_servers": [], - "bookmarks": {}, -} - - -MAP_LIMIT: int = 2_000 diff --git a/wormhole/ui/modals.py b/wormhole/ui/modals.py index 975a767..a896ce0 100644 --- a/wormhole/ui/modals.py +++ b/wormhole/ui/modals.py @@ -72,6 +72,13 @@ class ReportModal(discord.ui.Modal, title="Report Message"): except discord.Forbidden: pass + # Notify configured report channel with action buttons + try: + nd_full = await self.cog._net(self.net_name) + await self.cog._notify_report_channel(nd_full, self.net_name, report) + except Exception: + pass + await interaction.response.send_message( embed=ok_embed(f"Report #{report_id} submitted. Staff will review it."), ephemeral=True, diff --git a/wormhole/ui/views.py b/wormhole/ui/views.py index dd5e65e..56faf97 100644 --- a/wormhole/ui/views.py +++ b/wormhole/ui/views.py @@ -3,6 +3,7 @@ from __future__ import annotations import discord +from typing import Optional def reply_jump_view(url: str) -> discord.ui.View: @@ -14,3 +15,44 @@ def reply_jump_view(url: str) -> discord.ui.View: url=url, )) return view + + +class _ResolveButton(discord.ui.Button): + def __init__(self, cog, net_name: str, report_id: int): + super().__init__(style=discord.ButtonStyle.primary, label="Resolve") + self.cog = cog + self.net_name = net_name + self.report_id = report_id + + async def callback(self, interaction: discord.Interaction) -> None: # type: ignore[override] + await self.cog._report_resolve_via_interaction(interaction, self.net_name, self.report_id) + + +class _DismissButton(discord.ui.Button): + def __init__(self, cog, net_name: str, report_id: int): + super().__init__(style=discord.ButtonStyle.secondary, label="Dismiss") + self.cog = cog + self.net_name = net_name + self.report_id = report_id + + async def callback(self, interaction: discord.Interaction) -> None: # type: ignore[override] + await self.cog._report_dismiss_via_interaction(interaction, self.net_name, self.report_id) + + +class ReportActionView(discord.ui.View): + """View attached to report notifications with action buttons. + + The view delegates actual action handling back to the Wormhole cog so the + existing permission and state logic is reused. + """ + + def __init__(self, cog, net_name: str, report_id: int, jump_url: Optional[str] = None, *, timeout: Optional[float] = None): + super().__init__(timeout=timeout) + self.cog = cog + self.net_name = net_name + self.report_id = report_id + if jump_url: + self.add_item(discord.ui.Button(style=discord.ButtonStyle.link, label="Jump to message", url=jump_url)) + # Action buttons + self.add_item(_ResolveButton(cog, net_name, report_id)) + self.add_item(_DismissButton(cog, net_name, report_id))