0.3.9.5.a4

Incorporated fault-tolerant startup messages with proper fetching of commit messages
This commit is contained in:
Franz Rolfsvaag 2025-08-11 01:56:28 +02:00
parent 0038a1889c
commit b780c4069e
2 changed files with 85 additions and 101 deletions

2
bot.py
View File

@ -9,7 +9,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.5.a3" VERSION = "0.3.9.5.a4"
# ---------- Env loading ---------- # ---------- Env loading ----------

View File

@ -1,18 +1,16 @@
# modules/common/boot_notice.py # modules/common/boot_notice.py
import os import os
import re import re
import base64
import json
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse, urlencode
import discord import discord
import aiohttp import aiohttp
from modules.common.settings import cfg from modules.common.settings import cfg
# ---------------- Version helpers ---------------- # ---------- Version helpers ----------
_VERSION_RE = re.compile(r'\b\d+\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?\b') _VERSION_RE = re.compile(r'\b\d+\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?\b')
@ -26,7 +24,6 @@ def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
if not full_message: if not full_message:
return None, None return None, None
lines = [ln.rstrip() for ln in full_message.splitlines()] lines = [ln.rstrip() for ln in full_message.splitlines()]
# subject = first non-empty line
subject = None subject = None
i = 0 i = 0
while i < len(lines) and subject is None: while i < len(lines) and subject is None:
@ -37,19 +34,14 @@ def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
return subject or None, (body or None) return subject or None, (body or None)
def _cmp_versions(a: str | None, b: str | None) -> int: def _cmp_versions(a: str | None, b: str | None) -> int:
""" """Compare 1.2.3.4.a2 style; if either missing, treat as equal (0)."""
Compare versions like 1.2.3.4.a2 (last segment may be alnum).
Returns -1 if a<b, 0 if equal/unknown, +1 if a>b.
If either is None, treat as equal to avoid false positives.
"""
if not a or not b: if not a or not b:
return 0 return 0
pa = a.split('.') pa, pb = a.split('.'), b.split('.')
pb = b.split('.')
while len(pa) < 5: pa.append('0') while len(pa) < 5: pa.append('0')
while len(pb) < 5: pb.append('0') while len(pb) < 5: pb.append('0')
def part_key(x: str): def key(x: str):
if x.isdigit(): if x.isdigit():
return (int(x), '', 1) return (int(x), '', 1)
m = re.match(r'(\d+)(.*)', x) m = re.match(r'(\d+)(.*)', x)
@ -58,7 +50,7 @@ def _cmp_versions(a: str | None, b: str | None) -> int:
return (0, x, 3) return (0, x, 3)
for xa, xb in zip(pa, pb): for xa, xb in zip(pa, pb):
ka, kb = part_key(xa), part_key(xb) ka, kb = key(xa), key(xb)
if ka[0] != kb[0]: if ka[0] != kb[0]:
return 1 if ka[0] > kb[0] else -1 return 1 if ka[0] > kb[0] else -1
if ka[2] != kb[2]: if ka[2] != kb[2]:
@ -67,7 +59,7 @@ def _cmp_versions(a: str | None, b: str | None) -> int:
return 1 if ka[1] > kb[1] else -1 return 1 if ka[1] > kb[1] else -1
return 0 return 0
# ---------------- Gitea helpers ---------------- # ---------- Gitea helpers ----------
def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]: def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
""" """
@ -75,11 +67,10 @@ def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
api_base = https://host/api/v1 api_base = https://host/api/v1
""" """
try: try:
pr = urlparse(repo_url) pr = urlparse(repo_url.strip().rstrip('/'))
parts = [p for p in pr.path.split('/') if p] parts = [p for p in pr.path.split('/') if p]
if len(parts) >= 2: if len(parts) >= 2:
owner = parts[0] owner, repo = parts[0], parts[1]
repo = parts[1]
if repo.endswith('.git'): if repo.endswith('.git'):
repo = repo[:-4] repo = repo[:-4]
api_base = f"{pr.scheme}://{pr.netloc}/api/v1" api_base = f"{pr.scheme}://{pr.netloc}/api/v1"
@ -88,68 +79,74 @@ def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
pass pass
return None, None, None return None, None, None
async def _gitea_get_json(url: str, token: str | None, user: str | None, timeout_sec: int = 10): def _auth_headers_from_cfg(r):
headers = {} """
if token and user: Build Authorization header using SHAI_REPO_AHTOKEN (cfg: repo_ahtoken).
# Basic auth with user:token The value may be raw; we prefix 'token ' if needed.
cred = base64.b64encode(f"{user}:{token}".encode()).decode() Also supports SHAI_GITEA_TOKEN / SHAI_GITEA_USER as secondary.
headers['Authorization'] = f"Basic {cred}" """
elif token: ahtoken = r.get('repo_ahtoken', '').strip() # SHAI_REPO_AHTOKEN
headers['Authorization'] = f"token {token}" if ahtoken:
if not ahtoken.lower().startswith('token '):
ahtoken = f"token {ahtoken}"
return {"Authorization": ahtoken}
# Optional secondary envs for future private usage
tok = os.getenv("SHAI_GITEA_TOKEN", "").strip()
usr = os.getenv("SHAI_GITEA_USER", "").strip()
if tok and usr:
import base64
b64 = base64.b64encode(f"{usr}:{tok}".encode()).decode()
return {"Authorization": f"Basic {b64}"}
if tok:
return {"Authorization": f"token {tok}"}
return {}
async def _http_json(url: str, headers: dict, timeout_sec: int = 10):
timeout = aiohttp.ClientTimeout(total=timeout_sec) timeout = aiohttp.ClientTimeout(total=timeout_sec)
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as sess: async with aiohttp.ClientSession(timeout=timeout, headers=headers or {}) as sess:
async with sess.get(url) as resp: async with sess.get(url) as resp:
ctype = resp.headers.get("Content-Type", "")
if resp.status != 200: if resp.status != 200:
text = await resp.text() text = await resp.text()
raise RuntimeError(f"Gitea GET {url} -> {resp.status}: {text[:200]}") raise RuntimeError(f"Gitea GET {url} -> {resp.status} ({ctype}): {text[:200]}")
# Gitea returns either dict or list depending on endpoint
ctype = resp.headers.get("Content-Type", "")
if "application/json" not in ctype: if "application/json" not in ctype:
text = await resp.text() text = await resp.text()
raise RuntimeError(f"Gitea GET {url} non-JSON: {ctype} {text[:200]}") raise RuntimeError(f"Gitea GET {url} non-JSON {ctype}: {text[:200]}")
return await resp.json() return await resp.json()
async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str, async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str | None,
token: str | None, user: str | None) -> tuple[str | None, str | None, str | None]: headers: dict) -> tuple[str | None, str | None, str | None]:
""" """
Returns (sha, subject, body) for the latest commit on branch using the Returns (sha, subject, body) for the latest commit using the list-commits endpoint:
list-commits endpoint which includes the full commit message: /api/v1/repos/{owner}/{repo}/commits?sha=main&stat=false&verification=false&files=false&limit=1
/api/v1/repos/{owner}/{repo}/commits?sha={branch}&limit=1&stat=false&verification=false&files=false If branch is falsy, omits 'sha' and lets server default.
""" """
url = ( params = {
f"{api_base}/repos/{owner}/{repo}/commits" "stat": "false",
f"?sha={branch}&limit=1&stat=false&verification=false&files=false" "verification": "false",
) "files": "false",
data = await _gitea_get_json(url, token, user) "limit": "1",
}
if branch:
params["sha"] = branch
url = f"{api_base}/repos/{owner}/{repo}/commits?{urlencode(params)}"
data = await _http_json(url, headers)
if not isinstance(data, list) or not data: if not isinstance(data, list) or not data:
raise RuntimeError("Commits list empty or invalid.") raise RuntimeError("Commits list empty or invalid")
latest = data[0] latest = data[0]
sha = latest.get("sha") or latest.get("id") sha = latest.get("sha") or latest.get("id")
message = "" message = ""
commit_obj = latest.get("commit") or {} commit_obj = latest.get("commit") or {}
if isinstance(commit_obj, dict): if isinstance(commit_obj, dict):
message = commit_obj.get("message") or "" message = commit_obj.get("message") or ""
if not message:
# Extremely unlikely on this endpoint, but try secondary fetch subject, body = _split_subject_body(message or "")
if sha:
for endpoint in (
f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}",
f"{api_base}/repos/{owner}/{repo}/commits/{sha}",
):
try:
det = await _gitea_get_json(endpoint, token, user)
if isinstance(det, dict):
m = det.get("message") or det.get("commit", {}).get("message")
if m:
message = str(m)
break
except Exception:
continue
subject, body = _split_subject_body(message)
return sha, (subject or ""), (body or "") return sha, (subject or ""), (body or "")
# ---------------- Boot reason inference ---------------- # ---------- Boot reason inference ----------
def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int = 5) -> bool: def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int = 5) -> bool:
if not hhmm_utc: if not hhmm_utc:
@ -159,8 +156,7 @@ def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int
except Exception: except Exception:
return False return False
sched = now_utc.replace(hour=hh, minute=mm, second=0, microsecond=0) sched = now_utc.replace(hour=hh, minute=mm, second=0, microsecond=0)
delta = abs((now_utc - sched).total_seconds()) return abs((now_utc - sched).total_seconds()) <= window_min * 60
return delta <= window_min * 60
def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) -> str: def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) -> str:
if kind == "updated": if kind == "updated":
@ -173,25 +169,20 @@ def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) ->
return f"⚠️ Version rollback detected: **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**" return f"⚠️ Version rollback detected: **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
return "🟢 Bot started" return "🟢 Bot started"
# ---------------- Main entry ---------------- # ---------- Main entry ----------
async def post_boot_notice(bot): async def post_boot_notice(bot):
""" """
Always post a boot status to the modlog channel. Always posts a startup status + the latest commit message (Version + md body)
- Waits for ready. to the modlog channel. Uses SHAI_REPO_URL and SHAI_REPO_AHTOKEN via cfg().
- Infers reason (updated/scheduled/manual/rollback).
- Fetches latest commit (full message) via list-commits endpoint.
- Posts status + commit message (Version bold + md details).
- Pings guild owner only on rollback.
- Persists last sha/version in data_manager['boot_state'].
""" """
try: try:
await bot.wait_until_ready() await bot.wait_until_ready()
except Exception as e: except Exception as e:
print(f"[boot_notice] wait_until_ready failed: {e}") print(f"[boot_notice] wait_until_ready failed: {e}")
# Resolve modlog channel r = cfg(bot)
modlog_channel_id = cfg(bot).int('modlog_channel_id', 0) modlog_channel_id = r.int('modlog_channel_id', 0)
if not modlog_channel_id: if not modlog_channel_id:
print("[boot_notice] modlog_channel_id not configured; skipping.") print("[boot_notice] modlog_channel_id not configured; skipping.")
return return
@ -206,37 +197,34 @@ async def post_boot_notice(bot):
print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.") print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
return return
# Repo info repo_url = r.get('repo_url', '') # SHAI_REPO_URL
r = cfg(bot) # Branch optional; if empty, we omit 'sha='
repo_url = r.get('repo_url', '') branch = r.get('repo_branch', 'main') or None # SHAI_REPO_BRANCH (optional)
branch = r.get('repo_branch', 'main') check_time_utc = r.get('check_time_utc', '') # SHAI_CHECK_TIME_UTC (optional, e.g. "03:00")
check_time_utc = r.get('check_time_utc', '') # e.g., "03:00" headers = _auth_headers_from_cfg(r)
now_utc = datetime.now(timezone.utc)
api_base = owner = repo = None api_base = owner = repo = None
if repo_url: if repo_url:
api_base, owner, repo = _parse_repo_url(repo_url) api_base, owner, repo = _parse_repo_url(repo_url)
if not all([api_base, owner, repo]):
print(f"[boot_notice] failed to parse repo_url={repo_url!r}")
else: else:
print("[boot_notice] repo_url missing; commit lookup skipped.") print("[boot_notice] repo_url missing; commit lookup skipped.")
token = os.getenv("SHAI_GITEA_TOKEN", "").strip() or None
user = os.getenv("SHAI_GITEA_USER", "").strip() or None
# State # State
dm = getattr(bot, "data_manager", None) dm = getattr(bot, "data_manager", None)
if not dm: if not dm:
print("[boot_notice] data_manager missing on bot; cannot persist state.") print("[boot_notice] data_manager missing on bot; cannot persist state.")
return return
prev = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {} prev = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
prev_sha = prev.get('last_sha') or None prev_sha = prev.get('last_sha') or None
prev_ver = prev.get('last_version') or None prev_ver = prev.get('last_version') or None
# Fetch latest commit (sha, subject, body) # Fetch latest commit
sha = subject = body = None sha = subject = body = None
if api_base and owner and repo: if api_base and owner and repo:
try: try:
sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, token, user) sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, headers)
except Exception as e: except Exception as e:
print(f"[boot_notice] fetch latest commit failed: {e}") print(f"[boot_notice] fetch latest commit failed: {e}")
else: else:
@ -245,34 +233,31 @@ async def post_boot_notice(bot):
curr_ver = _extract_version(subject) if subject else None curr_ver = _extract_version(subject) if subject else None
# Decide reason # Decide reason
reason = "manual" now_utc = datetime.now(timezone.utc)
mention_owner = False
if prev_ver and curr_ver: if prev_ver and curr_ver:
cmpv = _cmp_versions(prev_ver, curr_ver) cmpv = _cmp_versions(prev_ver, curr_ver)
if cmpv < 0: if cmpv < 0:
reason = "updated" reason, ping_owner = "updated", False
elif cmpv > 0: elif cmpv > 0:
reason = "rollback" reason, ping_owner = "rollback", True
mention_owner = True
else: else:
reason = "scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual" reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
else: else:
if prev_sha and sha and prev_sha != sha: if prev_sha and sha and prev_sha != sha:
reason = "updated" reason, ping_owner = "updated", False
else: else:
reason = "scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual" reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
# Post status line # Post status
status_line = _format_status_line(reason, prev_ver, curr_ver) status_line = _format_status_line(reason, prev_ver, curr_ver)
try: try:
allowed = discord.AllowedMentions( allowed = discord.AllowedMentions(
everyone=False, everyone=False,
users=True if mention_owner else False, users=True if (ping_owner and ch.guild and ch.guild.owner_id) else False,
roles=False, roles=False,
replied_user=False replied_user=False
) )
if mention_owner and ch.guild and ch.guild.owner_id: if ping_owner and ch.guild and ch.guild.owner_id:
status_line = f"{status_line}\n<@{ch.guild.owner_id}>" status_line = f"{status_line}\n<@{ch.guild.owner_id}>"
await ch.send(status_line, allowed_mentions=allowed) await ch.send(status_line, allowed_mentions=allowed)
except Exception as e: except Exception as e:
@ -281,7 +266,7 @@ async def post_boot_notice(bot):
# Post commit message (Version + md details) # Post commit message (Version + md details)
try: try:
title = curr_ver or (subject or "Latest commit") title = (curr_ver or subject or "Latest commit").strip()
if title or body: if title or body:
commit_msg = f"**{title}**\n{body}" if body else f"**{title}**" commit_msg = f"**{title}**\n{body}" if body else f"**{title}**"
await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none()) await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())
@ -290,12 +275,11 @@ async def post_boot_notice(bot):
# Persist state # Persist state
try: try:
new_state = { dm.add('boot_state', {
'last_sha': sha, 'last_sha': sha,
'last_version': curr_ver, 'last_version': curr_ver,
'last_subject': subject, 'last_subject': subject,
'last_boot_ts': time.time(), 'last_boot_ts': time.time(),
} })
dm.add('boot_state', new_state)
except Exception as e: except Exception as e:
print(f"[boot_notice] failed to persist boot_state: {e}") print(f"[boot_notice] failed to persist boot_state: {e}")