diff --git a/bot.py b/bot.py index 727480d..523da3d 100644 --- a/bot.py +++ b/bot.py @@ -6,6 +6,8 @@ from dotenv import load_dotenv from configparser import ConfigParser from data_manager import DataManager import pathlib +import os, asyncio, xml.etree.ElementTree as ET +import aiohttp # ---------- Env & config loading ---------- @@ -35,12 +37,10 @@ def _overlay_env_into_config(cfg: ConfigParser): if not k.startswith('SHAI_'): continue key = k[5:].lower() # drop 'SHAI_' prefix - # normalize common aliases if key == 'data': key = 'data_file' d[key] = str(v) - # If neither env nor file provided data_file, set a safe default if not d.get('data_file', '').strip(): d['data_file'] = '/data/data.json' @@ -92,25 +92,100 @@ async def _guild_selfcheck(g: discord.Guild, cfg): if not getattr(p, perm, False): problems.append(f"Missing permission on #{ch.name}: {perm}") - # Mod channel (for report moderation) _need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history') - # Modlog channel _need_channel('modlog_channel_id', 'read_messages', 'send_messages') - # Pirates list channel _need_channel('pirates_list_channel_id', 'read_messages', 'send_messages') - # Auto-VC category/trigger are handled inside the cog if problems: print(f"[SelfCheck:{g.name}]") for p in problems: print(" -", p) +async def _fetch_latest_from_rss(url: str): + try: + timeout = aiohttp.ClientTimeout(total=8) + async with aiohttp.ClientSession(timeout=timeout) as sess: + async with sess.get(url) as resp: + if resp.status != 200: + return None, None + text = await resp.text() + # Gitea RSS structure: + root = ET.fromstring(text) + item = root.find('./channel/item') + if item is None: + return None, None + title = (item.findtext('title') or '').strip() + link = (item.findtext('link') or '').strip() + # Try to extract short sha from link tail if it's a commit URL + sha = None + if '/commit/' in link: + sha = link.rsplit('/commit/', 1)[-1][:7] + # Many Gitea feeds put the commit subject in + subject = title if title else None + return subject, sha + except Exception: + return None, None + +# ---------- boot notice ---------- + +async def _post_boot_notice(): + # 1) try build files + commit = None + subject = None + try: + with open("/app/.build_commit", "r") as f: + commit = f.read().strip() + except Exception: + pass + try: + with open("/app/.build_subject", "r") as f: + subject = f.read().strip() + except Exception: + pass + + # 2) env fallback + if not commit: + commit = os.getenv("SHAI_BUILD_COMMIT", "").strip() or None + if not subject: + subject = os.getenv("SHAI_BUILD_SUBJECT", "").strip() or None + + # 3) RSS fallback (optional, set SHAI_GIT_RSS to enable; default to your repo) + if (not commit or not subject): + rss_url = os.getenv("SHAI_GIT_RSS", "https://git.rolfsvaag.no/frarol96/shaiwatcher.rss").strip() + if rss_url: + sub2, sha2 = await _fetch_latest_from_rss(rss_url) + subject = subject or sub2 + commit = commit or sha2 + + msg = "Self-update and reboot successful!" + if subject and len(subject) > 5: + msg += f" — {subject}" + if commit: + msg += f" (`{commit}`)" + + ch_id_raw = bot.config['DEFAULT'].get('modlog_channel_id', '') + try: + ch_id = int(ch_id_raw) if ch_id_raw else 0 + except Exception: + ch_id = 0 + if not ch_id: + return + for g in bot.guilds: + ch = g.get_channel(ch_id) + if ch: + try: + await ch.send(msg) + except Exception: + pass + break + +# ---------- events ---------- + @bot.event async def on_ready(): print(f"Logged in as {bot.user} (ID: {bot.user.id})") print("[Intents] members:", bot.intents.members, "/ message_content:", bot.intents.message_content, "/ voice_states:", bot.intents.voice_states) - # Per-guild permission sanity checks (console log) await asyncio.gather(*[_guild_selfcheck(g, bot.config['DEFAULT']) for g in bot.guilds]) # Slash command sync @@ -130,6 +205,9 @@ async def on_ready(): except Exception as e: print("[Slash] Sync failed:", repr(e)) + # Boot notice in modlog + await _post_boot_notice() + # ---------- Auto-discover extensions ---------- modules_path = pathlib.Path(__file__).parent / 'modules' diff --git a/modules/nick_nudge/nick_nudge.py b/modules/nick_nudge/nick_nudge.py index f551223..807771a 100644 --- a/modules/nick_nudge/nick_nudge.py +++ b/modules/nick_nudge/nick_nudge.py @@ -172,7 +172,7 @@ class NickNudgeCog(commands.Cog): @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - # 1) Handle DM nudge confirmations (user reacts ✅ in DM) + # 1) Handle DM nudge confirmations (user reacts with an accept in DM) if payload.guild_id is None and is_accept(payload.emoji) and payload.user_id != self.bot.user.id: entry = next((m for m in self.bot.data_manager.get('nick_dm_map') if m['message_id'] == payload.message_id), None) if not entry: @@ -181,20 +181,21 @@ class NickNudgeCog(commands.Cog): member = guild.get_member(entry['user_id']) if guild else None if not member: return - # Treat as a claim: mark pending + create review + # Treat as a claim: mark pending (idempotent) and open review only on first transition dm = self.bot.data_manager if member.id not in dm.get('agreed_nickname'): dm.add('agreed_nickname', int(member.id)) - # Always (re)mark pending & clear verified dm.remove('nick_verified', lambda x: x == member.id) + newly_pending = False if member.id not in dm.get('nick_claim_pending'): dm.add('nick_claim_pending', int(member.id)) + newly_pending = True - # Create review - try: - await self.start_nick_review(guild, member, source="nick_same") - except Exception: - pass + if newly_pending: + try: + await self.start_nick_review(guild, member, source="nick_same") + except Exception: + pass # Clean map entry self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id) diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index 8b82112..74aa428 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -3,17 +3,15 @@ from discord.ext import commands from modules.common.emoji_accept import is_accept CHECKMARK = '✅' -ACCEPT = {CHECKMARK, '🫡'} class ReactionRoleCog(commands.Cog): """ Records agreements and manages Full Access. - Now integrates nickname *pending/verified* flow: - • Nickname reaction add -> mark agreed + pending, open review via NickNudgeCog - • Nickname reaction remove -> clear agreed/pending/verified and re-check access - • /nick_same -> same as claim (no reaction required) - Full Access: granted when Rules ✅ + RoE ✅ + Nickname **claimed (pending or verified)**. - Revoked only when nickname becomes unclaimed (rejected or unreacted) or when Rules/RoE are missing. + Nickname flow: + • Add accept on nickname message -> mark agreed + pending (idempotent) and open ONE review + • Remove accept on nickname message -> clear only if user has no accept reactions left + Full Access: granted when Rules ✅ + RoE ✅ + Nickname *claimed* (pending or verified). + Revoked when any of the three is missing. """ def __init__(self, bot): @@ -82,6 +80,22 @@ class ReactionRoleCog(commands.Cog): return None return m + async def _user_has_any_accept(self, guild: discord.Guild, channel_id: int, message_id: int, user_id: int) -> bool: + """Return True if the user still has at least one 'accept' reaction on the message.""" + try: + ch = guild.get_channel(channel_id) + if not ch: + return False + msg = await ch.fetch_message(message_id) + for rxn in msg.reactions: + if is_accept(rxn.emoji): + async for u in rxn.users(limit=None): + if u.id == user_id: + return True + return False + except Exception: + return False + # ---- commands (hybrid = prefix + slash) ---- @commands.hybrid_command(name='nick_same', description='Claim that your global display name matches your in-game name (triggers mod review)') async def nick_same(self, ctx: commands.Context): @@ -94,16 +108,19 @@ class ReactionRoleCog(commands.Cog): dm.add('agreed_nickname', int(member.id)) # Mark pending (clear verified if present) dm.remove('nick_verified', lambda x: x == member.id) + newly_pending = False if member.id not in dm.get('nick_claim_pending'): dm.add('nick_claim_pending', int(member.id)) + newly_pending = True - # Open/refresh a review with NickNudge - nn = self.bot.get_cog('NickNudgeCog') - if nn and hasattr(nn, 'start_nick_review'): - try: - await nn.start_nick_review(ctx.guild, member, source="nick_same") - except Exception: - pass + # Open/refresh a review with NickNudge (only on first transition to pending) + if newly_pending: + nn = self.bot.get_cog('NickNudgeCog') + if nn and hasattr(nn, 'start_nick_review'): + try: + await nn.start_nick_review(ctx.guild, member, source="nick_same") + except Exception: + pass await self.maybe_apply_full_access(member) await ctx.reply("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) @@ -135,20 +152,23 @@ class ReactionRoleCog(commands.Cog): dm.add('agreed_engagement', int(member.id)) elif payload.message_id == self.nick_msg_id: - # Claim nickname via reaction -> mark agreed + pending, clear verified + # Claim nickname via reaction -> mark agreed + pending (idempotent) + newly_pending = False if member.id not in dm.get('agreed_nickname'): dm.add('agreed_nickname', int(member.id)) dm.remove('nick_verified', lambda x: x == member.id) if member.id not in dm.get('nick_claim_pending'): dm.add('nick_claim_pending', int(member.id)) + newly_pending = True - # Kick off a review in mod channel - nn = self.bot.get_cog('NickNudgeCog') - if nn and hasattr(nn, 'start_nick_review'): - try: - await nn.start_nick_review(guild, member, source="claim") - except Exception: - pass + # Only open a review when we just transitioned to pending + if newly_pending: + nn = self.bot.get_cog('NickNudgeCog') + if nn and hasattr(nn, 'start_nick_review'): + try: + await nn.start_nick_review(guild, member, source="claim") + except Exception: + pass else: return except Exception: @@ -180,10 +200,14 @@ class ReactionRoleCog(commands.Cog): await member.remove_roles(role, reason="Engagement un-ticked") elif payload.message_id == self.nick_msg_id: - # Un-claim nickname -> clear everything related - dm.remove('agreed_nickname', lambda x: x == member.id) - dm.remove('nick_claim_pending', lambda x: x == member.id) - dm.remove('nick_verified', lambda x: x == member.id) + # Clear only if the user has NO accept reactions left on the message + still_has_accept = await self._user_has_any_accept( + guild, payload.channel_id, payload.message_id, member.id + ) + if not still_has_accept: + dm.remove('agreed_nickname', lambda x: x == member.id) + dm.remove('nick_claim_pending', lambda x: x == member.id) + dm.remove('nick_verified', lambda x: x == member.id) else: return except Exception: