Initial commit

This commit is contained in:
Franz Rolfsvaag 2025-08-09 14:29:00 +02:00
commit 7a0c4645d7
25 changed files with 4607 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Python
__pycache__/
*.pyc
.venv/
venv/
.env
# Local data & secrets (well mount these in Docker)
data/
data.json
settings.conf

113
bot.py Normal file
View File

@ -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())

505
data.json Normal file
View File

@ -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 <t:1754701495:R>."
},
{
"ts": 1754701499.1338458,
"guild_id": 1396826999095427253,
"content": "\ud83d\udd0e Nickname review opened for <@224950449600462858> \u2014 Method: reaction \u2014 <t:1754701497:R>."
},
{
"ts": 1754701503.1822891,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 <t:1754701501:R>."
},
{
"ts": 1754701508.2414587,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@224950449600462858> by <@203190147582394369> \u2014 <t:1754701506:R>."
},
{
"ts": 1754702071.883038,
"guild_id": 1396826999095427253,
"content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 <t:1754702070:R>."
},
{
"ts": 1754702079.5288155,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 <t:1754702078:R>."
},
{
"ts": 1754702839.6018815,
"guild_id": 1396826999095427253,
"content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 <t:1754702837:R>."
},
{
"ts": 1754702843.558799,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 <t:1754702842:R>."
}
],
"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
]
}

500
data.json.bak Normal file
View File

@ -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 <t:1754701495:R>."
},
{
"ts": 1754701499.1338458,
"guild_id": 1396826999095427253,
"content": "\ud83d\udd0e Nickname review opened for <@224950449600462858> \u2014 Method: reaction \u2014 <t:1754701497:R>."
},
{
"ts": 1754701503.1822891,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 <t:1754701501:R>."
},
{
"ts": 1754701508.2414587,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@224950449600462858> by <@203190147582394369> \u2014 <t:1754701506:R>."
},
{
"ts": 1754702071.883038,
"guild_id": 1396826999095427253,
"content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 <t:1754702070:R>."
},
{
"ts": 1754702079.5288155,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 <t:1754702078:R>."
},
{
"ts": 1754702839.6018815,
"guild_id": 1396826999095427253,
"content": "\ud83d\udd0e Nickname review opened for <@203190147582394369> \u2014 Method: reaction \u2014 <t:1754702837:R>."
},
{
"ts": 1754702843.558799,
"guild_id": 1396826999095427253,
"content": "\u2705 Nickname **verified** for <@203190147582394369> by <@203190147582394369> \u2014 <t:1754702842:R>."
}
],
"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
]
}

84
data_manager.py Normal file
View File

@ -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

5
example/.env.example Normal file
View File

@ -0,0 +1,5 @@
DISCORD_TOKEN={DISCORD_BOT_TOKEN}
DISCORD_APPLICATION_ID=
DISCORD_PUBLIC_KEY=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

View File

@ -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

60
mod_perms.py Normal file
View File

@ -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 dont 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 havent 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

View File

254
modules/auto_vc/auto_vc.py Normal file
View File

@ -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))

View File

View File

