Initial commit
This commit is contained in:
commit
7a0c4645d7
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
|
||||
# Local data & secrets (we’ll mount these in Docker)
|
||||
data/
|
||||
data.json
|
||||
settings.conf
|
113
bot.py
Normal file
113
bot.py
Normal 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
505
data.json
Normal 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
500
data.json.bak
Normal 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
84
data_manager.py
Normal 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
5
example/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
DISCORD_TOKEN={DISCORD_BOT_TOKEN}
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_PUBLIC_KEY=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
63
example/settings.conf.example
Normal file
63
example/settings.conf.example
Normal 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
60
mod_perms.py
Normal 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 don’t have permission to use this."):
|
||||
if not is_moderator_member(ctx.author, ctx.bot):
|
||||
await ctx.reply(msg)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def require_mod_interaction(interaction: discord.Interaction, msg="This command is restricted to moderators."):
|
||||
user = interaction.user
|
||||
if isinstance(user, discord.Member) and is_moderator_member(user, interaction.client):
|
||||
return True
|
||||
# Only send a response if we haven’t already
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(msg, ephemeral=True)
|
||||
return False
|
0
modules/auto_vc/__init__.py
Normal file
0
modules/auto_vc/__init__.py
Normal file
254
modules/auto_vc/auto_vc.py
Normal file
254
modules/auto_vc/auto_vc.py
Normal 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))
|
0
modules/nick_nudge/__init__.py
Normal file
0
modules/nick_nudge/__init__.py
Normal file
329
modules/nick_nudge/nick_nudge.py
Normal file
329
modules/nick_nudge/nick_nudge.py
Normal 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))
|
0
modules/pirate_cards/__init__.py
Normal file
0
modules/pirate_cards/__init__.py
Normal file
226
modules/pirate_cards/pirate_cards.py
Normal file
226
modules/pirate_cards/pirate_cards.py
Normal 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 (don’t ping)
|
||||
self._no_mentions = discord.AllowedMentions.none()
|
||||
# serialize rebuilds per guild
|
||||
self._locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
# -------- internals / helpers --------
|
||||
def _lock_for(self, guild_id: int) -> asyncio.Lock:
|
||||
self._locks.setdefault(guild_id, asyncio.Lock())
|
||||
return self._locks[guild_id]
|
||||
|
||||
@staticmethod
|
||||
def _esc(s: str) -> str:
|
||||
"""Escape markdown & neutralize mentions for any user-sourced strings we show."""
|
||||
safe = discord.utils.escape_markdown(str(s))
|
||||
return safe.replace("@", "@\u200b")
|
||||
|
||||
def _color_for_threat(self, threat: int, enc_count: int) -> discord.Color:
|
||||
"""
|
||||
Gradient:
|
||||
- If too few samples: dark gray (unknown)
|
||||
- Else 0 -> green (0,255,0), 100 -> red (255,0,0)
|
||||
"""
|
||||
if enc_count < self.min_samples:
|
||||
return discord.Color.dark_gray()
|
||||
|
||||
t = max(0, min(100, int(threat))) / 100.0
|
||||
r = int(round(255 * t))
|
||||
g = int(round(255 * (1.0 - t)))
|
||||
return discord.Color.from_rgb(r, g, 0)
|
||||
|
||||
def _bucket(self, rate: float, samples: int) -> str:
|
||||
if samples < self.min_samples:
|
||||
return "unknown"
|
||||
if rate <= 0:
|
||||
return "never"
|
||||
if rate <= 0.25:
|
||||
return "rarely"
|
||||
if rate <= 0.60:
|
||||
return "sometimes"
|
||||
if rate <= 0.85:
|
||||
return "often"
|
||||
return "always"
|
||||
|
||||
def _encounters_for(self, pirate: dict):
|
||||
acct_l = str(pirate.get('account_name', '')).lower()
|
||||
char_l = str(pirate.get('character_name', '')).lower()
|
||||
out = []
|
||||
for e in self.bot.data_manager.get('encounters'):
|
||||
try:
|
||||
ident = str(e.get('identifier', '')).lower()
|
||||
if ident in (acct_l, char_l):
|
||||
out.append(e)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
def _get_card_record(self, account_lower: str):
|
||||
for r in self.bot.data_manager.get('pirate_cards'):
|
||||
if r.get('account_lower') == account_lower:
|
||||
return r
|
||||
return None
|
||||
|
||||
async def _modlog(self, guild: discord.Guild, content: str):
|
||||
if not self.modlog_channel_id:
|
||||
return
|
||||
ch = guild.get_channel(self.modlog_channel_id)
|
||||
if ch:
|
||||
try:
|
||||
await ch.send(content, allowed_mentions=self._no_mentions)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _build_embed(self, pirate: dict) -> discord.Embed:
|
||||
encs = self._encounters_for(pirate)
|
||||
total = len(encs)
|
||||
# guard numeric fields
|
||||
def _i(v, d=0):
|
||||
try:
|
||||
return int(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
group_rate = (sum(1 for e in encs if _i(e.get('group_size'), 0) >= self.group_threshold) / total) if total else 0.0
|
||||
destroy_rate = (sum(1 for e in encs if bool(e.get('destruction'))) / total) if total else 0.0
|
||||
|
||||
# last encountered: date only (UTC)
|
||||
last_date = "—"
|
||||
if total:
|
||||
try:
|
||||
last_ts = max(int(float(e.get('timestamp', 0))) for e in encs)
|
||||
last_date = datetime.utcfromtimestamp(last_ts).strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
last_date = "—"
|
||||
|
||||
groups_str = self._bucket(group_rate, total)
|
||||
destr_str = self._bucket(destroy_rate, total)
|
||||
|
||||
# threat & color
|
||||
threat = int(pirate.get('threat_level', 0))
|
||||
color = self._color_for_threat(threat, total)
|
||||
|
||||
char = self._esc(pirate.get('character_name', 'Unknown'))
|
||||
acct = self._esc(pirate.get('account_name', 'Unknown#00000'))
|
||||
|
||||
embed = discord.Embed(title=char, color=color)
|
||||
embed.add_field(name="Account name", value=f"*{acct}*", inline=False)
|
||||
embed.add_field(name="Threat", value=f"{threat}%", inline=True)
|
||||
embed.add_field(name="In groups", value=groups_str, inline=True)
|
||||
embed.add_field(name="Destructive", value=destr_str, inline=True)
|
||||
embed.set_footer(text=f"Encounters: {total} | Last: {last_date}")
|
||||
return embed
|
||||
|
||||
async def refresh_card_for_account(self, guild: discord.Guild, account_name: str):
|
||||
"""Create or update a single pirate card by account name."""
|
||||
acct_l = str(account_name or "").lower()
|
||||
pirates = self.bot.data_manager.get('pirates')
|
||||
p = next((x for x in pirates if str(x.get('account_name', '')).lower() == acct_l), None)
|
||||
if not p:
|
||||
return
|
||||
|
||||
channel = guild.get_channel(self.pirates_channel_id)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
embed = await self._build_embed(p)
|
||||
rec = self._get_card_record(acct_l)
|
||||
|
||||
if rec:
|
||||
try:
|
||||
msg = await channel.fetch_message(int(rec['message_id']))
|
||||
await msg.edit(embed=embed)
|
||||
return
|
||||
except Exception:
|
||||
# fall-through to recreate
|
||||
pass
|
||||
|
||||
try:
|
||||
msg = await channel.send(embed=embed, allowed_mentions=self._no_mentions)
|
||||
self.bot.data_manager.add('pirate_cards', {
|
||||
'account_lower': acct_l,
|
||||
'message_id': msg.id
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def delete_card_for_account(self, guild: discord.Guild, account_name: str):
|
||||
"""Delete a pirate card by account and remove its record."""
|
||||
acct_l = str(account_name or "").lower()
|
||||
channel = guild.get_channel(self.pirates_channel_id)
|
||||
rec = self._get_card_record(acct_l)
|
||||
|
||||
if rec and channel:
|
||||
try:
|
||||
msg = await channel.fetch_message(int(rec['message_id']))
|
||||
await msg.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ensure record is gone regardless
|
||||
self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == acct_l)
|
||||
|
||||
async def update_on_edit(self, guild: discord.Guild, old_account: str, new_account: str):
|
||||
"""When an account name changes, carry over the card mapping and refresh."""
|
||||
old_l = str(old_account or "").lower()
|
||||
new_l = str(new_account or "").lower()
|
||||
rec = self._get_card_record(old_l)
|
||||
if rec:
|
||||
# replace mapping to new key
|
||||
self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == old_l)
|
||||
self.bot.data_manager.add('pirate_cards', {
|
||||
'account_lower': new_l,
|
||||
'message_id': rec['message_id']
|
||||
})
|
||||
await self.refresh_card_for_account(guild, new_account)
|
||||
|
||||
# -------- command (mod-gated via require_mod_ctx) --------
|
||||
@commands.hybrid_command(name="pirate_cards_rebuild", description="Rebuild pirate cards for all known pirates")
|
||||
async def pirate_cards_rebuild(self, ctx: commands.Context):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
return
|
||||
if not ctx.guild:
|
||||
return await ctx.reply("Use this in a server.", ephemeral=True)
|
||||
|
||||
async with self._lock_for(ctx.guild.id):
|
||||
guild = ctx.guild
|
||||
ch = guild.get_channel(self.pirates_channel_id)
|
||||
if not ch:
|
||||
return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True)
|
||||
|
||||
count = 0
|
||||
for p in self.bot.data_manager.get('pirates'):
|
||||
try:
|
||||
await self.refresh_card_for_account(guild, p.get('account_name', ''))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
|
||||
await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(PirateCardsCog(bot))
|
0
modules/pirate_report/__init__.py
Normal file
0
modules/pirate_report/__init__.py
Normal file
579
modules/pirate_report/pirate_report.py
Normal file
579
modules/pirate_report/pirate_report.py
Normal 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 (0–5, 0 = unknown)",
|
||||
placeholder="0..5",
|
||||
required=True
|
||||
)
|
||||
|
||||
self.add_item(self.identifier)
|
||||
self.add_item(self.group_size)
|
||||
self.add_item(self.kills)
|
||||
self.add_item(self.destruction)
|
||||
self.add_item(self.skill)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
ident_raw = self.identifier.value.strip()
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
return await interaction.response.send_message("This can only be used in a server.", ephemeral=True)
|
||||
|
||||
# Parse numeric fields
|
||||
try:
|
||||
group_size = int(self.group_size.value.strip()); assert group_size >= 1
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ **Group size** must be an integer ≥ 1.", ephemeral=True)
|
||||
try:
|
||||
kills = int(self.kills.value.strip()); assert kills >= 0
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ **Kills** must be an integer ≥ 0.", ephemeral=True)
|
||||
try:
|
||||
destruction = _parse_bool(self.destruction.value)
|
||||
except ValueError:
|
||||
return await interaction.response.send_message("❌ **Destructive** must be **yes** or **no**.", ephemeral=True)
|
||||
try:
|
||||
skill = int(self.skill.value.strip()); assert 0 <= skill <= 5
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ **Skill** must be an integer between **0** and **5**.", ephemeral=True)
|
||||
|
||||
# Resolve to a single pirate record and canonical account identifier
|
||||
pirate, error = self.cog.resolve_pirate(ident_raw)
|
||||
if error:
|
||||
return await interaction.response.send_message(error, ephemeral=True)
|
||||
if not pirate:
|
||||
return await interaction.response.send_message(
|
||||
"❌ No such pirate registered. Ask a moderator to add them, or submit a **/report** for review.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
canonical_acct = pirate['account_name'] # <- always store account here
|
||||
acct_l = canonical_acct.lower()
|
||||
char_l = pirate['character_name'].lower()
|
||||
|
||||
dm = self.cog.bot.data_manager
|
||||
now = time.time()
|
||||
|
||||
# Rate-limit by canonical account
|
||||
recent = [
|
||||
e for e in dm.get('encounters')
|
||||
if e['reporter_id'] == interaction.user.id
|
||||
and e.get('identifier','').lower() in (acct_l, char_l) # respect legacy
|
||||
and (now - e['timestamp']) < 600
|
||||
]
|
||||
if recent:
|
||||
return await interaction.response.send_message(
|
||||
"⏱️ You can only report the same pirate once every **10 minutes**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# Store with canonical account as identifier
|
||||
rec = {
|
||||
'identifier': canonical_acct,
|
||||
'reporter_id': interaction.user.id,
|
||||
'timestamp': now,
|
||||
'group_size': group_size,
|
||||
'kills': kills,
|
||||
'destruction': destruction,
|
||||
'skill': skill
|
||||
}
|
||||
dm.add('encounters', rec)
|
||||
|
||||
# Recalculate threat (count both new account-based and legacy name-based entries)
|
||||
encs = [e for e in dm.get('encounters') if e.get('identifier','').lower() in (acct_l, char_l)]
|
||||
total = len(encs)
|
||||
if total:
|
||||
group_threshold = self.cog.group_threshold
|
||||
kill_rate = sum(e['kills'] > 0 for e in encs) / total
|
||||
group_rate = sum(e['group_size'] >= group_threshold for e in encs) / total
|
||||
destroy_rate = sum(bool(e['destruction']) for e in encs) / total
|
||||
skill_samples = [e['skill'] / 5 for e in encs if e['skill'] > 0]
|
||||
skill_rate = (sum(skill_samples) / len(skill_samples)) if skill_samples else 0.0
|
||||
|
||||
w_k, w_d, w_g, w_s = self.cog.w_kill, self.cog.w_destruction, self.cog.w_group, self.cog.w_skill
|
||||
weighted = (w_k * kill_rate) + (w_d * destroy_rate) + (w_g * group_rate) + (w_s * skill_rate)
|
||||
threat_level = int(round(100 * min(1.0, max(0.0, weighted))))
|
||||
|
||||
# Update pirate record
|
||||
pirates = dm.get('pirates')
|
||||
def match(p): return p['account_name'].lower() == acct_l
|
||||
rec0 = next((p for p in pirates if match(p)), None)
|
||||
if rec0:
|
||||
dm.remove('pirates', match)
|
||||
rec0.update({'threat_level': threat_level, 'encounter_count': total})
|
||||
dm.add('pirates', rec0)
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"✅ Encounter recorded for **{canonical_acct}**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
await self.cog._refresh_pirates_list(interaction.guild)
|
||||
|
||||
# -------------- Cog: commands + listeners ---------------
|
||||
|
||||
class PirateReportCog(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config['DEFAULT']
|
||||
self.mod_channel = int(cfg['mod_channel_id'])
|
||||
self.modlog_channel_id = int(cfg['modlog_channel_id'])
|
||||
|
||||
# Optional threat weights (normalized elsewhere if you added them)
|
||||
try:
|
||||
self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
|
||||
except Exception:
|
||||
self.group_threshold = 3
|
||||
# Defaults if not already present in your earlier version:
|
||||
self.w_kill = float(cfg.get('threat_w_kill', '0.35'))
|
||||
self.w_destruction = float(cfg.get('threat_w_destruction', '0.30'))
|
||||
self.w_group = float(cfg.get('threat_w_group', '0.20'))
|
||||
self.w_skill = float(cfg.get('threat_w_skill', '0.15'))
|
||||
|
||||
async def _refresh_pirates_list(self, guild: discord.Guild):
|
||||
plist = self.bot.get_cog('PiratesListCog')
|
||||
if plist:
|
||||
await plist.refresh_list(guild)
|
||||
|
||||
async def _modlog(self, guild: discord.Guild, content: str):
|
||||
ch = guild.get_channel(self.modlog_channel_id)
|
||||
if ch:
|
||||
try:
|
||||
await ch.send(content)
|
||||
except Exception as e:
|
||||
print("[pirate_report] modlog send failed:", repr(e))
|
||||
self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
|
||||
|
||||
# --- Resolver: name/account -> pirate record (handles ambiguity) ---
|
||||
def resolve_pirate(self, ident: str):
|
||||
"""Return (pirate_record or None, error_message or None)."""
|
||||
dm = self.bot.data_manager
|
||||
pirates = dm.get('pirates')
|
||||
s = ident.strip()
|
||||
s_l = s.lower()
|
||||
if '#' in s and _acct_ok(s):
|
||||
rec = next((p for p in pirates if p['account_name'].lower() == s_l), None)
|
||||
return (rec, None)
|
||||
# resolve by character; must be unambiguous
|
||||
matches = [p for p in pirates if p['character_name'].lower() == s_l]
|
||||
if not matches:
|
||||
return (None, None)
|
||||
if len(matches) > 1:
|
||||
return (None, "❌ That character name is **ambiguous**. Please use the account (e.g. `Name#12345`).")
|
||||
return (matches[0], None)
|
||||
|
||||
# Remove pirate (mod-only)
|
||||
@commands.hybrid_command(name='remove_pirate', description='Remove an approved pirate entry')
|
||||
async def remove_pirate(self, ctx, account_name: str):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
return
|
||||
acct_lower = account_name.strip().lower()
|
||||
dm = self.bot.data_manager
|
||||
if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
|
||||
return await ctx.reply("Pirate not found.")
|
||||
dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower)
|
||||
await self._modlog(ctx.guild, f"🗑️ Removed pirate {account_name} by {ctx.author.mention}")
|
||||
await self._refresh_pirates_list(ctx.guild)
|
||||
await ctx.reply("Removed.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None)
|
||||
|
||||
# Modal launchers
|
||||
@app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)")
|
||||
async def report(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
await interaction.response.send_modal(ReportModal(self))
|
||||
|
||||
@app_commands.command(name="edit_pirate", description="Edit a pirate entry (opens a form)")
|
||||
async def edit_pirate(self, interaction: discord.Interaction):
|
||||
if not await require_mod_interaction(interaction):
|
||||
return
|
||||
await interaction.response.send_modal(EditPirateModal(self))
|
||||
|
||||
@app_commands.command(name="encounter", description="Log a pirate encounter (opens a form)")
|
||||
async def encounter(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
await interaction.response.send_modal(EncounterModal(self))
|
||||
|
||||
# ---- Migration: convert encounter identifiers to accounts (mod-only) ----
|
||||
@commands.hybrid_command(name='encounters_migrate_ids', description='Migrate encounter identifiers to account names')
|
||||
async def encounters_migrate_ids(self, ctx: commands.Context):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
return
|
||||
dm = self.bot.data_manager
|
||||
pirates = dm.get('pirates')
|
||||
by_char = {}
|
||||
for p in pirates:
|
||||
by_char.setdefault(p['character_name'].lower(), []).append(p)
|
||||
by_acct = {p['account_name'].lower(): p for p in pirates}
|
||||
|
||||
changed = 0
|
||||
ambiguous = 0
|
||||
missing = 0
|
||||
already = 0
|
||||
|
||||
for e in dm.get('encounters'):
|
||||
ident = e.get('identifier', '')
|
||||
if not ident:
|
||||
continue
|
||||
if '#' in ident:
|
||||
already += 1
|
||||
continue
|
||||
key = ident.lower()
|
||||
matches = by_char.get(key, [])
|
||||
if not matches:
|
||||
missing += 1
|
||||
continue
|
||||
if len(matches) > 1:
|
||||
ambiguous += 1
|
||||
continue
|
||||
acct = matches[0]['account_name']
|
||||
# update this one entry atomically
|
||||
def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident):
|
||||
return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old
|
||||
def upd(x, acct_new=acct):
|
||||
x['identifier'] = acct_new
|
||||
return x
|
||||
ok = dm.update('encounters', pred, upd)
|
||||
if ok:
|
||||
changed += 1
|
||||
|
||||
await ctx.reply(
|
||||
f"Migration complete.\n"
|
||||
f"- Updated to accounts: **{changed}**\n"
|
||||
f"- Already accounts: **{already}**\n"
|
||||
f"- Ambiguous character names (skipped): **{ambiguous}**\n"
|
||||
f"- Not found in pirates list (skipped): **{missing}**",
|
||||
ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None
|
||||
)
|
||||
|
||||
await self._refresh_pirates_list(ctx.guild)
|
||||
|
||||
# Moderator reaction handling (atomic claim)
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
||||
if (
|
||||
payload.channel_id != self.mod_channel
|
||||
or str(payload.emoji) not in (CHECK, CROSS)
|
||||
or payload.user_id == self.bot.user.id
|
||||
):
|
||||
return
|
||||
|
||||
channel = self.bot.get_channel(payload.channel_id)
|
||||
if not channel or not channel.guild:
|
||||
return
|
||||
if not is_moderator_userid(channel.guild, payload.user_id, self.bot):
|
||||
return
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
except Exception as e:
|
||||
print("[pirate_report] fetch mod msg failed:", repr(e))
|
||||
return
|
||||
|
||||
dm = self.bot.data_manager
|
||||
|
||||
# Atomically claim this report
|
||||
claimed = dm.update(
|
||||
'reports',
|
||||
lambda r: r.get('report_id') == msg.id and r.get('status') == 'pending',
|
||||
lambda r: (r.update({'status': 'deciding'}), r)[1]
|
||||
)
|
||||
if not claimed:
|
||||
return
|
||||
|
||||
report = next((r for r in dm.get('reports') if r.get('report_id') == msg.id), None)
|
||||
if not report:
|
||||
return
|
||||
|
||||
approved = (str(payload.emoji) == CHECK)
|
||||
guild = channel.guild
|
||||
stamp = _now_utc_str()
|
||||
|
||||
header_emoji = CHECK if approved else CROSS
|
||||
new_content = (
|
||||
f"{header_emoji} **Pirate Report**\n"
|
||||
f"**Character:** {report['character_name']}\n"
|
||||
f"**Account:** {report['account_name']}\n"
|
||||
f"**Submitted by:** <@{report['submitter_id']}>\n\n"
|
||||
f"**Status:** {'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
|
||||
)
|
||||
try:
|
||||
await msg.clear_reactions()
|
||||
except Exception as e:
|
||||
print("[pirate_report] clear reactions failed:", repr(e))
|
||||
try:
|
||||
await msg.edit(content=new_content)
|
||||
except Exception as e:
|
||||
print("[pirate_report] edit mod msg failed:", repr(e))
|
||||
|
||||
if approved:
|
||||
acct_lower = report['account_name'].lower()
|
||||
if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
|
||||
dm.add('pirates', {
|
||||
'character_name': report['character_name'],
|
||||
'account_name': report['account_name'],
|
||||
'submitter': str(report['submitter_id']),
|
||||
'threat_level': 0,
|
||||
'encounter_count': 0,
|
||||
'added_ts': time.time()
|
||||
})
|
||||
await self._refresh_pirates_list(guild)
|
||||
|
||||
await self._modlog(guild, f"{'✅ Approved' if approved else '❌ Rejected'} report for {report['character_name']} ({report['account_name']}) by <@{payload.user_id}>.")
|
||||
|
||||
try:
|
||||
if report.get('origin_channel_id') and report.get('ack_message_id'):
|
||||
ch = guild.get_channel(report['origin_channel_id'])
|
||||
if ch:
|
||||
ack_msg = await ch.fetch_message(report['ack_message_id'])
|
||||
await ack_msg.edit(content=f"{('✅ Approved' if approved else '❌ Rejected')} pirate report of {report['character_name']} ({report['account_name']}) by <@{payload.user_id}> on {stamp}.")
|
||||
except Exception as e:
|
||||
print("[pirate_report] edit ack msg failed:", repr(e))
|
||||
|
||||
dm.remove('reports', lambda r: r.get('report_id') == msg.id)
|
||||
|
||||
async def setup(bot):
|
||||
cog = PirateReportCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
|
||||
try:
|
||||
home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
|
||||
except Exception:
|
||||
home_gid = 0
|
||||
|
||||
if home_gid:
|
||||
guild_obj = discord.Object(id=home_gid)
|
||||
bot.tree.add_command(cog.report, guild=guild_obj)
|
||||
bot.tree.add_command(cog.edit_pirate, guild=guild_obj)
|
||||
bot.tree.add_command(cog.encounter, guild=guild_obj)
|
||||
else:
|
||||
bot.tree.add_command(cog.report)
|
||||
bot.tree.add_command(cog.edit_pirate)
|
||||
bot.tree.add_command(cog.encounter)
|
0
modules/pirates_list/__init__.py
Normal file
0
modules/pirates_list/__init__.py
Normal file
256
modules/pirates_list/pirates_list.py
Normal file
256
modules/pirates_list/pirates_list.py
Normal 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))
|
0
modules/reaction_role/__init__.py
Normal file
0
modules/reaction_role/__init__.py
Normal file
194
modules/reaction_role/reaction_role.py
Normal file
194
modules/reaction_role/reaction_role.py
Normal 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))
|
0
modules/spicepay/__init__.py
Normal file
0
modules/spicepay/__init__.py
Normal file
917
modules/spicepay/spicepay.py
Normal file
917
modules/spicepay/spicepay.py
Normal 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 0–100.")
|
||||
continue
|
||||
|
||||
if isinstance(roles, str):
|
||||
roles = _normalize_roles(roles)
|
||||
if not isinstance(roles, list):
|
||||
errors.append(f"Participant '{name}' has invalid roles payload.")
|
||||
continue
|
||||
|
||||
invalid = [r for r in roles if r not in VALID_ROLES]
|
||||
if invalid:
|
||||
errors.append(f"Participant '{name}' has invalid roles: {invalid}.")
|
||||
continue
|
||||
|
||||
if 'refiner_owner' in roles or 'lsr_owner' in roles:
|
||||
refiner_owners.append(name)
|
||||
|
||||
cleaned.append({'name': name, 'participation': float(participation), 'roles': roles})
|
||||
|
||||
# Refiner checks depend on payout mode
|
||||
if lsr_required:
|
||||
if len(refiner_owners) > 1:
|
||||
errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only one is allowed.")
|
||||
elif len(refiner_owners) == 0:
|
||||
errors.append("No refiner owner found. Exactly one is required for melange payouts.")
|
||||
else:
|
||||
if len(refiner_owners) > 1:
|
||||
errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only zero or one is allowed for sand payouts.")
|
||||
|
||||
if errors:
|
||||
return [], errors
|
||||
|
||||
refiner_owner = refiner_owners[0] if refiner_owners else None
|
||||
refinery_cut = int(spice_total * (lsr_cut_percent / 100.0)) if (lsr_required and refiner_owner) else 0
|
||||
distributable = spice_total - refinery_cut
|
||||
|
||||
# Compute weights
|
||||
weights: Dict[str, float] = {}
|
||||
total_weight = 0.0
|
||||
for p in cleaned:
|
||||
w = float(base_weight) * (p['participation'] / 100.0)
|
||||
if 'carrier_owner' in p['roles']:
|
||||
w += float(carrier_bonus)
|
||||
if 'crawler_owner' in p['roles']:
|
||||
w += float(crawler_bonus)
|
||||
weights[p['name']] = w
|
||||
total_weight += w
|
||||
|
||||
if total_weight <= 0:
|
||||
return [], [
|
||||
"Total effective weight is zero. Increase participation or weighting factors "
|
||||
"(e.g., raise base ×% and/or bonuses) so at least one participant has non-zero weight."
|
||||
]
|
||||
|
||||
# Proportional allocation
|
||||
spice_raw: Dict[str, int] = {}
|
||||
total_floored = 0
|
||||
for p in cleaned:
|
||||
name = p['name']
|
||||
w = weights.get(name, 0.0)
|
||||
share = int((w / total_weight) * distributable)
|
||||
spice_raw[name] = share
|
||||
total_floored += share
|
||||
|
||||
remainder = distributable - total_floored
|
||||
if refiner_owner:
|
||||
spice_raw[refiner_owner] = spice_raw.get(refiner_owner, 0) + refinery_cut + remainder
|
||||
else:
|
||||
top = max(spice_raw.items(), key=lambda kv: kv[1])[0]
|
||||
spice_raw[top] += remainder
|
||||
|
||||
result = [{
|
||||
'name': p['name'],
|
||||
'spice': int(spice_raw.get(p['name'], 0)),
|
||||
'participation': p['participation'],
|
||||
'roles': p['roles'],
|
||||
} for p in cleaned]
|
||||
|
||||
return result, []
|
||||
|
||||
# ------------------------ formatting ------------------------
|
||||
|
||||
def _fmt_roles_short(roles: List[str]) -> str:
|
||||
if not roles:
|
||||
return "Escort"
|
||||
short = []
|
||||
for r in roles:
|
||||
if r in ("refiner_owner", "lsr_owner"): short.append("Refiner")
|
||||
elif r == "crawler_owner": short.append("Crawler")
|
||||
elif r == "carrier_owner": short.append("Carrier")
|
||||
return ",".join(short) if short else "Escort"
|
||||
|
||||
def _format_table(results: List[Dict], unit_header: str, unit_suffix: str) -> str:
|
||||
rows = sorted(results, key=lambda r: (-r['spice'], r['name'].lower()))
|
||||
any_zero = any((r.get('participation') or 0) <= 0 for r in rows)
|
||||
|
||||
own_header = "Owner of/Role"
|
||||
name_w = max(4, *(len(r['name']) for r in rows)) if rows else 4
|
||||
own_w = max(len(own_header), *(len(_fmt_roles_short(r['roles'])) for r in rows)) if rows else len(own_header)
|
||||
pay_w = max(6, len(unit_header))
|
||||
|
||||
header = f"{'Name'.ljust(name_w)} {'Active %'.rjust(8)} {own_header.ljust(own_w)} {unit_header.rjust(pay_w)}"
|
||||
sep = "-" * len(header)
|
||||
lines = [header, sep]
|
||||
|
||||
for r in rows:
|
||||
name = r['name'].ljust(name_w)
|
||||
pct_val = int(round(r.get('participation') or 0))
|
||||
pct = f"{pct_val}%"
|
||||
if pct_val == 0:
|
||||
pct += "*" # owner-only marker
|
||||
pct = pct.rjust(8)
|
||||
owned = _fmt_roles_short(r.get('roles', [])).ljust(own_w)
|
||||
amount = f"{r['spice']} {unit_suffix}"
|
||||
lines.append(f"{name} {pct} {owned} {amount}")
|
||||
|
||||
if any_zero:
|
||||
lines.append("")
|
||||
lines.append("* 0% = owner only (did not actively join the run)")
|
||||
return "```\n" + "\n".join(lines) + "\n```"
|
||||
|
||||
# -------- Modals --------
|
||||
|
||||
class _SetupModal(discord.ui.Modal, title="Spice Pay — Setup"):
|
||||
def __init__(self, cog: "SpicePayCog", pre_participants: Optional[int]):
|
||||
super().__init__()
|
||||
self.cog = cog
|
||||
self.pre_participants = pre_participants
|
||||
|
||||
self.total_field = discord.ui.TextInput(
|
||||
label="Total spice yield (sand)",
|
||||
placeholder="e.g. 12345",
|
||||
required=True,
|
||||
max_length=10
|
||||
)
|
||||
self.count_field = discord.ui.TextInput(
|
||||
label="Participants (incl. owners)",
|
||||
placeholder="Total number of people (add refiner/carrier/crawler owners too)",
|
||||
required=True,
|
||||
max_length=45, # allow a clearer hint
|
||||
default=str(pre_participants) if pre_participants is not None else None
|
||||
)
|
||||
|
||||
self.add_item(self.total_field)
|
||||
self.add_item(self.count_field)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
try:
|
||||
total = int(self.total_field.value.strip())
|
||||
if total < 0:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ Total must be a non-negative integer.", ephemeral=True)
|
||||
|
||||
try:
|
||||
count = int(str(self.count_field.value).strip())
|
||||
if not (1 <= count <= 25):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ Participants must be between 1 and 25.", ephemeral=True)
|
||||
|
||||
await self.cog.start_session(interaction, total, count)
|
||||
|
||||
class _ParticipantModal(discord.ui.Modal):
|
||||
def __init__(self, cog: "SpicePayCog", session_key, index: int, existing: Optional[Dict]):
|
||||
super().__init__(title=f"Participant {index}")
|
||||
self.cog = cog
|
||||
self.session_key = session_key
|
||||
self.index = index
|
||||
ex = existing or {}
|
||||
|
||||
self.name = discord.ui.TextInput(
|
||||
label="Name",
|
||||
placeholder="Player name (or @mention text)",
|
||||
required=True,
|
||||
max_length=64,
|
||||
default=ex.get("name") if ex else None
|
||||
)
|
||||
self.part = discord.ui.TextInput(
|
||||
label="Active participation % (0–100)",
|
||||
placeholder="How much they actively joined the run (0..100; 0% = owner only)",
|
||||
required=True,
|
||||
max_length=45,
|
||||
default=str(int(ex["participation"])) if "participation" in ex else None
|
||||
)
|
||||
self.roles = discord.ui.TextInput(
|
||||
label="Owner of (optional)",
|
||||
placeholder="refiner, crawler, carrier — leave empty if escort/non-owner",
|
||||
required=False,
|
||||
max_length=64,
|
||||
default=",".join(ex["roles"]) if ex and ex.get("roles") else None
|
||||
)
|
||||
self.add_item(self.name)
|
||||
self.add_item(self.part)
|
||||
self.add_item(self.roles)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
|
||||
|
||||
name = self.name.value.strip()
|
||||
part_raw = str(self.part.value).strip().replace("%", "")
|
||||
try:
|
||||
participation = float(part_raw)
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ Participation must be a number (0–100).", ephemeral=True)
|
||||
if participation < 0 or participation > 100:
|
||||
return await interaction.response.send_message("❌ Participation must be 0–100.", ephemeral=True)
|
||||
|
||||
roles = _normalize_roles(self.roles.value or "")
|
||||
|
||||
idx = self.index - 1
|
||||
while len(sess["participants"]) < idx + 1:
|
||||
sess["participants"].append({})
|
||||
sess["participants"][idx] = {"name": name, "participation": participation, "roles": roles}
|
||||
|
||||
if sess["next_index"] == self.index and sess["next_index"] < sess["count"]:
|
||||
sess["next_index"] += 1
|
||||
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
class _MelangeModal(discord.ui.Modal, title="Set melange refinery yield"):
|
||||
def __init__(self, cog: "SpicePayCog", session_key):
|
||||
super().__init__()
|
||||
self.cog = cog
|
||||
self.session_key = session_key
|
||||
self.amount = discord.ui.TextInput(
|
||||
label="Melange refinery yield (integer)",
|
||||
placeholder="e.g. 123",
|
||||
required=True,
|
||||
max_length=10
|
||||
)
|
||||
self.add_item(self.amount)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
val = self.amount.value.strip()
|
||||
try:
|
||||
m = int(val)
|
||||
if m < 0:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
return await interaction.response.send_message("❌ Melange total must be a non-negative integer.", ephemeral=True)
|
||||
sess["melange_total"] = m
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
class _WeightsModal(discord.ui.Modal, title="Adjust weighting factors"):
|
||||
def __init__(self, cog: "SpicePayCog", session_key):
|
||||
super().__init__()
|
||||
self.cog = cog
|
||||
self.session_key = session_key
|
||||
|
||||
sess = self.cog.sessions.get(session_key) or {}
|
||||
w = (sess.get("weights") or {})
|
||||
bw = w.get("base", self.cog.base_weight)
|
||||
cab = w.get("carrier", self.cog.carrier_bonus)
|
||||
crb = w.get("crawler", self.cog.crawler_bonus)
|
||||
lsr = w.get("lsr", self.cog.lsr_cut_percent)
|
||||
|
||||
self.base = discord.ui.TextInput(label="Base × active %", required=False, max_length=10, placeholder=str(bw))
|
||||
self.carrier = discord.ui.TextInput(label="Carrier bonus (+)", required=False, max_length=10, placeholder=str(cab))
|
||||
self.crawler = discord.ui.TextInput(label="Crawler bonus (+)", required=False, max_length=10, placeholder=str(crb))
|
||||
self.lsr = discord.ui.TextInput(label="Refinery cut % (melange)", required=False, max_length=10, placeholder=str(lsr))
|
||||
|
||||
self.add_item(self.base)
|
||||
self.add_item(self.carrier)
|
||||
self.add_item(self.crawler)
|
||||
self.add_item(self.lsr)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
|
||||
def _parse_nonneg(opt_str, pct=False):
|
||||
if not opt_str:
|
||||
return None
|
||||
s = str(opt_str).strip().replace("%", "")
|
||||
try:
|
||||
v = float(s)
|
||||
except Exception:
|
||||
return "bad"
|
||||
if v < 0:
|
||||
return "bad"
|
||||
if pct and (v < 0 or v > 100):
|
||||
return "bad"
|
||||
return v
|
||||
|
||||
b = _parse_nonneg(self.base.value)
|
||||
cab = _parse_nonneg(self.carrier.value)
|
||||
crb = _parse_nonneg(self.crawler.value)
|
||||
lsr = _parse_nonneg(self.lsr.value, pct=True)
|
||||
|
||||
if "bad" in (b, cab, crb, lsr):
|
||||
return await interaction.response.send_message(
|
||||
"❌ Invalid values. Use non-negative numbers; refinery % must be 0–100.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
w = sess.get("weights") or {}
|
||||
if b is not None: w["base"] = b
|
||||
if cab is not None: w["carrier"] = cab
|
||||
if crb is not None: w["crawler"] = crb
|
||||
if lsr is not None: w["lsr"] = lsr
|
||||
sess["weights"] = w
|
||||
|
||||
# Soft warning for extreme values
|
||||
if any(v is not None and v > 1000 for v in (b, cab, crb)):
|
||||
try:
|
||||
await interaction.response.send_message(
|
||||
"⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.",
|
||||
ephemeral=True
|
||||
)
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(
|
||||
"⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.",
|
||||
ephemeral=True
|
||||
)
|
||||
return await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
# ------------------------ Views ------------------------
|
||||
|
||||
class _HelpView(discord.ui.View):
|
||||
def __init__(self, cog: "SpicePayCog", session_key: tuple):
|
||||
super().__init__(timeout=600) # keep session alive
|
||||
self.cog = cog
|
||||
self.session_key = session_key
|
||||
|
||||
async def on_timeout(self):
|
||||
pass
|
||||
|
||||
@discord.ui.button(label="Return to setup", style=discord.ButtonStyle.primary)
|
||||
async def back(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
@discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary)
|
||||
async def open_weights(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))
|
||||
|
||||
class _WizardView(discord.ui.View):
|
||||
def __init__(self, cog: "SpicePayCog", session_key: tuple):
|
||||
super().__init__(timeout=600)
|
||||
self.cog = cog
|
||||
self.session_key = session_key
|
||||
|
||||
async def on_timeout(self):
|
||||
pass
|
||||
|
||||
@discord.ui.button(label="Add / Edit participant", style=discord.ButtonStyle.primary, row=0)
|
||||
async def add_edit(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
|
||||
idx = sess["next_index"]
|
||||
if idx > sess["count"]:
|
||||
idx = sess["count"]
|
||||
existing = sess["participants"][idx-1] if 0 <= idx-1 < len(sess["participants"]) else None
|
||||
await interaction.response.send_modal(_ParticipantModal(self.cog, self.session_key, idx, existing))
|
||||
|
||||
@discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary, row=0)
|
||||
async def previous(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
sess["next_index"] = max(1, sess["next_index"] - 1)
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
@discord.ui.button(label="Next", style=discord.ButtonStyle.secondary, row=0)
|
||||
async def next(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
sess["next_index"] = min(sess["count"], sess["next_index"] + 1)
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
@discord.ui.button(label="Toggle payout: Sand/Melange", style=discord.ButtonStyle.secondary, row=1)
|
||||
async def toggle_payout(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
sess = self.cog.sessions.get(self.session_key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
sess["payout_type"] = "melange" if sess.get("payout_type") == "sand" else "sand"
|
||||
if sess["payout_type"] == "melange" and not sess.get("melange_total"):
|
||||
return await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
|
||||
await self.cog.render_progress(interaction, self.session_key)
|
||||
|
||||
@discord.ui.button(label="Set melange refinery yield", style=discord.ButtonStyle.primary, row=1)
|
||||
async def set_melange(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
|
||||
|
||||
@discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary, row=1)
|
||||
async def adjust_weights(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))
|
||||
|
||||
@discord.ui.button(label="Weights help", style=discord.ButtonStyle.secondary, row=2)
|
||||
async def weights_help(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
help_txt = (
|
||||
"**How the split works**\n"
|
||||
"• **Participants are people** (or a guild as one person). Vehicles/refinery are **owned** by someone.\n"
|
||||
"• Everyone gets a *weight*: **Base × active %** + **bonuses** (Carrier/Crawler if they own them).\n"
|
||||
"• We split the pot **proportionally** to those weights (normalized; only ratios matter).\n"
|
||||
"• **Melange payout** only: a **refinery cut %** is taken first and given to the **Refiner owner**.\n"
|
||||
"• People with **0%** are treated as **owner only** (get owner bonuses/cut but didn’t actively join the run).\n"
|
||||
"• Rounding leftovers go to the Refiner (melange) or the top earner (sand).\n\n"
|
||||
"_Tap **Return to setup** below to go back, or **Adjust weighting factors** to change numbers._"
|
||||
)
|
||||
try:
|
||||
await interaction.response.edit_message(content=help_txt, view=_HelpView(self.cog, self.session_key))
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(help_txt, ephemeral=True, view=_HelpView(self.cog, self.session_key))
|
||||
|
||||
# ---------- Presets row ----------
|
||||
@discord.ui.button(label="Preset: Owner-heavy", style=discord.ButtonStyle.secondary, row=3)
|
||||
async def preset_owner(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.cog.apply_preset(interaction, self.session_key, preset="owner")
|
||||
|
||||
@discord.ui.button(label="Preset: Participation-heavy", style=discord.ButtonStyle.secondary, row=3)
|
||||
async def preset_part(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.cog.apply_preset(interaction, self.session_key, preset="participation")
|
||||
|
||||
@discord.ui.button(label="Preset: Fair (server defaults)", style=discord.ButtonStyle.secondary, row=3)
|
||||
async def preset_fair(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.cog.apply_preset(interaction, self.session_key, preset="fair")
|
||||
|
||||
@discord.ui.button(label="Preset: Even split", style=discord.ButtonStyle.secondary, row=3)
|
||||
async def preset_even(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.cog.apply_preset(interaction, self.session_key, preset="even")
|
||||
|
||||
@discord.ui.button(label="Save as my defaults", style=discord.ButtonStyle.success, row=3)
|
||||
async def save_defaults(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.cog.save_user_defaults(interaction, self.session_key)
|
||||
|
||||
@discord.ui.button(label="Finish", style=discord.ButtonStyle.success, row=2)
|
||||
async def finish(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
await self.cog.finish_and_show(interaction, self.session_key)
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, row=2)
|
||||
async def cancel(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
self.cog.sessions.pop(self.session_key, None)
|
||||
await interaction.response.edit_message(content="Spice pay session cancelled.", view=None)
|
||||
|
||||
class _StartView(discord.ui.View):
|
||||
def __init__(self, cog: "SpicePayCog", participants: Optional[int]):
|
||||
super().__init__(timeout=600) # was 120
|
||||
self.cog = cog
|
||||
self.participants = participants
|
||||
|
||||
@discord.ui.button(label="Open setup", style=discord.ButtonStyle.primary)
|
||||
async def open_setup(self, interaction: discord.Interaction, _btn: discord.ui.Button):
|
||||
try:
|
||||
await interaction.response.send_modal(_SetupModal(self.cog, self.participants))
|
||||
except Exception as e:
|
||||
print("[spicepay] fallback button failed:", repr(e))
|
||||
await interaction.response.send_message("Still couldn’t open the modal. Do I have the right permissions here?", ephemeral=True)
|
||||
|
||||
# ------------------------ Cog ------------------------
|
||||
|
||||
class SpicePayCog(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.sessions: Dict[tuple, Dict] = {}
|
||||
|
||||
cfg = bot.config['DEFAULT']
|
||||
def _f(key, default):
|
||||
try:
|
||||
return float(cfg.get(key, str(default)))
|
||||
except Exception:
|
||||
return float(default)
|
||||
self.base_weight = _f('spicepay_base_weight', 25.0)
|
||||
self.carrier_bonus = _f('spicepay_carrier_bonus', 12.5)
|
||||
self.crawler_bonus = _f('spicepay_crawler_bonus', 12.5)
|
||||
self.lsr_cut_percent = _f('spicepay_lsr_cut_percent', 10.0) # keep key; UI calls it refinery cut
|
||||
|
||||
def _i(key):
|
||||
try:
|
||||
return int(cfg.get(key)) if cfg.get(key) else None
|
||||
except Exception:
|
||||
return None
|
||||
self.emoji_sand_id = _i('emoji_sand_id')
|
||||
self.emoji_melange_id = _i('emoji_melange_id')
|
||||
self.emoji_cc_id = _i('emoji_carrier_crawler_id')
|
||||
|
||||
# Session reaper (clean up sessions older than 60 minutes)
|
||||
self._reaper_task = asyncio.create_task(self._session_reaper())
|
||||
|
||||
def cog_unload(self):
|
||||
try:
|
||||
self._reaper_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _session_reaper(self):
|
||||
await self.bot.wait_until_ready()
|
||||
while not self.bot.is_closed():
|
||||
now = time.time()
|
||||
ttl = 3600 # 60 min
|
||||
dead = [k for k, s in list(self.sessions.items())
|
||||
if now - s.get("created_ts", now) > ttl]
|
||||
for k in dead:
|
||||
self.sessions.pop(k, None)
|
||||
await asyncio.sleep(300) # sweep every 5 min
|
||||
|
||||
# ----- user prefs helpers (persisted to data_file) -----
|
||||
|
||||
def _get_user_prefs(self, user_id: int) -> Optional[Dict]:
|
||||
prefs = self.bot.data_manager.get('spicepay_prefs')
|
||||
for row in prefs:
|
||||
if row.get('user_id') == user_id:
|
||||
return row
|
||||
return None
|
||||
|
||||
def _save_user_prefs(self, user_id: int, weights: Dict[str, float]):
|
||||
# overwrite existing
|
||||
self.bot.data_manager.remove('spicepay_prefs', lambda r: r.get('user_id') == user_id)
|
||||
self.bot.data_manager.add('spicepay_prefs', {
|
||||
'user_id': user_id,
|
||||
'weights': {
|
||||
'base': float(weights.get('base', self.base_weight)),
|
||||
'carrier': float(weights.get('carrier', self.carrier_bonus)),
|
||||
'crawler': float(weights.get('crawler', self.crawler_bonus)),
|
||||
'lsr': float(weights.get('lsr', self.lsr_cut_percent)),
|
||||
},
|
||||
'saved_ts': time.time()
|
||||
})
|
||||
|
||||
# ----- commands -----
|
||||
|
||||
@app_commands.command(name="spicepay", description="Calculate spice payout via a guided wizard (opens a form)")
|
||||
@app_commands.describe(
|
||||
participants="Total number of people involved (include owners of refiner/carrier/crawler)",
|
||||
force_new="Start a fresh session even if one is active"
|
||||
)
|
||||
async def spicepay(self, interaction: discord.Interaction, participants: Optional[int] = None, force_new: Optional[bool] = False):
|
||||
key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
|
||||
if not force_new and key in self.sessions:
|
||||
return await self.render_progress(interaction, key, new_message=True)
|
||||
|
||||
try:
|
||||
await interaction.response.send_modal(_SetupModal(self, participants))
|
||||
except Exception as e:
|
||||
print("[spicepay] send_modal failed:", repr(e))
|
||||
try:
|
||||
await interaction.response.defer(ephemeral=True, thinking=False)
|
||||
except Exception:
|
||||
pass
|
||||
await interaction.followup.send(
|
||||
"Couldn’t open the setup modal automatically. Click below to start:",
|
||||
ephemeral=True,
|
||||
view=_StartView(self, participants)
|
||||
)
|
||||
|
||||
@app_commands.command(name="spicepay_resume", description="Reopen your active spice pay session")
|
||||
async def spicepay_resume(self, interaction: discord.Interaction):
|
||||
key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
|
||||
if key in self.sessions:
|
||||
await self.render_progress(interaction, key, new_message=True)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"No active session found. Run **/spicepay** to start a new one.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(name="spicepay_cancel", description="Cancel your active spicepay session")
|
||||
async def spicepay_cancel(self, interaction: discord.Interaction):
|
||||
key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
|
||||
if key in self.sessions:
|
||||
self.sessions.pop(key, None)
|
||||
await interaction.response.send_message("Cancelled your spice pay session.", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message("No active spice pay session.", ephemeral=True)
|
||||
|
||||
@app_commands.command(name="spicepay_config", description="Show the current spicepay weight settings")
|
||||
async def spicepay_config(self, interaction: discord.Interaction):
|
||||
txt = (
|
||||
"**SpicePay configuration**\n"
|
||||
f"- Refinery cut (melange payouts): **{self.lsr_cut_percent}%**\n"
|
||||
f"- Base weight: **{self.base_weight} × active %**\n"
|
||||
f"- Carrier bonus: **+{self.carrier_bonus}**\n"
|
||||
f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
|
||||
"_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._"
|
||||
)
|
||||
await interaction.response.send_message(txt, ephemeral=True)
|
||||
|
||||
# ----- session helpers -----
|
||||
|
||||
async def start_session(self, interaction: discord.Interaction, total: int, count: int):
|
||||
g = interaction.guild
|
||||
key = (g.id if g else 0, interaction.user.id)
|
||||
|
||||
# seed weights from user prefs if present
|
||||
w = {}
|
||||
user_prefs = self._get_user_prefs(interaction.user.id)
|
||||
if user_prefs and isinstance(user_prefs.get('weights'), dict):
|
||||
w = {
|
||||
'base': float(user_prefs['weights'].get('base', self.base_weight)),
|
||||
'carrier': float(user_prefs['weights'].get('carrier', self.carrier_bonus)),
|
||||
'crawler': float(user_prefs['weights'].get('crawler', self.crawler_bonus)),
|
||||
'lsr': float(user_prefs['weights'].get('lsr', self.lsr_cut_percent)),
|
||||
}
|
||||
|
||||
# Auto-fill initiator as participant #1 (easy to adjust)
|
||||
display = interaction.user.display_name if isinstance(interaction.user, discord.Member) else interaction.user.name
|
||||
participants_seed = [{'name': display, 'participation': 50.0, 'roles': []}]
|
||||
|
||||
self.sessions[key] = {
|
||||
"total": total, # sand yield
|
||||
"count": count,
|
||||
"participants": participants_seed,
|
||||
"next_index": 1, # stays on 1 so they can review/edit their autofill
|
||||
"created_ts": time.time(),
|
||||
"payout_type": "sand", # 'sand' or 'melange'
|
||||
"melange_total": None, # required if payout_type == 'melange'
|
||||
"weights": w # optional overrides: base, carrier, crawler, lsr(refinery)
|
||||
}
|
||||
await self.render_progress(interaction, key, new_message=True)
|
||||
|
||||
async def render_progress(self, interaction: discord.Interaction, key: tuple, new_message: bool = False):
|
||||
sess = self.sessions.get(key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
|
||||
sand_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
|
||||
mel_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
|
||||
cc_emoji = _emoji_str(self.bot, self.emoji_cc_id, "🚛")
|
||||
|
||||
w = sess.get("weights") or {}
|
||||
bw = w.get("base", self.base_weight)
|
||||
cab = w.get("carrier", self.carrier_bonus)
|
||||
crb = w.get("crawler", self.crawler_bonus)
|
||||
lsrp = w.get("lsr", self.lsr_cut_percent)
|
||||
|
||||
filled = sum(1 for p in sess["participants"] if p.get("name"))
|
||||
idx = sess["next_index"]
|
||||
total_pct = int(round(sum((p.get("participation") or 0) for p in sess["participants"])))
|
||||
|
||||
payout_line = f"Payout: **Sand Spice** {sand_emoji}"
|
||||
if sess.get("payout_type") == "melange":
|
||||
if sess.get("melange_total") is None:
|
||||
payout_line = f"Payout: **Spice Melange** {mel_emoji} — _set melange refinery yield_"
|
||||
else:
|
||||
payout_line = f"Payout: **Spice Melange** {mel_emoji} — total **{sess['melange_total']}**"
|
||||
|
||||
progress = (
|
||||
f"**Spice Pay Setup** — sand yield: **{sess['total']}** {sand_emoji} | participants: **{sess['count']}** | "
|
||||
f"total active ≈ **{total_pct}%**\n"
|
||||
f"{payout_line}\n"
|
||||
f"Filled: **{filled}/{sess['count']}** — next: **{idx}**\n"
|
||||
f"_Weighting factors: Refinery cut {lsrp}% (melange only) | Base {bw}× active % | Carrier +{cab} | Crawler +{crb} — "
|
||||
f"normalized; only ratios matter._ {cc_emoji}"
|
||||
)
|
||||
|
||||
preview_lines = []
|
||||
for i in range(sess["count"]):
|
||||
p = sess["participants"][i] if i < len(sess["participants"]) else None
|
||||
if p and p.get("name"):
|
||||
mark = " (owner only)" if int(round(p.get("participation") or 0)) == 0 else ""
|
||||
preview_lines.append(f"{i+1}. {p['name']} — {int(p['participation'])}%{mark} — {_fmt_roles_short(p.get('roles', []))}")
|
||||
else:
|
||||
preview_lines.append(f"{i+1}. _empty_")
|
||||
|
||||
content = progress + "\n" + "\n".join(preview_lines)
|
||||
view = _WizardView(self, key)
|
||||
|
||||
try:
|
||||
if new_message:
|
||||
await interaction.response.send_message(content, ephemeral=True, view=view)
|
||||
else:
|
||||
await interaction.response.edit_message(content=content, view=view)
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(content, ephemeral=True, view=view)
|
||||
|
||||
async def apply_preset(self, interaction: discord.Interaction, key: tuple, preset: str):
|
||||
sess = self.sessions.get(key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
|
||||
if preset == "owner":
|
||||
w = {'base': 15.0, 'carrier': 25.0, 'crawler': 25.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
|
||||
elif preset == "participation":
|
||||
w = {'base': 50.0, 'carrier': 5.0, 'crawler': 5.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
|
||||
elif preset == "even":
|
||||
w = {'base': 1.0, 'carrier': 0.0, 'crawler': 0.0, 'lsr': 0.0}
|
||||
else: # "fair" -> server defaults
|
||||
w = {'base': self.base_weight, 'carrier': self.carrier_bonus, 'crawler': self.crawler_bonus, 'lsr': self.lsr_cut_percent}
|
||||
|
||||
sess['weights'] = w
|
||||
await self.render_progress(interaction, key)
|
||||
|
||||
async def save_user_defaults(self, interaction: discord.Interaction, key: tuple):
|
||||
sess = self.sessions.get(key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
w = sess.get('weights') or {
|
||||
'base': self.base_weight,
|
||||
'carrier': self.carrier_bonus,
|
||||
'crawler': self.crawler_bonus,
|
||||
'lsr': self.lsr_cut_percent
|
||||
}
|
||||
self._save_user_prefs(interaction.user.id, w)
|
||||
try:
|
||||
await interaction.response.send_message("Saved these weighting factors as your defaults ✅", ephemeral=True)
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send("Saved these weighting factors as your defaults ✅", ephemeral=True)
|
||||
|
||||
async def finish_and_show(self, interaction: discord.Interaction, key: tuple):
|
||||
sess = self.sessions.get(key)
|
||||
if not sess:
|
||||
return await interaction.response.send_message("Session expired.", ephemeral=True)
|
||||
|
||||
payout_type = sess.get("payout_type", "sand")
|
||||
if payout_type == "melange" and sess.get("melange_total") is None:
|
||||
return await interaction.response.send_message("❌ Set the melange refinery yield first.", ephemeral=True)
|
||||
|
||||
w = sess.get("weights") or {}
|
||||
bw = w.get("base", self.base_weight)
|
||||
cab = w.get("carrier", self.carrier_bonus)
|
||||
crb = w.get("crawler", self.crawler_bonus)
|
||||
lsrp = w.get("lsr", self.lsr_cut_percent)
|
||||
|
||||
participants = []
|
||||
names_seen = set()
|
||||
for i in range(sess["count"]):
|
||||
p = sess["participants"][i] if i < len(sess["participants"]) else {}
|
||||
name = (p.get("name") or f"User{i+1}").strip()
|
||||
if name in names_seen:
|
||||
name = f"{name}_{i+1}"
|
||||
names_seen.add(name)
|
||||
part = p.get("participation", 0)
|
||||
roles = p.get("roles", [])
|
||||
participants.append({"name": name, "participation": part, "roles": roles})
|
||||
|
||||
if payout_type == "sand":
|
||||
total = int(sess["total"])
|
||||
lsr_req = False
|
||||
lsr_pct = 0.0
|
||||
unit_header = "Sand Spice"
|
||||
unit_suffix = "Sand Spice"
|
||||
title_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
|
||||
payout_name = "Sand Spice"
|
||||
else:
|
||||
total = int(sess["melange_total"])
|
||||
lsr_req = True
|
||||
lsr_pct = float(lsrp)
|
||||
unit_header = "Spice Melange"
|
||||
unit_suffix = "Spice Melange"
|
||||
title_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
|
||||
payout_name = "Spice Melange"
|
||||
|
||||
results, errors = calculate_spice_distribution(
|
||||
total,
|
||||
participants,
|
||||
base_weight=bw,
|
||||
carrier_bonus=cab,
|
||||
crawler_bonus=crb,
|
||||
lsr_cut_percent=lsr_pct,
|
||||
lsr_required=lsr_req
|
||||
)
|
||||
if errors:
|
||||
err_text = "❌ **Problems found:**\n" + "\n".join(f"- {e}" for e in errors) + "\n\n" \
|
||||
"Use **Add / Edit participant** to fix, or **Cancel**."
|
||||
try:
|
||||
await interaction.response.edit_message(content=err_text, view=_WizardView(self, key))
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(err_text, ephemeral=True, view=_WizardView(self, key))
|
||||
return
|
||||
|
||||
table = _format_table(results, unit_header, unit_suffix)
|
||||
|
||||
class _PostView(discord.ui.View):
|
||||
def __init__(self, outer: "SpicePayCog"):
|
||||
super().__init__(timeout=600)
|
||||
self.outer = outer
|
||||
|
||||
@discord.ui.button(label="Post to channel", style=discord.ButtonStyle.primary)
|
||||
async def post(self, inter: discord.Interaction, _btn: discord.ui.Button):
|
||||
header = f"**Payout: {payout_name}** {title_emoji}"
|
||||
settings = f"_Base {bw}× active % | Carrier +{cab} | Crawler +{crb}_"
|
||||
if payout_type == "melange":
|
||||
settings = f"_Refinery cut {lsrp}% | " + settings
|
||||
txt = f"{header}\n{settings}\n{table}"
|
||||
await inter.channel.send(txt)
|
||||
await inter.response.edit_message(content="Posted to channel ✅", view=None)
|
||||
|
||||
@discord.ui.button(label="Back", style=discord.ButtonStyle.secondary)
|
||||
async def back(self, inter: discord.Interaction, _btn: discord.ui.Button):
|
||||
await self.outer.render_progress(inter, key)
|
||||
|
||||
content = f"**Preview** (not posted): **Payout: {payout_name}** {title_emoji}\n{table}"
|
||||
try:
|
||||
await interaction.response.edit_message(content=content, view=_PostView(self))
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(content=content, ephemeral=True, view=_PostView(self))
|
||||
|
||||
# ------------------------ setup ------------------------
|
||||
|
||||
async def setup(bot):
|
||||
cog = SpicePayCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
|
||||
try:
|
||||
home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
|
||||
except Exception:
|
||||
home_gid = 0
|
||||
|
||||
if home_gid:
|
||||
guild_obj = discord.Object(id=home_gid)
|
||||
bot.tree.add_command(cog.spicepay, guild=guild_obj)
|
||||
bot.tree.add_command(cog.spicepay_resume, guild=guild_obj)
|
||||
bot.tree.add_command(cog.spicepay_cancel, guild=guild_obj)
|
||||
bot.tree.add_command(cog.spicepay_config, guild=guild_obj)
|
||||
else:
|
||||
bot.tree.add_command(cog.spicepay)
|
||||
bot.tree.add_command(cog.spicepay_resume)
|
||||
bot.tree.add_command(cog.spicepay_cancel)
|
||||
bot.tree.add_command(cog.spicepay_config)
|
0
modules/user_cards/__init__.py
Normal file
0
modules/user_cards/__init__.py
Normal file
509
modules/user_cards/user_cards.py
Normal file
509
modules/user_cards/user_cards.py
Normal 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 couldn’t find by record, try to find by footer marker in channel history
|
||||
if not msg:
|
||||
msg = await self._find_existing_card(channel, member.id)
|
||||
|
||||
# 3) Edit in place if found
|
||||
if msg:
|
||||
try:
|
||||
await msg.edit(embed=embed, allowed_mentions=NO_MENTIONS)
|
||||
except Exception:
|
||||
pass
|
||||
# upsert record with the definitive location
|
||||
dm.remove('user_cards', lambda r: r.get('user_id') == member.id)
|
||||
dm.add('user_cards', {'user_id': member.id, 'message_id': msg.id, 'channel_id': msg.channel.id})
|
||||
return
|
||||
|
||||
# 4) Post fresh card
|
||||
new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
|
||||
|
||||
# 5) Clean up any other messages that look like this user's card
|
||||
try:
|
||||
# Find any *other* occurrences with the same footer marker and delete them
|
||||
marker = f"UID:{member.id}"
|
||||
async for m in channel.history(limit=400, oldest_first=False):
|
||||
if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds:
|
||||
continue
|
||||
foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else ""
|
||||
if foot == marker:
|
||||
try:
|
||||
await m.delete()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 6) Persist mapping
|
||||
dm.remove('user_cards', lambda r: r.get('user_id') == member.id)
|
||||
dm.add('user_cards', {'user_id': member.id, 'message_id': new_msg.id, 'channel_id': new_msg.channel.id})
|
||||
|
||||
def _lock_for(self, user_id: int) -> asyncio.Lock:
|
||||
lk = self._refresh_locks.get(user_id)
|
||||
if not lk:
|
||||
lk = asyncio.Lock()
|
||||
self._refresh_locks[user_id] = lk
|
||||
return lk
|
||||
|
||||
async def _find_existing_card(self, channel: discord.TextChannel, user_id: int) -> Optional[discord.Message]:
|
||||
"""Search recent history for a card we posted for this user (by footer marker)."""
|
||||
marker = f"UID:{user_id}"
|
||||
try:
|
||||
async for m in channel.history(limit=400, oldest_first=False):
|
||||
if m.author.id != self.bot.user.id:
|
||||
continue
|
||||
if not m.embeds:
|
||||
continue
|
||||
foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else ""
|
||||
if foot == marker:
|
||||
return m
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _log(self, guild: discord.Guild, content: str):
|
||||
ch = guild.get_channel(self.modlog_channel_id)
|
||||
if ch:
|
||||
try: await ch.send(content, allowed_mentions=NO_MENTIONS)
|
||||
except Exception: pass
|
||||
self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content})
|
||||
|
||||
# ---------- RR message lookup & reactor collection ----------
|
||||
|
||||
def _get_cached_msg_channel_id(self, guild_id: int, message_id: int) -> Optional[int]:
|
||||
for r in self.bot.data_manager.get('rr_msg_channels'):
|
||||
if r.get('guild_id') == guild_id and r.get('message_id') == message_id:
|
||||
return int(r.get('channel_id'))
|
||||
return None
|
||||
|
||||
def _cache_msg_channel_id(self, guild_id: int, message_id: int, channel_id: int):
|
||||
dm = self.bot.data_manager
|
||||
dm.remove('rr_msg_channels', lambda r: r.get('guild_id') == guild_id and r.get('message_id') == message_id)
|
||||
dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_id)})
|
||||
|
||||
async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]:
|
||||
ch_id = self._get_cached_msg_channel_id(guild.id, message_id)
|
||||
if ch_id:
|
||||
ch = guild.get_channel(ch_id)
|
||||
if ch:
|
||||
try:
|
||||
return await ch.fetch_message(message_id)
|
||||
except Exception:
|
||||
pass
|
||||
for ch in guild.text_channels:
|
||||
try:
|
||||
msg = await ch.fetch_message(message_id)
|
||||
self._cache_msg_channel_id(guild.id, message_id, ch.id)
|
||||
return msg
|
||||
except (discord.Forbidden, discord.NotFound, discord.HTTPException):
|
||||
continue
|
||||
return None
|
||||
|
||||
async def _collect_reactors(self, message: Optional[discord.Message]) -> Set[int]:
|
||||
ids: Set[int] = set()
|
||||
if not message:
|
||||
return ids
|
||||
for rxn in message.reactions:
|
||||
if str(rxn.emoji) in ACCEPT: # <-- was == CHECK
|
||||
async for u in rxn.users(limit=None):
|
||||
if not u.bot:
|
||||
ids.add(u.id)
|
||||
return ids
|
||||
|
||||
# ---------- reconcile (creates reviews for offline nickname claims) ----------
|
||||
|
||||
async def _open_review_fallback(self, guild: discord.Guild, member: discord.Member, source: str):
|
||||
"""If NickNudgeCog.start_nick_review is missing, post a simple review to mod channel."""
|
||||
if not self.mod_channel_id:
|
||||
return
|
||||
ch = guild.get_channel(self.mod_channel_id)
|
||||
if not ch:
|
||||
return
|
||||
try:
|
||||
await ch.send(
|
||||
f"📝 **Nickname claim review (fallback)**\n"
|
||||
f"User: {member.mention} (`{member.id}`)\n"
|
||||
f"Source: `{source}`\n"
|
||||
f"Current name: **{self._primary_name(member)}**\n"
|
||||
f"Please verify and handle manually.",
|
||||
allowed_mentions=NO_MENTIONS
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _reconcile_agreements(self, guild: discord.Guild) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Reconcile from live reaction messages.
|
||||
Returns (rules_changed, engage_changed, nick_added_claims, nick_removed_claims).
|
||||
Also opens nickname reviews for any **unreviewed** claimers (startup/offline).
|
||||
"""
|
||||
dm = self.bot.data_manager
|
||||
|
||||
rules_msg = await self._get_message_by_id(guild, self.rules_msg_id)
|
||||
engage_msg = await self._get_message_by_id(guild, self.engage_msg_id)
|
||||
nick_msg = await self._get_message_by_id(guild, self.nick_msg_id)
|
||||
|
||||
new_rules = await self._collect_reactors(rules_msg)
|
||||
new_engage = await self._collect_reactors(engage_msg)
|
||||
new_nick = await self._collect_reactors(nick_msg)
|
||||
|
||||
old_rules = set(dm.get('agreed_rules'))
|
||||
old_engage = set(dm.get('agreed_engagement'))
|
||||
old_nick = set(dm.get('agreed_nickname'))
|
||||
|
||||
rules_added, rules_removed = new_rules - old_rules, old_rules - new_rules
|
||||
engage_added, engage_removed = new_engage - old_engage, old_engage - new_engage
|
||||
nick_added, nick_removed = new_nick - old_nick, old_nick - new_nick
|
||||
|
||||
# Persist live truth for rules/engagement/nickname claim (reaction path)
|
||||
if old_rules != new_rules:
|
||||
dm.remove('agreed_rules', lambda _x: True)
|
||||
for uid in new_rules:
|
||||
dm.add('agreed_rules', int(uid))
|
||||
|
||||
if old_engage != new_engage:
|
||||
dm.remove('agreed_engagement', lambda _x: True)
|
||||
for uid in new_engage:
|
||||
dm.add('agreed_engagement', int(uid))
|
||||
|
||||
if old_nick != new_nick:
|
||||
dm.remove('agreed_nickname', lambda _x: True)
|
||||
for uid in new_nick:
|
||||
dm.add('agreed_nickname', int(uid))
|
||||
|
||||
# Apply/repair roles for Rules/Engagement
|
||||
rules_role = guild.get_role(self.rules_role_id)
|
||||
engage_role = guild.get_role(self.engage_role_id)
|
||||
for uid in (rules_added | rules_removed | engage_added | engage_removed):
|
||||
m = guild.get_member(uid)
|
||||
if not m or m.bot:
|
||||
continue
|
||||
if rules_role:
|
||||
try:
|
||||
if uid in new_rules and rules_role not in m.roles:
|
||||
await m.add_roles(rules_role, reason="Reconcile: reacted to Rules ✅")
|
||||
elif uid not in new_rules and rules_role in m.roles:
|
||||
await m.remove_roles(rules_role, reason="Reconcile: removed Rules ✅")
|
||||
except Exception:
|
||||
pass
|
||||
if engage_role:
|
||||
try:
|
||||
if uid in new_engage and engage_role not in m.roles:
|
||||
await m.add_roles(engage_role, reason="Reconcile: reacted to RoE ✅")
|
||||
elif uid not in new_engage and engage_role in m.roles:
|
||||
await m.remove_roles(engage_role, reason="Reconcile: removed RoE ✅")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- New part: open reviews for *any* unreviewed claimers (startup/offline) ---
|
||||
nn = self.bot.get_cog('NickNudgeCog')
|
||||
|
||||
verified_set = set(dm.get('nick_verified'))
|
||||
pending_set = set(dm.get('nick_claim_pending'))
|
||||
open_review_user_ids = {
|
||||
int(r['user_id'])
|
||||
for r in dm.get('nick_reviews')
|
||||
if r.get('guild_id') == guild.id and r.get('status') == 'pending'
|
||||
}
|
||||
|
||||
# Anyone who currently claims (reacted) but isn't verified/pending/under review
|
||||
need_review = [
|
||||
uid for uid in new_nick
|
||||
if uid not in verified_set and uid not in pending_set and uid not in open_review_user_ids
|
||||
]
|
||||
|
||||
for uid in need_review:
|
||||
dm.add('nick_claim_pending', int(uid))
|
||||
m = guild.get_member(uid)
|
||||
if m and not m.bot:
|
||||
if nn and hasattr(nn, 'start_nick_review'):
|
||||
try:
|
||||
await nn.start_nick_review(guild, m, source="claim_offline")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await self._open_review_fallback(guild, m, source="claim_offline")
|
||||
|
||||
# Anyone who *lost* their reaction -> clear pending/verified
|
||||
for uid in nick_removed:
|
||||
dm.remove('nick_claim_pending', lambda x: x == uid)
|
||||
dm.remove('nick_verified', lambda x: x == uid)
|
||||
|
||||
# Nudge roles/full access via ReactionRoleCog for anyone impacted
|
||||
rr = self.bot.get_cog('ReactionRoleCog')
|
||||
impacted = (rules_added | rules_removed | engage_added | engage_removed |
|
||||
set(need_review) | nick_removed)
|
||||
if rr:
|
||||
for uid in impacted:
|
||||
m = guild.get_member(uid)
|
||||
if m and not m.bot:
|
||||
try:
|
||||
await rr.maybe_apply_full_access(m)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
for uid in impacted:
|
||||
m = guild.get_member(uid)
|
||||
if m and not m.bot:
|
||||
try:
|
||||
await self.refresh_card(m)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (
|
||||
len(rules_added) + len(rules_removed),
|
||||
len(engage_added) + len(engage_removed),
|
||||
len(nick_added),
|
||||
len(nick_removed),
|
||||
)
|
||||
|
||||
# ---------- listeners ----------
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_join(self, member: discord.Member):
|
||||
await self._log(member.guild, f"📝 User joined: {member.mention} (ID: {member.id})")
|
||||
await self.refresh_card(member)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
if before.nick != after.nick or before.roles != after.roles:
|
||||
await self.refresh_card(after)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_user_update(self, before: discord.User, after: discord.User):
|
||||
if before.global_name == after.global_name and before.name == after.name:
|
||||
return
|
||||
for g in self.bot.guilds:
|
||||
m = g.get_member(after.id)
|
||||
if m:
|
||||
try: await self.refresh_card(m)
|
||||
except Exception: pass
|
||||
|
||||
# ---------- periodic + startup ----------
|
||||
|
||||
async def _startup_reconcile(self):
|
||||
await self.bot.wait_until_ready()
|
||||
for g in list(self.bot.guilds):
|
||||
try:
|
||||
await self._reconcile_agreements(g)
|
||||
except Exception:
|
||||
pass
|
||||
for g in list(self.bot.guilds):
|
||||
for m in g.members:
|
||||
if not m.bot:
|
||||
try: await self.refresh_card(m)
|
||||
except Exception: pass
|
||||
|
||||
async def _periodic_refresh(self):
|
||||
await self.bot.wait_until_ready()
|
||||
while not self.bot.is_closed():
|
||||
try:
|
||||
for g in self.bot.guilds:
|
||||
try: await self._reconcile_agreements(g)
|
||||
except Exception: pass
|
||||
for m in g.members:
|
||||
if not m.bot:
|
||||
await self.refresh_card(m)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(12 * 60 * 60) # twice a day
|
||||
|
||||
# ---------- mod command: rescan + live reconcile ----------
|
||||
|
||||
@commands.hybrid_command(
|
||||
name="usercards_rescan",
|
||||
description="Re-check all users and refresh cards (mod-only)"
|
||||
)
|
||||
@commands.has_permissions(manage_guild=True)
|
||||
async def usercards_rescan(self, ctx: commands.Context):
|
||||
g = ctx.guild
|
||||
if not g:
|
||||
return await ctx.reply("Use this in a server.", ephemeral=True)
|
||||
|
||||
rchg, echg, nadd, nrem = await self._reconcile_agreements(g)
|
||||
|
||||
# Rebuild cards
|
||||
updated = 0
|
||||
for m in g.members:
|
||||
if not m.bot:
|
||||
try:
|
||||
await self.refresh_card(m)
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await ctx.reply(
|
||||
f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, "
|
||||
f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. "
|
||||
f"Refreshed cards for **{updated}** members.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(UserCardsCog(bot))
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
discord.py>=2.3.2
|
||||
python-dotenv
|
Loading…
Reference in New Issue
Block a user