From 5368d21be4c91d22145d30700092f80f50d39812 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 10 Aug 2025 20:23:09 +0200 Subject: [PATCH] 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. --- bot.py | 2 +- mod_perms.py | 27 +++++---- modules/auto_vc/auto_vc.py | 30 ++++++---- modules/common/boot_notice.py | 15 +++-- modules/common/settings.py | 34 +++++++++++ modules/nick_nudge/nick_nudge.py | 43 ++++++------- modules/pirate_cards/pirate_cards.py | 27 ++++----- modules/pirate_report/pirate_report.py | 44 ++++++++------ modules/reaction_role/reaction_role.py | 24 ++++---- modules/spicepay/spicepay.py | 18 +++--- modules/user_cards/user_cards.py | 83 ++++++++++++++++---------- 11 files changed, 209 insertions(+), 138 deletions(-) create mode 100644 modules/common/settings.py diff --git a/bot.py b/bot.py index c28da6d..927b27d 100644 --- a/bot.py +++ b/bot.py @@ -8,7 +8,7 @@ from modules.common.boot_notice import post_boot_notice # Version consists of: # 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 ---------- diff --git a/mod_perms.py b/mod_perms.py index 7c3b7d0..8b8a76d 100644 --- a/mod_perms.py +++ b/mod_perms.py @@ -2,12 +2,15 @@ import re import discord from discord.ext import commands +from modules.common.settings import cfg # ENV-first config helper def _parse_ids(raw: str): ids = [] if not raw: return ids for tok in re.split(r'[,\s]+', raw.strip()): + if not tok: + continue try: ids.append(int(tok)) except Exception: @@ -15,20 +18,20 @@ def _parse_ids(raw: str): return ids def get_mod_role_ids(bot: commands.Bot): - cfg = bot.config['DEFAULT'] - # read individually; allow comma-separated in any field for flexibility + # 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"] - ids = [] + collected = [] 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 + 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: if not isinstance(member, discord.Member): diff --git a/modules/auto_vc/auto_vc.py b/modules/auto_vc/auto_vc.py index 176b4c3..41aa9fd 100644 --- a/modules/auto_vc/auto_vc.py +++ b/modules/auto_vc/auto_vc.py @@ -3,10 +3,13 @@ import asyncio import time import discord from discord.ext import commands +from modules.common.settings import cfg # ENV-first config helper + def now() -> float: return time.time() + class AutoVCCog(commands.Cog): """ Auto-VC: @@ -23,17 +26,17 @@ class AutoVCCog(commands.Cog): def __init__(self, bot): self.bot = bot - cfg = bot.config['DEFAULT'] + r = cfg(bot) - # 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 + # Config (ENV/INI via helper; safe defaults) + self.trigger_id = r.int('trigger_channel_id', 0) + self.category_id = r.int('auto_vc_category_id', 0) + self.prefix = r.get('vc_name_prefix', 'Room') + self.delay = r.int('auto_vc_cleanup_delay', 30) + self.modlog_channel_id = r.int('modlog_channel_id', 0) # 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 self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam) self._create_lock = asyncio.Lock() @@ -92,6 +95,8 @@ class AutoVCCog(commands.Cog): async def _cleanup_pass(self, guild: discord.Guild): """Delete empty tracked channels that exceeded delay and renumber.""" + if not self.category_id: + return cat = guild.get_channel(self.category_id) if not cat: return @@ -148,6 +153,9 @@ class AutoVCCog(commands.Cog): async def _spawn_and_move(self, member: discord.Member): 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) if not cat: await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.") @@ -195,7 +203,7 @@ class AutoVCCog(commands.Cog): guild = member.guild # 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) if now() - last < 5.0: return @@ -206,7 +214,7 @@ class AutoVCCog(commands.Cog): print("[auto_vc] spawn/move failed:", repr(e)) # Mark empties immediately on leave - if before.channel: + if before.channel and self.category_id: ch = before.channel if ch.category_id == self.category_id: rec = self._find_record(guild.id, ch.id) @@ -221,7 +229,7 @@ class AutoVCCog(commands.Cog): 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" + 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): ch = g.get_channel(rec['channel_id']) diff --git a/modules/common/boot_notice.py b/modules/common/boot_notice.py index 2dd27bb..dbe6117 100644 --- a/modules/common/boot_notice.py +++ b/modules/common/boot_notice.py @@ -1,7 +1,10 @@ +# modules/common/boot_notice.py import os import discord import aiohttp 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]: """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: return None, None + async def post_boot_notice(bot): """ Posts a boot status message to the configured modlog channel. @@ -53,16 +57,15 @@ async def post_boot_notice(bot): if not line: return # nothing to say - try: - ch_id = int(bot.config['DEFAULT'].get('modlog_channel_id', "0") or 0) - except Exception: - ch_id = 0 - if not ch_id: + # Read modlog channel from ENV/INI via helper + modlog_channel_id = cfg(bot).int('modlog_channel_id', 0) + if not modlog_channel_id: return + # Find channel across guilds ch = None for g in bot.guilds: - ch = g.get_channel(ch_id) + ch = g.get_channel(modlog_channel_id) if ch: break if not ch: diff --git a/modules/common/settings.py b/modules/common/settings.py new file mode 100644 index 0000000..2bc291c --- /dev/null +++ b/modules/common/settings.py @@ -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) diff --git a/modules/nick_nudge/nick_nudge.py b/modules/nick_nudge/nick_nudge.py index ca1e19f..740bdfd 100644 --- a/modules/nick_nudge/nick_nudge.py +++ b/modules/nick_nudge/nick_nudge.py @@ -1,3 +1,4 @@ +# modules/nick_nudge/nick_nudge.py import asyncio import time from typing import Optional, Tuple @@ -7,19 +8,22 @@ from discord import app_commands from mod_perms import is_moderator_userid from modules.common.emoji_accept import is_accept +from modules.common.settings import cfg # ENV-first config helper CHECK = '✅' # approved/verified CROSS = '❌' # reject / no PENDING = '✔️' # heavy check mark = pending claim -ACCEPT = {CHECK, '🫡'} +ACCEPT = {CHECK, '🫡'} NO_MENTIONS = discord.AllowedMentions.none() + def _ts_rel(ts: Optional[float] = None) -> str: """Discord relative timestamp like .""" if ts is None: ts = time.time() return f"" + class NickNudgeCog(commands.Cog): """ Handles: @@ -32,14 +36,14 @@ class NickNudgeCog(commands.Cog): 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 + r = cfg(bot) + + # 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 - try: - self.loop_enabled = cfg.getboolean('nick_nudge_loop_enabled') - except Exception: - self.loop_enabled = False + self.loop_enabled = r.bool('nick_nudge_loop_enabled', False) self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None # ---------- utils ---------- @@ -52,6 +56,8 @@ class NickNudgeCog(commands.Cog): pass 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: @@ -85,7 +91,7 @@ class NickNudgeCog(commands.Cog): - source: "claim" or "nick_same" 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 # If a pending review already exists for this user in this guild, do nothing for r in self.bot.data_manager.get('nick_reviews'): @@ -135,12 +141,12 @@ class NickNudgeCog(commands.Cog): await self.bot.wait_until_ready() while not self.bot.is_closed(): try: - now = time.time() + now_t = 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: + if (now_t - 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 @@ -162,7 +168,7 @@ class NickNudgeCog(commands.Cog): 'message_id': int(msg.id), 'user_id': int(member.id), 'guild_id': int(guild.id), - 'ts': now + 'ts': now_t }) self.bot.data_manager.add('nick_nudged', int(member.id)) 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 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 guild = self.bot.get_guild(payload.guild_id) if not guild: @@ -239,7 +245,7 @@ class NickNudgeCog(commands.Cog): 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]) + 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) @@ -276,9 +282,7 @@ class NickNudgeCog(commands.Cog): pass # Modlog - await self._modlog(guild, - f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}." - ) + 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') @@ -313,9 +317,7 @@ class NickNudgeCog(commands.Cog): except Exception: pass - await self._modlog(guild, - f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}." - ) + 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') @@ -331,5 +333,6 @@ class NickNudgeCog(commands.Cog): except Exception: pass + async def setup(bot): await bot.add_cog(NickNudgeCog(bot)) diff --git a/modules/pirate_cards/pirate_cards.py b/modules/pirate_cards/pirate_cards.py index 7e87798..37088b7 100644 --- a/modules/pirate_cards/pirate_cards.py +++ b/modules/pirate_cards/pirate_cards.py @@ -3,24 +3,22 @@ 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 +from mod_perms import require_mod_ctx +from modules.common.settings import cfg # ENV-first config helper + 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 + r = cfg(bot) - # 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 + # IDs / config (ENV -> optional INI fallback) + self.pirates_channel_id = r.int('pirates_list_channel_id', 0) + self.modlog_channel_id = r.int('modlog_channel_id', 0) + + # thresholds / samples (with defaults) + self.group_threshold = r.int('threat_group_threshold', 3) + self.min_samples = r.int('threat_min_samples_for_stats', 3) # safe posting (don’t ping) self._no_mentions = discord.AllowedMentions.none() @@ -97,7 +95,7 @@ class PirateCardsCog(commands.Cog): 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) @@ -222,5 +220,6 @@ class PirateCardsCog(commands.Cog): is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash) + async def setup(bot): await bot.add_cog(PirateCardsCog(bot)) diff --git a/modules/pirate_report/pirate_report.py b/modules/pirate_report/pirate_report.py index 21e2749..560d93a 100644 --- a/modules/pirate_report/pirate_report.py +++ b/modules/pirate_report/pirate_report.py @@ -1,9 +1,11 @@ +# modules/pirate_report/pirate_report.py import re import time from datetime import datetime, timezone import discord from discord.ext import commands from discord import app_commands +from modules.common.settings import cfg from mod_perms import ( is_moderator_member, @@ -341,20 +343,25 @@ class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"): 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']) + r = cfg(bot) - # 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')) + # Channels + self.mod_channel = r.int('mod_channel_id', 0) + self.modlog_channel_id = r.int('modlog_channel_id', 0) + + # Threat config + self.group_threshold = r.int('threat_group_threshold', 3) + + def _f(key: str, default: float) -> float: + try: + return float(r.get(key, str(default))) + except Exception: + return default + + self.w_kill = _f('threat_w_kill', 0.35) + self.w_destruction = _f('threat_w_destruction', 0.30) + self.w_group = _f('threat_w_group', 0.20) + self.w_skill = _f('threat_w_skill', 0.15) async def _refresh_pirates_list(self, guild: discord.Guild): plist = self.bot.get_cog('PiratesListCog') @@ -362,6 +369,8 @@ class PirateReportCog(commands.Cog): await plist.refresh_list(guild) async def _modlog(self, guild: discord.Guild, content: str): + if not self.modlog_channel_id: + return ch = guild.get_channel(self.modlog_channel_id) if ch: try: @@ -563,11 +572,8 @@ 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 - + # Register commands either globally or to a specific guild if configured + home_gid = cfg(bot).int('home_guild_id', 0) if home_gid: guild_obj = discord.Object(id=home_gid) bot.tree.add_command(cog.report, guild=guild_obj) @@ -576,4 +582,4 @@ async def setup(bot): else: bot.tree.add_command(cog.report) bot.tree.add_command(cog.edit_pirate) - bot.tree.add_command(cog.encounter) \ No newline at end of file + bot.tree.add_command(cog.encounter) diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index 74aa428..34f0fe9 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -1,6 +1,8 @@ +# modules/reaction_role/reaction_role.py import discord from discord.ext import commands from modules.common.emoji_accept import is_accept +from modules.common.settings import cfg # ENV-first helper CHECKMARK = '✅' @@ -16,21 +18,16 @@ class ReactionRoleCog(commands.Cog): def __init__(self, bot): self.bot = bot - cfg = bot.config['DEFAULT'] + r = cfg(bot) - def _i(key): - try: - v = cfg.get(key) - return int(v) if v else 0 - except Exception: - return 0 + # Message + role IDs from ENV/INI (default 0 if unset) + self.rules_msg_id = r.int('rules_message_id', 0) + self.engage_msg_id = r.int('engagement_message_id', 0) + self.nick_msg_id = r.int('nickname_message_id', 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') + self.rules_role = r.int('rules_role_id', 0) + self.engage_role = r.int('engagement_role_id', 0) + self.full_access_role = r.int('full_access_role_id', 0) # ---- helpers ---- def _has_rules(self, member_id: int) -> bool: @@ -215,5 +212,6 @@ class ReactionRoleCog(commands.Cog): await self.maybe_apply_full_access(member) + async def setup(bot): await bot.add_cog(ReactionRoleCog(bot)) diff --git a/modules/spicepay/spicepay.py b/modules/spicepay/spicepay.py index b7fdc25..1e84407 100644 --- a/modules/spicepay/spicepay.py +++ b/modules/spicepay/spicepay.py @@ -6,6 +6,7 @@ from typing import List, Dict, Tuple, Optional import discord from discord.ext import commands from discord import app_commands +from modules.common.settings import cfg # Accept both for backward compatibility; display uses "Refiner" VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"} @@ -560,12 +561,12 @@ class _StartView(discord.ui.View): class SpicePayCog(commands.Cog): def __init__(self, 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): try: - return float(cfg.get(key, str(default))) + return float(r.get(key, str(default))) except Exception: return float(default) self.base_weight = _f('spicepay_base_weight', 25.0) @@ -575,7 +576,8 @@ class SpicePayCog(commands.Cog): def _i(key): 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: return None 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"- 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._" + "_Set via environment variables or your INI. Restart the bot after changing._" ) await interaction.response.send_message(txt, ephemeral=True) @@ -899,11 +901,7 @@ 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 - + home_gid = cfg(bot).int('home_guild_id', 0) if home_gid: guild_obj = discord.Object(id=home_gid) bot.tree.add_command(cog.spicepay, guild=guild_obj) diff --git a/modules/user_cards/user_cards.py b/modules/user_cards/user_cards.py index 287be69..1e3d786 100644 --- a/modules/user_cards/user_cards.py +++ b/modules/user_cards/user_cards.py @@ -1,9 +1,11 @@ +# modules/user_cards/user_cards.py import asyncio import time from typing import Optional, Set, Tuple import discord from discord.ext import commands from modules.common.emoji_accept import is_accept +from modules.common.settings import cfg # ENV-first helper CHECK = '✅' # verified CROSS = '❌' # not done @@ -11,6 +13,7 @@ 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. @@ -25,34 +28,35 @@ class UserCardsCog(commands.Cog): 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) + r = cfg(bot) + + # Channels / IDs from ENV/INI + 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 - 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.rules_msg_id = r.int('rules_message_id', 0) + self.engage_msg_id = r.int('engagement_message_id', 0) + self.nick_msg_id = r.int('nickname_message_id', 0) + self.rules_role_id = r.int('rules_role_id', 0) + self.engage_role_id = r.int('engagement_role_id', 0) + self.full_access_role_id = r.int('full_access_role_id', 0) + 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_enabled = r.bool('user_cards_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 + try: + t.cancel() + except Exception: + pass # ---------- status helpers ---------- @@ -126,7 +130,7 @@ class UserCardsCog(commands.Cog): if member.avatar: 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}") return embed @@ -137,7 +141,7 @@ class UserCardsCog(commands.Cog): 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: + if not member or not member.guild or not self.userslist_channel_id: return async with self._lock_for(member.id): @@ -183,11 +187,13 @@ class UserCardsCog(commands.Cog): return # 4) Post fresh card - new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS) + try: + 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 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: @@ -211,7 +217,7 @@ class UserCardsCog(commands.Cog): 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}" @@ -229,10 +235,14 @@ class UserCardsCog(commands.Cog): return None async def _log(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=NO_MENTIONS) - except Exception: pass + 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 ---------- @@ -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)}) 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) if ch_id: ch = guild.get_channel(ch_id) @@ -362,7 +374,7 @@ class UserCardsCog(commands.Cog): except Exception: 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') 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.refresh_card(member) - @commands.Cog.listener() + @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) @@ -443,8 +455,10 @@ class UserCardsCog(commands.Cog): for g in self.bot.guilds: m = g.get_member(after.id) if m: - try: await self.refresh_card(m) - except Exception: pass + try: + await self.refresh_card(m) + except Exception: + pass # ---------- periodic + startup ---------- @@ -458,16 +472,20 @@ class UserCardsCog(commands.Cog): 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 + 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 + try: + await self._reconcile_agreements(g) + except Exception: + pass for m in g.members: if not m.bot: await self.refresh_card(m) @@ -506,5 +524,6 @@ class UserCardsCog(commands.Cog): ephemeral=True ) + async def setup(bot): await bot.add_cog(UserCardsCog(bot))