0.3.9.4.a3
Added support for Gitea multi-line commit messages to be posted as startup messages
This commit is contained in:
parent
6e85897ca8
commit
5f71ee8ebf
2
bot.py
2
bot.py
@ -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; doesn’t trigger auto update)
|
# 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 ----------
|
# ---------- Env loading ----------
|
||||||
|
|
||||||
|
@ -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())
|
||||||
|
Loading…
Reference in New Issue
Block a user