0.3.9.2.a5
performance improvements, stability, and primarily settings-handling improvements. - Due to the container transition, some settings handling became quietly broken or defunct.
This commit is contained in:
		
							parent
							
								
									9bdb286d38
								
							
						
					
					
						commit
						5368d21be4
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								bot.py
									
									
									
									
									
								
							@ -8,7 +8,7 @@ from modules.common.boot_notice import post_boot_notice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Version consists of:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
				
			||||||
VERSION = "0.3.9.2.a4"
 | 
					VERSION = "0.3.9.2.a5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------- Env & config loading ----------
 | 
					# ---------- Env & config loading ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										31
									
								
								mod_perms.py
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								mod_perms.py
									
									
									
									
									
								
							@ -2,26 +2,13 @@
 | 
				
			|||||||
import re
 | 
					import re
 | 
				
			||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
 | 
					from modules.common.settings import cfg  # ENV-first config helper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _parse_ids(raw: str):
 | 
					def _parse_ids(raw: str):
 | 
				
			||||||
    ids = []
 | 
					    ids = []
 | 
				
			||||||
    if not raw:
 | 
					    if not raw:
 | 
				
			||||||
        return ids
 | 
					        return ids
 | 
				
			||||||
    for tok in re.split(r'[,\s]+', raw.strip()):
 | 
					    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:
 | 
					        if not tok:
 | 
				
			||||||
            continue
 | 
					            continue
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
@ -30,6 +17,22 @@ def get_mod_role_ids(bot: commands.Bot):
 | 
				
			|||||||
            pass
 | 
					            pass
 | 
				
			||||||
    return ids
 | 
					    return ids
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_mod_role_ids(bot: commands.Bot):
 | 
				
			||||||
 | 
					    # Read from ENV/INI via helper; allow comma-separated lists in any field
 | 
				
			||||||
 | 
					    reader = cfg(bot)
 | 
				
			||||||
 | 
					    keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
 | 
				
			||||||
 | 
					    collected = []
 | 
				
			||||||
 | 
					    for k in keys:
 | 
				
			||||||
 | 
					        collected.extend(_parse_ids(reader.get(k, "")))
 | 
				
			||||||
 | 
					    # dedupe while preserving order
 | 
				
			||||||
 | 
					    seen = set()
 | 
				
			||||||
 | 
					    unique = []
 | 
				
			||||||
 | 
					    for i in collected:
 | 
				
			||||||
 | 
					        if i not in seen:
 | 
				
			||||||
 | 
					            seen.add(i)
 | 
				
			||||||
 | 
					            unique.append(i)
 | 
				
			||||||
 | 
					    return unique
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool:
 | 
					def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool:
 | 
				
			||||||
    if not isinstance(member, discord.Member):
 | 
					    if not isinstance(member, discord.Member):
 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
 | 
				
			|||||||
@ -3,10 +3,13 @@ import asyncio
 | 
				
			|||||||
import time
 | 
					import time
 | 
				
			||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
 | 
					from modules.common.settings import cfg  # ENV-first config helper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def now() -> float:
 | 
					def now() -> float:
 | 
				
			||||||
    return time.time()
 | 
					    return time.time()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AutoVCCog(commands.Cog):
 | 
					class AutoVCCog(commands.Cog):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Auto-VC:
 | 
					    Auto-VC:
 | 
				
			||||||
@ -23,14 +26,14 @@ class AutoVCCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Config
 | 
					        # Config (ENV/INI via helper; safe defaults)
 | 
				
			||||||
        self.trigger_id = int(cfg['trigger_channel_id'])
 | 
					        self.trigger_id = r.int('trigger_channel_id', 0)
 | 
				
			||||||
        self.category_id = int(cfg['auto_vc_category_id'])
 | 
					        self.category_id = r.int('auto_vc_category_id', 0)
 | 
				
			||||||
        self.prefix = cfg['vc_name_prefix']
 | 
					        self.prefix = r.get('vc_name_prefix', 'Room')
 | 
				
			||||||
        self.delay = int(cfg.get('auto_vc_cleanup_delay', 30))
 | 
					        self.delay = r.int('auto_vc_cleanup_delay', 30)
 | 
				
			||||||
        self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
 | 
					        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # State
 | 
					        # State
 | 
				
			||||||
        self.empty_since: dict[int, float] = {}    # channel_id -> ts when became empty
 | 
					        self.empty_since: dict[int, float] = {}    # channel_id -> ts when became empty
 | 
				
			||||||
@ -92,6 +95,8 @@ class AutoVCCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    async def _cleanup_pass(self, guild: discord.Guild):
 | 
					    async def _cleanup_pass(self, guild: discord.Guild):
 | 
				
			||||||
        """Delete empty tracked channels that exceeded delay and renumber."""
 | 
					        """Delete empty tracked channels that exceeded delay and renumber."""
 | 
				
			||||||
 | 
					        if not self.category_id:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        cat = guild.get_channel(self.category_id)
 | 
					        cat = guild.get_channel(self.category_id)
 | 
				
			||||||
        if not cat:
 | 
					        if not cat:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
@ -148,6 +153,9 @@ class AutoVCCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    async def _spawn_and_move(self, member: discord.Member):
 | 
					    async def _spawn_and_move(self, member: discord.Member):
 | 
				
			||||||
        guild = member.guild
 | 
					        guild = member.guild
 | 
				
			||||||
 | 
					        if not self.category_id:
 | 
				
			||||||
 | 
					            await self._log(guild, "⚠️ auto_vc_category_id not configured; cannot create rooms.")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        cat = guild.get_channel(self.category_id)
 | 
					        cat = guild.get_channel(self.category_id)
 | 
				
			||||||
        if not cat:
 | 
					        if not cat:
 | 
				
			||||||
            await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.")
 | 
					            await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.")
 | 
				
			||||||
@ -195,7 +203,7 @@ class AutoVCCog(commands.Cog):
 | 
				
			|||||||
        guild = member.guild
 | 
					        guild = member.guild
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Create on trigger join (with 5s per-user cooldown)
 | 
					        # Create on trigger join (with 5s per-user cooldown)
 | 
				
			||||||
        if after.channel and after.channel.id == self.trigger_id:
 | 
					        if self.trigger_id and after.channel and after.channel.id == self.trigger_id:
 | 
				
			||||||
            last = self._vc_cooldowns.get(member.id, 0.0)
 | 
					            last = self._vc_cooldowns.get(member.id, 0.0)
 | 
				
			||||||
            if now() - last < 5.0:
 | 
					            if now() - last < 5.0:
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
@ -206,7 +214,7 @@ class AutoVCCog(commands.Cog):
 | 
				
			|||||||
                print("[auto_vc] spawn/move failed:", repr(e))
 | 
					                print("[auto_vc] spawn/move failed:", repr(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Mark empties immediately on leave
 | 
					        # Mark empties immediately on leave
 | 
				
			||||||
        if before.channel:
 | 
					        if before.channel and self.category_id:
 | 
				
			||||||
            ch = before.channel
 | 
					            ch = before.channel
 | 
				
			||||||
            if ch.category_id == self.category_id:
 | 
					            if ch.category_id == self.category_id:
 | 
				
			||||||
                rec = self._find_record(guild.id, ch.id)
 | 
					                rec = self._find_record(guild.id, ch.id)
 | 
				
			||||||
@ -221,7 +229,7 @@ class AutoVCCog(commands.Cog):
 | 
				
			|||||||
        g = ctx.guild
 | 
					        g = ctx.guild
 | 
				
			||||||
        recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
 | 
					        recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
 | 
				
			||||||
        lines = [
 | 
					        lines = [
 | 
				
			||||||
            f"Trigger: <#{self.trigger_id}> | Category: <#{self.category_id}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
 | 
					            f"Trigger: <#{self.trigger_id or 0}> | Category: <#{self.category_id or 0}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        for idx, rec in enumerate(recs, start=1):
 | 
					        for idx, rec in enumerate(recs, start=1):
 | 
				
			||||||
            ch = g.get_channel(rec['channel_id'])
 | 
					            ch = g.get_channel(rec['channel_id'])
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,10 @@
 | 
				
			|||||||
 | 
					# modules/common/boot_notice.py
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import discord
 | 
					import discord
 | 
				
			||||||
import aiohttp
 | 
					import aiohttp
 | 
				
			||||||
import xml.etree.ElementTree as ET
 | 
					import xml.etree.ElementTree as ET
 | 
				
			||||||
 | 
					from modules.common.settings import cfg
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | None]:
 | 
					async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | None]:
 | 
				
			||||||
    """Best-effort: read latest commit subject + short sha from a Gitea RSS feed."""
 | 
					    """Best-effort: read latest commit subject + short sha from a Gitea RSS feed."""
 | 
				
			||||||
@ -23,6 +26,7 @@ async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | Non
 | 
				
			|||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        return None, None
 | 
					        return None, None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def post_boot_notice(bot):
 | 
					async def post_boot_notice(bot):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Posts a boot status message to the configured modlog channel.
 | 
					    Posts a boot status message to the configured modlog channel.
 | 
				
			||||||
@ -53,16 +57,15 @@ async def post_boot_notice(bot):
 | 
				
			|||||||
    if not line:
 | 
					    if not line:
 | 
				
			||||||
        return  # nothing to say
 | 
					        return  # nothing to say
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    # Read modlog channel from ENV/INI via helper
 | 
				
			||||||
        ch_id = int(bot.config['DEFAULT'].get('modlog_channel_id', "0") or 0)
 | 
					    modlog_channel_id = cfg(bot).int('modlog_channel_id', 0)
 | 
				
			||||||
    except Exception:
 | 
					    if not modlog_channel_id:
 | 
				
			||||||
        ch_id = 0
 | 
					 | 
				
			||||||
    if not ch_id:
 | 
					 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Find channel across guilds
 | 
				
			||||||
    ch = None
 | 
					    ch = None
 | 
				
			||||||
    for g in bot.guilds:
 | 
					    for g in bot.guilds:
 | 
				
			||||||
        ch = g.get_channel(ch_id)
 | 
					        ch = g.get_channel(modlog_channel_id)
 | 
				
			||||||
        if ch:
 | 
					        if ch:
 | 
				
			||||||
            break
 | 
					            break
 | 
				
			||||||
    if not ch:
 | 
					    if not ch:
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										34
									
								
								modules/common/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								modules/common/settings.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					# modules/common/settings.py
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ConfigView:
 | 
				
			||||||
 | 
					    """Unified read: ENV first, then optional bot.config['DEFAULT'], then fallback."""
 | 
				
			||||||
 | 
					    def __init__(self, bot):
 | 
				
			||||||
 | 
					        self._env = os.environ
 | 
				
			||||||
 | 
					        self._default = {}
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            self._default = getattr(bot, "config", {}).get("DEFAULT", {}) or {}
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get(self, key: str, default: str = "") -> str:
 | 
				
			||||||
 | 
					        v = self._env.get(key.upper(), "")
 | 
				
			||||||
 | 
					        if not v:
 | 
				
			||||||
 | 
					            v = self._default.get(key, "")
 | 
				
			||||||
 | 
					        v = (v or "").strip().strip('"').strip("'")
 | 
				
			||||||
 | 
					        return v if v else default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def int(self, key: str, default: int = 0) -> int:
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return int(self.get(key, ""))
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            return default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def bool(self, key: str, default: bool = False) -> bool:
 | 
				
			||||||
 | 
					        v = self.get(key, "")
 | 
				
			||||||
 | 
					        if not v:
 | 
				
			||||||
 | 
					            return default
 | 
				
			||||||
 | 
					        return v.lower() in ("1", "true", "yes", "on")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def cfg(bot) -> ConfigView:
 | 
				
			||||||
 | 
					    return ConfigView(bot)
 | 
				
			||||||
@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					# modules/nick_nudge/nick_nudge.py
 | 
				
			||||||
import asyncio
 | 
					import asyncio
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
from typing import Optional, Tuple
 | 
					from typing import Optional, Tuple
 | 
				
			||||||
@ -7,6 +8,7 @@ from discord import app_commands
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from mod_perms import is_moderator_userid
 | 
					from mod_perms import is_moderator_userid
 | 
				
			||||||
from modules.common.emoji_accept import is_accept
 | 
					from modules.common.emoji_accept import is_accept
 | 
				
			||||||
 | 
					from modules.common.settings import cfg  # ENV-first config helper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CHECK = '✅'          # approved/verified
 | 
					CHECK = '✅'          # approved/verified
 | 
				
			||||||
CROSS = '❌'          # reject / no
 | 
					CROSS = '❌'          # reject / no
 | 
				
			||||||
@ -14,12 +16,14 @@ PENDING = '✔️'        # heavy check mark = pending claim
 | 
				
			|||||||
ACCEPT = {CHECK, '🫡'}
 | 
					ACCEPT = {CHECK, '🫡'}
 | 
				
			||||||
NO_MENTIONS = discord.AllowedMentions.none()
 | 
					NO_MENTIONS = discord.AllowedMentions.none()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _ts_rel(ts: Optional[float] = None) -> str:
 | 
					def _ts_rel(ts: Optional[float] = None) -> str:
 | 
				
			||||||
    """Discord relative timestamp like <t:12345:R>."""
 | 
					    """Discord relative timestamp like <t:12345:R>."""
 | 
				
			||||||
    if ts is None:
 | 
					    if ts is None:
 | 
				
			||||||
        ts = time.time()
 | 
					        ts = time.time()
 | 
				
			||||||
    return f"<t:{int(ts)}:R>"
 | 
					    return f"<t:{int(ts)}:R>"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NickNudgeCog(commands.Cog):
 | 
					class NickNudgeCog(commands.Cog):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Handles:
 | 
					    Handles:
 | 
				
			||||||
@ -32,14 +36,14 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
        self.modlog_channel_id = int(cfg['modlog_channel_id'])
 | 
					
 | 
				
			||||||
        self.mod_channel_id = int(cfg['mod_channel_id'])  # same review channel as pirate reports
 | 
					        # Config via helper (ENV -> optional INI fallback)
 | 
				
			||||||
 | 
					        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
				
			||||||
 | 
					        self.mod_channel_id = r.int('mod_channel_id', 0)  # same review channel as pirate reports
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Optional DM nudge loop retained
 | 
					        # Optional DM nudge loop retained
 | 
				
			||||||
        try:
 | 
					        self.loop_enabled = r.bool('nick_nudge_loop_enabled', False)
 | 
				
			||||||
            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
 | 
					        self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---------- utils ----------
 | 
					    # ---------- utils ----------
 | 
				
			||||||
@ -52,6 +56,8 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
            pass
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def _modlog(self, guild: discord.Guild, content: str):
 | 
					    async def _modlog(self, guild: discord.Guild, content: str):
 | 
				
			||||||
 | 
					        if not self.modlog_channel_id:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
					        ch = guild.get_channel(self.modlog_channel_id)
 | 
				
			||||||
        if ch:
 | 
					        if ch:
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
@ -85,7 +91,7 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
        - source: "claim" or "nick_same"
 | 
					        - source: "claim" or "nick_same"
 | 
				
			||||||
        Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
 | 
					        Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if not guild:
 | 
					        if not guild or not self.mod_channel_id:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        # If a pending review already exists for this user in this guild, do nothing
 | 
					        # If a pending review already exists for this user in this guild, do nothing
 | 
				
			||||||
        for r in self.bot.data_manager.get('nick_reviews'):
 | 
					        for r in self.bot.data_manager.get('nick_reviews'):
 | 
				
			||||||
@ -135,12 +141,12 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
        await self.bot.wait_until_ready()
 | 
					        await self.bot.wait_until_ready()
 | 
				
			||||||
        while not self.bot.is_closed():
 | 
					        while not self.bot.is_closed():
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                now = time.time()
 | 
					                now_t = time.time()
 | 
				
			||||||
                for guild in self.bot.guilds:
 | 
					                for guild in self.bot.guilds:
 | 
				
			||||||
                    for member in guild.members:
 | 
					                    for member in guild.members:
 | 
				
			||||||
                        if member.bot or not member.joined_at:
 | 
					                        if member.bot or not member.joined_at:
 | 
				
			||||||
                            continue
 | 
					                            continue
 | 
				
			||||||
                        if (now - member.joined_at.timestamp()) < 24*3600:
 | 
					                        if (now_t - member.joined_at.timestamp()) < 24 * 3600:
 | 
				
			||||||
                            continue
 | 
					                            continue
 | 
				
			||||||
                        # If they already have a server nick OR already claimed/verified, skip nudging
 | 
					                        # If they already have a server nick OR already claimed/verified, skip nudging
 | 
				
			||||||
                        dm = self.bot.data_manager
 | 
					                        dm = self.bot.data_manager
 | 
				
			||||||
@ -162,7 +168,7 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
                                'message_id': int(msg.id),
 | 
					                                'message_id': int(msg.id),
 | 
				
			||||||
                                'user_id': int(member.id),
 | 
					                                'user_id': int(member.id),
 | 
				
			||||||
                                'guild_id': int(guild.id),
 | 
					                                'guild_id': int(guild.id),
 | 
				
			||||||
                                'ts': now
 | 
					                                'ts': now_t
 | 
				
			||||||
                            })
 | 
					                            })
 | 
				
			||||||
                            self.bot.data_manager.add('nick_nudged', int(member.id))
 | 
					                            self.bot.data_manager.add('nick_nudged', int(member.id))
 | 
				
			||||||
                            await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
 | 
					                            await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
 | 
				
			||||||
