commit 7a0c4645d71ee5c56ab92eee28cd82b22ed043d5 Author: Franz Rolfsvaag Date: Sat Aug 9 14:29:00 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76da19c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python +__pycache__/ +*.pyc +.venv/ +venv/ +.env + +# Local data & secrets (we’ll mount these in Docker) +data/ +data.json +settings.conf \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..921a64c --- /dev/null +++ b/bot.py @@ -0,0 +1,113 @@ +import os +import asyncio +import discord +from discord.ext import commands +from dotenv import load_dotenv +from configparser import ConfigParser +from data_manager import DataManager +import pathlib + +# Load env +load_dotenv() +TOKEN = os.getenv('DISCORD_TOKEN') + +# Load settings (we won't overwrite your file) +config = ConfigParser() +config.read('settings.conf') + +# Intents (enable Members + Message Content in Dev Portal) +intents = discord.Intents.default() +intents.guilds = True +intents.members = True +intents.message_content = True +intents.reactions = True +intents.emojis_and_stickers = True +intents.voice_states = True + +bot = commands.Bot(command_prefix='!', intents=intents) +bot.config = config +bot.data_manager = DataManager(config['DEFAULT']['data_file']) + +async def _guild_selfcheck(g: discord.Guild, cfg): + problems = [] + + def _need_channel(id_key, *perms): + raw = cfg.get(id_key) + if not raw: + problems.append(f"Missing config key: {id_key}") + return + try: + cid = int(raw) + except Exception: + problems.append(f"Bad channel id for {id_key}: {raw}") + return + ch = g.get_channel(cid) + if not ch: + problems.append(f"Channel not found: {id_key}={cid}") + return + me = g.me + p = ch.permissions_for(me) + for perm in perms: + if not getattr(p, perm, False): + problems.append(f"Missing permission on #{ch.name}: {perm}") + + # Mod channel (for report moderation) + _need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history') + # Modlog channel + _need_channel('modlog_channel_id', 'read_messages', 'send_messages') + # Pirates list channel + _need_channel('pirates_list_channel_id', 'read_messages', 'send_messages') + # Auto-VC category/trigger are handled inside the cog + + if problems: + print(f"[SelfCheck:{g.name}]") + for p in problems: + print(" -", p) + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print("[Intents] members:", bot.intents.members, "/ message_content:", bot.intents.message_content, "/ voice_states:", bot.intents.voice_states) + + # Per-guild permission sanity checks (console log) + await asyncio.gather(*[_guild_selfcheck(g, bot.config['DEFAULT']) for g in bot.guilds]) + + # Slash command sync + try: + dev_gid = bot.config['DEFAULT'].get('dev_guild_id') + if dev_gid: + guild = bot.get_guild(int(dev_gid)) + if guild: + synced = await bot.tree.sync(guild=guild) + print(f"[Slash] Synced {len(synced)} commands to {guild.name}") + else: + synced = await bot.tree.sync() + print(f"[Slash] Synced {len(synced)} commands globally (dev_guild_id not in cache)") + else: + synced = await bot.tree.sync() + print(f"[Slash] Synced {len(synced)} commands globally") + except Exception as e: + print("[Slash] Sync failed:", repr(e)) + +# Auto-discover extensions +modules_path = pathlib.Path(__file__).parent / 'modules' +extensions = [] +for folder in modules_path.iterdir(): + if folder.is_dir(): + for file in folder.glob('*.py'): + if file.name == '__init__.py': + continue + extensions.append(f"modules.{folder.name}.{file.stem}") + +async def main(): + async with bot: + for ext in extensions: + try: + await bot.load_extension(ext) + print(f"[Modules] Loaded: {ext}") + except Exception as e: + print(f"[Modules] Failed to load {ext}:", repr(e)) + await bot.start(TOKEN) + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/data.json b/data.json new file mode 100644 index 0000000..6b70d53 --- /dev/null +++ b/data.json @@ -0,0 +1,505 @@ +{ + "reports": [], + "pirates": [ + { + "character_name": "Legoon", + "account_name": "LegoPlayerone#27433", + "submitter": "203190147582394369", + "threat_level": 0, + "encounter_count": 0, + "added_ts": 1754697188.1891513 + }, + { + "character_name": "chaosmachine", + "account_name": "chaosMachine#41235", + "submitter": "203190147582394369", + "threat_level": 0, + "encounter_count": 0, + "added_ts": 1754699547.5410054 + } + ], + "modlog": [ + { + "action": "report_review", + "report": { + "report_id": 1403153473578602649, + "character_name": "test user", + "account_name": "test user#12345", + "submitter": "ookamikuntv" + }, + "approved": true, + "moderator": 203190147582394369 + }, + { + "action": "report_review", + "report": { + "report_id": 1403155201006768270, + "character_name": "test user", + "account_name": "test user#12345", + "submitter": "ookamikuntv" + }, + "approved": false, + "moderator": 1403080373151207505 + }, + { + "action": "report_review", + "report": { + "report_id": 1403155655530905611, + "character_name": "fake user", + "account_name": "fake user#12345", + "submitter": "ookamikuntv" + }, + "approved": false, + "moderator": 1403080373151207505 + }, + { + "action": "report_review", + "report": { + "report_id": 1403155864881201353, + "character_name": "test user", + "account_name": "test user#12345", + "submitter": "ookamikuntv" + }, + "approved": false, + "moderator": 1403080373151207505 + }, + { + "ts": 1754615280.1873968, + "guild_id": 1396826999095427253, + "content": "\ud83d\udce8 Sent nickname nudge to <@127663183560179719>" + }, + { + "ts": 1754615284.1742756, + "guild_id": 1396826999095427253, + "content": "\ud83d\udce8 Sent nickname nudge to <@215530106632798209>" + }, + { + "ts": 1754615284.9865704, + "guild_id": 1396826999095427253, + "content": "\ud83d\udce8 Sent nickname nudge to <@224950449600462858>" + }, + { + "ts": 1754615737.6719832, + "guild_id": 1396826999095427253, + "content": "\u274c Rejected report for fake user (fake user#12345) by <@1403080373151207505>." + }, + { + "ts": 1754616046.1252441, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for fake user (fake user#12345) by <@203190147582394369>." + }, + { + "ts": 1754616086.2937784, + "guild_id": 1396826999095427253, + "content": "\u274c Rejected report for someone (someuser#45678) by <@203190147582394369>." + }, + { + "ts": 1754616158.871839, + "guild_id": 1396826999095427253, + "content": "\u270f\ufe0f Edited pirate fake user#12345 \u2192 fake user#12345 / fake user \u2192 Completely fake user by <@203190147582394369>" + }, + { + "ts": 1754616202.042328, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for pirate (pirate#99999) by <@203190147582394369>." + }, + { + "ts": 1754618571.632796, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for some pirate (some pirate#88888) by <@203190147582394369>." + }, + { + "ts": 1754620319.6991072, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for Kenny (kennychan#69696) by <@203190147582394369>." + }, + { + "ts": 1754657273.3843057, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for Ookami (Ookami#33333) by <@228275858068996097>." + }, + { + "ts": 1754681795.3581228, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for some pirate (some pirate#66699) by <@203190147582394369>." + }, + { + "ts": 1754682159.6520398, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate fake user#12345 by <@203190147582394369>" + }, + { + "ts": 1754682173.22108, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate kennychan#69696 by <@203190147582394369>" + }, + { + "ts": 1754682184.5745888, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate Ookami#33333 by <@203190147582394369>" + }, + { + "ts": 1754682195.7231941, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate pirate#99999 by <@203190147582394369>" + }, + { + "ts": 1754682207.6092849, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate some pirate#66699 by <@203190147582394369>" + }, + { + "ts": 1754682218.26256, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate some pirate#88888 by <@203190147582394369>" + }, + { + "ts": 1754682316.3804264, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for herobrine (mcfan#69696) by <@203190147582394369>." + }, + { + "ts": 1754684715.6450791, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for a pirate (apirate#55555) by <@203190147582394369>." + }, + { + "ts": 1754685159.3577137, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for shadow (shadow#99999) by <@203190147582394369>." + }, + { + "ts": 1754696958.3435717, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate apirate#55555 by <@203190147582394369>" + }, + { + "ts": 1754697018.401811, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate mcfan#69696 by <@203190147582394369>" + }, + { + "ts": 1754697044.1546986, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate shadow#99999 by <@203190147582394369>" + }, + { + "ts": 1754697189.081039, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for Legoon (LegoPlayerone#27433) by <@203190147582394369>." + }, + { + "ts": 1754699548.5175529, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for chaosmachine (chaosMachine#41235) by <@203190147582394369>." + }, + { + "ts": 1754701497.00778, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754701499.1338458, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@224950449600462858> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754701503.1822891, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 ." + }, + { + "ts": 1754701508.2414587, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@224950449600462858> by <@203190147582394369> \u2014 ." + }, + { + "ts": 1754702071.883038, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754702079.5288155, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 ." + }, + { + "ts": 1754702839.6018815, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754702843.558799, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 ." + } + ], + "agreed_rules": [ + 203190147582394369, + 228275858068996097, + 215530106632798209, + 224950449600462858, + 360145627209924619, + 328957176657739799 + ], + "user_cards": [ + { + "user_id": 127663183560179719, + "message_id": 1403543072322621605, + "channel_id": 1403146908385542215 + }, + { + "user_id": 138586881183973376, + "message_id": 1403543077238341763, + "channel_id": 1403146908385542215 + }, + { + "user_id": 195261650683691009, + "message_id": 1403543081495560262, + "channel_id": 1403146908385542215 + }, + { + "user_id": 206785544053587968, + "message_id": 1403543106879754303, + "channel_id": 1403146908385542215 + }, + { + "user_id": 215530106632798209, + "message_id": 1403543111749341335, + "channel_id": 1403146908385542215 + }, + { + "user_id": 224950449600462858, + "message_id": 1403543115717017688, + "channel_id": 1403146908385542215 + }, + { + "user_id": 229019596131139584, + "message_id": 1403543141524439151, + "channel_id": 1403146908385542215 + }, + { + "user_id": 277710459468120064, + "message_id": 1403543164274348225, + "channel_id": 1403146908385542215 + }, + { + "user_id": 293062110097702912, + "message_id": 1403543167999021112, + "channel_id": 1403146908385542215 + }, + { + "user_id": 310092807152992267, + "message_id": 1403543172440916019, + "channel_id": 1403146908385542215 + }, + { + "user_id": 316230993025695755, + "message_id": 1403543176970768481, + "channel_id": 1403146908385542215 + }, + { + "user_id": 328957176657739799, + "message_id": 1403543186613211136, + "channel_id": 1403146908385542215 + }, + { + "user_id": 360145627209924619, + "message_id": 1403543208515993674, + "channel_id": 1403146908385542215 + }, + { + "user_id": 360371525695700993, + "message_id": 1403543212462706709, + "channel_id": 1403146908385542215 + }, + { + "user_id": 439118808654020610, + "message_id": 1403543217130967090, + "channel_id": 1403146908385542215 + }, + { + "user_id": 445154856471232513, + "message_id": 1403543238312333367, + "channel_id": 1403146908385542215 + }, + { + "user_id": 481132945156079616, + "message_id": 1403543242657759243, + "channel_id": 1403146908385542215 + }, + { + "user_id": 518899510177693706, + "message_id": 1403543246986154037, + "channel_id": 1403146908385542215 + }, + { + "user_id": 530001218681045001, + "message_id": 1403543251335774342, + "channel_id": 1403146908385542215 + }, + { + "user_id": 822830700427345930, + "message_id": 1403543274358313032, + "channel_id": 1403146908385542215 + }, + { + "user_id": 888795375778017330, + "message_id": 1403543284617314355, + "channel_id": 1403146908385542215 + }, + { + "user_id": 910312987506663424, + "message_id": 1403543288887119953, + "channel_id": 1403146908385542215 + }, + { + "user_id": 1019366962511495278, + "message_id": 1403543293203054673, + "channel_id": 1403146908385542215 + }, + { + "user_id": 1348692487639924829, + "message_id": 1403543297552683080, + "channel_id": 1403146908385542215 + }, + { + "user_id": 228275858068996097, + "message_id": 1403543120616099891, + "channel_id": 1403146908385542215 + }, + { + "user_id": 203190147582394369, + "message_id": 1403543085975076914, + "channel_id": 1403146908385542215 + } + ], + "agreed_engagement": [ + 293062110097702912, + 445154856471232513, + 228275858068996097, + 215530106632798209, + 203190147582394369, + 195261650683691009, + 138586881183973376, + 822830700427345930, + 360145627209924619, + 224950449600462858, + 328957176657739799, + 1348692487639924829 + ], + "agreed_nickname": [ + 224950449600462858, + 203190147582394369 + ], + "vc_channels": [ + { + "id": 1403157214960549929, + "num": 1 + } + ], + "nick_dm_map": [], + "nick_nudged": [], + "encounters": [ + { + "identifier": "mcfan#69696", + "reporter_id": 203190147582394369, + "timestamp": 1754682756.8377693, + "group_size": 2, + "kills": 1, + "destruction": false, + "skill": 3 + }, + { + "identifier": "mcfan#69696", + "reporter_id": 203190147582394369, + "timestamp": 1754684375.3286953, + "group_size": 2, + "kills": 1, + "destruction": false, + "skill": 3 + } + ], + "pirate_cards": [], + "pirates_list_posts": [ + { + "guild_id": 1396826999095427253, + "channel_id": 1403147077285843034, + "message_id": 1403525761960247367 + } + ], + "nick_same_confirmed": [], + "rr_msg_channels": [ + { + "guild_id": 1396826999095427253, + "message_id": 1396831304460402738, + "channel_id": 1396830487653126195 + }, + { + "guild_id": 1396826999095427253, + "message_id": 1397668657143742574, + "channel_id": 1396830487653126195 + }, + { + "guild_id": 1396826999095427253, + "message_id": 1403513532108247141, + "channel_id": 1396830487653126195 + } + ], + "nick_pending": [], + "nick_claim_pending": [], + "nick_reviews": [ + { + "message_id": 1403544533249032384, + "guild_id": 1396826999095427253, + "user_id": 203190147582394369, + "before_nick": null, + "claimed_nick": "Alex Wolfgaze", + "status": "approved", + "source": "claim_offline", + "ts": 1754701495, + "decided_ts": 1754701501, + "moderator_id": 203190147582394369 + }, + { + "message_id": 1403544541436448818, + "guild_id": 1396826999095427253, + "user_id": 224950449600462858, + "before_nick": null, + "claimed_nick": null, + "status": "approved", + "source": "claim_offline", + "ts": 1754701497, + "decided_ts": 1754701506, + "moderator_id": 203190147582394369 + }, + { + "message_id": 1403546943812141087, + "guild_id": 1396826999095427253, + "user_id": 203190147582394369, + "before_nick": null, + "claimed_nick": "Alex Wolfgaze", + "status": "approved", + "source": "claim", + "ts": 1754702070, + "decided_ts": 1754702078, + "moderator_id": 203190147582394369 + }, + { + "message_id": 1403550167499538576, + "guild_id": 1396826999095427253, + "user_id": 203190147582394369, + "before_nick": null, + "claimed_nick": "Alex Wolfgaze", + "status": "approved", + "source": "claim", + "ts": 1754702837, + "decided_ts": 1754702842, + "moderator_id": 203190147582394369 + } + ], + "nick_verified": [ + 224950449600462858, + 203190147582394369 + ] +} \ No newline at end of file diff --git a/data.json.bak b/data.json.bak new file mode 100644 index 0000000..a7cd820 --- /dev/null +++ b/data.json.bak @@ -0,0 +1,500 @@ +{ + "reports": [], + "pirates": [ + { + "character_name": "Legoon", + "account_name": "LegoPlayerone#27433", + "submitter": "203190147582394369", + "threat_level": 0, + "encounter_count": 0, + "added_ts": 1754697188.1891513 + }, + { + "character_name": "chaosmachine", + "account_name": "chaosMachine#41235", + "submitter": "203190147582394369", + "threat_level": 0, + "encounter_count": 0, + "added_ts": 1754699547.5410054 + } + ], + "modlog": [ + { + "action": "report_review", + "report": { + "report_id": 1403153473578602649, + "character_name": "test user", + "account_name": "test user#12345", + "submitter": "ookamikuntv" + }, + "approved": true, + "moderator": 203190147582394369 + }, + { + "action": "report_review", + "report": { + "report_id": 1403155201006768270, + "character_name": "test user", + "account_name": "test user#12345", + "submitter": "ookamikuntv" + }, + "approved": false, + "moderator": 1403080373151207505 + }, + { + "action": "report_review", + "report": { + "report_id": 1403155655530905611, + "character_name": "fake user", + "account_name": "fake user#12345", + "submitter": "ookamikuntv" + }, + "approved": false, + "moderator": 1403080373151207505 + }, + { + "action": "report_review", + "report": { + "report_id": 1403155864881201353, + "character_name": "test user", + "account_name": "test user#12345", + "submitter": "ookamikuntv" + }, + "approved": false, + "moderator": 1403080373151207505 + }, + { + "ts": 1754615280.1873968, + "guild_id": 1396826999095427253, + "content": "\ud83d\udce8 Sent nickname nudge to <@127663183560179719>" + }, + { + "ts": 1754615284.1742756, + "guild_id": 1396826999095427253, + "content": "\ud83d\udce8 Sent nickname nudge to <@215530106632798209>" + }, + { + "ts": 1754615284.9865704, + "guild_id": 1396826999095427253, + "content": "\ud83d\udce8 Sent nickname nudge to <@224950449600462858>" + }, + { + "ts": 1754615737.6719832, + "guild_id": 1396826999095427253, + "content": "\u274c Rejected report for fake user (fake user#12345) by <@1403080373151207505>." + }, + { + "ts": 1754616046.1252441, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for fake user (fake user#12345) by <@203190147582394369>." + }, + { + "ts": 1754616086.2937784, + "guild_id": 1396826999095427253, + "content": "\u274c Rejected report for someone (someuser#45678) by <@203190147582394369>." + }, + { + "ts": 1754616158.871839, + "guild_id": 1396826999095427253, + "content": "\u270f\ufe0f Edited pirate fake user#12345 \u2192 fake user#12345 / fake user \u2192 Completely fake user by <@203190147582394369>" + }, + { + "ts": 1754616202.042328, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for pirate (pirate#99999) by <@203190147582394369>." + }, + { + "ts": 1754618571.632796, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for some pirate (some pirate#88888) by <@203190147582394369>." + }, + { + "ts": 1754620319.6991072, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for Kenny (kennychan#69696) by <@203190147582394369>." + }, + { + "ts": 1754657273.3843057, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for Ookami (Ookami#33333) by <@228275858068996097>." + }, + { + "ts": 1754681795.3581228, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for some pirate (some pirate#66699) by <@203190147582394369>." + }, + { + "ts": 1754682159.6520398, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate fake user#12345 by <@203190147582394369>" + }, + { + "ts": 1754682173.22108, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate kennychan#69696 by <@203190147582394369>" + }, + { + "ts": 1754682184.5745888, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate Ookami#33333 by <@203190147582394369>" + }, + { + "ts": 1754682195.7231941, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate pirate#99999 by <@203190147582394369>" + }, + { + "ts": 1754682207.6092849, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate some pirate#66699 by <@203190147582394369>" + }, + { + "ts": 1754682218.26256, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate some pirate#88888 by <@203190147582394369>" + }, + { + "ts": 1754682316.3804264, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for herobrine (mcfan#69696) by <@203190147582394369>." + }, + { + "ts": 1754684715.6450791, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for a pirate (apirate#55555) by <@203190147582394369>." + }, + { + "ts": 1754685159.3577137, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for shadow (shadow#99999) by <@203190147582394369>." + }, + { + "ts": 1754696958.3435717, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate apirate#55555 by <@203190147582394369>" + }, + { + "ts": 1754697018.401811, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate mcfan#69696 by <@203190147582394369>" + }, + { + "ts": 1754697044.1546986, + "guild_id": 1396826999095427253, + "content": "\ud83d\uddd1\ufe0f Removed pirate shadow#99999 by <@203190147582394369>" + }, + { + "ts": 1754697189.081039, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for Legoon (LegoPlayerone#27433) by <@203190147582394369>." + }, + { + "ts": 1754699548.5175529, + "guild_id": 1396826999095427253, + "content": "\u2705 Approved report for chaosmachine (chaosMachine#41235) by <@203190147582394369>." + }, + { + "ts": 1754701497.00778, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754701499.1338458, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@224950449600462858> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754701503.1822891, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 ." + }, + { + "ts": 1754701508.2414587, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@224950449600462858> by <@203190147582394369> \u2014 ." + }, + { + "ts": 1754702071.883038, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754702079.5288155, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 ." + }, + { + "ts": 1754702839.6018815, + "guild_id": 1396826999095427253, + "content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 ." + }, + { + "ts": 1754702843.558799, + "guild_id": 1396826999095427253, + "content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 ." + } + ], + "agreed_rules": [ + 203190147582394369, + 228275858068996097, + 215530106632798209, + 224950449600462858, + 360145627209924619, + 328957176657739799 + ], + "user_cards": [ + { + "user_id": 127663183560179719, + "message_id": 1403543072322621605, + "channel_id": 1403146908385542215 + }, + { + "user_id": 138586881183973376, + "message_id": 1403543077238341763, + "channel_id": 1403146908385542215 + }, + { + "user_id": 195261650683691009, + "message_id": 1403543081495560262, + "channel_id": 1403146908385542215 + }, + { + "user_id": 206785544053587968, + "message_id": 1403543106879754303, + "channel_id": 1403146908385542215 + }, + { + "user_id": 215530106632798209, + "message_id": 1403543111749341335, + "channel_id": 1403146908385542215 + }, + { + "user_id": 224950449600462858, + "message_id": 1403543115717017688, + "channel_id": 1403146908385542215 + }, + { + "user_id": 229019596131139584, + "message_id": 1403543141524439151, + "channel_id": 1403146908385542215 + }, + { + "user_id": 277710459468120064, + "message_id": 1403543164274348225, + "channel_id": 1403146908385542215 + }, + { + "user_id": 293062110097702912, + "message_id": 1403543167999021112, + "channel_id": 1403146908385542215 + }, + { + "user_id": 310092807152992267, + "message_id": 1403543172440916019, + "channel_id": 1403146908385542215 + }, + { + "user_id": 316230993025695755, + "message_id": 1403543176970768481, + "channel_id": 1403146908385542215 + }, + { + "user_id": 328957176657739799, + "message_id": 1403543186613211136, + "channel_id": 1403146908385542215 + }, + { + "user_id": 360145627209924619, + "message_id": 1403543208515993674, + "channel_id": 1403146908385542215 + }, + { + "user_id": 360371525695700993, + "message_id": 1403543212462706709, + "channel_id": 1403146908385542215 + }, + { + "user_id": 439118808654020610, + "message_id": 1403543217130967090, + "channel_id": 1403146908385542215 + }, + { + "user_id": 445154856471232513, + "message_id": 1403543238312333367, + "channel_id": 1403146908385542215 + }, + { + "user_id": 481132945156079616, + "message_id": 1403543242657759243, + "channel_id": 1403146908385542215 + }, + { + "user_id": 518899510177693706, + "message_id": 1403543246986154037, + "channel_id": 1403146908385542215 + }, + { + "user_id": 530001218681045001, + "message_id": 1403543251335774342, + "channel_id": 1403146908385542215 + }, + { + "user_id": 822830700427345930, + "message_id": 1403543274358313032, + "channel_id": 1403146908385542215 + }, + { + "user_id": 888795375778017330, + "message_id": 1403543284617314355, + "channel_id": 1403146908385542215 + }, + { + "user_id": 910312987506663424, + "message_id": 1403543288887119953, + "channel_id": 1403146908385542215 + }, + { + "user_id": 1019366962511495278, + "message_id": 1403543293203054673, + "channel_id": 1403146908385542215 + }, + { + "user_id": 1348692487639924829, + "message_id": 1403543297552683080, + "channel_id": 1403146908385542215 + }, + { + "user_id": 228275858068996097, + "message_id": 1403543120616099891, + "channel_id": 1403146908385542215 + } + ], + "agreed_engagement": [ + 293062110097702912, + 445154856471232513, + 228275858068996097, + 215530106632798209, + 203190147582394369, + 195261650683691009, + 138586881183973376, + 822830700427345930, + 360145627209924619, + 224950449600462858, + 328957176657739799, + 1348692487639924829 + ], + "agreed_nickname": [ + 224950449600462858, + 203190147582394369 + ], + "vc_channels": [ + { + "id": 1403157214960549929, + "num": 1 + } + ], + "nick_dm_map": [], + "nick_nudged": [], + "encounters": [ + { + "identifier": "mcfan#69696", + "reporter_id": 203190147582394369, + "timestamp": 1754682756.8377693, + "group_size": 2, + "kills": 1, + "destruction": false, + "skill": 3 + }, + { + "identifier": "mcfan#69696", + "reporter_id": 203190147582394369, + "timestamp": 1754684375.3286953, + "group_size": 2, + "kills": 1, + "destruction": false, + "skill": 3 + } + ], + "pirate_cards": [], + "pirates_list_posts": [ + { + "guild_id": 1396826999095427253, + "channel_id": 1403147077285843034, + "message_id": 1403525761960247367 + } + ], + "nick_same_confirmed": [], + "rr_msg_channels": [ + { + "guild_id": 1396826999095427253, + "message_id": 1396831304460402738, + "channel_id": 1396830487653126195 + }, + { + "guild_id": 1396826999095427253, + "message_id": 1397668657143742574, + "channel_id": 1396830487653126195 + }, + { + "guild_id": 1396826999095427253, + "message_id": 1403513532108247141, + "channel_id": 1396830487653126195 + } + ], + "nick_pending": [], + "nick_claim_pending": [], + "nick_reviews": [ + { + "message_id": 1403544533249032384, + "guild_id": 1396826999095427253, + "user_id": 203190147582394369, + "before_nick": null, + "claimed_nick": "Alex Wolfgaze", + "status": "approved", + "source": "claim_offline", + "ts": 1754701495, + "decided_ts": 1754701501, + "moderator_id": 203190147582394369 + }, + { + "message_id": 1403544541436448818, + "guild_id": 1396826999095427253, + "user_id": 224950449600462858, + "before_nick": null, + "claimed_nick": null, + "status": "approved", + "source": "claim_offline", + "ts": 1754701497, + "decided_ts": 1754701506, + "moderator_id": 203190147582394369 + }, + { + "message_id": 1403546943812141087, + "guild_id": 1396826999095427253, + "user_id": 203190147582394369, + "before_nick": null, + "claimed_nick": "Alex Wolfgaze", + "status": "approved", + "source": "claim", + "ts": 1754702070, + "decided_ts": 1754702078, + "moderator_id": 203190147582394369 + }, + { + "message_id": 1403550167499538576, + "guild_id": 1396826999095427253, + "user_id": 203190147582394369, + "before_nick": null, + "claimed_nick": "Alex Wolfgaze", + "status": "approved", + "source": "claim", + "ts": 1754702837, + "decided_ts": 1754702842, + "moderator_id": 203190147582394369 + } + ], + "nick_verified": [ + 224950449600462858, + 203190147582394369 + ] +} \ No newline at end of file diff --git a/data_manager.py b/data_manager.py new file mode 100644 index 0000000..b6e2efd --- /dev/null +++ b/data_manager.py @@ -0,0 +1,84 @@ +import json +import threading +import shutil +import os +from typing import Callable, Any + +class DataManager: + def __init__(self, json_path: str): + self.json_path = json_path + self.lock = threading.Lock() + self._data = self._load() + + def _load(self): + try: + with open(self.json_path, 'r') as f: + return json.load(f) + except FileNotFoundError: + default = { + 'agreed_rules': [], + 'agreed_engagement': [], + 'agreed_nickname': [], + 'nick_same_confirmed': [], + 'nick_nudged': [], + 'nick_dm_map': [], + 'pirates': [], + 'modlog': [], + 'reports': [], + 'encounters': [], + 'vc_channels': [], + 'user_cards': [], + 'pirates_list_posts': [], + 'spicepay_prefs': [], + } + self._save(default) + return default + + def _safe_write(self, data: dict): + tmp = self.json_path + ".tmp" + with open(tmp, 'w') as f: + json.dump(data, f, indent=4) + # backup current file (best-effort) + if os.path.exists(self.json_path): + try: + shutil.copy2(self.json_path, self.json_path + ".bak") + except Exception: + pass + os.replace(tmp, self.json_path) + + def _save(self, data: dict): + # single place to write (atomic replace + rolling .bak) + self._safe_write(data) + + def get(self, category: str): + with self.lock: + return list(self._data.get(category, [])) + + def add(self, category: str, item: Any): + with self.lock: + self._data.setdefault(category, []).append(item) + self._save(self._data) + + def remove(self, category: str, predicate: Callable[[Any], bool]): + with self.lock: + arr = self._data.get(category, []) + self._data[category] = [i for i in arr if not predicate(i)] + self._save(self._data) + + def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool: + """ + Atomically find one item in `category` matching predicate and update it with `updater`. + Returns True if an item was updated, else False. + """ + with self.lock: + arr = self._data.get(category, []) + for idx, item in enumerate(arr): + if predicate(item): + # Copy → mutate → save back + new_item = dict(item) + new_item = updater(new_item) or new_item + arr[idx] = new_item + self._data[category] = arr + self._save(self._data) + return True + return False diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 0000000..efa6482 --- /dev/null +++ b/example/.env.example @@ -0,0 +1,5 @@ +DISCORD_TOKEN={DISCORD_BOT_TOKEN} +DISCORD_APPLICATION_ID= +DISCORD_PUBLIC_KEY= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= \ No newline at end of file diff --git a/example/settings.conf.example b/example/settings.conf.example new file mode 100644 index 0000000..ea88761 --- /dev/null +++ b/example/settings.conf.example @@ -0,0 +1,63 @@ +[DEFAULT] +### Reaction gating messages (✅ reactions) +rules_message_id = # DISCORD RULES MESSAGE ID +engagement_message_id = # DISCORD EoR MESSAGE ID +nickname_message_id = # DISCORD NICKNAME MESSAGE ID + +### Roles +rules_role_id = # RULES ACCEPTANCE ROLE ID +engagement_role_id = # RoE ACCEPTANCE ROLE ID +full_access_role_id = # FULL ACCESS ROLE ID +# Moderator roles (IDs) +admin_role_id = # AMIN ROLE ID +field_mod_role_id = # IN-GAME MOD ROLE ID +intel_mod_role_id = # INTELLIGENCE MOD ROLE ID +moderator_role_id = # REGULAR MOD ROLE ID + +### Channels +# for approving pirate reports +mod_channel_id = # MODERATOR REVIEWS CHANNEL ID +# for join/nickname logs +modlog_channel_id = # MODLOG CHANNEL ID +# where user cards live +userslist_channel_id = # USERSLIST CHANNEL ID +# optional public notifications +report_channel_id = # PUBLIC REPORTS CHANNEL ID +# pirates list channel +pirates_list_channel_id = # PIRATES LIST CHANNEL ID + +### Auto-VC settings +trigger_channel_id = # VC TRIGGER CHANNEL ID +auto_vc_category_id = # AUTO VC CHANNEL CATEGORY ID +# Prefix to created voice channels +vc_name_prefix = DD Crew +# Seconds before an abandoned voice channel is deleted +auto_vc_cleanup_delay = 30 + +# Pirate threat calculations weights +threat_w_kill = 0.30 +threat_w_destruction = 0.40 +threat_w_group = 0.20 +threat_w_skill = 0.10 +threat_group_threshold = 3 +threat_min_samples_for_stats = 3 + +data_file = data/data.json +release_version = false +nick_nudge_loop_enabled = false +home_guild_id = # DISCORD SERVER/GUILD ID +user_cards_cron_enabled = true + +# Spice Refinery Owner Processing Fee in % +spicepay_lsr_cut_percent = 10 +# Default participation weight +spicepay_base_weight = 25 +# Carrier owner weight bonus +spicepay_carrier_bonus = 12.5 +# Crawler owner weight bonus +spicepay_crawler_bonus = 12.5 + +# Optional emojis +emoji_melange_id = 1401965356775510210 +emoji_sand_id = 1401965308805255310 +emoji_carrier_crawler_id = 1402285453037666386 \ No newline at end of file diff --git a/mod_perms.py b/mod_perms.py new file mode 100644 index 0000000..7c3b7d0 --- /dev/null +++ b/mod_perms.py @@ -0,0 +1,60 @@ +# mod_perms.py +import re +import discord +from discord.ext import commands + +def _parse_ids(raw: str): + ids = [] + if not raw: + return ids + for tok in re.split(r'[,\s]+', raw.strip()): + try: + ids.append(int(tok)) + except Exception: + pass + return ids + +def get_mod_role_ids(bot: commands.Bot): + cfg = bot.config['DEFAULT'] + # read individually; allow comma-separated in any field for flexibility + keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"] + ids = [] + for k in keys: + raw = cfg.get(k, "") + for tok in re.split(r"[,\s]+", raw.strip()): + if not tok: + continue + try: + ids.append(int(tok)) + except Exception: + pass + return ids + +def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool: + if not isinstance(member, discord.Member): + return False + if member.guild_permissions.administrator: + return True + mod_ids = set(get_mod_role_ids(bot)) + return any(r.id in mod_ids for r in member.roles) + +def is_moderator_userid(guild: discord.Guild, user_id: int, bot: commands.Bot) -> bool: + m = guild.get_member(user_id) + return is_moderator_member(m, bot) if m else False + +async def require_mod_ctx(ctx: commands.Context, msg="You don’t have permission to use this."): + if not is_moderator_member(ctx.author, ctx.bot): + await ctx.reply(msg) + return False + return True + +async def require_mod_interaction(interaction: discord.Interaction, msg="This command is restricted to moderators."): + user = interaction.user + if isinstance(user, discord.Member) and is_moderator_member(user, interaction.client): + return True + # Only send a response if we haven’t already + if not interaction.response.is_done(): + await interaction.response.send_message(msg, ephemeral=True) + else: + await interaction.followup.send(msg, ephemeral=True) + return False diff --git a/modules/auto_vc/__init__.py b/modules/auto_vc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/auto_vc/auto_vc.py b/modules/auto_vc/auto_vc.py new file mode 100644 index 0000000..176b4c3 --- /dev/null +++ b/modules/auto_vc/auto_vc.py @@ -0,0 +1,254 @@ +# modules/auto_vc/auto_vc.py +import asyncio +import time +import discord +from discord.ext import commands + +def now() -> float: + return time.time() + +class AutoVCCog(commands.Cog): + """ + Auto-VC: + • When someone joins the trigger voice channel, create a new VC under the target category, + name it "{prefix} N", and move the member there. + • When an auto-VC is empty for `delay` seconds, delete it and renumber the remaining ones. + • Only channels created by this cog are managed (tracked in data_manager['vc_channels']). + + Admin commands: + /avc_status -> show current state + /avc_cleanup_now -> run a cleanup/renumber pass now + /avc_renumber -> renumber without deleting + """ + + def __init__(self, bot): + self.bot = bot + cfg = bot.config['DEFAULT'] + + # Config + self.trigger_id = int(cfg['trigger_channel_id']) + self.category_id = int(cfg['auto_vc_category_id']) + self.prefix = cfg['vc_name_prefix'] + self.delay = int(cfg.get('auto_vc_cleanup_delay', 30)) + self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0 + + # State + self.empty_since: dict[int, float] = {} # channel_id -> ts when became empty + self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam) + self._create_lock = asyncio.Lock() + + # Background sweeper + self._task = asyncio.create_task(self._sweeper()) + + # ------------- utilities ------------- + + def cog_unload(self): + try: + self._task.cancel() + except Exception: + pass + + def _prefixed(self, num: int) -> str: + return f"{self.prefix.strip()} {num}" + + def _vc_records(self, guild_id: int): + """Return list of tracked records for this guild from persistent store.""" + return [r for r in self.bot.data_manager.get('vc_channels') if r.get('guild_id') == guild_id] + + def _find_record(self, guild_id: int, channel_id: int): + for r in self._vc_records(guild_id): + if r.get('channel_id') == channel_id: + return r + return None + + async def _log(self, guild: discord.Guild, msg: str): + if not self.modlog_channel_id: + print(f"[AutoVC][{guild.name}] {msg}") + return + ch = guild.get_channel(self.modlog_channel_id) + if ch: + try: + await ch.send(msg) + except Exception: + print(f"[AutoVC][{guild.name}] (fallback) {msg}") + else: + print(f"[AutoVC][{guild.name}] {msg}") + + async def _renumber(self, guild: discord.Guild): + """Rename tracked channels to {prefix} 1..N in stable order (by creation_ts).""" + recs = sorted(self._vc_records(guild.id), key=lambda r: r.get('created_ts', 0)) + for i, rec in enumerate(recs, start=1): + ch = guild.get_channel(rec['channel_id']) + if not ch: + # prune dead record + self.bot.data_manager.remove('vc_channels', lambda x: x.get('channel_id') == rec['channel_id']) + continue + desired = self._prefixed(i) + if ch.name != desired: + try: + await ch.edit(name=desired, reason="Auto-VC renumber") + except Exception as e: + print("[auto_vc] rename failed:", repr(e)) + + async def _cleanup_pass(self, guild: discord.Guild): + """Delete empty tracked channels that exceeded delay and renumber.""" + cat = guild.get_channel(self.category_id) + if not cat: + return + + tracked_ids = {r['channel_id'] for r in self._vc_records(guild.id)} + now_ts = now() + to_delete: list[discord.VoiceChannel] = [] + + # Mark empties & collect deletions + for ch in cat.voice_channels: + if ch.id not in tracked_ids: + continue # unmanaged room + if len(ch.members) == 0: + started = self.empty_since.get(ch.id) + if started is None: + self.empty_since[ch.id] = now_ts + elif now_ts - started >= self.delay: + to_delete.append(ch) + else: + self.empty_since.pop(ch.id, None) + + # Delete idle channels + for ch in to_delete: + try: + await ch.delete(reason=f"Auto-VC idle > {self.delay}s") + await self._log(guild, f"🗑️ Deleted idle room: `{ch.name}`") + except Exception as e: + print("[auto_vc] delete failed:", repr(e)) + # purge record and emptiness stamp + self.bot.data_manager.remove('vc_channels', lambda r, cid=ch.id: r.get('channel_id') == cid) + self.empty_since.pop(ch.id, None) + + # purge records for channels that vanished by other means + for rec in list(self._vc_records(guild.id)): + if not guild.get_channel(rec['channel_id']): + self.bot.data_manager.remove('vc_channels', lambda r, cid=rec['channel_id']: r.get('channel_id') == cid) + + if to_delete: + await self._renumber(guild) + + # ------------- background worker ------------- + + async def _sweeper(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + try: + for guild in self.bot.guilds: + await self._cleanup_pass(guild) + except Exception as e: + print("[auto_vc] sweeper loop error:", repr(e)) + await asyncio.sleep(30) + + # ------------- channel creation ------------- + + async def _spawn_and_move(self, member: discord.Member): + guild = member.guild + cat = guild.get_channel(self.category_id) + if not cat: + await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.") + return + + async with self._create_lock: + # Determine next index based on *tracked* channels + recs = sorted(self._vc_records(guild.id), key=lambda r: r.get('created_ts', 0)) + next_index = len(recs) + 1 + name = self._prefixed(next_index) + + # Create room + try: + new_ch = await cat.create_voice_channel(name, reason="Auto-VC spawn") + except discord.Forbidden: + await self._log(guild, "❌ Missing permission to create voice channels in the category.") + return + except Exception as e: + await self._log(guild, f"❌ Failed to create voice channel: {e}") + return + + # Persist record + self.bot.data_manager.add('vc_channels', { + 'guild_id': guild.id, + 'channel_id': new_ch.id, + 'created_ts': now() + }) + + # Move the user + try: + await member.move_to(new_ch, reason="Auto-VC move") + except discord.Forbidden: + await self._log(guild, "⚠️ I need **Move Members** and **Connect** permissions to move users.") + except Exception as e: + await self._log(guild, f"⚠️ Could not move {member} into `{name}`: {e}") + + # Start its idle timer if it immediately empties + if len(new_ch.members) == 0: + self.empty_since[new_ch.id] = now() + + # ------------- core flow ------------- + + @commands.Cog.listener() + async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): + guild = member.guild + + # Create on trigger join (with 5s per-user cooldown) + if after.channel and after.channel.id == self.trigger_id: + last = self._vc_cooldowns.get(member.id, 0.0) + if now() - last < 5.0: + return + self._vc_cooldowns[member.id] = now() + try: + await self._spawn_and_move(member) + except Exception as e: + print("[auto_vc] spawn/move failed:", repr(e)) + + # Mark empties immediately on leave + if before.channel: + ch = before.channel + if ch.category_id == self.category_id: + rec = self._find_record(guild.id, ch.id) + if rec and len(ch.members) == 0: + self.empty_since[ch.id] = now() + + # ------------- admin commands ------------- + + @commands.hybrid_command(name="avc_status", description="Show Auto-VC status for this guild") + @commands.has_permissions(manage_guild=True) + async def avc_status(self, ctx: commands.Context): + g = ctx.guild + recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0)) + lines = [ + f"Trigger: <#{self.trigger_id}> | Category: <#{self.category_id}> | Prefix: `{self.prefix}` | Delay: {self.delay}s" + ] + for idx, rec in enumerate(recs, start=1): + ch = g.get_channel(rec['channel_id']) + if not ch: + state = "missing" + name = f"(deleted #{idx})" + else: + name = ch.name + state = f"{len(ch.members)} inside" if len(ch.members) else "empty" + t = self.empty_since.get(rec['channel_id']) + tail = f" | idle {int(now()-t)}s" if t and (not ch or (ch and not ch.members)) else "" + lines.append(f"- #{idx}: {name} — {state}{tail}") + msg = "Auto-VC status:\n" + "\n".join(lines) if lines else "No Auto-VC rooms tracked." + await ctx.reply(msg) + + @commands.hybrid_command(name="avc_cleanup_now", description="Run an immediate cleanup pass (delete idle rooms & renumber)") + @commands.has_permissions(manage_guild=True) + async def avc_cleanup_now(self, ctx: commands.Context): + await self._cleanup_pass(ctx.guild) + await ctx.reply("Cleanup pass complete.") + + @commands.hybrid_command(name="avc_renumber", description="Force a renumber of tracked rooms") + @commands.has_permissions(manage_guild=True) + async def avc_renumber(self, ctx: commands.Context): + await self._renumber(ctx.guild) + await ctx.reply("Renumbered.") + + +async def setup(bot): + await bot.add_cog(AutoVCCog(bot)) diff --git a/modules/nick_nudge/__init__.py b/modules/nick_nudge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/nick_nudge/nick_nudge.py b/modules/nick_nudge/nick_nudge.py new file mode 100644 index 0000000..e2902b4 --- /dev/null +++ b/modules/nick_nudge/nick_nudge.py @@ -0,0 +1,329 @@ +import asyncio +import time +from typing import Optional, Tuple +import discord +from discord.ext import commands +from discord import app_commands + +from mod_perms import is_moderator_userid + +CHECK = '✅' # approved/verified +CROSS = '❌' # reject / no +PENDING = '✔️' # heavy check mark = pending claim +ACCEPT = {CHECK, '🫡'} +NO_MENTIONS = discord.AllowedMentions.none() + +def _ts_rel(ts: Optional[float] = None) -> str: + """Discord relative timestamp like .""" + if ts is None: + ts = time.time() + return f"" + +class NickNudgeCog(commands.Cog): + """ + Handles: + • DM nickname nudge loop (optional; unchanged behavior) + • Nickname *review* workflow for claims: + - On claim (via reaction or /nick_same): create a mod review in mod_channel with ✅/❌ + - Mods react: ✅ -> mark verified; ❌ -> clear claim and revoke Full Access + • Stores review mapping in data_manager['nick_reviews'] + """ + + def __init__(self, bot): + self.bot = bot + cfg = bot.config['DEFAULT'] + self.modlog_channel_id = int(cfg['modlog_channel_id']) + self.mod_channel_id = int(cfg['mod_channel_id']) # same review channel as pirate reports + # Optional DM nudge loop retained + try: + self.loop_enabled = cfg.getboolean('nick_nudge_loop_enabled') + except Exception: + self.loop_enabled = False + self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None + + # ---------- utils ---------- + + def cog_unload(self): + try: + if self._task: + self._task.cancel() + except Exception: + pass + + async def _modlog(self, guild: discord.Guild, content: str): + ch = guild.get_channel(self.modlog_channel_id) + if ch: + try: + await ch.send(content, allowed_mentions=NO_MENTIONS) + except Exception: + pass + self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content}) + + async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]: + """ + Best-effort: look up last nickname change via audit logs. + Returns (before_nick, after_nick) or (None, None) if not found/allowed. + """ + try: + async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update): + if entry.target.id != member.id or not entry.changes: + continue + before_n = getattr(entry.changes.before, 'get', lambda *_: None)('nick') + after_n = getattr(entry.changes.after, 'get', lambda *_: None)('nick') + if before_n is not None or after_n is not None: + return before_n, after_n + except Exception: + pass + return None, None + + # ---------- public API (called by ReactionRole cog) ---------- + + async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"): + """ + Create (or update) a nickname review entry for this member in the mod channel. + - source: "claim" or "nick_same" + Stores in data_manager['nick_reviews'] a record keyed by the review message_id. + """ + if not guild: + return + mod_ch = guild.get_channel(self.mod_channel_id) + if not mod_ch: + return + + before_n, after_n = await self._find_last_nick_change(guild, member) + now_ts = int(time.time()) + + # Compose review text + title = "📝 **Nickname Verification Request**" + who = f"User: {member.mention} (`{member.id}`)" + change = f"Claimed {_ts_rel(now_ts)}" + from_to = f"From: {repr(before_n) if before_n is not None else 'unknown'} → To: {repr(member.nick) if member.nick else 'None'}" + method = f"Method: {'/nick_same' if source == 'nick_same' else 'reaction'}" + instructions = "Moderators: react ✅ to **approve** or ❌ to **reject**." + content = f"{title}\n{who}\n{from_to}\n{method}\n{change}\n\n{instructions}" + + try: + msg = await mod_ch.send(content, allowed_mentions=NO_MENTIONS) + await msg.add_reaction(CHECK) + await msg.add_reaction(CROSS) + except Exception: + return + + # Persist review mapping + self.bot.data_manager.add('nick_reviews', { + 'message_id': int(msg.id), + 'guild_id': int(guild.id), + 'user_id': int(member.id), + 'before_nick': before_n if before_n is None or isinstance(before_n, str) else str(before_n), + 'claimed_nick': member.nick if member.nick else None, + 'status': 'pending', + 'source': source, + 'ts': now_ts + }) + + # Log to modlog + await self._modlog(guild, f"🔎 Nickname review opened for {member.mention} — {method} — {_ts_rel(now_ts)}.") + + # ---------- DM nudge loop (unchanged) ---------- + + async def _nudge_loop(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + try: + now = time.time() + for guild in self.bot.guilds: + for member in guild.members: + if member.bot or not member.joined_at: + continue + if (now - member.joined_at.timestamp()) < 24*3600: + continue + # If they already have a server nick OR already claimed/verified, skip nudging + dm = self.bot.data_manager + if (member.nick and member.nick.strip()): + continue + if member.id in dm.get('nick_verified') or member.id in dm.get('nick_claim_pending'): + continue + if member.id in dm.get('nick_nudged'): + continue + try: + dmchan = await member.create_dm() + msg = await dmchan.send( + "Hey! On this server we require your **server nickname** to match your in-game character name.\n\n" + "If your default Discord display name is already identical to your in-game name, react ✅ **or 🫡** below " + "or run `/nick_same`. Otherwise, please set your **server nickname** to your in-game name and react ✅ **or 🫡**." + ) + await msg.add_reaction(CHECK) + self.bot.data_manager.add('nick_dm_map', { + 'message_id': int(msg.id), + 'user_id': int(member.id), + 'guild_id': int(guild.id), + 'ts': now + }) + self.bot.data_manager.add('nick_nudged', int(member.id)) + await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}") + except Exception: + pass + except Exception: + pass + await asyncio.sleep(1800) # every 30 minutes + + # ---------- listeners ---------- + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + # 1) Handle DM nudge confirmations (user reacts ✅ in DM) + if payload.guild_id is None and str(payload.emoji) in ACCEPT and payload.user_id != self.bot.user.id: + entry = next((m for m in self.bot.data_manager.get('nick_dm_map') if m['message_id'] == payload.message_id), None) + if not entry: + return + guild = self.bot.get_guild(entry['guild_id']) + member = guild.get_member(entry['user_id']) if guild else None + if not member: + return + # Treat as a claim: mark pending + create review + dm = self.bot.data_manager + if member.id not in dm.get('agreed_nickname'): + dm.add('agreed_nickname', int(member.id)) + # Always (re)mark pending & clear verified + dm.remove('nick_verified', lambda x: x == member.id) + if member.id not in dm.get('nick_claim_pending'): + dm.add('nick_claim_pending', int(member.id)) + + # Create review + try: + await self.start_nick_review(guild, member, source="nick_same") + except Exception: + pass + + # Clean map entry + self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id) + + # Refresh card and maybe full access (pending does NOT block full access) + rr = self.bot.get_cog('ReactionRoleCog') + if rr: + try: + await rr.maybe_apply_full_access(member) + except Exception: + pass + cards = self.bot.get_cog('UserCardsCog') + if cards: + try: + await cards.refresh_card(member) + except Exception: + pass + return + + # 2) Handle moderator review reactions in mod channel + if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id: + if payload.channel_id != self.mod_channel_id: + return + guild = self.bot.get_guild(payload.guild_id) + if not guild: + return + # Only moderators can act + if not is_moderator_userid(guild, payload.user_id, self.bot): + return + + # Is this a review message? + reviews = self.bot.data_manager.get('nick_reviews') + review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None) + if not review or review.get('status') != 'pending': + return + + member = guild.get_member(int(review['user_id'])) + if not member: + # mark closed missing + self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status':'closed_missing'}), r)[1]) + return + + # Fetch and edit the review message content (best-effort) + try: + ch = self.bot.get_channel(payload.channel_id) + msg = await ch.fetch_message(payload.message_id) + except Exception: + msg = None + + dm = self.bot.data_manager + now_ts = int(time.time()) + approver = f"<@{payload.user_id}>" + + if str(payload.emoji) == CHECK: + # Approve: mark verified, clear pending, ensure agreed flag set + if member.id not in dm.get('agreed_nickname'): + dm.add('agreed_nickname', int(member.id)) + dm.remove('nick_claim_pending', lambda x: x == member.id) + if member.id not in dm.get('nick_verified'): + dm.add('nick_verified', int(member.id)) + + # Update review record + dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1]) + + # Edit the review message + if msg: + try: + await msg.clear_reactions() + except Exception: + pass + try: + await msg.edit(content=f"✅ **Nickname Approved** for {member.mention} by {approver} — {_ts_rel(now_ts)}") + except Exception: + pass + + # Modlog + await self._modlog(guild, + f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}." + ) + + # Refresh roles / card + rr = self.bot.get_cog('ReactionRoleCog') + if rr: + try: + await rr.maybe_apply_full_access(member) + except Exception: + pass + cards = self.bot.get_cog('UserCardsCog') + if cards: + try: + await cards.refresh_card(member) + except Exception: + pass + + else: + # Reject: clear all nickname flags; Full Access should be revoked by maybe_apply_full_access + dm.remove('agreed_nickname', lambda x: x == member.id) + dm.remove('nick_claim_pending', lambda x: x == member.id) + dm.remove('nick_verified', lambda x: x == member.id) + + # Update review record + dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1]) + + if msg: + try: + await msg.clear_reactions() + except Exception: + pass + try: + await msg.edit(content=f"❌ **Nickname Rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}") + except Exception: + pass + + await self._modlog(guild, + f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}." + ) + + # Refresh roles / card + rr = self.bot.get_cog('ReactionRoleCog') + if rr: + try: + await rr.maybe_apply_full_access(member) + except Exception: + pass + cards = self.bot.get_cog('UserCardsCog') + if cards: + try: + await cards.refresh_card(member) + except Exception: + pass + +async def setup(bot): + await bot.add_cog(NickNudgeCog(bot)) diff --git a/modules/pirate_cards/__init__.py b/modules/pirate_cards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/pirate_cards/pirate_cards.py b/modules/pirate_cards/pirate_cards.py new file mode 100644 index 0000000..7e87798 --- /dev/null +++ b/modules/pirate_cards/pirate_cards.py @@ -0,0 +1,226 @@ +# modules/pirate_cards/pirate_cards.py +import asyncio +import discord +from discord.ext import commands +from datetime import datetime +from mod_perms import require_mod_ctx # use your configured moderator roles + +class PirateCardsCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + cfg = bot.config['DEFAULT'] + self.pirates_channel_id = int(cfg['pirates_list_channel_id']) + self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0 + + # thresholds / samples (optional, with defaults) + try: + self.group_threshold = int(cfg.get('threat_group_threshold', '3')) + except Exception: + self.group_threshold = 3 + try: + self.min_samples = int(cfg.get('threat_min_samples_for_stats', '3')) + except Exception: + self.min_samples = 3 + + # safe posting (don’t ping) + self._no_mentions = discord.AllowedMentions.none() + # serialize rebuilds per guild + self._locks: dict[int, asyncio.Lock] = {} + + # -------- internals / helpers -------- + def _lock_for(self, guild_id: int) -> asyncio.Lock: + self._locks.setdefault(guild_id, asyncio.Lock()) + return self._locks[guild_id] + + @staticmethod + def _esc(s: str) -> str: + """Escape markdown & neutralize mentions for any user-sourced strings we show.""" + safe = discord.utils.escape_markdown(str(s)) + return safe.replace("@", "@\u200b") + + def _color_for_threat(self, threat: int, enc_count: int) -> discord.Color: + """ + Gradient: + - If too few samples: dark gray (unknown) + - Else 0 -> green (0,255,0), 100 -> red (255,0,0) + """ + if enc_count < self.min_samples: + return discord.Color.dark_gray() + + t = max(0, min(100, int(threat))) / 100.0 + r = int(round(255 * t)) + g = int(round(255 * (1.0 - t))) + return discord.Color.from_rgb(r, g, 0) + + def _bucket(self, rate: float, samples: int) -> str: + if samples < self.min_samples: + return "unknown" + if rate <= 0: + return "never" + if rate <= 0.25: + return "rarely" + if rate <= 0.60: + return "sometimes" + if rate <= 0.85: + return "often" + return "always" + + def _encounters_for(self, pirate: dict): + acct_l = str(pirate.get('account_name', '')).lower() + char_l = str(pirate.get('character_name', '')).lower() + out = [] + for e in self.bot.data_manager.get('encounters'): + try: + ident = str(e.get('identifier', '')).lower() + if ident in (acct_l, char_l): + out.append(e) + except Exception: + continue + return out + + def _get_card_record(self, account_lower: str): + for r in self.bot.data_manager.get('pirate_cards'): + if r.get('account_lower') == account_lower: + return r + return None + + 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, allowed_mentions=self._no_mentions) + except Exception: + pass + + async def _build_embed(self, pirate: dict) -> discord.Embed: + encs = self._encounters_for(pirate) + total = len(encs) + # guard numeric fields + def _i(v, d=0): + try: + return int(v) + except Exception: + return d + + group_rate = (sum(1 for e in encs if _i(e.get('group_size'), 0) >= self.group_threshold) / total) if total else 0.0 + destroy_rate = (sum(1 for e in encs if bool(e.get('destruction'))) / total) if total else 0.0 + + # last encountered: date only (UTC) + last_date = "—" + if total: + try: + last_ts = max(int(float(e.get('timestamp', 0))) for e in encs) + last_date = datetime.utcfromtimestamp(last_ts).strftime('%Y-%m-%d') + except Exception: + last_date = "—" + + groups_str = self._bucket(group_rate, total) + destr_str = self._bucket(destroy_rate, total) + + # threat & color + threat = int(pirate.get('threat_level', 0)) + color = self._color_for_threat(threat, total) + + char = self._esc(pirate.get('character_name', 'Unknown')) + acct = self._esc(pirate.get('account_name', 'Unknown#00000')) + + embed = discord.Embed(title=char, color=color) + embed.add_field(name="Account name", value=f"*{acct}*", inline=False) + embed.add_field(name="Threat", value=f"{threat}%", inline=True) + embed.add_field(name="In groups", value=groups_str, inline=True) + embed.add_field(name="Destructive", value=destr_str, inline=True) + embed.set_footer(text=f"Encounters: {total} | Last: {last_date}") + return embed + + async def refresh_card_for_account(self, guild: discord.Guild, account_name: str): + """Create or update a single pirate card by account name.""" + acct_l = str(account_name or "").lower() + pirates = self.bot.data_manager.get('pirates') + p = next((x for x in pirates if str(x.get('account_name', '')).lower() == acct_l), None) + if not p: + return + + channel = guild.get_channel(self.pirates_channel_id) + if not channel: + return + + embed = await self._build_embed(p) + rec = self._get_card_record(acct_l) + + if rec: + try: + msg = await channel.fetch_message(int(rec['message_id'])) + await msg.edit(embed=embed) + return + except Exception: + # fall-through to recreate + pass + + try: + msg = await channel.send(embed=embed, allowed_mentions=self._no_mentions) + self.bot.data_manager.add('pirate_cards', { + 'account_lower': acct_l, + 'message_id': msg.id + }) + except Exception: + pass + + async def delete_card_for_account(self, guild: discord.Guild, account_name: str): + """Delete a pirate card by account and remove its record.""" + acct_l = str(account_name or "").lower() + channel = guild.get_channel(self.pirates_channel_id) + rec = self._get_card_record(acct_l) + + if rec and channel: + try: + msg = await channel.fetch_message(int(rec['message_id'])) + await msg.delete() + except Exception: + pass + + # ensure record is gone regardless + self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == acct_l) + + async def update_on_edit(self, guild: discord.Guild, old_account: str, new_account: str): + """When an account name changes, carry over the card mapping and refresh.""" + old_l = str(old_account or "").lower() + new_l = str(new_account or "").lower() + rec = self._get_card_record(old_l) + if rec: + # replace mapping to new key + self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == old_l) + self.bot.data_manager.add('pirate_cards', { + 'account_lower': new_l, + 'message_id': rec['message_id'] + }) + await self.refresh_card_for_account(guild, new_account) + + # -------- command (mod-gated via require_mod_ctx) -------- + @commands.hybrid_command(name="pirate_cards_rebuild", description="Rebuild pirate cards for all known pirates") + async def pirate_cards_rebuild(self, ctx: commands.Context): + if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + return + if not ctx.guild: + return await ctx.reply("Use this in a server.", ephemeral=True) + + async with self._lock_for(ctx.guild.id): + guild = ctx.guild + ch = guild.get_channel(self.pirates_channel_id) + if not ch: + return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True) + + count = 0 + for p in self.bot.data_manager.get('pirates'): + try: + await self.refresh_card_for_account(guild, p.get('account_name', '')) + count += 1 + except Exception: + continue + + is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None + await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash) + +async def setup(bot): + await bot.add_cog(PirateCardsCog(bot)) diff --git a/modules/pirate_report/__init__.py b/modules/pirate_report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/pirate_report/pirate_report.py b/modules/pirate_report/pirate_report.py new file mode 100644 index 0000000..21e2749 --- /dev/null +++ b/modules/pirate_report/pirate_report.py @@ -0,0 +1,579 @@ +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 diff --git a/modules/pirates_list/__init__.py b/modules/pirates_list/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/pirates_list/pirates_list.py b/modules/pirates_list/pirates_list.py new file mode 100644 index 0000000..cec95a6 --- /dev/null +++ b/modules/pirates_list/pirates_list.py @@ -0,0 +1,256 @@ +# modules/pirates_list/pirates_list.py +import asyncio +import discord +from discord.ext import commands +from mod_perms import require_mod_ctx # ctx-aware mod gate + + +class PiratesListCog(commands.Cog): + """ + Maintains a compact, alphabetized pirates list in the configured channel. + + Entry format: + - **{Character}** (*{Account}*) [{Threat}%] + - In group: {never/rarely/...}. Destructive: {never/rarely/...}. Encounters: N. Last: + + Posts are chunked to stay <2000 chars and previous posts are deleted on refresh. + """ + + def __init__(self, bot): + self.bot = bot + cfg = bot.config["DEFAULT"] + self.list_channel_id = int(cfg["pirates_list_channel_id"]) + try: + self.group_threshold = int(cfg.get("threat_group_threshold", "3")) + except Exception: + self.group_threshold = 3 + try: + self.min_samples = int(cfg.get("threat_min_samples_for_stats", "3")) + except Exception: + self.min_samples = 3 + + # serialize refreshes per guild + self._locks = {} + self._no_mentions = discord.AllowedMentions.none() + + def _lock_for(self, guild_id: int): + import asyncio + self._locks.setdefault(guild_id, asyncio.Lock()) + return self._locks[guild_id] + + # send settings: never ping on posted content + self._no_mentions = discord.AllowedMentions.none() + + # ----------------- utils ----------------- + + def _lock_for(self, guild_id: int) -> asyncio.Lock: + self._locks.setdefault(guild_id, asyncio.Lock()) + return self._locks[guild_id] + + @staticmethod + def _esc(s: str) -> str: + """Escape markdown and neuter @mentions in user-provided strings.""" + safe = discord.utils.escape_markdown(str(s)) + return safe.replace("@", "@\u200b") + + def _encounters_for(self, pirate: dict): + """Return encounters matching either char name or account (case-insensitive), defensively.""" + acct_l = str(pirate.get("account_name", "")).lower() + char_l = str(pirate.get("character_name", "")).lower() + out = [] + for e in self.bot.data_manager.get("encounters"): + try: + ident = str(e.get("identifier", "")).lower() + if ident in (acct_l, char_l): + out.append(e) + except Exception: + continue + return out + + def _bucket(self, rate: float, samples: int) -> str: + if samples < self.min_samples: + return "unknown" + if rate <= 0: + return "never" + if rate <= 0.25: + return "rarely" + if rate <= 0.60: + return "sometimes" + if rate <= 0.85: + return "often" + return "always" + + def _format_entry(self, p: dict) -> str: + encs = self._encounters_for(p) + total = len(encs) + + def _safe_int(v, default=0): + try: + return int(v) + except Exception: + return default + + group_rate = ( + sum(1 for e in encs if _safe_int(e.get("group_size"), 0) >= self.group_threshold) / total + if total else 0.0 + ) + destroy_rate = ( + sum(1 for e in encs if bool(e.get("destruction"))) / total + if total else 0.0 + ) + groups_str = self._bucket(group_rate, total) + destr_str = self._bucket(destroy_rate, total) + + last_ts = None + if total: + try: + last_ts = max(int(float(e.get("timestamp", 0))) for e in encs) + except Exception: + last_ts = None + + char = self._esc(p.get("character_name", "Unknown")) + acct = self._esc(p.get("account_name", "Unknown#00000")) + threat = int(p.get("threat_level", 0)) + last_str = f"" if last_ts else "—" + + line1 = f"- **{char}** (*{acct}*) [{threat}%]" + line2 = f" - In group: {groups_str}. Destructive: {destr_str}. Encounters: {total}. Last: {last_str}" + return f"{line1}\n{line2}" + + async def refresh_list(self, guild: discord.Guild): + """Edit list messages in place; only send extra messages when we need more chunks (new pirates).""" + # ---- serialize per guild ---- + lock = getattr(self, "_locks", {}).get(guild.id) + if lock is None: + # tiny fallback if you didn't add _lock_for() + import asyncio as _asyncio + if not hasattr(self, "_locks"): + self._locks = {} + self._locks[guild.id] = _asyncio.Lock() + lock = self._locks[guild.id] + + async with lock: + channel = guild.get_channel(self.list_channel_id) + if not channel: + print("[pirates_list] list channel not found:", self.list_channel_id) + return + + dm = self.bot.data_manager + allow = getattr(self, "_no_mentions", discord.AllowedMentions.none()) + + # ---- load & prune existing posts for this guild/channel ---- + records = [r for r in dm.get("pirates_list_posts") + if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id] + + # fetch messages (drop any that vanished) + msgs, kept_records = [], [] + for r in records: + try: + m = await channel.fetch_message(int(r["message_id"])) + msgs.append(m) + kept_records.append(r) + except Exception: + # prune dead record + dm.remove("pirates_list_posts", lambda x, mid=r["message_id"]: x.get("message_id") == mid) + + records = kept_records # only live ones, in stored order + + # ---- build fresh, sorted contents ---- + pirates = sorted( + dm.get("pirates"), + key=lambda x: ( + str(x.get("character_name", "")).lower(), + str(x.get("account_name", "")).lower() + ) + ) + + # Empty state + if not pirates: + placeholder = "_No verified pirates yet._" + if msgs: + # edit first, delete the rest + if msgs[0].content != placeholder: + try: + await msgs[0].edit(content=placeholder, allowed_mentions=allow) + except Exception as e: + print("[pirates_list] edit placeholder failed:", repr(e)) + # remove extra posts/records + for extra in msgs[1:]: + try: + await extra.delete() + except Exception: + pass + dm.remove("pirates_list_posts", lambda r, mid=extra.id: r.get("message_id") == mid) + else: + try: + m = await channel.send(placeholder, allowed_mentions=allow) + dm.add("pirates_list_posts", { + "guild_id": guild.id, + "channel_id": self.list_channel_id, + "message_id": m.id + }) + except Exception as e: + print("[pirates_list] send placeholder failed:", repr(e)) + return + + # Chunk into <2000 char blocks (~1900 for margin) + chunks, current = [], "" + for p in pirates: + entry = self._format_entry(p) + if len(current) + len(entry) + 1 > 1900: + chunks.append(current.rstrip()) + current = entry + "\n" + else: + current += entry + "\n" + if current.strip(): + chunks.append(current.rstrip()) + + # ---- edit existing messages with new content ---- + common = min(len(msgs), len(chunks)) + for i in range(common): + if msgs[i].content != chunks[i]: + try: + await msgs[i].edit(content=chunks[i], allowed_mentions=allow) + except Exception as e: + print("[pirates_list] edit block failed:", repr(e)) + + # ---- if we need *more* messages (usually after adding a pirate), send them ---- + if len(chunks) > len(msgs): + for i in range(len(msgs), len(chunks)): + try: + m = await channel.send(chunks[i], allowed_mentions=allow) + dm.add("pirates_list_posts", { + "guild_id": guild.id, + "channel_id": self.list_channel_id, + "message_id": m.id + }) + except Exception as e: + print("[pirates_list] send block failed:", repr(e)) + + # ---- if we need fewer messages (e.g., pirate removed), delete extras ---- + elif len(chunks) < len(msgs): + extras = msgs[len(chunks):] + for m in extras: + try: + await m.delete() + except Exception: + pass + dm.remove("pirates_list_posts", lambda r, mid=m.id: r.get("message_id") == mid) + + # Manual refresh command (hybrid: works as /pirates_list_refresh and !pirates_list_refresh) + @commands.hybrid_command(name="pirates_list_refresh", description="Rebuild the compact pirates list") + @commands.cooldown(1, 10, commands.BucketType.guild) # tiny anti-spam + async def pirates_list_refresh(self, ctx: commands.Context): + if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + return + if not ctx.guild: + return await ctx.reply("Use this in a server.", ephemeral=True) + + await self.refresh_list(ctx.guild) + + is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None + await ctx.reply("Pirates list refreshed.", ephemeral=is_slash) + + +async def setup(bot): + await bot.add_cog(PiratesListCog(bot)) diff --git a/modules/reaction_role/__init__.py b/modules/reaction_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py new file mode 100644 index 0000000..567ac51 --- /dev/null +++ b/modules/reaction_role/reaction_role.py @@ -0,0 +1,194 @@ +import discord +from discord.ext import commands + +CHECKMARK = '✅' +ACCEPT = {CHECKMARK, '🫡'} + +class ReactionRoleCog(commands.Cog): + """ + Records agreements and manages Full Access. + Now integrates nickname *pending/verified* flow: + • Nickname reaction add -> mark agreed + pending, open review via NickNudgeCog + • Nickname reaction remove -> clear agreed/pending/verified and re-check access + • /nick_same -> same as claim (no reaction required) + Full Access: granted when Rules ✅ + RoE ✅ + Nickname **claimed (pending or verified)**. + Revoked only when nickname becomes unclaimed (rejected or unreacted) or when Rules/RoE are missing. + """ + + def __init__(self, bot): + self.bot = bot + cfg = bot.config['DEFAULT'] + + def _i(key): + try: + v = cfg.get(key) + return int(v) if v else 0 + except Exception: + return 0 + + self.rules_msg_id = _i('rules_message_id') + self.engage_msg_id = _i('engagement_message_id') + self.nick_msg_id = _i('nickname_message_id') + self.rules_role = _i('rules_role_id') + self.engage_role = _i('engagement_role_id') + self.full_access_role = _i('full_access_role_id') + + # ---- helpers ---- + def _has_rules(self, member_id: int) -> bool: + return member_id in self.bot.data_manager.get('agreed_rules') + + def _has_engage(self, member_id: int) -> bool: + return member_id in self.bot.data_manager.get('agreed_engagement') + + def _has_nick_claim(self, member_id: int) -> bool: + """Claimed = agreed_nickname; pending/verified tracked separately.""" + return member_id in self.bot.data_manager.get('agreed_nickname') + + async def maybe_apply_full_access(self, member: discord.Member): + """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing.""" + guild = member.guild + role = guild.get_role(self.full_access_role) if self.full_access_role else None + if not role: + return + + has_all = self._has_rules(member.id) and self._has_engage(member.id) and self._has_nick_claim(member.id) + try: + if has_all and role not in member.roles: + await member.add_roles(role, reason="All agreements completed (nickname may be pending)") + elif not has_all and role in member.roles: + await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed") + except discord.Forbidden: + pass + except Exception: + pass + + # Best-effort: refresh user card + cards = self.bot.get_cog('UserCardsCog') + if cards: + try: + await cards.refresh_card(member) + except Exception: + pass + + async def _get_member(self, guild: discord.Guild, user_id: int): + if not guild: + return None + m = guild.get_member(user_id) + if m is None: + try: + m = await guild.fetch_member(user_id) + except Exception: + return None + return m + + # ---- commands (hybrid = prefix + slash) ---- + @commands.hybrid_command(name='nick_same', description='Claim that your global display name matches your in-game name (triggers mod review)') + async def nick_same(self, ctx: commands.Context): + member = ctx.author if isinstance(ctx.author, discord.Member) else None + if not member or not ctx.guild: + return await ctx.reply("Use this in a server.", ephemeral=True) + + dm = self.bot.data_manager + if member.id not in dm.get('agreed_nickname'): + dm.add('agreed_nickname', int(member.id)) + # Mark pending (clear verified if present) + dm.remove('nick_verified', lambda x: x == member.id) + if member.id not in dm.get('nick_claim_pending'): + dm.add('nick_claim_pending', int(member.id)) + + # Open/refresh a review with NickNudge + nn = self.bot.get_cog('NickNudgeCog') + if nn and hasattr(nn, 'start_nick_review'): + try: + await nn.start_nick_review(ctx.guild, member, source="nick_same") + except Exception: + pass + + await self.maybe_apply_full_access(member) + await ctx.reply("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) + + # ---- listeners ---- + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + if str(payload.emoji) not in ACCEPT or not payload.guild_id: + return + guild = self.bot.get_guild(payload.guild_id) + member = await self._get_member(guild, payload.user_id) + if not member or member.bot: + return + + dm = self.bot.data_manager + try: + if payload.message_id == self.rules_msg_id: + role = guild.get_role(self.rules_role) + if role: + await member.add_roles(role, reason="Agreed to rules") + if member.id not in dm.get('agreed_rules'): + dm.add('agreed_rules', int(member.id)) + + elif payload.message_id == self.engage_msg_id: + role = guild.get_role(self.engage_role) + if role: + await member.add_roles(role, reason="Agreed to engagement") + if member.id not in dm.get('agreed_engagement'): + dm.add('agreed_engagement', int(member.id)) + + elif payload.message_id == self.nick_msg_id: + # Claim nickname via reaction -> mark agreed + pending, clear verified + if member.id not in dm.get('agreed_nickname'): + dm.add('agreed_nickname', int(member.id)) + dm.remove('nick_verified', lambda x: x == member.id) + if member.id not in dm.get('nick_claim_pending'): + dm.add('nick_claim_pending', int(member.id)) + + # Kick off a review in mod channel + nn = self.bot.get_cog('NickNudgeCog') + if nn and hasattr(nn, 'start_nick_review'): + try: + await nn.start_nick_review(guild, member, source="claim") + except Exception: + pass + else: + return + except Exception: + pass + + await self.maybe_apply_full_access(member) + + @commands.Cog.listener() + async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): + if str(payload.emoji) not in ACCEPT or not payload.guild_id: + return + guild = self.bot.get_guild(payload.guild_id) + member = await self._get_member(guild, payload.user_id) + if not member or member.bot: + return + + dm = self.bot.data_manager + try: + if payload.message_id == self.rules_msg_id: + dm.remove('agreed_rules', lambda x: x == member.id) + role = guild.get_role(self.rules_role) + if role: + await member.remove_roles(role, reason="Rules un-ticked") + + elif payload.message_id == self.engage_msg_id: + dm.remove('agreed_engagement', lambda x: x == member.id) + role = guild.get_role(self.engage_role) + if role: + await member.remove_roles(role, reason="Engagement un-ticked") + + elif payload.message_id == self.nick_msg_id: + # Un-claim nickname -> clear everything related + dm.remove('agreed_nickname', lambda x: x == member.id) + dm.remove('nick_claim_pending', lambda x: x == member.id) + dm.remove('nick_verified', lambda x: x == member.id) + else: + return + except Exception: + pass + + await self.maybe_apply_full_access(member) + +async def setup(bot): + await bot.add_cog(ReactionRoleCog(bot)) diff --git a/modules/spicepay/__init__.py b/modules/spicepay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/spicepay/spicepay.py b/modules/spicepay/spicepay.py new file mode 100644 index 0000000..b7fdc25 --- /dev/null +++ b/modules/spicepay/spicepay.py @@ -0,0 +1,917 @@ +# modules/spicepay/spicepay.py +import re +import time +import asyncio +from typing import List, Dict, Tuple, Optional +import discord +from discord.ext import commands +from discord import app_commands + +# Accept both for backward compatibility; display uses "Refiner" +VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"} + +# ------------------------ emoji helpers ------------------------ + +def _emoji_str(bot: commands.Bot, emoji_id: Optional[int], fallback: str) -> str: + if emoji_id: + e = bot.get_emoji(int(emoji_id)) + if e: + return str(e) + return fallback + +# ------------------------ core math ------------------------ + +def _normalize_roles(raw: str) -> List[str]: + """ + Accepts flexible input; maps common aliases to canonical role keys. + New canonical: refiner_owner (old: lsr_owner still accepted). + """ + if not raw: + return [] + txt = raw.strip().lower() + tokens = re.split(r"[,\s]+", txt) + out = set() + for tok in tokens: + if not tok: + continue + t = tok.replace("-", "").replace("_", "") + if t in ("crawler", "cr", "craw", "crawlerowner"): + out.add("crawler_owner") + elif t in ("carrier", "ca", "car", "carrierowner"): + out.add("carrier_owner") + elif t in ("refiner", "refinery", "refineryowner", "refinerowner", "lsr", "lsrowner", "largespicerefinery"): + out.add("refiner_owner") + elif t in ("none", "no", "nil"): + return [] + return list(out) + +def calculate_spice_distribution( + spice_total: int, + participants: List[Dict], + *, + base_weight: float = 25.0, + carrier_bonus: float = 12.5, + crawler_bonus: float = 12.5, + lsr_cut_percent: float = 10.0, # config key name retained; UI calls it "refinery cut" + lsr_required: bool = True, # True for melange payouts +) -> Tuple[List[Dict], List[str]]: + errors: List[str] = [] + result: List[Dict] = [] + + if not isinstance(spice_total, int) or spice_total < 0: + return [], ["Spice total must be a non-negative integer."] + if base_weight < 0 or carrier_bonus < 0 or crawler_bonus < 0: + return [], ["Weights must be non-negative."] + if lsr_cut_percent < 0 or lsr_cut_percent > 100: + return [], ["Refinery cut percent must be between 0 and 100."] + + seen_names = set() + refiner_owners: List[str] = [] + cleaned: List[Dict] = [] + + for i, p in enumerate(participants): + if not isinstance(p, dict): + errors.append(f"Participant at index {i} is not a dict.") + continue + + name = p.get('name') + participation = p.get('participation', 0) + roles = p.get('roles', []) + + if not name or not isinstance(name, str): + errors.append(f"Participant at index {i} has an invalid or missing name.") + continue + name = name.strip() + if name in seen_names: + errors.append(f"Duplicate participant name: '{name}'.") + continue + seen_names.add(name) + + if isinstance(participation, str): + participation = participation.strip().replace("%", "") + try: + participation = float(participation) + except Exception: + errors.append(f"Participant '{name}' has invalid participation value.") + continue + + if not isinstance(participation, (int, float)) or not (0 <= participation <= 100): + errors.append(f"Participant '{name}' has invalid participation: {participation}. Must be 0–100.") + continue + + if isinstance(roles, str): + roles = _normalize_roles(roles) + if not isinstance(roles, list): + errors.append(f"Participant '{name}' has invalid roles payload.") + continue + + invalid = [r for r in roles if r not in VALID_ROLES] + if invalid: + errors.append(f"Participant '{name}' has invalid roles: {invalid}.") + continue + + if 'refiner_owner' in roles or 'lsr_owner' in roles: + refiner_owners.append(name) + + cleaned.append({'name': name, 'participation': float(participation), 'roles': roles}) + + # Refiner checks depend on payout mode + if lsr_required: + if len(refiner_owners) > 1: + errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only one is allowed.") + elif len(refiner_owners) == 0: + errors.append("No refiner owner found. Exactly one is required for melange payouts.") + else: + if len(refiner_owners) > 1: + errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only zero or one is allowed for sand payouts.") + + if errors: + return [], errors + + refiner_owner = refiner_owners[0] if refiner_owners else None + refinery_cut = int(spice_total * (lsr_cut_percent / 100.0)) if (lsr_required and refiner_owner) else 0 + distributable = spice_total - refinery_cut + + # Compute weights + weights: Dict[str, float] = {} + total_weight = 0.0 + for p in cleaned: + w = float(base_weight) * (p['participation'] / 100.0) + if 'carrier_owner' in p['roles']: + w += float(carrier_bonus) + if 'crawler_owner' in p['roles']: + w += float(crawler_bonus) + weights[p['name']] = w + total_weight += w + + if total_weight <= 0: + return [], [ + "Total effective weight is zero. Increase participation or weighting factors " + "(e.g., raise base ×% and/or bonuses) so at least one participant has non-zero weight." + ] + + # Proportional allocation + spice_raw: Dict[str, int] = {} + total_floored = 0 + for p in cleaned: + name = p['name'] + w = weights.get(name, 0.0) + share = int((w / total_weight) * distributable) + spice_raw[name] = share + total_floored += share + + remainder = distributable - total_floored + if refiner_owner: + spice_raw[refiner_owner] = spice_raw.get(refiner_owner, 0) + refinery_cut + remainder + else: + top = max(spice_raw.items(), key=lambda kv: kv[1])[0] + spice_raw[top] += remainder + + result = [{ + 'name': p['name'], + 'spice': int(spice_raw.get(p['name'], 0)), + 'participation': p['participation'], + 'roles': p['roles'], + } for p in cleaned] + + return result, [] + +# ------------------------ formatting ------------------------ + +def _fmt_roles_short(roles: List[str]) -> str: + if not roles: + return "Escort" + short = [] + for r in roles: + if r in ("refiner_owner", "lsr_owner"): short.append("Refiner") + elif r == "crawler_owner": short.append("Crawler") + elif r == "carrier_owner": short.append("Carrier") + return ",".join(short) if short else "Escort" + +def _format_table(results: List[Dict], unit_header: str, unit_suffix: str) -> str: + rows = sorted(results, key=lambda r: (-r['spice'], r['name'].lower())) + any_zero = any((r.get('participation') or 0) <= 0 for r in rows) + + own_header = "Owner of/Role" + name_w = max(4, *(len(r['name']) for r in rows)) if rows else 4 + own_w = max(len(own_header), *(len(_fmt_roles_short(r['roles'])) for r in rows)) if rows else len(own_header) + pay_w = max(6, len(unit_header)) + + header = f"{'Name'.ljust(name_w)} {'Active %'.rjust(8)} {own_header.ljust(own_w)} {unit_header.rjust(pay_w)}" + sep = "-" * len(header) + lines = [header, sep] + + for r in rows: + name = r['name'].ljust(name_w) + pct_val = int(round(r.get('participation') or 0)) + pct = f"{pct_val}%" + if pct_val == 0: + pct += "*" # owner-only marker + pct = pct.rjust(8) + owned = _fmt_roles_short(r.get('roles', [])).ljust(own_w) + amount = f"{r['spice']} {unit_suffix}" + lines.append(f"{name} {pct} {owned} {amount}") + + if any_zero: + lines.append("") + lines.append("* 0% = owner only (did not actively join the run)") + return "```\n" + "\n".join(lines) + "\n```" + +# -------- Modals -------- + +class _SetupModal(discord.ui.Modal, title="Spice Pay — Setup"): + def __init__(self, cog: "SpicePayCog", pre_participants: Optional[int]): + super().__init__() + self.cog = cog + self.pre_participants = pre_participants + + self.total_field = discord.ui.TextInput( + label="Total spice yield (sand)", + placeholder="e.g. 12345", + required=True, + max_length=10 + ) + self.count_field = discord.ui.TextInput( + label="Participants (incl. owners)", + placeholder="Total number of people (add refiner/carrier/crawler owners too)", + required=True, + max_length=45, # allow a clearer hint + default=str(pre_participants) if pre_participants is not None else None + ) + + self.add_item(self.total_field) + self.add_item(self.count_field) + + async def on_submit(self, interaction: discord.Interaction): + try: + total = int(self.total_field.value.strip()) + if total < 0: + raise ValueError + except Exception: + return await interaction.response.send_message("❌ Total must be a non-negative integer.", ephemeral=True) + + try: + count = int(str(self.count_field.value).strip()) + if not (1 <= count <= 25): + raise ValueError + except Exception: + return await interaction.response.send_message("❌ Participants must be between 1 and 25.", ephemeral=True) + + await self.cog.start_session(interaction, total, count) + +class _ParticipantModal(discord.ui.Modal): + def __init__(self, cog: "SpicePayCog", session_key, index: int, existing: Optional[Dict]): + super().__init__(title=f"Participant {index}") + self.cog = cog + self.session_key = session_key + self.index = index + ex = existing or {} + + self.name = discord.ui.TextInput( + label="Name", + placeholder="Player name (or @mention text)", + required=True, + max_length=64, + default=ex.get("name") if ex else None + ) + self.part = discord.ui.TextInput( + label="Active participation % (0–100)", + placeholder="How much they actively joined the run (0..100; 0% = owner only)", + required=True, + max_length=45, + default=str(int(ex["participation"])) if "participation" in ex else None + ) + self.roles = discord.ui.TextInput( + label="Owner of (optional)", + placeholder="refiner, crawler, carrier — leave empty if escort/non-owner", + required=False, + max_length=64, + default=",".join(ex["roles"]) if ex and ex.get("roles") else None + ) + self.add_item(self.name) + self.add_item(self.part) + self.add_item(self.roles) + + async def on_submit(self, interaction: discord.Interaction): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True) + + name = self.name.value.strip() + part_raw = str(self.part.value).strip().replace("%", "") + try: + participation = float(part_raw) + except Exception: + return await interaction.response.send_message("❌ Participation must be a number (0–100).", ephemeral=True) + if participation < 0 or participation > 100: + return await interaction.response.send_message("❌ Participation must be 0–100.", ephemeral=True) + + roles = _normalize_roles(self.roles.value or "") + + idx = self.index - 1 + while len(sess["participants"]) < idx + 1: + sess["participants"].append({}) + sess["participants"][idx] = {"name": name, "participation": participation, "roles": roles} + + if sess["next_index"] == self.index and sess["next_index"] < sess["count"]: + sess["next_index"] += 1 + + await self.cog.render_progress(interaction, self.session_key) + +class _MelangeModal(discord.ui.Modal, title="Set melange refinery yield"): + def __init__(self, cog: "SpicePayCog", session_key): + super().__init__() + self.cog = cog + self.session_key = session_key + self.amount = discord.ui.TextInput( + label="Melange refinery yield (integer)", + placeholder="e.g. 123", + required=True, + max_length=10 + ) + self.add_item(self.amount) + + async def on_submit(self, interaction: discord.Interaction): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + val = self.amount.value.strip() + try: + m = int(val) + if m < 0: + raise ValueError + except Exception: + return await interaction.response.send_message("❌ Melange total must be a non-negative integer.", ephemeral=True) + sess["melange_total"] = m + await self.cog.render_progress(interaction, self.session_key) + +class _WeightsModal(discord.ui.Modal, title="Adjust weighting factors"): + def __init__(self, cog: "SpicePayCog", session_key): + super().__init__() + self.cog = cog + self.session_key = session_key + + sess = self.cog.sessions.get(session_key) or {} + w = (sess.get("weights") or {}) + bw = w.get("base", self.cog.base_weight) + cab = w.get("carrier", self.cog.carrier_bonus) + crb = w.get("crawler", self.cog.crawler_bonus) + lsr = w.get("lsr", self.cog.lsr_cut_percent) + + self.base = discord.ui.TextInput(label="Base × active %", required=False, max_length=10, placeholder=str(bw)) + self.carrier = discord.ui.TextInput(label="Carrier bonus (+)", required=False, max_length=10, placeholder=str(cab)) + self.crawler = discord.ui.TextInput(label="Crawler bonus (+)", required=False, max_length=10, placeholder=str(crb)) + self.lsr = discord.ui.TextInput(label="Refinery cut % (melange)", required=False, max_length=10, placeholder=str(lsr)) + + self.add_item(self.base) + self.add_item(self.carrier) + self.add_item(self.crawler) + self.add_item(self.lsr) + + async def on_submit(self, interaction: discord.Interaction): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + + def _parse_nonneg(opt_str, pct=False): + if not opt_str: + return None + s = str(opt_str).strip().replace("%", "") + try: + v = float(s) + except Exception: + return "bad" + if v < 0: + return "bad" + if pct and (v < 0 or v > 100): + return "bad" + return v + + b = _parse_nonneg(self.base.value) + cab = _parse_nonneg(self.carrier.value) + crb = _parse_nonneg(self.crawler.value) + lsr = _parse_nonneg(self.lsr.value, pct=True) + + if "bad" in (b, cab, crb, lsr): + return await interaction.response.send_message( + "❌ Invalid values. Use non-negative numbers; refinery % must be 0–100.", + ephemeral=True + ) + + w = sess.get("weights") or {} + if b is not None: w["base"] = b + if cab is not None: w["carrier"] = cab + if crb is not None: w["crawler"] = crb + if lsr is not None: w["lsr"] = lsr + sess["weights"] = w + + # Soft warning for extreme values + if any(v is not None and v > 1000 for v in (b, cab, crb)): + try: + await interaction.response.send_message( + "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.", + ephemeral=True + ) + except discord.InteractionResponded: + await interaction.followup.send( + "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.", + ephemeral=True + ) + return await self.cog.render_progress(interaction, self.session_key) + + await self.cog.render_progress(interaction, self.session_key) + +# ------------------------ Views ------------------------ + +class _HelpView(discord.ui.View): + def __init__(self, cog: "SpicePayCog", session_key: tuple): + super().__init__(timeout=600) # keep session alive + self.cog = cog + self.session_key = session_key + + async def on_timeout(self): + pass + + @discord.ui.button(label="Return to setup", style=discord.ButtonStyle.primary) + async def back(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await self.cog.render_progress(interaction, self.session_key) + + @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary) + async def open_weights(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key)) + +class _WizardView(discord.ui.View): + def __init__(self, cog: "SpicePayCog", session_key: tuple): + super().__init__(timeout=600) + self.cog = cog + self.session_key = session_key + + async def on_timeout(self): + pass + + @discord.ui.button(label="Add / Edit participant", style=discord.ButtonStyle.primary, row=0) + async def add_edit(self, interaction: discord.Interaction, _button: discord.ui.Button): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True) + idx = sess["next_index"] + if idx > sess["count"]: + idx = sess["count"] + existing = sess["participants"][idx-1] if 0 <= idx-1 < len(sess["participants"]) else None + await interaction.response.send_modal(_ParticipantModal(self.cog, self.session_key, idx, existing)) + + @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary, row=0) + async def previous(self, interaction: discord.Interaction, _button: discord.ui.Button): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + sess["next_index"] = max(1, sess["next_index"] - 1) + await self.cog.render_progress(interaction, self.session_key) + + @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary, row=0) + async def next(self, interaction: discord.Interaction, _button: discord.ui.Button): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + sess["next_index"] = min(sess["count"], sess["next_index"] + 1) + await self.cog.render_progress(interaction, self.session_key) + + @discord.ui.button(label="Toggle payout: Sand/Melange", style=discord.ButtonStyle.secondary, row=1) + async def toggle_payout(self, interaction: discord.Interaction, _button: discord.ui.Button): + sess = self.cog.sessions.get(self.session_key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + sess["payout_type"] = "melange" if sess.get("payout_type") == "sand" else "sand" + if sess["payout_type"] == "melange" and not sess.get("melange_total"): + return await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key)) + await self.cog.render_progress(interaction, self.session_key) + + @discord.ui.button(label="Set melange refinery yield", style=discord.ButtonStyle.primary, row=1) + async def set_melange(self, interaction: discord.Interaction, _button: discord.ui.Button): + await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key)) + + @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary, row=1) + async def adjust_weights(self, interaction: discord.Interaction, _button: discord.ui.Button): + await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key)) + + @discord.ui.button(label="Weights help", style=discord.ButtonStyle.secondary, row=2) + async def weights_help(self, interaction: discord.Interaction, _button: discord.ui.Button): + help_txt = ( + "**How the split works**\n" + "• **Participants are people** (or a guild as one person). Vehicles/refinery are **owned** by someone.\n" + "• Everyone gets a *weight*: **Base × active %** + **bonuses** (Carrier/Crawler if they own them).\n" + "• We split the pot **proportionally** to those weights (normalized; only ratios matter).\n" + "• **Melange payout** only: a **refinery cut %** is taken first and given to the **Refiner owner**.\n" + "• People with **0%** are treated as **owner only** (get owner bonuses/cut but didn’t actively join the run).\n" + "• Rounding leftovers go to the Refiner (melange) or the top earner (sand).\n\n" + "_Tap **Return to setup** below to go back, or **Adjust weighting factors** to change numbers._" + ) + try: + await interaction.response.edit_message(content=help_txt, view=_HelpView(self.cog, self.session_key)) + except discord.InteractionResponded: + await interaction.followup.send(help_txt, ephemeral=True, view=_HelpView(self.cog, self.session_key)) + + # ---------- Presets row ---------- + @discord.ui.button(label="Preset: Owner-heavy", style=discord.ButtonStyle.secondary, row=3) + async def preset_owner(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await self.cog.apply_preset(interaction, self.session_key, preset="owner") + + @discord.ui.button(label="Preset: Participation-heavy", style=discord.ButtonStyle.secondary, row=3) + async def preset_part(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await self.cog.apply_preset(interaction, self.session_key, preset="participation") + + @discord.ui.button(label="Preset: Fair (server defaults)", style=discord.ButtonStyle.secondary, row=3) + async def preset_fair(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await self.cog.apply_preset(interaction, self.session_key, preset="fair") + + @discord.ui.button(label="Preset: Even split", style=discord.ButtonStyle.secondary, row=3) + async def preset_even(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await self.cog.apply_preset(interaction, self.session_key, preset="even") + + @discord.ui.button(label="Save as my defaults", style=discord.ButtonStyle.success, row=3) + async def save_defaults(self, interaction: discord.Interaction, _btn: discord.ui.Button): + await self.cog.save_user_defaults(interaction, self.session_key) + + @discord.ui.button(label="Finish", style=discord.ButtonStyle.success, row=2) + async def finish(self, interaction: discord.Interaction, _button: discord.ui.Button): + await self.cog.finish_and_show(interaction, self.session_key) + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, row=2) + async def cancel(self, interaction: discord.Interaction, _button: discord.ui.Button): + self.cog.sessions.pop(self.session_key, None) + await interaction.response.edit_message(content="Spice pay session cancelled.", view=None) + +class _StartView(discord.ui.View): + def __init__(self, cog: "SpicePayCog", participants: Optional[int]): + super().__init__(timeout=600) # was 120 + self.cog = cog + self.participants = participants + + @discord.ui.button(label="Open setup", style=discord.ButtonStyle.primary) + async def open_setup(self, interaction: discord.Interaction, _btn: discord.ui.Button): + try: + await interaction.response.send_modal(_SetupModal(self.cog, self.participants)) + except Exception as e: + print("[spicepay] fallback button failed:", repr(e)) + await interaction.response.send_message("Still couldn’t open the modal. Do I have the right permissions here?", ephemeral=True) + +# ------------------------ Cog ------------------------ + +class SpicePayCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.sessions: Dict[tuple, Dict] = {} + + cfg = bot.config['DEFAULT'] + def _f(key, default): + try: + return float(cfg.get(key, str(default))) + except Exception: + return float(default) + self.base_weight = _f('spicepay_base_weight', 25.0) + self.carrier_bonus = _f('spicepay_carrier_bonus', 12.5) + self.crawler_bonus = _f('spicepay_crawler_bonus', 12.5) + self.lsr_cut_percent = _f('spicepay_lsr_cut_percent', 10.0) # keep key; UI calls it refinery cut + + def _i(key): + try: + return int(cfg.get(key)) if cfg.get(key) else None + except Exception: + return None + self.emoji_sand_id = _i('emoji_sand_id') + self.emoji_melange_id = _i('emoji_melange_id') + self.emoji_cc_id = _i('emoji_carrier_crawler_id') + + # Session reaper (clean up sessions older than 60 minutes) + self._reaper_task = asyncio.create_task(self._session_reaper()) + + def cog_unload(self): + try: + self._reaper_task.cancel() + except Exception: + pass + + async def _session_reaper(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + now = time.time() + ttl = 3600 # 60 min + dead = [k for k, s in list(self.sessions.items()) + if now - s.get("created_ts", now) > ttl] + for k in dead: + self.sessions.pop(k, None) + await asyncio.sleep(300) # sweep every 5 min + + # ----- user prefs helpers (persisted to data_file) ----- + + def _get_user_prefs(self, user_id: int) -> Optional[Dict]: + prefs = self.bot.data_manager.get('spicepay_prefs') + for row in prefs: + if row.get('user_id') == user_id: + return row + return None + + def _save_user_prefs(self, user_id: int, weights: Dict[str, float]): + # overwrite existing + self.bot.data_manager.remove('spicepay_prefs', lambda r: r.get('user_id') == user_id) + self.bot.data_manager.add('spicepay_prefs', { + 'user_id': user_id, + 'weights': { + 'base': float(weights.get('base', self.base_weight)), + 'carrier': float(weights.get('carrier', self.carrier_bonus)), + 'crawler': float(weights.get('crawler', self.crawler_bonus)), + 'lsr': float(weights.get('lsr', self.lsr_cut_percent)), + }, + 'saved_ts': time.time() + }) + + # ----- commands ----- + + @app_commands.command(name="spicepay", description="Calculate spice payout via a guided wizard (opens a form)") + @app_commands.describe( + participants="Total number of people involved (include owners of refiner/carrier/crawler)", + force_new="Start a fresh session even if one is active" + ) + async def spicepay(self, interaction: discord.Interaction, participants: Optional[int] = None, force_new: Optional[bool] = False): + key = (interaction.guild.id if interaction.guild else 0, interaction.user.id) + if not force_new and key in self.sessions: + return await self.render_progress(interaction, key, new_message=True) + + try: + await interaction.response.send_modal(_SetupModal(self, participants)) + except Exception as e: + print("[spicepay] send_modal failed:", repr(e)) + try: + await interaction.response.defer(ephemeral=True, thinking=False) + except Exception: + pass + await interaction.followup.send( + "Couldn’t open the setup modal automatically. Click below to start:", + ephemeral=True, + view=_StartView(self, participants) + ) + + @app_commands.command(name="spicepay_resume", description="Reopen your active spice pay session") + async def spicepay_resume(self, interaction: discord.Interaction): + key = (interaction.guild.id if interaction.guild else 0, interaction.user.id) + if key in self.sessions: + await self.render_progress(interaction, key, new_message=True) + else: + await interaction.response.send_message( + "No active session found. Run **/spicepay** to start a new one.", + ephemeral=True + ) + + @app_commands.command(name="spicepay_cancel", description="Cancel your active spicepay session") + async def spicepay_cancel(self, interaction: discord.Interaction): + key = (interaction.guild.id if interaction.guild else 0, interaction.user.id) + if key in self.sessions: + self.sessions.pop(key, None) + await interaction.response.send_message("Cancelled your spice pay session.", ephemeral=True) + else: + await interaction.response.send_message("No active spice pay session.", ephemeral=True) + + @app_commands.command(name="spicepay_config", description="Show the current spicepay weight settings") + async def spicepay_config(self, interaction: discord.Interaction): + txt = ( + "**SpicePay configuration**\n" + f"- Refinery cut (melange payouts): **{self.lsr_cut_percent}%**\n" + f"- Base weight: **{self.base_weight} × active %**\n" + f"- Carrier bonus: **+{self.carrier_bonus}**\n" + f"- Crawler bonus: **+{self.crawler_bonus}**\n\n" + "_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._" + ) + await interaction.response.send_message(txt, ephemeral=True) + + # ----- session helpers ----- + + async def start_session(self, interaction: discord.Interaction, total: int, count: int): + g = interaction.guild + key = (g.id if g else 0, interaction.user.id) + + # seed weights from user prefs if present + w = {} + user_prefs = self._get_user_prefs(interaction.user.id) + if user_prefs and isinstance(user_prefs.get('weights'), dict): + w = { + 'base': float(user_prefs['weights'].get('base', self.base_weight)), + 'carrier': float(user_prefs['weights'].get('carrier', self.carrier_bonus)), + 'crawler': float(user_prefs['weights'].get('crawler', self.crawler_bonus)), + 'lsr': float(user_prefs['weights'].get('lsr', self.lsr_cut_percent)), + } + + # Auto-fill initiator as participant #1 (easy to adjust) + display = interaction.user.display_name if isinstance(interaction.user, discord.Member) else interaction.user.name + participants_seed = [{'name': display, 'participation': 50.0, 'roles': []}] + + self.sessions[key] = { + "total": total, # sand yield + "count": count, + "participants": participants_seed, + "next_index": 1, # stays on 1 so they can review/edit their autofill + "created_ts": time.time(), + "payout_type": "sand", # 'sand' or 'melange' + "melange_total": None, # required if payout_type == 'melange' + "weights": w # optional overrides: base, carrier, crawler, lsr(refinery) + } + await self.render_progress(interaction, key, new_message=True) + + async def render_progress(self, interaction: discord.Interaction, key: tuple, new_message: bool = False): + sess = self.sessions.get(key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + + sand_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️") + mel_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪") + cc_emoji = _emoji_str(self.bot, self.emoji_cc_id, "🚛") + + w = sess.get("weights") or {} + bw = w.get("base", self.base_weight) + cab = w.get("carrier", self.carrier_bonus) + crb = w.get("crawler", self.crawler_bonus) + lsrp = w.get("lsr", self.lsr_cut_percent) + + filled = sum(1 for p in sess["participants"] if p.get("name")) + idx = sess["next_index"] + total_pct = int(round(sum((p.get("participation") or 0) for p in sess["participants"]))) + + payout_line = f"Payout: **Sand Spice** {sand_emoji}" + if sess.get("payout_type") == "melange": + if sess.get("melange_total") is None: + payout_line = f"Payout: **Spice Melange** {mel_emoji} — _set melange refinery yield_" + else: + payout_line = f"Payout: **Spice Melange** {mel_emoji} — total **{sess['melange_total']}**" + + progress = ( + f"**Spice Pay Setup** — sand yield: **{sess['total']}** {sand_emoji} | participants: **{sess['count']}** | " + f"total active ≈ **{total_pct}%**\n" + f"{payout_line}\n" + f"Filled: **{filled}/{sess['count']}** — next: **{idx}**\n" + f"_Weighting factors: Refinery cut {lsrp}% (melange only) | Base {bw}× active % | Carrier +{cab} | Crawler +{crb} — " + f"normalized; only ratios matter._ {cc_emoji}" + ) + + preview_lines = [] + for i in range(sess["count"]): + p = sess["participants"][i] if i < len(sess["participants"]) else None + if p and p.get("name"): + mark = " (owner only)" if int(round(p.get("participation") or 0)) == 0 else "" + preview_lines.append(f"{i+1}. {p['name']} — {int(p['participation'])}%{mark} — {_fmt_roles_short(p.get('roles', []))}") + else: + preview_lines.append(f"{i+1}. _empty_") + + content = progress + "\n" + "\n".join(preview_lines) + view = _WizardView(self, key) + + try: + if new_message: + await interaction.response.send_message(content, ephemeral=True, view=view) + else: + await interaction.response.edit_message(content=content, view=view) + except discord.InteractionResponded: + await interaction.followup.send(content, ephemeral=True, view=view) + + async def apply_preset(self, interaction: discord.Interaction, key: tuple, preset: str): + sess = self.sessions.get(key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + + if preset == "owner": + w = {'base': 15.0, 'carrier': 25.0, 'crawler': 25.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)} + elif preset == "participation": + w = {'base': 50.0, 'carrier': 5.0, 'crawler': 5.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)} + elif preset == "even": + w = {'base': 1.0, 'carrier': 0.0, 'crawler': 0.0, 'lsr': 0.0} + else: # "fair" -> server defaults + w = {'base': self.base_weight, 'carrier': self.carrier_bonus, 'crawler': self.crawler_bonus, 'lsr': self.lsr_cut_percent} + + sess['weights'] = w + await self.render_progress(interaction, key) + + async def save_user_defaults(self, interaction: discord.Interaction, key: tuple): + sess = self.sessions.get(key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + w = sess.get('weights') or { + 'base': self.base_weight, + 'carrier': self.carrier_bonus, + 'crawler': self.crawler_bonus, + 'lsr': self.lsr_cut_percent + } + self._save_user_prefs(interaction.user.id, w) + try: + await interaction.response.send_message("Saved these weighting factors as your defaults ✅", ephemeral=True) + except discord.InteractionResponded: + await interaction.followup.send("Saved these weighting factors as your defaults ✅", ephemeral=True) + + async def finish_and_show(self, interaction: discord.Interaction, key: tuple): + sess = self.sessions.get(key) + if not sess: + return await interaction.response.send_message("Session expired.", ephemeral=True) + + payout_type = sess.get("payout_type", "sand") + if payout_type == "melange" and sess.get("melange_total") is None: + return await interaction.response.send_message("❌ Set the melange refinery yield first.", ephemeral=True) + + w = sess.get("weights") or {} + bw = w.get("base", self.base_weight) + cab = w.get("carrier", self.carrier_bonus) + crb = w.get("crawler", self.crawler_bonus) + lsrp = w.get("lsr", self.lsr_cut_percent) + + participants = [] + names_seen = set() + for i in range(sess["count"]): + p = sess["participants"][i] if i < len(sess["participants"]) else {} + name = (p.get("name") or f"User{i+1}").strip() + if name in names_seen: + name = f"{name}_{i+1}" + names_seen.add(name) + part = p.get("participation", 0) + roles = p.get("roles", []) + participants.append({"name": name, "participation": part, "roles": roles}) + + if payout_type == "sand": + total = int(sess["total"]) + lsr_req = False + lsr_pct = 0.0 + unit_header = "Sand Spice" + unit_suffix = "Sand Spice" + title_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️") + payout_name = "Sand Spice" + else: + total = int(sess["melange_total"]) + lsr_req = True + lsr_pct = float(lsrp) + unit_header = "Spice Melange" + unit_suffix = "Spice Melange" + title_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪") + payout_name = "Spice Melange" + + results, errors = calculate_spice_distribution( + total, + participants, + base_weight=bw, + carrier_bonus=cab, + crawler_bonus=crb, + lsr_cut_percent=lsr_pct, + lsr_required=lsr_req + ) + if errors: + err_text = "❌ **Problems found:**\n" + "\n".join(f"- {e}" for e in errors) + "\n\n" \ + "Use **Add / Edit participant** to fix, or **Cancel**." + try: + await interaction.response.edit_message(content=err_text, view=_WizardView(self, key)) + except discord.InteractionResponded: + await interaction.followup.send(err_text, ephemeral=True, view=_WizardView(self, key)) + return + + table = _format_table(results, unit_header, unit_suffix) + + class _PostView(discord.ui.View): + def __init__(self, outer: "SpicePayCog"): + super().__init__(timeout=600) + self.outer = outer + + @discord.ui.button(label="Post to channel", style=discord.ButtonStyle.primary) + async def post(self, inter: discord.Interaction, _btn: discord.ui.Button): + header = f"**Payout: {payout_name}** {title_emoji}" + settings = f"_Base {bw}× active % | Carrier +{cab} | Crawler +{crb}_" + if payout_type == "melange": + settings = f"_Refinery cut {lsrp}% | " + settings + txt = f"{header}\n{settings}\n{table}" + await inter.channel.send(txt) + await inter.response.edit_message(content="Posted to channel ✅", view=None) + + @discord.ui.button(label="Back", style=discord.ButtonStyle.secondary) + async def back(self, inter: discord.Interaction, _btn: discord.ui.Button): + await self.outer.render_progress(inter, key) + + content = f"**Preview** (not posted): **Payout: {payout_name}** {title_emoji}\n{table}" + try: + await interaction.response.edit_message(content=content, view=_PostView(self)) + except discord.InteractionResponded: + await interaction.followup.send(content=content, ephemeral=True, view=_PostView(self)) + +# ------------------------ setup ------------------------ + +async def setup(bot): + cog = SpicePayCog(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.spicepay, guild=guild_obj) + bot.tree.add_command(cog.spicepay_resume, guild=guild_obj) + bot.tree.add_command(cog.spicepay_cancel, guild=guild_obj) + bot.tree.add_command(cog.spicepay_config, guild=guild_obj) + else: + bot.tree.add_command(cog.spicepay) + bot.tree.add_command(cog.spicepay_resume) + bot.tree.add_command(cog.spicepay_cancel) + bot.tree.add_command(cog.spicepay_config) diff --git a/modules/user_cards/__init__.py b/modules/user_cards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/user_cards/user_cards.py b/modules/user_cards/user_cards.py new file mode 100644 index 0000000..2d38fc0 --- /dev/null +++ b/modules/user_cards/user_cards.py @@ -0,0 +1,509 @@ +import asyncio +import time +from typing import Optional, Set, Tuple +import discord +from discord.ext import commands + +CHECK = '✅' # verified +CROSS = '❌' # not done +PENDING = '✔️' # claimed / pending review +ACCEPT = {CHECK, '🫡'} +NO_MENTIONS = discord.AllowedMentions.none() + +class UserCardsCog(commands.Cog): + """ + Per-user status cards with live reconcile and offline review triggers. + Title = Nickname → Display → Global → Username (adds '*' if NO server nickname) + Fields: Rules / RoE = ✅/❌, Nickname = ❌ | ✔️(pending) | ✅(verified) + Colors: + - Green: Rules✅ & RoE✅ & Nickname✅ + - Blue: Rules✅ & RoE✅ & Nickname✔️ (pending) + - Yellow: Some/partial + - Red: None + """ + + def __init__(self, bot): + self.bot = bot + cfg = bot.config['DEFAULT'] + self.userslist_channel_id = int(cfg['userslist_channel_id']) + self.modlog_channel_id = int(cfg['modlog_channel_id']) + self.mod_channel_id = int(cfg.get('mod_channel_id', '0') or 0) + + # reaction-role authoritative messages/roles + self.rules_msg_id = int(cfg['rules_message_id']) + self.engage_msg_id = int(cfg['engagement_message_id']) + self.nick_msg_id = int(cfg['nickname_message_id']) + self.rules_role_id = int(cfg['rules_role_id']) + self.engage_role_id = int(cfg['engagement_role_id']) + self.full_access_role_id = int(cfg['full_access_role_id']) + self._refresh_locks = {} # per-user locks to avoid racey double-posts + + # Optional periodic refresh (twice a day) + try: + self.cron_enabled = cfg.getboolean('user_cards_cron_enabled') + except Exception: + self.cron_enabled = False + + self._cron_task = asyncio.create_task(self._periodic_refresh()) if self.cron_enabled else None + self._startup_task = asyncio.create_task(self._startup_reconcile()) + + def cog_unload(self): + for t in (self._cron_task, self._startup_task): + if t: + try: t.cancel() + except Exception: pass + + # ---------- status helpers ---------- + + def _rules_ok(self, uid: int) -> bool: + return uid in self.bot.data_manager.get('agreed_rules') + + def _engage_ok(self, uid: int) -> bool: + return uid in self.bot.data_manager.get('agreed_engagement') + + def _nick_status(self, uid: int) -> str: + dm = self.bot.data_manager + if uid in dm.get('nick_verified'): + return 'verified' + if uid in dm.get('nick_claim_pending') or uid in dm.get('agreed_nickname'): + return 'pending' + return 'none' + + def _primary_name(self, m: discord.Member) -> str: + has_nick = bool(m.nick and m.nick.strip()) + base = m.nick or m.display_name or m.global_name or m.name + if not has_nick: + base = f"{base} *" + return base + + def _secondary_name(self, m: discord.Member) -> str: + return m.global_name or m.name + + def _card_color(self, uid: int) -> discord.Color: + r = self._rules_ok(uid) + e = self._engage_ok(uid) + n = self._nick_status(uid) + if r and e and n == 'verified': + return discord.Color.green() + if r and e and n == 'pending': + return discord.Color.blurple() + if r or e or n != 'none': + return discord.Color.yellow() + return discord.Color.red() + + # ---------- persistence ---------- + + def _get_card_record(self, user_id: int): + for r in self.bot.data_manager.get('user_cards'): + if r.get('user_id') == user_id: + return r + return None + + async def _build_embed(self, member: discord.Member) -> discord.Embed: + uid = member.id + rules = CHECK if self._rules_ok(uid) else CROSS + roe = CHECK if self._engage_ok(uid) else CROSS + ns = self._nick_status(uid) + if ns == 'verified': + nick_val = CHECK + elif ns == 'pending': + nick_val = PENDING + else: + nick_val = CROSS + + title_left = self._primary_name(member) + title_right = self._secondary_name(member) + title = f"{title_left} ({title_right})" + + join_val = f"" if member.joined_at else "Unknown" + + embed = discord.Embed(title=title, color=self._card_color(uid)) + embed.add_field(name="Rules", value=rules) + embed.add_field(name="RoE", value=roe) + embed.add_field(name="Nickname Set", value=nick_val) + embed.add_field(name="Joined", value=join_val, inline=False) + if member.avatar: + embed.set_thumbnail(url=member.avatar.url) + + # NEW: stable identity so we can find/edit the right card later + embed.set_footer(text=f"UID:{member.id}") + return embed + + async def refresh_card(self, member: discord.Member): + """ + Idempotent refresh: + 1) Try to edit the tracked message. + 2) If not found, search the channel by footer marker and edit that. + 3) If still not found, post a new one, then delete any stragglers with the same marker. + """ + if not member or not member.guild: + return + + async with self._lock_for(member.id): + dm = self.bot.data_manager + channel: discord.TextChannel = member.guild.get_channel(self.userslist_channel_id) + if not channel: + return + + record = self._get_card_record(member.id) + embed = await self._build_embed(member) + + async def _fetch_in(ch_id: int, mid: int) -> Optional[discord.Message]: + ch = member.guild.get_channel(ch_id) + if not ch: + return None + try: + return await ch.fetch_message(mid) + except Exception: + return None + + # 1) Attempt to edit the exact tracked message (configured channel, then stored channel) + msg = None + stored_ch_id = int(record.get('channel_id')) if record and record.get('channel_id') else None + if record and record.get('message_id'): + mid = int(record['message_id']) + msg = await _fetch_in(channel.id, mid) + if not msg and stored_ch_id and stored_ch_id != channel.id: + msg = await _fetch_in(stored_ch_id, mid) + + # 2) If we couldn’t find by record, try to find by footer marker in channel history + if not msg: + msg = await self._find_existing_card(channel, member.id) + + # 3) Edit in place if found + if msg: + try: + await msg.edit(embed=embed, allowed_mentions=NO_MENTIONS) + except Exception: + pass + # upsert record with the definitive location + dm.remove('user_cards', lambda r: r.get('user_id') == member.id) + dm.add('user_cards', {'user_id': member.id, 'message_id': msg.id, 'channel_id': msg.channel.id}) + return + + # 4) Post fresh card + new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS) + + # 5) Clean up any other messages that look like this user's card + try: + # Find any *other* occurrences with the same footer marker and delete them + marker = f"UID:{member.id}" + async for m in channel.history(limit=400, oldest_first=False): + if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds: + continue + foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else "" + if foot == marker: + try: + await m.delete() + except Exception: + pass + except Exception: + pass + + # 6) Persist mapping + dm.remove('user_cards', lambda r: r.get('user_id') == member.id) + dm.add('user_cards', {'user_id': member.id, 'message_id': new_msg.id, 'channel_id': new_msg.channel.id}) + + def _lock_for(self, user_id: int) -> asyncio.Lock: + lk = self._refresh_locks.get(user_id) + if not lk: + lk = asyncio.Lock() + self._refresh_locks[user_id] = lk + return lk + + async def _find_existing_card(self, channel: discord.TextChannel, user_id: int) -> Optional[discord.Message]: + """Search recent history for a card we posted for this user (by footer marker).""" + marker = f"UID:{user_id}" + try: + async for m in channel.history(limit=400, oldest_first=False): + if m.author.id != self.bot.user.id: + continue + if not m.embeds: + continue + foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else "" + if foot == marker: + return m + except Exception: + pass + return None + + async def _log(self, guild: discord.Guild, content: str): + ch = guild.get_channel(self.modlog_channel_id) + if ch: + try: await ch.send(content, allowed_mentions=NO_MENTIONS) + except Exception: pass + self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content}) + + # ---------- RR message lookup & reactor collection ---------- + + def _get_cached_msg_channel_id(self, guild_id: int, message_id: int) -> Optional[int]: + for r in self.bot.data_manager.get('rr_msg_channels'): + if r.get('guild_id') == guild_id and r.get('message_id') == message_id: + return int(r.get('channel_id')) + return None + + def _cache_msg_channel_id(self, guild_id: int, message_id: int, channel_id: int): + dm = self.bot.data_manager + dm.remove('rr_msg_channels', lambda r: r.get('guild_id') == guild_id and r.get('message_id') == message_id) + dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_id)}) + + async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]: + ch_id = self._get_cached_msg_channel_id(guild.id, message_id) + if ch_id: + ch = guild.get_channel(ch_id) + if ch: + try: + return await ch.fetch_message(message_id) + except Exception: + pass + for ch in guild.text_channels: + try: + msg = await ch.fetch_message(message_id) + self._cache_msg_channel_id(guild.id, message_id, ch.id) + return msg + except (discord.Forbidden, discord.NotFound, discord.HTTPException): + continue + return None + + async def _collect_reactors(self, message: Optional[discord.Message]) -> Set[int]: + ids: Set[int] = set() + if not message: + return ids + for rxn in message.reactions: + if str(rxn.emoji) in ACCEPT: # <-- was == CHECK + async for u in rxn.users(limit=None): + if not u.bot: + ids.add(u.id) + return ids + + # ---------- reconcile (creates reviews for offline nickname claims) ---------- + + async def _open_review_fallback(self, guild: discord.Guild, member: discord.Member, source: str): + """If NickNudgeCog.start_nick_review is missing, post a simple review to mod channel.""" + if not self.mod_channel_id: + return + ch = guild.get_channel(self.mod_channel_id) + if not ch: + return + try: + await ch.send( + f"📝 **Nickname claim review (fallback)**\n" + f"User: {member.mention} (`{member.id}`)\n" + f"Source: `{source}`\n" + f"Current name: **{self._primary_name(member)}**\n" + f"Please verify and handle manually.", + allowed_mentions=NO_MENTIONS + ) + except Exception: + pass + + async def _reconcile_agreements(self, guild: discord.Guild) -> Tuple[int, int, int, int]: + """ + Reconcile from live reaction messages. + Returns (rules_changed, engage_changed, nick_added_claims, nick_removed_claims). + Also opens nickname reviews for any **unreviewed** claimers (startup/offline). + """ + dm = self.bot.data_manager + + rules_msg = await self._get_message_by_id(guild, self.rules_msg_id) + engage_msg = await self._get_message_by_id(guild, self.engage_msg_id) + nick_msg = await self._get_message_by_id(guild, self.nick_msg_id) + + new_rules = await self._collect_reactors(rules_msg) + new_engage = await self._collect_reactors(engage_msg) + new_nick = await self._collect_reactors(nick_msg) + + old_rules = set(dm.get('agreed_rules')) + old_engage = set(dm.get('agreed_engagement')) + old_nick = set(dm.get('agreed_nickname')) + + rules_added, rules_removed = new_rules - old_rules, old_rules - new_rules + engage_added, engage_removed = new_engage - old_engage, old_engage - new_engage + nick_added, nick_removed = new_nick - old_nick, old_nick - new_nick + + # Persist live truth for rules/engagement/nickname claim (reaction path) + if old_rules != new_rules: + dm.remove('agreed_rules', lambda _x: True) + for uid in new_rules: + dm.add('agreed_rules', int(uid)) + + if old_engage != new_engage: + dm.remove('agreed_engagement', lambda _x: True) + for uid in new_engage: + dm.add('agreed_engagement', int(uid)) + + if old_nick != new_nick: + dm.remove('agreed_nickname', lambda _x: True) + for uid in new_nick: + dm.add('agreed_nickname', int(uid)) + + # Apply/repair roles for Rules/Engagement + rules_role = guild.get_role(self.rules_role_id) + engage_role = guild.get_role(self.engage_role_id) + for uid in (rules_added | rules_removed | engage_added | engage_removed): + m = guild.get_member(uid) + if not m or m.bot: + continue + if rules_role: + try: + if uid in new_rules and rules_role not in m.roles: + await m.add_roles(rules_role, reason="Reconcile: reacted to Rules ✅") + elif uid not in new_rules and rules_role in m.roles: + await m.remove_roles(rules_role, reason="Reconcile: removed Rules ✅") + except Exception: + pass + if engage_role: + try: + if uid in new_engage and engage_role not in m.roles: + await m.add_roles(engage_role, reason="Reconcile: reacted to RoE ✅") + elif uid not in new_engage and engage_role in m.roles: + await m.remove_roles(engage_role, reason="Reconcile: removed RoE ✅") + except Exception: + pass + + # --- New part: open reviews for *any* unreviewed claimers (startup/offline) --- + nn = self.bot.get_cog('NickNudgeCog') + + verified_set = set(dm.get('nick_verified')) + pending_set = set(dm.get('nick_claim_pending')) + open_review_user_ids = { + int(r['user_id']) + for r in dm.get('nick_reviews') + if r.get('guild_id') == guild.id and r.get('status') == 'pending' + } + + # Anyone who currently claims (reacted) but isn't verified/pending/under review + need_review = [ + uid for uid in new_nick + if uid not in verified_set and uid not in pending_set and uid not in open_review_user_ids + ] + + for uid in need_review: + dm.add('nick_claim_pending', int(uid)) + m = guild.get_member(uid) + if m and not m.bot: + if nn and hasattr(nn, 'start_nick_review'): + try: + await nn.start_nick_review(guild, m, source="claim_offline") + except Exception: + pass + else: + await self._open_review_fallback(guild, m, source="claim_offline") + + # Anyone who *lost* their reaction -> clear pending/verified + for uid in nick_removed: + dm.remove('nick_claim_pending', lambda x: x == uid) + dm.remove('nick_verified', lambda x: x == uid) + + # Nudge roles/full access via ReactionRoleCog for anyone impacted + rr = self.bot.get_cog('ReactionRoleCog') + impacted = (rules_added | rules_removed | engage_added | engage_removed | + set(need_review) | nick_removed) + if rr: + for uid in impacted: + m = guild.get_member(uid) + if m and not m.bot: + try: + await rr.maybe_apply_full_access(m) + except Exception: + pass + else: + for uid in impacted: + m = guild.get_member(uid) + if m and not m.bot: + try: + await self.refresh_card(m) + except Exception: + pass + + return ( + len(rules_added) + len(rules_removed), + len(engage_added) + len(engage_removed), + len(nick_added), + len(nick_removed), + ) + + # ---------- listeners ---------- + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + await self._log(member.guild, f"📝 User joined: {member.mention} (ID: {member.id})") + await self.refresh_card(member) + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + if before.nick != after.nick or before.roles != after.roles: + await self.refresh_card(after) + + @commands.Cog.listener() + async def on_user_update(self, before: discord.User, after: discord.User): + if before.global_name == after.global_name and before.name == after.name: + return + for g in self.bot.guilds: + m = g.get_member(after.id) + if m: + try: await self.refresh_card(m) + except Exception: pass + + # ---------- periodic + startup ---------- + + async def _startup_reconcile(self): + await self.bot.wait_until_ready() + for g in list(self.bot.guilds): + try: + await self._reconcile_agreements(g) + except Exception: + pass + for g in list(self.bot.guilds): + for m in g.members: + if not m.bot: + try: await self.refresh_card(m) + except Exception: pass + + async def _periodic_refresh(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + try: + for g in self.bot.guilds: + try: await self._reconcile_agreements(g) + except Exception: pass + for m in g.members: + if not m.bot: + await self.refresh_card(m) + except Exception: + pass + await asyncio.sleep(12 * 60 * 60) # twice a day + + # ---------- mod command: rescan + live reconcile ---------- + + @commands.hybrid_command( + name="usercards_rescan", + description="Re-check all users and refresh cards (mod-only)" + ) + @commands.has_permissions(manage_guild=True) + async def usercards_rescan(self, ctx: commands.Context): + g = ctx.guild + if not g: + return await ctx.reply("Use this in a server.", ephemeral=True) + + rchg, echg, nadd, nrem = await self._reconcile_agreements(g) + + # Rebuild cards + updated = 0 + for m in g.members: + if not m.bot: + try: + await self.refresh_card(m) + updated += 1 + except Exception: + pass + + await ctx.reply( + f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, " + f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. " + f"Refreshed cards for **{updated}** members.", + ephemeral=True + ) + +async def setup(bot): + await bot.add_cog(UserCardsCog(bot)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e7b898 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +discord.py>=2.3.2 +python-dotenv