diff --git a/modules/pirate_report/pirate_report.py b/modules/pirate_report/pirate_report.py deleted file mode 100644 index 21e2749..0000000 --- a/modules/pirate_report/pirate_report.py +++ /dev/null @@ -1,579 +0,0 @@ -import re -import time -from datetime import datetime, timezone -import discord -from discord.ext import commands -from discord import app_commands - -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}$') - -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") - -# --------------------- 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.add_item(self.character_name) - self.add_item(self.account_name) - - 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() - - 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 - ) - - 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 ✅/❌ - 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) - - try: - mod_msg = await mod_ch.send( - f"🚩 **Pirate Report**\n" - f"**Character:** {char}\n" - f"**Account:** {acct}\n" - f"**Submitted by:** {interaction.user.mention}" - ) - 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 - }) - - 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 - cfg = bot.config['DEFAULT'] - self.mod_channel = int(cfg['mod_channel_id']) - self.modlog_channel_id = int(cfg['modlog_channel_id']) - - # Optional threat weights (normalized elsewhere if you added them) - try: - self.group_threshold = int(cfg.get('threat_group_threshold', '3')) - except Exception: - self.group_threshold = 3 - # Defaults if not already present in your earlier version: - self.w_kill = float(cfg.get('threat_w_kill', '0.35')) - self.w_destruction = float(cfg.get('threat_w_destruction', '0.30')) - self.w_group = float(cfg.get('threat_w_group', '0.20')) - self.w_skill = float(cfg.get('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): - 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) - @commands.hybrid_command(name='remove_pirate', description='Remove an approved pirate entry') - async def remove_pirate(self, ctx, account_name: str): - if not await require_mod_ctx(ctx, "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 ctx.reply("Pirate not found.") - dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower) - await self._modlog(ctx.guild, f"🗑️ Removed pirate {account_name} by {ctx.author.mention}") - await self._refresh_pirates_list(ctx.guild) - await ctx.reply("Removed.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None) - - # 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="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) ---- - @commands.hybrid_command(name='encounters_migrate_ids', description='Migrate encounter identifiers to account names') - async def encounters_migrate_ids(self, ctx: commands.Context): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): - return - 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 = 0 - ambiguous = 0 - missing = 0 - 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'] - # update this one entry atomically - 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 ctx.reply( - f"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=hasattr(ctx, "interaction") and ctx.interaction is not None - ) - - await self._refresh_pirates_list(ctx.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() - - header_emoji = CHECK if approved else CROSS - new_content = ( - f"{header_emoji} **Pirate Report**\n" - f"**Character:** {report['character_name']}\n" - f"**Account:** {report['account_name']}\n" - f"**Submitted by:** <@{report['submitter_id']}>\n\n" - f"**Status:** {'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}" - ) - try: - await msg.clear_reactions() - except Exception as e: - print("[pirate_report] clear reactions failed:", repr(e)) - try: - await msg.edit(content=new_content) - 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): - cog = PirateReportCog(bot) - await bot.add_cog(cog) - - try: - home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0')) - except Exception: - home_gid = 0 - - if home_gid: - guild_obj = discord.Object(id=home_gid) - bot.tree.add_command(cog.report, guild=guild_obj) - bot.tree.add_command(cog.edit_pirate, guild=guild_obj) - bot.tree.add_command(cog.encounter, guild=guild_obj) - else: - bot.tree.add_command(cog.report) - bot.tree.add_command(cog.edit_pirate) - bot.tree.add_command(cog.encounter) \ No newline at end of file