0.3.9.4.a3

Added support for Gitea multi-line commit messages to be posted as startup messages
This commit is contained in:
Franz Rolfsvaag 2025-08-11 00:45:39 +02:00
parent 6e85897ca8
commit 5f71ee8ebf
2 changed files with 161 additions and 83 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.4.a2" VERSION = "0.3.9.4.a3"
# ---------- Env loading ---------- # ---------- Env loading ----------

View File

@ -2,86 +2,29 @@
import os import os
import re import re
import html import html
import json
import discord import discord
import aiohttp import aiohttp
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from urllib.parse import urlparse
from modules.common.settings import cfg from modules.common.settings import cfg
# ---------------- RSS helpers ---------------- # ---------------- Utilities ----------------
def _strip_html_keep_text(s: str) -> str: def _strip_html_keep_text(s: str) -> str:
"""Remove HTML tags, unescape entities, collapse excessive blank lines.""" """Remove HTML tags, unescape entities, collapse excessive blank lines."""
if not s: if not s:
return "" return ""
# Replace <br> and <p> with newlines before stripping tags
s = re.sub(r'(?i)<\s*br\s*/?\s*>', '\n', s) s = re.sub(r'(?i)<\s*br\s*/?\s*>', '\n', s)
s = re.sub(r'(?i)</\s*p\s*>', '\n', s) s = re.sub(r'(?i)</\s*p\s*>', '\n', s)
s = re.sub(r'(?i)<\s*p\s*>', '', s) s = re.sub(r'(?i)<\s*p\s*>', '', s)
# Strip all remaining tags
s = re.sub(r'<[^>]+>', '', s) s = re.sub(r'<[^>]+>', '', s)
# Unescape HTML entities
s = html.unescape(s) s = html.unescape(s)
# Trim trailing spaces on each line
s = '\n'.join(line.rstrip() for line in s.splitlines()) 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() s = re.sub(r'\n{3,}', '\n\n', s).strip()
return s 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 <description>
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: 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() status = (status or "").strip()
old_v = (old_v or "").strip() old_v = (old_v or "").strip()
new_v = (new_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" line = "🕒 Scheduled restart executed"
else: else:
return None return None
return f"{line}\n_{desc.strip()}_" if desc else line return f"{line}\n_{desc.strip()}_" if desc else line
def _only_version_and_details(subject: str | None, body: str | None) -> str | None: def _only_version_and_details(subject: str | None, body: str | None) -> str | None:
""" """Format to 'Version number' (bold) + 'Version details' (md)."""
Format to 'Version number' (bold) + 'Version details' (md).
We try to extract a version-like token from subject; otherwise we use subject as-is.
"""
if not subject and not body: if not subject and not body:
return None return None
version = None version = None
if subject: 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) m = re.search(r'\bv?(\d+\.\d+(?:\.\d+){0,2})\b', subject)
if m: version = m.group(0) if m else subject.strip()
version = m.group(0)
else:
# Fall back to the subject line itself as "version-ish" title
version = subject.strip()
if version and body: if version and body:
return f"**{version}**\n{body.strip()}" return f"**{version}**\n{body.strip()}"
if version: if version:
return f"**{version}**" return f"**{version}**"
# No subject/version, only body
return body.strip() if body else None 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 <title>/<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 ---------------- # ---------------- Main entry ----------------
async def post_boot_notice(bot): async def post_boot_notice(bot):
""" """
Posts concise boot status to the modlog channel. Posts concise boot status to the modlog channel.
- Always: one status line (if SHAI_BOOT_STATUS is set to a known value). - 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 - If SHAI_BOOT_STATUS == 'fetched_new': fetch **full commit message**.
and post ONLY the commit message (version number + markdown details). 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 | '' status = os.getenv("SHAI_BOOT_STATUS", "").strip() # fetched_new | cached_no_update | cache_only_error | scheduled_restart | ''
desc = os.getenv("SHAI_BOOT_DESC", "").strip() desc = os.getenv("SHAI_BOOT_DESC", "").strip()
old_v = os.getenv("SHAI_BOOT_OLD", "").strip() old_v = os.getenv("SHAI_BOOT_OLD", "").strip()
new_v = os.getenv("SHAI_BOOT_NEW", "").strip() new_v = os.getenv("SHAI_BOOT_NEW", "").strip()
rss = os.getenv("SHAI_REPO_RSS", "").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) status_line = _build_status_line(status, old_v, new_v, desc)
if not status_line: if not status_line:
return # nothing to say return # nothing to say
@ -148,7 +207,6 @@ async def post_boot_notice(bot):
modlog_channel_id = cfg(bot).int('modlog_channel_id', 0) modlog_channel_id = cfg(bot).int('modlog_channel_id', 0)
if not modlog_channel_id: if not modlog_channel_id:
return return
ch = None ch = None
for g in bot.guilds: for g in bot.guilds:
ch = g.get_channel(modlog_channel_id) ch = g.get_channel(modlog_channel_id)
@ -157,16 +215,36 @@ async def post_boot_notice(bot):
if not ch: if not ch:
return return
# Post the status # 1) Post the status line
try: try:
await ch.send(status_line, allowed_mentions=discord.AllowedMentions.none()) await ch.send(status_line, allowed_mentions=discord.AllowedMentions.none())
except Exception: except Exception:
return 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: if status == "fetched_new" and rss:
subj, body = await _fetch_latest_commit_from_rss(rss) subj, body, link = await _fetch_latest_rss_item(rss)
commit_msg = _only_version_and_details(subj, body)
# 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: if commit_msg:
try: try:
await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none()) await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())