diff --git a/bot.py b/bot.py index a03ad7f..a16ea81 100644 --- a/bot.py +++ b/bot.py @@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice # Version consists of: # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update) -VERSION = "0.3.9.4.a2" +VERSION = "0.3.9.4.a3" # ---------- Env loading ---------- diff --git a/modules/common/boot_notice.py b/modules/common/boot_notice.py index a45e8b6..940d672 100644 --- a/modules/common/boot_notice.py +++ b/modules/common/boot_notice.py @@ -2,86 +2,29 @@ import os import re import html +import json import discord import aiohttp import xml.etree.ElementTree as ET +from urllib.parse import urlparse from modules.common.settings import cfg -# ---------------- RSS helpers ---------------- +# ---------------- Utilities ---------------- def _strip_html_keep_text(s: str) -> str: """Remove HTML tags, unescape entities, collapse excessive blank lines.""" if not s: return "" - # Replace
and

with newlines before stripping tags s = re.sub(r'(?i)<\s*br\s*/?\s*>', '\n', s) s = re.sub(r'(?i)', '\n', s) s = re.sub(r'(?i)<\s*p\s*>', '', s) - # Strip all remaining tags s = re.sub(r'<[^>]+>', '', s) - # Unescape HTML entities s = html.unescape(s) - # Trim trailing spaces on each line s = '\n'.join(line.rstrip() for line in s.splitlines()) - # Collapse 3+ blank lines to max 2 s = re.sub(r'\n{3,}', '\n\n', s).strip() return s -async def _fetch_latest_commit_from_rss(rss_url: str): - """ - Return (subject:str|None, body:str|None) from the newest item in a Gitea/Git RSS feed. - Best-effort. We avoid posting links/usernames and keep content human-friendly. - """ - try: - timeout = aiohttp.ClientTimeout(total=8) - async with aiohttp.ClientSession(timeout=timeout) as sess: - async with sess.get(rss_url) as resp: - if resp.status != 200: - return None, None - text = await resp.text() - root = ET.fromstring(text) - item = root.find('./channel/item') - if item is None: - return None, None - - title = (item.findtext('title') or '').strip() - # Gitea typically puts commit message (possibly HTML-wrapped) in - desc_raw = (item.findtext('description') or '').strip() - body = _strip_html_keep_text(desc_raw) - - # Some feeds stuff noise/usernames into title; keep it short & human: - # If title contains " pushed " etc., try to fall back to first line of body. - if title and re.search(r'\b(pushed|commit|committed)\b', title, re.I): - # If body has a first line that looks like a summary, use it. - first_line = body.splitlines()[0].strip() if body else "" - if first_line: - title = first_line - - # Clean again in case any entities lingered in title - title = _strip_html_keep_text(title) - - # If title empty but body present, use the first non-empty line of body as title. - if not title and body: - for line in body.splitlines(): - if line.strip(): - title = line.strip() - break - - return (title or None), (body or None) - except Exception: - return None, None - -# ---------------- Status helpers ---------------- - def _build_status_line(status: str, old_v: str, new_v: str, desc: str) -> str | None: - """ - Return a short human-readable boot status line, or None if nothing to post. - Known statuses: - - fetched_new -> updated & booted - - cached_no_update -> booted cached, no update - - cache_only_error -> booted cached, repo unavailable - - scheduled_restart -> a scheduled restart was initiated - """ status = (status or "").strip() old_v = (old_v or "").strip() new_v = (new_v or "").strip() @@ -96,50 +39,166 @@ def _build_status_line(status: str, old_v: str, new_v: str, desc: str) -> str | line = "🕒 Scheduled restart executed" else: return None - return f"{line}\n_{desc.strip()}_" if desc else line def _only_version_and_details(subject: str | None, body: str | None) -> str | None: - """ - Format to 'Version number' (bold) + 'Version details' (md). - We try to extract a version-like token from subject; otherwise we use subject as-is. - """ + """Format to 'Version number' (bold) + 'Version details' (md).""" if not subject and not body: return None - version = None if subject: - # Try to find a version-like token (v1.2.3 or 1.2.3.4 etc.) m = re.search(r'\bv?(\d+\.\d+(?:\.\d+){0,2})\b', subject) - if m: - version = m.group(0) - else: - # Fall back to the subject line itself as "version-ish" title - version = subject.strip() - + version = m.group(0) if m else subject.strip() if version and body: return f"**{version}**\n{body.strip()}" if version: return f"**{version}**" - # No subject/version, only body return body.strip() if body else None +# ---------------- RSS helpers ---------------- + +async def _fetch_latest_rss_item(rss_url: str): + """ + Return (title:str|None, body:str|None, link:str|None) from newest item. + Gitea RSS often only has the first line in /<description>. + """ + try: + timeout = aiohttp.ClientTimeout(total=8) + async with aiohttp.ClientSession(timeout=timeout) as sess: + async with sess.get(rss_url) as resp: + if resp.status != 200: + return None, None, None + text = await resp.text() + root = ET.fromstring(text) + item = root.find('./channel/item') + if item is None: + return None, None, None + title = (item.findtext('title') or '').strip() + desc_raw = (item.findtext('description') or '').strip() + body = _strip_html_keep_text(desc_raw) or None + link = (item.findtext('link') or '').strip() or None + + # If title looks like noise ("pushed", etc.), prefer body's first line + if title and re.search(r'\b(pushed|commit|committed)\b', title, re.I): + first = (body.splitlines()[0].strip() if body else "") or "" + if first: + title = first + + title = _strip_html_keep_text(title) or None + return title, body, link + except Exception: + return None, None, None + +def _parse_gitea_link_for_api(link: str): + """ + From a Gitea commit link like: + https://git.example.com/owner/repo/commit/abcdef... + derive: + api_base: https://git.example.com/api/v1 + owner: owner + repo: repo + sha: abcdef... + """ + try: + pr = urlparse(link) + parts = [p for p in pr.path.split('/') if p] + # Expect: [owner, repo, 'commit', sha] + if len(parts) >= 4 and parts[2] == 'commit': + owner, repo, sha = parts[0], parts[1], parts[3] + api_base = f"{pr.scheme}://{pr.netloc}/api/v1" + return api_base, owner, repo, sha + except Exception: + pass + return None, None, None, None + +async def _fetch_gitea_commit_message(commit_link: str, token: str | None): + """ + Ask Gitea API for the full commit message. + Try both endpoints: + 1) /api/v1/repos/{owner}/{repo}/git/commits/{sha} + 2) /api/v1/repos/{owner}/{repo}/commits/{sha} + Return full_message:str|None on success. + """ + api_base, owner, repo, sha = _parse_gitea_link_for_api(commit_link) + if not all([api_base, owner, repo, sha]): + return None + + headers = {} + if token: + headers['Authorization'] = f'token {token}' + + timeout = aiohttp.ClientTimeout(total=8) + async with aiohttp.ClientSession(timeout=timeout, headers=headers) as sess: + # 1) git/commits + url1 = f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}" + try: + async with sess.get(url1) as resp: + if resp.status == 200: + data = await resp.json() + # Common shapes: {"message": "..."} or {"commit":{"message":"..."}} + msg = data.get('message') if isinstance(data, dict) else None + if not msg and isinstance(data, dict): + commit = data.get('commit') or {} + msg = commit.get('message') + if msg: + return str(msg) + except Exception: + pass + + # 2) commits + url2 = f"{api_base}/repos/{owner}/{repo}/commits/{sha}" + try: + async with sess.get(url2) as resp: + if resp.status == 200: + data = await resp.json() + msg = None + if isinstance(data, dict): + # Gitea returns {"commit":{"message":"..."}, ...} + commit = data.get('commit') or {} + msg = commit.get('message') or data.get('message') + if msg: + return str(msg) + except Exception: + pass + + return None + +def _split_subject_body(full_message: str): + """ + Split a full commit message into (subject, body). + Subject = first non-empty line; body = rest (preserve markdown). + """ + if not full_message: + return None, None + lines = [ln.rstrip() for ln in full_message.splitlines()] + # find first non-empty line + subject = None + i = 0 + while i < len(lines) and subject is None: + if lines[i].strip(): + subject = lines[i].strip() + i += 1 + body = '\n'.join(lines[i:]).strip() if i < len(lines) else '' + return subject or None, (body or None) + # ---------------- Main entry ---------------- async def post_boot_notice(bot): """ Posts concise boot status to the modlog channel. - Always: one status line (if SHAI_BOOT_STATUS is set to a known value). - - If SHAI_BOOT_STATUS == 'fetched_new': fetch latest commit from SHAI_REPO_RSS - and post ONLY the commit message (version number + markdown details). + - If SHAI_BOOT_STATUS == 'fetched_new': fetch **full commit message**. + • Prefer Gitea API (requires only the commit link from RSS). + • Fallback to RSS subject/description if API fails. + Then post ONLY: Version number (bold) + markdown details. """ status = os.getenv("SHAI_BOOT_STATUS", "").strip() # fetched_new | cached_no_update | cache_only_error | scheduled_restart | '' desc = os.getenv("SHAI_BOOT_DESC", "").strip() old_v = os.getenv("SHAI_BOOT_OLD", "").strip() new_v = os.getenv("SHAI_BOOT_NEW", "").strip() rss = os.getenv("SHAI_REPO_RSS", "").strip() + token = os.getenv("SHAI_GITEA_TOKEN", "").strip() or None # optional - # Build status line status_line = _build_status_line(status, old_v, new_v, desc) if not status_line: return # nothing to say @@ -148,7 +207,6 @@ async def post_boot_notice(bot): modlog_channel_id = cfg(bot).int('modlog_channel_id', 0) if not modlog_channel_id: return - ch = None for g in bot.guilds: ch = g.get_channel(modlog_channel_id) @@ -157,16 +215,36 @@ async def post_boot_notice(bot): if not ch: return - # Post the status + # 1) Post the status line try: await ch.send(status_line, allowed_mentions=discord.AllowedMentions.none()) except Exception: return - # If we fetched & booted a new version, follow up with the commit message from RSS (no env details assumed). + # 2) If updated, post ONLY the commit message (version + details) if status == "fetched_new" and rss: - subj, body = await _fetch_latest_commit_from_rss(rss) - commit_msg = _only_version_and_details(subj, body) + subj, body, link = await _fetch_latest_rss_item(rss) + + # Try to get the full commit message from Gitea API using the link's SHA + full_msg = None + if link: + full_msg = await _fetch_gitea_commit_message(link, token) + + # If API failed, fall back to what RSS gave us + if not full_msg: + # if RSS body is a single-line/empty, we only have subject anyway + if body and subj and body.startswith(subj): + # avoid duplicate subject at start + body_trimmed = body[len(subj):].lstrip() + full_msg = f"{subj}\n{body_trimmed}".strip() if body_trimmed else subj + else: + combined = "\n".join(x for x in [subj or "", body or ""] if x).strip() + full_msg = combined or (subj or body or "") + + # Split into subject/body and format as Version + Details + c_subject, c_body = _split_subject_body(full_msg or "") + commit_msg = _only_version_and_details(c_subject, c_body) + if commit_msg: try: await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())