@ -221,7 +227,7 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        # 2) Handle moderator review reactions in mod channel
 | 
					        # 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.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:
 | 
					            if payload.channel_id != self.mod_channel_id or not self.mod_channel_id:
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
            guild = self.bot.get_guild(payload.guild_id)
 | 
					            guild = self.bot.get_guild(payload.guild_id)
 | 
				
			||||||
            if not guild:
 | 
					            if not guild:
 | 
				
			||||||
@ -276,9 +282,7 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
                        pass
 | 
					                        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                # Modlog
 | 
					                # Modlog
 | 
				
			||||||
                await self._modlog(guild,
 | 
					                await self._modlog(guild, f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}.")
 | 
				
			||||||
                    f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                # Refresh roles / card
 | 
					                # Refresh roles / card
 | 
				
			||||||
                rr = self.bot.get_cog('ReactionRoleCog')
 | 
					                rr = self.bot.get_cog('ReactionRoleCog')
 | 
				
			||||||
@ -313,9 +317,7 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
                    except Exception:
 | 
					                    except Exception:
 | 
				
			||||||
                        pass
 | 
					                        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await self._modlog(guild,
 | 
					                await self._modlog(guild, f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}.")
 | 
				
			||||||
                    f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                # Refresh roles / card
 | 
					                # Refresh roles / card
 | 
				
			||||||
                rr = self.bot.get_cog('ReactionRoleCog')
 | 
					                rr = self.bot.get_cog('ReactionRoleCog')
 | 
				
			||||||
@ -331,5 +333,6 @@ class NickNudgeCog(commands.Cog):
 | 
				
			|||||||
                    except Exception:
 | 
					                    except Exception:
 | 
				
			||||||
                        pass
 | 
					                        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def setup(bot):
 | 
					async def setup(bot):
 | 
				
			||||||
    await bot.add_cog(NickNudgeCog(bot))
 | 
					    await bot.add_cog(NickNudgeCog(bot))
 | 
				
			||||||
 | 
				
			|||||||
@ -3,24 +3,22 @@ import asyncio
 | 
				
			|||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
from datetime import datetime
 | 
					from datetime import datetime
 | 
				
			||||||
from mod_perms import require_mod_ctx  # use your configured moderator roles
 | 
					from mod_perms import require_mod_ctx
 | 
				
			||||||
 | 
					from modules.common.settings import cfg  # ENV-first config helper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PirateCardsCog(commands.Cog):
 | 
					class PirateCardsCog(commands.Cog):
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
        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)
 | 
					        # IDs / config (ENV -> optional INI fallback)
 | 
				
			||||||
        try:
 | 
					        self.pirates_channel_id = r.int('pirates_list_channel_id', 0)
 | 
				
			||||||
            self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
 | 
					        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
				
			||||||
        except Exception:
 | 
					
 | 
				
			||||||
            self.group_threshold = 3
 | 
					        # thresholds / samples (with defaults)
 | 
				
			||||||
        try:
 | 
					        self.group_threshold = r.int('threat_group_threshold', 3)
 | 
				
			||||||
            self.min_samples = int(cfg.get('threat_min_samples_for_stats', '3'))
 | 
					        self.min_samples = r.int('threat_min_samples_for_stats', 3)
 | 
				
			||||||
        except Exception:
 | 
					 | 
				
			||||||
            self.min_samples = 3
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # safe posting (don’t ping)
 | 
					        # safe posting (don’t ping)
 | 
				
			||||||
        self._no_mentions = discord.AllowedMentions.none()
 | 
					        self._no_mentions = discord.AllowedMentions.none()
 | 
				
			||||||
@ -97,7 +95,7 @@ class PirateCardsCog(commands.Cog):
 | 
				
			|||||||
    async def _build_embed(self, pirate: dict) -> discord.Embed:
 | 
					    async def _build_embed(self, pirate: dict) -> discord.Embed:
 | 
				
			||||||
        encs = self._encounters_for(pirate)
 | 
					        encs = self._encounters_for(pirate)
 | 
				
			||||||
        total = len(encs)
 | 
					        total = len(encs)
 | 
				
			||||||
        # guard numeric fields
 | 
					
 | 
				
			||||||
        def _i(v, d=0):
 | 
					        def _i(v, d=0):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                return int(v)
 | 
					                return int(v)
 | 
				
			||||||
@ -222,5 +220,6 @@ class PirateCardsCog(commands.Cog):
 | 
				
			|||||||
        is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
 | 
					        is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
 | 
				
			||||||
        await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
 | 
					        await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def setup(bot):
 | 
					async def setup(bot):
 | 
				
			||||||
    await bot.add_cog(PirateCardsCog(bot))
 | 
					    await bot.add_cog(PirateCardsCog(bot))
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,11 @@
 | 
				
			|||||||
 | 
					# modules/pirate_report/pirate_report.py
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
from datetime import datetime, timezone
 | 
					from datetime import datetime, timezone
 | 
				
			||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
from discord import app_commands
 | 
					from discord import app_commands
 | 
				
			||||||
 | 
					from modules.common.settings import cfg
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from mod_perms import (
 | 
					from mod_perms import (
 | 
				
			||||||
    is_moderator_member,
 | 
					    is_moderator_member,
 | 
				
			||||||
@ -341,20 +343,25 @@ class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
 | 
				
			|||||||
class PirateReportCog(commands.Cog):
 | 
					class PirateReportCog(commands.Cog):
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
        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)
 | 
					        # Channels
 | 
				
			||||||
 | 
					        self.mod_channel = r.int('mod_channel_id', 0)
 | 
				
			||||||
 | 
					        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Threat config
 | 
				
			||||||
 | 
					        self.group_threshold = r.int('threat_group_threshold', 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def _f(key: str, default: float) -> float:
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
            self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
 | 
					                return float(r.get(key, str(default)))
 | 
				
			||||||
            except Exception:
 | 
					            except Exception:
 | 
				
			||||||
            self.group_threshold = 3
 | 
					                return default
 | 
				
			||||||
        # Defaults if not already present in your earlier version:
 | 
					
 | 
				
			||||||
        self.w_kill = float(cfg.get('threat_w_kill', '0.35'))
 | 
					        self.w_kill = _f('threat_w_kill', 0.35)
 | 
				
			||||||
        self.w_destruction = float(cfg.get('threat_w_destruction', '0.30'))
 | 
					        self.w_destruction = _f('threat_w_destruction', 0.30)
 | 
				
			||||||
        self.w_group = float(cfg.get('threat_w_group', '0.20'))
 | 
					        self.w_group = _f('threat_w_group', 0.20)
 | 
				
			||||||
        self.w_skill = float(cfg.get('threat_w_skill', '0.15'))
 | 
					        self.w_skill = _f('threat_w_skill', 0.15)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def _refresh_pirates_list(self, guild: discord.Guild):
 | 
					    async def _refresh_pirates_list(self, guild: discord.Guild):
 | 
				
			||||||
        plist = self.bot.get_cog('PiratesListCog')
 | 
					        plist = self.bot.get_cog('PiratesListCog')
 | 
				
			||||||
@ -362,6 +369,8 @@ class PirateReportCog(commands.Cog):
 | 
				
			|||||||
            await plist.refresh_list(guild)
 | 
					            await plist.refresh_list(guild)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def _modlog(self, guild: discord.Guild, content: str):
 | 
					    async def _modlog(self, guild: discord.Guild, content: str):
 | 
				
			||||||
 | 
					        if not self.modlog_channel_id:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
					        ch = guild.get_channel(self.modlog_channel_id)
 | 
				
			||||||
        if ch:
 | 
					        if ch:
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
@ -563,11 +572,8 @@ async def setup(bot):
 | 
				
			|||||||
    cog = PirateReportCog(bot)
 | 
					    cog = PirateReportCog(bot)
 | 
				
			||||||
    await bot.add_cog(cog)
 | 
					    await bot.add_cog(cog)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    # Register commands either globally or to a specific guild if configured
 | 
				
			||||||
        home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
 | 
					    home_gid = cfg(bot).int('home_guild_id', 0)
 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        home_gid = 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if home_gid:
 | 
					    if home_gid:
 | 
				
			||||||
        guild_obj = discord.Object(id=home_gid)
 | 
					        guild_obj = discord.Object(id=home_gid)
 | 
				
			||||||
        bot.tree.add_command(cog.report, guild=guild_obj)
 | 
					        bot.tree.add_command(cog.report, guild=guild_obj)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,8 @@
 | 
				
			|||||||
 | 
					# modules/reaction_role/reaction_role.py
 | 
				
			||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
from modules.common.emoji_accept import is_accept
 | 
					from modules.common.emoji_accept import is_accept
 | 
				
			||||||
 | 
					from modules.common.settings import cfg  # ENV-first helper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CHECKMARK = '✅'
 | 
					CHECKMARK = '✅'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -16,21 +18,16 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        def _i(key):
 | 
					        # Message + role IDs from ENV/INI (default 0 if unset)
 | 
				
			||||||
            try:
 | 
					        self.rules_msg_id = r.int('rules_message_id', 0)
 | 
				
			||||||
                v = cfg.get(key)
 | 
					        self.engage_msg_id = r.int('engagement_message_id', 0)
 | 
				
			||||||
                return int(v) if v else 0
 | 
					        self.nick_msg_id = r.int('nickname_message_id', 0)
 | 
				
			||||||
            except Exception:
 | 
					 | 
				
			||||||
                return 0
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.rules_msg_id = _i('rules_message_id')
 | 
					        self.rules_role = r.int('rules_role_id', 0)
 | 
				
			||||||
        self.engage_msg_id = _i('engagement_message_id')
 | 
					        self.engage_role = r.int('engagement_role_id', 0)
 | 
				
			||||||
        self.nick_msg_id = _i('nickname_message_id')
 | 
					        self.full_access_role = r.int('full_access_role_id', 0)
 | 
				
			||||||
        self.rules_role = _i('rules_role_id')
 | 
					 | 
				
			||||||
        self.engage_role = _i('engagement_role_id')
 | 
					 | 
				
			||||||
        self.full_access_role = _i('full_access_role_id')
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---- helpers ----
 | 
					    # ---- helpers ----
 | 
				
			||||||
    def _has_rules(self, member_id: int) -> bool:
 | 
					    def _has_rules(self, member_id: int) -> bool:
 | 
				
			||||||
@ -215,5 +212,6 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        await self.maybe_apply_full_access(member)
 | 
					        await self.maybe_apply_full_access(member)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def setup(bot):
 | 
					async def setup(bot):
 | 
				
			||||||
    await bot.add_cog(ReactionRoleCog(bot))
 | 
					    await bot.add_cog(ReactionRoleCog(bot))
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ from typing import List, Dict, Tuple, Optional
 | 
				
			|||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
from discord import app_commands
 | 
					from discord import app_commands
 | 
				
			||||||
 | 
					from modules.common.settings import cfg
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Accept both for backward compatibility; display uses "Refiner"
 | 
					# Accept both for backward compatibility; display uses "Refiner"
 | 
				
			||||||
VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}
 | 
					VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}
 | 
				
			||||||
@ -560,12 +561,12 @@ class _StartView(discord.ui.View):
 | 
				
			|||||||
class SpicePayCog(commands.Cog):
 | 
					class SpicePayCog(commands.Cog):
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        self.sessions: Dict[tuple, Dict] = {}
 | 
					        self-sessions: Dict[tuple, Dict] = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
        def _f(key, default):
 | 
					        def _f(key, default):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                return float(cfg.get(key, str(default)))
 | 
					                return float(r.get(key, str(default)))
 | 
				
			||||||
            except Exception:
 | 
					            except Exception:
 | 
				
			||||||
                return float(default)
 | 
					                return float(default)
 | 
				
			||||||
        self.base_weight = _f('spicepay_base_weight', 25.0)
 | 
					        self.base_weight = _f('spicepay_base_weight', 25.0)
 | 
				
			||||||
@ -575,7 +576,8 @@ class SpicePayCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        def _i(key):
 | 
					        def _i(key):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                return int(cfg.get(key)) if cfg.get(key) else None
 | 
					                v = r.get(key, "")
 | 
				
			||||||
 | 
					                return int(v) if v else None
 | 
				
			||||||
            except Exception:
 | 
					            except Exception:
 | 
				
			||||||
                return None
 | 
					                return None
 | 
				
			||||||
        self.emoji_sand_id = _i('emoji_sand_id')
 | 
					        self.emoji_sand_id = _i('emoji_sand_id')
 | 
				
			||||||
@ -679,7 +681,7 @@ class SpicePayCog(commands.Cog):
 | 
				
			|||||||
            f"- Base weight: **{self.base_weight} × active %**\n"
 | 
					            f"- Base weight: **{self.base_weight} × active %**\n"
 | 
				
			||||||
            f"- Carrier bonus: **+{self.carrier_bonus}**\n"
 | 
					            f"- Carrier bonus: **+{self.carrier_bonus}**\n"
 | 
				
			||||||
            f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
 | 
					            f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
 | 
				
			||||||
            "_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._"
 | 
					            "_Set via environment variables or your INI. Restart the bot after changing._"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        await interaction.response.send_message(txt, ephemeral=True)
 | 
					        await interaction.response.send_message(txt, ephemeral=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -899,11 +901,7 @@ async def setup(bot):
 | 
				
			|||||||
    cog = SpicePayCog(bot)
 | 
					    cog = SpicePayCog(bot)
 | 
				
			||||||
    await bot.add_cog(cog)
 | 
					    await bot.add_cog(cog)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    home_gid = cfg(bot).int('home_guild_id', 0)
 | 
				
			||||||
        home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        home_gid = 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if home_gid:
 | 
					    if home_gid:
 | 
				
			||||||
        guild_obj = discord.Object(id=home_gid)
 | 
					        guild_obj = discord.Object(id=home_gid)
 | 
				
			||||||
        bot.tree.add_command(cog.spicepay, guild=guild_obj)
 | 
					        bot.tree.add_command(cog.spicepay, guild=guild_obj)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,11 @@
 | 
				
			|||||||
 | 
					# modules/user_cards/user_cards.py
 | 
				
			||||||
import asyncio
 | 
					import asyncio
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
from typing import Optional, Set, Tuple
 | 
					from typing import Optional, Set, Tuple
 | 
				
			||||||
import discord
 | 
					import discord
 | 
				
			||||||
from discord.ext import commands
 | 
					from discord.ext import commands
 | 
				
			||||||
from modules.common.emoji_accept import is_accept
 | 
					from modules.common.emoji_accept import is_accept
 | 
				
			||||||
 | 
					from modules.common.settings import cfg  # ENV-first helper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CHECK = '✅'          # verified
 | 
					CHECK = '✅'          # verified
 | 
				
			||||||
CROSS = '❌'          # not done
 | 
					CROSS = '❌'          # not done
 | 
				
			||||||
@ -11,6 +13,7 @@ PENDING = '✔️'        # claimed / pending review
 | 
				
			|||||||
ACCEPT = {CHECK, '🫡'}
 | 
					ACCEPT = {CHECK, '🫡'}
 | 
				
			||||||
NO_MENTIONS = discord.AllowedMentions.none()
 | 
					NO_MENTIONS = discord.AllowedMentions.none()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserCardsCog(commands.Cog):
 | 
					class UserCardsCog(commands.Cog):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Per-user status cards with live reconcile and offline review triggers.
 | 
					    Per-user status cards with live reconcile and offline review triggers.
 | 
				
			||||||
@ -25,34 +28,35 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, bot):
 | 
					    def __init__(self, bot):
 | 
				
			||||||
        self.bot = bot
 | 
					        self.bot = bot
 | 
				
			||||||
        cfg = bot.config['DEFAULT']
 | 
					        r = cfg(bot)
 | 
				
			||||||
        self.userslist_channel_id = int(cfg['userslist_channel_id'])
 | 
					
 | 
				
			||||||
        self.modlog_channel_id = int(cfg['modlog_channel_id'])
 | 
					        # Channels / IDs from ENV/INI
 | 
				
			||||||
        self.mod_channel_id = int(cfg.get('mod_channel_id', '0') or 0)
 | 
					        self.userslist_channel_id = r.int('userslist_channel_id', 0)
 | 
				
			||||||
 | 
					        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
				
			||||||
 | 
					        self.mod_channel_id = r.int('mod_channel_id', 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # reaction-role authoritative messages/roles
 | 
					        # reaction-role authoritative messages/roles
 | 
				
			||||||
        self.rules_msg_id = int(cfg['rules_message_id'])
 | 
					        self.rules_msg_id = r.int('rules_message_id', 0)
 | 
				
			||||||
        self.engage_msg_id = int(cfg['engagement_message_id'])
 | 
					        self.engage_msg_id = r.int('engagement_message_id', 0)
 | 
				
			||||||
        self.nick_msg_id = int(cfg['nickname_message_id'])
 | 
					        self.nick_msg_id = r.int('nickname_message_id', 0)
 | 
				
			||||||
        self.rules_role_id = int(cfg['rules_role_id'])
 | 
					        self.rules_role_id = r.int('rules_role_id', 0)
 | 
				
			||||||
        self.engage_role_id = int(cfg['engagement_role_id'])
 | 
					        self.engage_role_id = r.int('engagement_role_id', 0)
 | 
				
			||||||
        self.full_access_role_id = int(cfg['full_access_role_id'])
 | 
					        self.full_access_role_id = r.int('full_access_role_id', 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self._refresh_locks = {}  # per-user locks to avoid racey double-posts
 | 
					        self._refresh_locks = {}  # per-user locks to avoid racey double-posts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Optional periodic refresh (twice a day)
 | 
					        # Optional periodic refresh (twice a day)
 | 
				
			||||||
        try:
 | 
					        self.cron_enabled = r.bool('user_cards_cron_enabled', False)
 | 
				
			||||||
            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._cron_task = asyncio.create_task(self._periodic_refresh()) if self.cron_enabled else None
 | 
				
			||||||
        self._startup_task = asyncio.create_task(self._startup_reconcile())
 | 
					        self._startup_task = asyncio.create_task(self._startup_reconcile())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def cog_unload(self):
 | 
					    def cog_unload(self):
 | 
				
			||||||
        for t in (self._cron_task, self._startup_task):
 | 
					        for t in (self._cron_task, self._startup_task):
 | 
				
			||||||
            if t:
 | 
					            if t:
 | 
				
			||||||
                try: t.cancel()
 | 
					                try:
 | 
				
			||||||
                except Exception: pass
 | 
					                    t.cancel()
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---------- status helpers ----------
 | 
					    # ---------- status helpers ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -126,7 +130,7 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        if member.avatar:
 | 
					        if member.avatar:
 | 
				
			||||||
            embed.set_thumbnail(url=member.avatar.url)
 | 
					            embed.set_thumbnail(url=member.avatar.url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # NEW: stable identity so we can find/edit the right card later
 | 
					        # Stable identity so we can find/edit the right card later
 | 
				
			||||||
        embed.set_footer(text=f"UID:{member.id}")
 | 
					        embed.set_footer(text=f"UID:{member.id}")
 | 
				
			||||||
        return embed
 | 
					        return embed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -137,7 +141,7 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        2) If not found, search the channel by footer marker and edit that.
 | 
					        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.
 | 
					        3) If still not found, post a new one, then delete any stragglers with the same marker.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if not member or not member.guild:
 | 
					        if not member or not member.guild or not self.userslist_channel_id:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        async with self._lock_for(member.id):
 | 
					        async with self._lock_for(member.id):
 | 
				
			||||||
@ -183,11 +187,13 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # 4) Post fresh card
 | 
					            # 4) Post fresh card
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
                new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
 | 
					                new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # 5) Clean up any other messages that look like this user's card
 | 
					            # 5) Clean up any other messages that look like this user's card
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                # Find any *other* occurrences with the same footer marker and delete them
 | 
					 | 
				
			||||||
                marker = f"UID:{member.id}"
 | 
					                marker = f"UID:{member.id}"
 | 
				
			||||||
                async for m in channel.history(limit=400, oldest_first=False):
 | 
					                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:
 | 
					                    if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds:
 | 
				
			||||||
@ -229,10 +235,14 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        return None
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def _log(self, guild: discord.Guild, content: str):
 | 
					    async def _log(self, guild: discord.Guild, content: str):
 | 
				
			||||||
 | 
					        if not self.modlog_channel_id:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
					        ch = guild.get_channel(self.modlog_channel_id)
 | 
				
			||||||
        if ch:
 | 
					        if ch:
 | 
				
			||||||
            try: await ch.send(content, allowed_mentions=NO_MENTIONS)
 | 
					            try:
 | 
				
			||||||
            except Exception: pass
 | 
					                await ch.send(content, allowed_mentions=NO_MENTIONS)
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                pass
 | 
				
			||||||
        self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content})
 | 
					        self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---------- RR message lookup & reactor collection ----------
 | 
					    # ---------- RR message lookup & reactor collection ----------
 | 
				
			||||||
@ -249,6 +259,8 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_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]:
 | 
					    async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]:
 | 
				
			||||||
 | 
					        if not message_id:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
        ch_id = self._get_cached_msg_channel_id(guild.id, message_id)
 | 
					        ch_id = self._get_cached_msg_channel_id(guild.id, message_id)
 | 
				
			||||||
        if ch_id:
 | 
					        if ch_id:
 | 
				
			||||||
            ch = guild.get_channel(ch_id)
 | 
					            ch = guild.get_channel(ch_id)
 | 
				
			||||||
@ -362,7 +374,7 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
                except Exception:
 | 
					                except Exception:
 | 
				
			||||||
                    pass
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # --- New part: open reviews for *any* unreviewed claimers (startup/offline) ---
 | 
					        # --- Open reviews for *any* unreviewed claimers (startup/offline) ---
 | 
				
			||||||
        nn = self.bot.get_cog('NickNudgeCog')
 | 
					        nn = self.bot.get_cog('NickNudgeCog')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        verified_set = set(dm.get('nick_verified'))
 | 
					        verified_set = set(dm.get('nick_verified'))
 | 
				
			||||||
@ -431,7 +443,7 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        await self._log(member.guild, f"📝 User joined: {member.mention} (ID: {member.id})")
 | 
					        await self._log(member.guild, f"📝 User joined: {member.mention} (ID: {member.id})")
 | 
				
			||||||
        await self.refresh_card(member)
 | 
					        await self.refresh_card(member)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @commands.Cog.listener()
 | 
					    @commands.Cog.listener())
 | 
				
			||||||
    async def on_member_update(self, before: discord.Member, after: discord.Member):
 | 
					    async def on_member_update(self, before: discord.Member, after: discord.Member):
 | 
				
			||||||
        if before.nick != after.nick or before.roles != after.roles:
 | 
					        if before.nick != after.nick or before.roles != after.roles:
 | 
				
			||||||
            await self.refresh_card(after)
 | 
					            await self.refresh_card(after)
 | 
				
			||||||
