Added experimental features related to self-startup, updates fetching, and simple status messages
This commit is contained in:
parent
40f4e6e499
commit
25b4e88a4b
92
bot.py
92
bot.py
@ -6,6 +6,8 @@ from dotenv import load_dotenv
|
|||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
from data_manager import DataManager
|
from data_manager import DataManager
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import os, asyncio, xml.etree.ElementTree as ET
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
# ---------- Env & config loading ----------
|
# ---------- Env & config loading ----------
|
||||||
|
|
||||||
@ -35,12 +37,10 @@ def _overlay_env_into_config(cfg: ConfigParser):
|
|||||||
if not k.startswith('SHAI_'):
|
if not k.startswith('SHAI_'):
|
||||||
continue
|
continue
|
||||||
key = k[5:].lower() # drop 'SHAI_' prefix
|
key = k[5:].lower() # drop 'SHAI_' prefix
|
||||||
# normalize common aliases
|
|
||||||
if key == 'data':
|
if key == 'data':
|
||||||
key = 'data_file'
|
key = 'data_file'
|
||||||
d[key] = str(v)
|
d[key] = str(v)
|
||||||
|
|
||||||
# If neither env nor file provided data_file, set a safe default
|
|
||||||
if not d.get('data_file', '').strip():
|
if not d.get('data_file', '').strip():
|
||||||
d['data_file'] = '/data/data.json'
|
d['data_file'] = '/data/data.json'
|
||||||
|
|
||||||
@ -92,25 +92,100 @@ async def _guild_selfcheck(g: discord.Guild, cfg):
|
|||||||
if not getattr(p, perm, False):
|
if not getattr(p, perm, False):
|
||||||
problems.append(f"Missing permission on #{ch.name}: {perm}")
|
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')
|
_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')
|
_need_channel('modlog_channel_id', 'read_messages', 'send_messages')
|
||||||
# Pirates list channel
|
|
||||||
_need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
|
_need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
|
||||||
# Auto-VC category/trigger are handled inside the cog
|
|
||||||
|
|
||||||
if problems:
|
if problems:
|
||||||
print(f"[SelfCheck:{g.name}]")
|
print(f"[SelfCheck:{g.name}]")
|
||||||
for p in problems:
|
for p in problems:
|
||||||
print(" -", p)
|
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: <rss><channel><item>…</item></channel></rss>
|
||||||
|
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 <title>
|
||||||
|
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
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
|
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)
|
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])
|
await asyncio.gather(*[_guild_selfcheck(g, bot.config['DEFAULT']) for g in bot.guilds])
|
||||||
|
|
||||||
# Slash command sync
|
# Slash command sync
|
||||||
@ -130,6 +205,9 @@ async def on_ready():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[Slash] Sync failed:", repr(e))
|
print("[Slash] Sync failed:", repr(e))
|
||||||
|
|
||||||
|
# Boot notice in modlog
|
||||||
|
await _post_boot_notice()
|
||||||
|
|
||||||
# ---------- Auto-discover extensions ----------
|
# ---------- Auto-discover extensions ----------
|
||||||
|
|
||||||
modules_path = pathlib.Path(__file__).parent / 'modules'
|
modules_path = pathlib.Path(__file__).parent / 'modules'
|
||||||
|
@ -172,7 +172,7 @@ class NickNudgeCog(commands.Cog):
|
|||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
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:
|
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)
|
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:
|
if not entry:
|
||||||
@ -181,20 +181,21 @@ class NickNudgeCog(commands.Cog):
|
|||||||
member = guild.get_member(entry['user_id']) if guild else None
|
member = guild.get_member(entry['user_id']) if guild else None
|
||||||
if not member:
|
if not member:
|
||||||
return
|
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
|
dm = self.bot.data_manager
|
||||||
if member.id not in dm.get('agreed_nickname'):
|
if member.id not in dm.get('agreed_nickname'):
|
||||||
dm.add('agreed_nickname', int(member.id))
|
dm.add('agreed_nickname', int(member.id))
|
||||||
# Always (re)mark pending & clear verified
|
|
||||||
dm.remove('nick_verified', lambda x: x == member.id)
|
dm.remove('nick_verified', lambda x: x == member.id)
|
||||||
|
newly_pending = False
|
||||||
if member.id not in dm.get('nick_claim_pending'):
|
if member.id not in dm.get('nick_claim_pending'):
|
||||||
dm.add('nick_claim_pending', int(member.id))
|
dm.add('nick_claim_pending', int(member.id))
|
||||||
|
newly_pending = True
|
||||||
|
|
||||||
# Create review
|
if newly_pending:
|
||||||
try:
|
try:
|
||||||
await self.start_nick_review(guild, member, source="nick_same")
|
await self.start_nick_review(guild, member, source="nick_same")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Clean map entry
|
# Clean map entry
|
||||||
self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
|
self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
|
||||||
|
@ -3,17 +3,15 @@ from discord.ext import commands
|
|||||||
from modules.common.emoji_accept import is_accept
|
from modules.common.emoji_accept import is_accept
|
||||||
|
|
||||||
CHECKMARK = '✅'
|
CHECKMARK = '✅'
|
||||||
ACCEPT = {CHECKMARK, '🫡'}
|
|
||||||
|
|
||||||
class ReactionRoleCog(commands.Cog):
|
class ReactionRoleCog(commands.Cog):
|
||||||
"""
|
"""
|
||||||
Records agreements and manages Full Access.
|
Records agreements and manages Full Access.
|
||||||
Now integrates nickname *pending/verified* flow:
|
Nickname flow:
|
||||||
• Nickname reaction add -> mark agreed + pending, open review via NickNudgeCog
|
• Add accept on nickname message -> mark agreed + pending (idempotent) and open ONE review
|
||||||
• Nickname reaction remove -> clear agreed/pending/verified and re-check access
|
• Remove accept on nickname message -> clear only if user has no accept reactions left
|
||||||
• /nick_same -> same as claim (no reaction required)
|
Full Access: granted when Rules ✅ + RoE ✅ + Nickname *claimed* (pending or verified).
|
||||||
Full Access: granted when Rules ✅ + RoE ✅ + Nickname **claimed (pending or verified)**.
|
Revoked when any of the three is missing.
|
||||||
Revoked only when nickname becomes unclaimed (rejected or unreacted) or when Rules/RoE are missing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
@ -82,6 +80,22 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
return None
|
return None
|
||||||
return m
|
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 = prefix + slash) ----
|
||||||
@commands.hybrid_command(name='nick_same', description='Claim that your global display name matches your in-game name (triggers mod review)')
|
@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):
|
async def nick_same(self, ctx: commands.Context):
|
||||||
@ -94,16 +108,19 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
dm.add('agreed_nickname', int(member.id))
|
dm.add('agreed_nickname', int(member.id))
|
||||||
# Mark pending (clear verified if present)
|
# Mark pending (clear verified if present)
|
||||||
dm.remove('nick_verified', lambda x: x == member.id)
|
dm.remove('nick_verified', lambda x: x == member.id)
|
||||||
|
newly_pending = False
|
||||||
if member.id not in dm.get('nick_claim_pending'):
|
if member.id not in dm.get('nick_claim_pending'):
|
||||||
dm.add('nick_claim_pending', int(member.id))
|
dm.add('nick_claim_pending', int(member.id))
|
||||||
|
newly_pending = True
|
||||||
|
|
||||||
# Open/refresh a review with NickNudge
|
# Open/refresh a review with NickNudge (only on first transition to pending)
|
||||||
nn = self.bot.get_cog('NickNudgeCog')
|
if newly_pending:
|
||||||
if nn and hasattr(nn, 'start_nick_review'):
|
nn = self.bot.get_cog('NickNudgeCog')
|
||||||
try:
|
if nn and hasattr(nn, 'start_nick_review'):
|
||||||
await nn.start_nick_review(ctx.guild, member, source="nick_same")
|
try:
|
||||||
except Exception:
|
await nn.start_nick_review(ctx.guild, member, source="nick_same")
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
await self.maybe_apply_full_access(member)
|
await self.maybe_apply_full_access(member)
|
||||||
await ctx.reply("Thanks — your nickname claim was sent for moderator review.", ephemeral=True)
|
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))
|
dm.add('agreed_engagement', int(member.id))
|
||||||
|
|
||||||
elif payload.message_id == self.nick_msg_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'):
|
if member.id not in dm.get('agreed_nickname'):
|
||||||
dm.add('agreed_nickname', int(member.id))
|
dm.add('agreed_nickname', int(member.id))
|
||||||
dm.remove('nick_verified', lambda x: x == member.id)
|
dm.remove('nick_verified', lambda x: x == member.id)
|
||||||
if member.id not in dm.get('nick_claim_pending'):
|
if member.id not in dm.get('nick_claim_pending'):
|
||||||
dm.add('nick_claim_pending', int(member.id))
|
dm.add('nick_claim_pending', int(member.id))
|
||||||
|
newly_pending = True
|
||||||
|
|
||||||
# Kick off a review in mod channel
|
# Only open a review when we just transitioned to pending
|
||||||
nn = self.bot.get_cog('NickNudgeCog')
|
if newly_pending:
|
||||||
if nn and hasattr(nn, 'start_nick_review'):
|
nn = self.bot.get_cog('NickNudgeCog')
|
||||||
try:
|
if nn and hasattr(nn, 'start_nick_review'):
|
||||||
await nn.start_nick_review(guild, member, source="claim")
|
try:
|
||||||
except Exception:
|
await nn.start_nick_review(guild, member, source="claim")
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -180,10 +200,14 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
await member.remove_roles(role, reason="Engagement un-ticked")
|
await member.remove_roles(role, reason="Engagement un-ticked")
|
||||||
|
|
||||||
elif payload.message_id == self.nick_msg_id:
|
elif payload.message_id == self.nick_msg_id:
|
||||||
# Un-claim nickname -> clear everything related
|
# Clear only if the user has NO accept reactions left on the message
|
||||||
dm.remove('agreed_nickname', lambda x: x == member.id)
|
still_has_accept = await self._user_has_any_accept(
|
||||||
dm.remove('nick_claim_pending', lambda x: x == member.id)
|
guild, payload.channel_id, payload.message_id, member.id
|
||||||
dm.remove('nick_verified', lambda x: x == 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:
|
else:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
|
Loading…
Reference in New Issue
Block a user