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: