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:
Franz Rolfsvaag 2025-08-10 20:23:09 +02:00
parent 9bdb286d38
commit 5368d21be4
11 changed files with 209 additions and 138 deletions

2
bot.py
View File

@ -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; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.3.9.2.a4" VERSION = "0.3.9.2.a5"
# ---------- Env & config loading ---------- # ---------- Env & config loading ----------

View File

@ -2,12 +2,15 @@
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()):
if not tok:
continue
try: try:
ids.append(int(tok)) ids.append(int(tok))
except Exception: except Exception:
@ -15,20 +18,20 @@ def _parse_ids(raw: str):
return ids return ids
def get_mod_role_ids(bot: commands.Bot): def get_mod_role_ids(bot: commands.Bot):
cfg = bot.config['DEFAULT'] # Read from ENV/INI via helper; allow comma-separated lists in any field
# read individually; allow comma-separated in any field for flexibility reader = cfg(bot)
keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"] keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
ids = [] collected = []
for k in keys: for k in keys:
raw = cfg.get(k, "") collected.extend(_parse_ids(reader.get(k, "")))
for tok in re.split(r"[,\s]+", raw.strip()): # dedupe while preserving order
if not tok: seen = set()
continue unique = []
try: for i in collected:
ids.append(int(tok)) if i not in seen:
except Exception: seen.add(i)
pass unique.append(i)
return ids 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):

View File

@ -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,17 +26,17 @@ 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
self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam) self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam)
self._create_lock = asyncio.Lock() self._create_lock = asyncio.Lock()
@ -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'])

View File

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

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

View File

@ -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:
@ -239,7 +245,7 @@ class NickNudgeCog(commands.Cog):
member = guild.get_member(int(review['user_id'])) member = guild.get_member(int(review['user_id']))
if not member: if not member:
# mark closed missing # 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 return
# Fetch and edit the review message content (best-effort) # Fetch and edit the review message content (best-effort)
@ -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))

View File

@ -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 (dont ping) # safe posting (dont 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))

View File

@ -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
try: self.mod_channel = r.int('mod_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 # Threat config
# Defaults if not already present in your earlier version: self.group_threshold = r.int('threat_group_threshold', 3)
self.w_kill = float(cfg.get('threat_w_kill', '0.35'))
self.w_destruction = float(cfg.get('threat_w_destruction', '0.30')) def _f(key: str, default: float) -> float:
self.w_group = float(cfg.get('threat_w_group', '0.20')) try:
self.w_skill = float(cfg.get('threat_w_skill', '0.15')) 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): 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)

View File

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

View File

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

View File

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