shaiwatcher/modules/pirate_report/pirate_report.py
Franz Rolfsvaag 77f92abe19 0.4.1.0.a8
- Converted all commands to slash-only commands
2025-08-16 03:12:33 +02:00

720 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (05, 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))