@ -443,8 +455,10 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        for g in self.bot.guilds:
 | 
					        for g in self.bot.guilds:
 | 
				
			||||||
            m = g.get_member(after.id)
 | 
					            m = g.get_member(after.id)
 | 
				
			||||||
            if m:
 | 
					            if m:
 | 
				
			||||||
                try: await self.refresh_card(m)
 | 
					                try:
 | 
				
			||||||
                except Exception: pass
 | 
					                    await self.refresh_card(m)
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---------- periodic + startup ----------
 | 
					    # ---------- periodic + startup ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -458,16 +472,20 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
        for g in list(self.bot.guilds):
 | 
					        for g in list(self.bot.guilds):
 | 
				
			||||||
            for m in g.members:
 | 
					            for m in g.members:
 | 
				
			||||||
                if not m.bot:
 | 
					                if not m.bot:
 | 
				
			||||||
                    try: await self.refresh_card(m)
 | 
					                    try:
 | 
				
			||||||
                    except Exception: pass
 | 
					                        await self.refresh_card(m)
 | 
				
			||||||
 | 
					                    except Exception:
 | 
				
			||||||
 | 
					                        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def _periodic_refresh(self):
 | 
					    async def _periodic_refresh(self):
 | 
				
			||||||
        await self.bot.wait_until_ready()
 | 
					        await self.bot.wait_until_ready()
 | 
				
			||||||
        while not self.bot.is_closed():
 | 
					        while not self.bot.is_closed():
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                for g in self.bot.guilds:
 | 
					                for g in self.bot.guilds:
 | 
				
			||||||
                    try: await self._reconcile_agreements(g)
 | 
					                    try:
 | 
				
			||||||
                    except Exception: pass
 | 
					                        await self._reconcile_agreements(g)
 | 
				
			||||||
 | 
					                    except Exception:
 | 
				
			||||||
 | 
					                        pass
 | 
				
			||||||
                    for m in g.members:
 | 
					                    for m in g.members:
 | 
				
			||||||
                        if not m.bot:
 | 
					                        if not m.bot:
 | 
				
			||||||
                            await self.refresh_card(m)
 | 
					                            await self.refresh_card(m)
 | 
				
			||||||
@ -506,5 +524,6 @@ class UserCardsCog(commands.Cog):
 | 
				
			|||||||
            ephemeral=True
 | 
					            ephemeral=True
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def setup(bot):
 | 
					async def setup(bot):
 | 
				
			||||||
    await bot.add_cog(UserCardsCog(bot))
 | 
					    await bot.add_cog(UserCardsCog(bot))
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user