# modules/pirate_report/pirate_report.py import re import time from datetime import datetime, timezone from urllib.parse import urlparse import discord from discord.ext import commands from discord import app_commands from modules.common.settings import cfg from mod_perms import ( is_moderator_member, is_moderator_userid, require_mod_ctx, require_mod_interaction, ) CHECK = '✅' CROSS = '❌' # --------------------- Helpers --------------------- _ACCT_RE = re.compile(r'.+#\d{5}$') DISCORD_MEDIA_HOSTS = {"cdn.discordapp.com", "media.discordapp.net"} MEDIA_EXTS_IMAGE = {".png", ".jpg", ".jpeg", ".gif", ".webp"} MEDIA_EXTS_VIDEO = {".mp4", ".webm", ".mov"} MEDIA_EXTS_ALL = MEDIA_EXTS_IMAGE | MEDIA_EXTS_VIDEO def _acct_ok(s: str) -> bool: return bool(_ACCT_RE.fullmatch(s.strip())) def _now_utc_str() -> str: return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC') def _parse_bool(s: str) -> bool: v = s.strip().lower() if v in ('y', 'yes', 'true', 't', '1'): return True if v in ('n', 'no', 'false', 'f', '0'): return False raise ValueError("Please enter yes or no") def _classify_discord_media(url: str): """ Return ('image'|'video', normalized_url) if valid Discord CDN media; else (None, reason). We only accept direct CDN links so media can render inline without leaving the channel. """ try: u = url.strip() if not u: return (None, "Empty URL.") pr = urlparse(u) if pr.scheme not in ("http", "https"): return (None, "URL must start with http:// or https://") host = pr.netloc.lower() if host not in DISCORD_MEDIA_HOSTS: return (None, "URL must be a **Discord media** link (cdn.discordapp.com or media.discordapp.net).") path = pr.path or "" dot = path.rfind(".") if dot == -1: return (None, "URL must end with a known media file extension.") ext = path[dot:].lower() if ext not in MEDIA_EXTS_ALL: return (None, f"Unsupported media type `{ext}`. Allowed: images {sorted(MEDIA_EXTS_IMAGE)}, videos {sorted(MEDIA_EXTS_VIDEO)}.") kind = "image" if ext in MEDIA_EXTS_IMAGE else "video" return (kind, u) except Exception: return (None, "Invalid URL format.") def _jump_url(guild_id: int, channel_id: int, message_id: int) -> str: return f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" def _make_report_embed(title: str, color: discord.Color, report_dict: dict, include_status: bool = False): e = discord.Embed(title=title, color=color, timestamp=datetime.utcnow()) e.add_field(name="Character", value=report_dict['character_name'], inline=False) e.add_field(name="Account", value=report_dict['account_name'], inline=False) e.add_field(name="Submitted by", value=f"<@{report_dict['submitter_id']}>", inline=False) proof_url = (report_dict.get('proof_url') or "").strip() proof_type = (report_dict.get('proof_type') or "").strip() if include_status: e.add_field( name="Status", value=report_dict.get('status_line', 'Pending'), inline=False ) # Show proof inline if it's an image; for videos we'll keep the URL in message content. if proof_url and proof_type == 'image': e.set_image(url=proof_url) # Add a compact proof field for quick visibility (always safe to include) e.add_field( name="Proof", value=proof_url if proof_url else "_No proof provided — strongly encouraged._", inline=False ) # A small hint footer e.set_footer(text="Attach proof when possible to speed up moderation.") return e # --------------------- Views ---------------------- class ReportJumpView(discord.ui.View): """Simple link button to jump to the user's original ack message.""" def __init__(self, url: str): super().__init__(timeout=None) if url: self.add_item(discord.ui.Button(label="Jump to message", style=discord.ButtonStyle.link, url=url)) # --------------------- Modals ---------------------- class ReportModal(discord.ui.Modal, title="Submit Pirate Report"): def __init__(self, cog: "PirateReportCog"): super().__init__() self.cog = cog self.character_name = discord.ui.TextInput( label="In-game nickname", placeholder="e.g., SandStalker", max_length=64, required=True ) self.account_name = discord.ui.TextInput( label="Account (Name#12345)", placeholder="Must end with # and 5 digits", max_length=64, required=True ) self.proof_url = discord.ui.TextInput( label="Proof (Discord media URL — optional)", placeholder="Direct Discord CDN link to image/video (highly encouraged, but optional)", required=False, max_length=300 ) self.add_item(self.character_name) self.add_item(self.account_name) self.add_item(self.proof_url) async def on_submit(self, interaction: discord.Interaction): if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) char = self.character_name.value.strip() acct = self.account_name.value.strip() proof_raw = (self.proof_url.value or "").strip() if not _acct_ok(acct): return await interaction.response.send_message( "❌ Invalid account format. It must end with `#` and **five digits** (e.g. `SomeUser#12345`).", ephemeral=True ) proof_type = "" proof_val = "" if proof_raw: pt, pv = _classify_discord_media(proof_raw) if pt is None: return await interaction.response.send_message(f"❌ Invalid proof link: {pv}", ephemeral=True) proof_type, proof_val = pt, pv # valid dm = self.cog.bot.data_manager char_l = char.lower() acct_l = acct.lower() # Anti-spam: 60s per reporter now = time.time() recent = [r for r in dm.get('reports') if r.get('submitter_id') == interaction.user.id and (now - r.get('ts', now)) < 60] if recent: return await interaction.response.send_message("⏱️ Please wait 60 seconds before submitting another report.", ephemeral=True) # Already approved? if any(p['account_name'].lower() == acct_l for p in dm.get('pirates')): return await interaction.response.send_message("❌ This player is already in the pirate list.", ephemeral=True) # Pending duplicate? if any(r['character_name'].lower() == char_l and r['account_name'].lower() == acct_l for r in dm.get('reports')): return await interaction.response.send_message("⏳ A report for this player is already pending moderation.", ephemeral=True) # Acknowledge in channel so we can edit later when approved/rejected try: ack = await interaction.channel.send(f"{interaction.user.mention} thanks — your report was sent to moderators for review.") except Exception as e: print("[pirate_report] ack send failed:", repr(e)) ack = None # Send to mod channel with ✅/❌, including inline media if present mod_ch = interaction.guild.get_channel(self.cog.mod_channel) if not mod_ch: return await interaction.response.send_message("❌ Mod channel not configured.", ephemeral=True) report_payload = { 'character_name': char, 'account_name': acct, 'submitter_id': interaction.user.id, 'proof_url': proof_val, 'proof_type': proof_type, } # Build jump button (if ack exists) view = None if ack: try: url = _jump_url(interaction.guild.id, interaction.channel.id, ack.id) view = ReportJumpView(url) except Exception: view = None try: embed = _make_report_embed("🚩 Pirate Report", discord.Color.orange(), report_payload, include_status=False) # Video must be in message content for inline player; images live in the embed content = proof_val if proof_type == 'video' else None mod_msg = await mod_ch.send(content=content, embed=embed, view=view) await mod_msg.add_reaction(CHECK) await mod_msg.add_reaction(CROSS) except Exception as e: print("[pirate_report] mod message failed:", repr(e)) return await interaction.response.send_message("❌ Failed to send to mod channel.", ephemeral=True) dm.add('reports', { 'report_id': mod_msg.id, 'guild_id': interaction.guild.id, 'character_name': char, 'account_name': acct, 'submitter_id': interaction.user.id, 'origin_channel_id': interaction.channel.id if interaction.channel else 0, 'ack_message_id': ack.id if ack else 0, 'status': 'pending', 'ts': now, 'proof_url': proof_val, 'proof_type': proof_type, }) await interaction.response.send_message("✅ Report submitted to moderators.", ephemeral=True) class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"): def __init__(self, cog: "PirateReportCog"): super().__init__() self.cog = cog self.old_account = discord.ui.TextInput( label="Current account (Name#12345)", placeholder="Exact current account in the list", max_length=64, required=True ) self.new_character = discord.ui.TextInput( label="New in-game nickname (optional)", placeholder="Leave blank to keep existing", max_length=64, required=False ) self.new_account = discord.ui.TextInput( label="New account (optional, Name#12345)", placeholder="Leave blank to keep existing", max_length=64, required=False ) self.add_item(self.old_account) self.add_item(self.new_character) self.add_item(self.new_account) async def on_submit(self, interaction: discord.Interaction): if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) if not is_moderator_member(interaction.user, self.cog.bot): return await interaction.response.send_message("This action is restricted to moderators.", ephemeral=True) old_acct = self.old_account.value.strip() new_char = (self.new_character.value or "").strip() new_acct = (self.new_account.value or "").strip() if not new_char and not new_acct: return await interaction.response.send_message( "❌ Provide **at least one** of: new nickname or new account.", ephemeral=True ) dm = self.cog.bot.data_manager pirates = dm.get('pirates') rec = next((p for p in pirates if p['account_name'].lower() == old_acct.lower()), None) if not rec: return await interaction.response.send_message("❌ Pirate not found.", ephemeral=True) if new_acct: if not _acct_ok(new_acct): return await interaction.response.send_message("❌ Invalid new account format (Name#12345).", ephemeral=True) if new_acct.lower() != old_acct.lower() and any(p['account_name'].lower() == new_acct.lower() for p in pirates): return await interaction.response.send_message("❌ Another pirate already has that account.", ephemeral=True) before = rec.copy() dm.remove('pirates', lambda p: p['account_name'].lower() == old_acct.lower()) if new_char: rec['character_name'] = new_char if new_acct: rec['account_name'] = new_acct dm.add('pirates', rec) await self.cog._modlog( interaction.guild, f"✏️ Edited pirate by {interaction.user.mention}: " f"{'name ' + before['character_name'] + ' → ' + rec['character_name'] if new_char else ''} " f"{'/ account ' + before['account_name'] + ' → ' + rec['account_name'] if new_acct else ''}".strip() ) await interaction.response.send_message("✅ Pirate updated.", ephemeral=True) await self.cog._refresh_pirates_list(interaction.guild) class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"): def __init__(self, cog: "PirateReportCog"): super().__init__() self.cog = cog self.identifier = discord.ui.TextInput( label="Pirate (name or account)", placeholder="MuadDib or MuadDib#12345", required=True, max_length=64 ) self.group_size = discord.ui.TextInput( label="Group size (including this pirate)", placeholder="How many pirates were in the group?", required=True ) self.kills = discord.ui.TextInput( label="Kills (integer ≥ 0; 0 = none/unknown)", placeholder="How many did the pirate kill during the encounter", required=True ) self.destruction = discord.ui.TextInput( label="Destructive? (yes/no)", placeholder="Did they destroy a base/ornithopter? yes or no", required=True ) self.skill = discord.ui.TextInput( label="Perceived Skill (0–5, 0 = unknown)", placeholder="0..5", required=True ) self.add_item(self.identifier) self.add_item(self.group_size) self.add_item(self.kills) self.add_item(self.destruction) self.add_item(self.skill) async def on_submit(self, interaction: discord.Interaction): ident_raw = self.identifier.value.strip() guild = interaction.guild if guild is None: return await interaction.response.send_message("This can only be used in a server.", ephemeral=True) # Parse numeric fields try: group_size = int(self.group_size.value.strip()); assert group_size >= 1 except Exception: return await interaction.response.send_message("❌ **Group size** must be an integer ≥ 1.", ephemeral=True) try: kills = int(self.kills.value.strip()); assert kills >= 0 except Exception: return await interaction.response.send_message("❌ **Kills** must be an integer ≥ 0.", ephemeral=True) try: destruction = _parse_bool(self.destruction.value) except ValueError: return await interaction.response.send_message("❌ **Destructive** must be **yes** or **no**.", ephemeral=True) try: skill = int(self.skill.value.strip()); assert 0 <= skill <= 5 except Exception: return await interaction.response.send_message("❌ **Skill** must be an integer between **0** and **5**.", ephemeral=True) # Resolve to a single pirate record and canonical account identifier pirate, error = self.cog.resolve_pirate(ident_raw) if error: return await interaction.response.send_message(error, ephemeral=True) if not pirate: return await interaction.response.send_message( "❌ No such pirate registered. Ask a moderator to add them, or submit a **/report** for review.", ephemeral=True ) canonical_acct = pirate['account_name'] # <- always store account here acct_l = canonical_acct.lower() char_l = pirate['character_name'].lower() dm = self.cog.bot.data_manager now = time.time() # Rate-limit by canonical account recent = [ e for e in dm.get('encounters') if e['reporter_id'] == interaction.user.id and e.get('identifier','').lower() in (acct_l, char_l) # respect legacy and (now - e['timestamp']) < 600 ] if recent: return await interaction.response.send_message( "⏱️ You can only report the same pirate once every **10 minutes**.", ephemeral=True ) # Store with canonical account as identifier rec = { 'identifier': canonical_acct, 'reporter_id': interaction.user.id, 'timestamp': now, 'group_size': group_size, 'kills': kills, 'destruction': destruction, 'skill': skill } dm.add('encounters', rec) # Recalculate threat (count both new account-based and legacy name-based entries) encs = [e for e in dm.get('encounters') if e.get('identifier','').lower() in (acct_l, char_l)] total = len(encs) if total: group_threshold = self.cog.group_threshold kill_rate = sum(e['kills'] > 0 for e in encs) / total group_rate = sum(e['group_size'] >= group_threshold for e in encs) / total destroy_rate = sum(bool(e['destruction']) for e in encs) / total skill_samples = [e['skill'] / 5 for e in encs if e['skill'] > 0] skill_rate = (sum(skill_samples) / len(skill_samples)) if skill_samples else 0.0 w_k, w_d, w_g, w_s = self.cog.w_kill, self.cog.w_destruction, self.cog.w_group, self.cog.w_skill weighted = (w_k * kill_rate) + (w_d * destroy_rate) + (w_g * group_rate) + (w_s * skill_rate) threat_level = int(round(100 * min(1.0, max(0.0, weighted)))) # Update pirate record pirates = dm.get('pirates') def match(p): return p['account_name'].lower() == acct_l rec0 = next((p for p in pirates if match(p)), None) if rec0: dm.remove('pirates', match) rec0.update({'threat_level': threat_level, 'encounter_count': total}) dm.add('pirates', rec0) await interaction.response.send_message( f"✅ Encounter recorded for **{canonical_acct}**.", ephemeral=True ) await self.cog._refresh_pirates_list(interaction.guild) # -------------- Cog: commands + listeners --------------- class PirateReportCog(commands.Cog): def __init__(self, bot): self.bot = bot r = cfg(bot) # Channels self.mod_channel = r.int('mod_channel_id', 0) self.modlog_channel_id = r.int('modlog_channel_id', 0) # Threat config self.group_threshold = r.int('threat_group_threshold', 3) def _f(key: str, default: float) -> float: try: return float(r.get(key, str(default))) except Exception: return default self.w_kill = _f('threat_w_kill', 0.35) self.w_destruction = _f('threat_w_destruction', 0.30) self.w_group = _f('threat_w_group', 0.20) self.w_skill = _f('threat_w_skill', 0.15) async def _refresh_pirates_list(self, guild: discord.Guild): plist = self.bot.get_cog('PiratesListCog') if plist: await plist.refresh_list(guild) async def _modlog(self, guild: discord.Guild, content: str): if not self.modlog_channel_id: return ch = guild.get_channel(self.modlog_channel_id) if ch: try: await ch.send(content) except Exception as e: print("[pirate_report] modlog send failed:", repr(e)) self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content}) # --- Resolver: name/account -> pirate record (handles ambiguity) --- def resolve_pirate(self, ident: str): """Return (pirate_record or None, error_message or None).""" dm = self.bot.data_manager pirates = dm.get('pirates') s = ident.strip() s_l = s.lower() if '#' in s and _acct_ok(s): rec = next((p for p in pirates if p['account_name'].lower() == s_l), None) return (rec, None) # resolve by character; must be unambiguous matches = [p for p in pirates if p['character_name'].lower() == s_l] if not matches: return (None, None) if len(matches) > 1: return (None, "❌ That character name is **ambiguous**. Please use the account (e.g. `Name#12345`).") return (matches[0], None) # Remove pirate (mod-only) @app_commands.command(name="remove_pirate", description="[MOD] Remove an approved pirate entry") @app_commands.describe(account_name="Account name to remove") async def remove_pirate(self, interaction: discord.Interaction, account_name: str): if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return acct_lower = account_name.strip().lower() dm = self.bot.data_manager if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')): return await interaction.response.send_message("Pirate not found.", ephemeral=True) dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower) await self._modlog(interaction.guild, f"🗑️ Removed pirate {account_name} by {interaction.user.mention}") await self._refresh_pirates_list(interaction.guild) await interaction.response.send_message("Removed.", ephemeral=True) # Modal launchers @app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)") async def report(self, interaction: discord.Interaction): if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) await interaction.response.send_modal(ReportModal(self)) @app_commands.command(name="edit_pirate", description="[MOD] Edit a pirate entry (opens a form)") async def edit_pirate(self, interaction: discord.Interaction): if not await require_mod_interaction(interaction): return await interaction.response.send_modal(EditPirateModal(self)) @app_commands.command(name="encounter", description="Log a pirate encounter (opens a form)") async def encounter(self, interaction: discord.Interaction): if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) await interaction.response.send_modal(EncounterModal(self)) # ---- Migration: convert encounter identifiers to accounts (mod-only) ---- @app_commands.command(name="encounters_migrate_ids", description="[MOD] Migrate encounter identifiers to account names") async def encounters_migrate_ids(self, interaction: discord.Interaction): if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return await interaction.response.defer(ephemeral=True) dm = self.bot.data_manager pirates = dm.get('pirates') by_char = {} for p in pirates: by_char.setdefault(p['character_name'].lower(), []).append(p) by_acct = {p['account_name'].lower(): p for p in pirates} changed = ambiguous = missing = already = 0 for e in dm.get('encounters'): ident = e.get('identifier', '') if not ident: continue if '#' in ident: already += 1 continue key = ident.lower() matches = by_char.get(key, []) if not matches: missing += 1 continue if len(matches) > 1: ambiguous += 1 continue acct = matches[0]['account_name'] def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident): return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old def upd(x, acct_new=acct): x['identifier'] = acct_new return x ok = dm.update('encounters', pred, upd) if ok: changed += 1 await interaction.followup.send( "Migration complete.\n" f"- Updated to accounts: **{changed}**\n" f"- Already accounts: **{already}**\n" f"- Ambiguous character names (skipped): **{ambiguous}**\n" f"- Not found in pirates list (skipped): **{missing}**", ephemeral=True ) await self._refresh_pirates_list(interaction.guild) # Moderator reaction handling (atomic claim) @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): if ( payload.channel_id != self.mod_channel or str(payload.emoji) not in (CHECK, CROSS) or payload.user_id == self.bot.user.id ): return channel = self.bot.get_channel(payload.channel_id) if not channel or not channel.guild: return if not is_moderator_userid(channel.guild, payload.user_id, self.bot): return try: msg = await channel.fetch_message(payload.message_id) except Exception as e: print("[pirate_report] fetch mod msg failed:", repr(e)) return dm = self.bot.data_manager # Atomically claim this report claimed = dm.update( 'reports', lambda r: r.get('report_id') == msg.id and r.get('status') == 'pending', lambda r: (r.update({'status': 'deciding'}), r)[1] ) if not claimed: return report = next((r for r in dm.get('reports') if r.get('report_id') == msg.id), None) if not report: return approved = (str(payload.emoji) == CHECK) guild = channel.guild stamp = _now_utc_str() # Build new embed + content (keep proof visible if present) status_text = f"{'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}" report_view = { 'character_name': report['character_name'], 'account_name': report['account_name'], 'submitter_id': report['submitter_id'], 'proof_url': report.get('proof_url', ''), 'proof_type': report.get('proof_type', ''), 'status_line': status_text, } color = discord.Color.green() if approved else discord.Color.red() title = "✅ Pirate Report — Approved" if approved else "❌ Pirate Report — Rejected" new_embed = _make_report_embed(title, color, report_view, include_status=True) # For videos, ensure the URL stays in message content so the inline player remains visible. new_content = report_view['proof_url'] if report_view.get('proof_type') == 'video' else None # Always (re)attach jump button if we have the ack info view = None try: if report.get('origin_channel_id') and report.get('ack_message_id'): url = _jump_url(guild.id, report['origin_channel_id'], report['ack_message_id']) view = ReportJumpView(url) except Exception: view = None try: await msg.clear_reactions() except Exception as e: print("[pirate_report] clear reactions failed:", repr(e)) try: await msg.edit(content=new_content, embed=new_embed, view=view) except Exception as e: print("[pirate_report] edit mod msg failed:", repr(e)) if approved: acct_lower = report['account_name'].lower() if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')): dm.add('pirates', { 'character_name': report['character_name'], 'account_name': report['account_name'], 'submitter': str(report['submitter_id']), 'threat_level': 0, 'encounter_count': 0, 'added_ts': time.time() }) await self._refresh_pirates_list(guild) await self._modlog(guild, f"{'✅ Approved' if approved else '❌ Rejected'} report for {report['character_name']} ({report['account_name']}) by <@{payload.user_id}>.") try: if report.get('origin_channel_id') and report.get('ack_message_id'): ch = guild.get_channel(report['origin_channel_id']) if ch: ack_msg = await ch.fetch_message(report['ack_message_id']) await ack_msg.edit(content=f"{('✅ Approved' if approved else '❌ Rejected')} pirate report of {report['character_name']} ({report['account_name']}) by <@{payload.user_id}> on {stamp}.") except Exception as e: print("[pirate_report] edit ack msg failed:", repr(e)) dm.remove('reports', lambda r: r.get('report_id') == msg.id) async def setup(bot): await bot.add_cog(PirateReportCog(bot))