@ -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 <t:12345:R>."""
if ts is None:
ts = time.time()
return f"<t:{int(ts)}:R>"
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))

View File

View File

@ -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 (dont 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))

View File

View File

@ -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 (05, 0 = unknown)",
placeholder="0..5",
required=True
)
self.add_item(self.identifier)
self.add_item(self.group_size)
self.add_item(self.kills)
self.add_item(self.destruction)
self.add_item(self.skill)
async def on_submit(self, interaction: discord.Interaction):
ident_raw = self.identifier.value.strip()
guild = interaction.guild
if guild is None:
return await interaction.response.send_message("This can only be used in a server.", ephemeral=True)
# Parse numeric fields
try:
group_size = int(self.group_size.value.strip()); assert group_size >= 1
except Exception:
return await interaction.response.send_message("❌ **Group size** must be an integer ≥ 1.", ephemeral=True)
try:
kills = int(self.kills.value.strip()); assert kills >= 0
except Exception:
return await interaction.response.send_message("❌ **Kills** must be an integer ≥ 0.", ephemeral=True)
try:
destruction = _parse_bool(self.destruction.value)
except ValueError:
return await interaction.response.send_message("❌ **Destructive** must be **yes** or **no**.", ephemeral=True)
try:
skill = int(self.skill.value.strip()); assert 0 <= skill <= 5
except Exception:
return await interaction.response.send_message("❌ **Skill** must be an integer between **0** and **5**.", ephemeral=True)
# Resolve to a single pirate record and canonical account identifier
pirate, error = self.cog.resolve_pirate(ident_raw)
if error:
return await interaction.response.send_message(error, ephemeral=True)
if not pirate:
return await interaction.response.send_message(
"❌ No such pirate registered. Ask a moderator to add them, or submit a **/report** for review.",
ephemeral=True
)
canonical_acct = pirate['account_name'] # <- always store account here
acct_l = canonical_acct.lower()
char_l = pirate['character_name'].lower()
dm = self.cog.bot.data_manager
now = time.time()
# Rate-limit by canonical account
recent = [
e for e in dm.get('encounters')
if e['reporter_id'] == interaction.user.id
and e.get('identifier','').lower() in (acct_l, char_l) # respect legacy
and (now - e['timestamp']) < 600
]
if recent:
return await interaction.response.send_message(
"⏱️ You can only report the same pirate once every **10 minutes**.",
ephemeral=True
)
# Store with canonical account as identifier
rec = {
'identifier': canonical_acct,
'reporter_id': interaction.user.id,
'timestamp': now,
'group_size': group_size,
'kills': kills,
'destruction': destruction,
'skill': skill
}
dm.add('encounters', rec)
# Recalculate threat (count both new account-based and legacy name-based entries)
encs = [e for e in dm.get('encounters') if e.get('identifier','').lower() in (acct_l, char_l)]
total = len(encs)
if total:
group_threshold = self.cog.group_threshold
kill_rate = sum(e['kills'] > 0 for e in encs) / total
group_rate = sum(e['group_size'] >= group_threshold for e in encs) / total
destroy_rate = sum(bool(e['destruction']) for e in encs) / total
skill_samples = [e['skill'] / 5 for e in encs if e['skill'] > 0]
skill_rate = (sum(skill_samples) / len(skill_samples)) if skill_samples else 0.0
w_k, w_d, w_g, w_s = self.cog.w_kill, self.cog.w_destruction, self.cog.w_group, self.cog.w_skill
weighted = (w_k * kill_rate) + (w_d * destroy_rate) + (w_g * group_rate) + (w_s * skill_rate)
threat_level = int(round(100 * min(1.0, max(0.0, weighted))))
# Update pirate record
pirates = dm.get('pirates')
def match(p): return p['account_name'].lower() == acct_l
rec0 = next((p for p in pirates if match(p)), None)
if rec0:
dm.remove('pirates', match)
rec0.update({'threat_level': threat_level, 'encounter_count': total})
dm.add('pirates', rec0)
await interaction.response.send_message(
f"✅ Encounter recorded for **{canonical_acct}**.",
ephemeral=True
)
await self.cog._refresh_pirates_list(interaction.guild)
# -------------- Cog: commands + listeners ---------------
class PirateReportCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
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)

View File

View File

@ -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: <t:UNIX:R>
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"<t:{last_ts}:R>" 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))

View File

View File

@ -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))

View File

View File

@ -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 0100.")
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 % (0100)",
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 (0100).", ephemeral=True)
if participation < 0 or participation > 100:
return await interaction.response.send_message("❌ Participation must be 0100.", 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 0100.",
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 dont change ratios (we normalize), but may affect rounding slightly.",
ephemeral=True
)
except discord.InteractionResponded:
await interaction.followup.send(
"⚠️ Very large weights dont 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 didnt 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 couldnt 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(
"Couldnt 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)

View File

View File

@ -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"<t:{int(member.joined_at.timestamp())}:R>" 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 couldnt 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))

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
discord.py>=2.3.2
python-dotenv