EveCogs/wormhole/commands/reports.py

243 lines
12 KiB
Python

"""Report system commands — create, view, resolve, dismiss reports."""
from __future__ import annotations
from datetime import datetime, timezone
import discord
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):
"""Mixin — user report system (per-network)."""
@WormholeBase.wh_report.command(name="msg")
async def wh_report_msg(self, ctx: commands.Context, message_id: int, *, reason: str = "No reason provided") -> None:
"""Report a message by ID."""
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."))
# 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:
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)
# 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)
async def wh_report_list(self, ctx: commands.Context, name: str, show_resolved: bool = False) -> None:
"""List reports for a network."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
reports = nd.get("reports", [])
if not show_resolved:
reports = [r for r in reports if not r.get("resolved")]
if not reports:
return await ctx.send(embed=info_embed("No open reports."))
lines = []
for r in reports[-20:]:
status = "✅" if r.get("resolved") else "🔴"
reporter = self.bot.get_user(r.get("reporter_id"))
lines.append(
f"{status} **#{r['id']}** — by {reporter or r.get('reporter_id')}\n"
f" Reason: {truncate(r.get('reason', ''), 80)}"
)
em = discord.Embed(title=f"🚩 Reports — {name}", description="\n".join(lines), colour=COLOUR_INFO)
await ctx.send(embed=em)
@WormholeBase.wh_report.command(name="view")
@requires_role(Role.HELPER)
async def wh_report_view(self, ctx: commands.Context, name: str, report_id: int) -> None:
"""View details of a specific report."""
nd = await self._net(name)
if not nd:
return await ctx.send(embed=err_embed(f"Network `{name}` not found."))
report = None
for r in nd.get("reports", []):
if r.get("id") == report_id:
report = r
break
if not report:
return await ctx.send(embed=err_embed(f"Report #{report_id} not found."))
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"):
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)
if report.get("resolution"):
em.add_field(name="Resolution", value=report["resolution"], inline=False)
await ctx.send(embed=em)
@WormholeBase.wh_report.command(name="resolve")
@requires_role(Role.MODERATOR)
async def wh_report_resolve(self, ctx: commands.Context, name: str, report_id: int, *, resolution: str = "Resolved") -> None:
"""Resolve a report."""
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"] = resolution
break
await ctx.send(embed=ok_embed(f"Report #{report_id} marked resolved."))
@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:
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
# 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)