Files
dotfiles/bin/.bin/hungryroot-notifications

254 lines
7.1 KiB
Plaintext
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests>=2.31.0",
# ]
# ///
import email
import imaplib
import os
import ssl
import time
import re
from email.header import decode_header, make_header
import requests
FORWARDED_HEADER_RE = re.compile(
r"^(From:.*?\n(?:.*\n)*?\n)",
re.IGNORECASE
)
PREFIX_RE = re.compile(r"^\s*(?:(?:re|fw|fwd)\s*:\s*)+", re.IGNORECASE)
HUNGRYROOT_REPOS_RE = re.compile(r"^\s*(?:\[hungryroot/[^\]]+\]\s*)+", re.IGNORECASE)
GITHUB_URL_RE = re.compile(r"https://github\.com/[^\s>,]+")
FOOTER_START_MARKERS = (
"reply to this email directly",
"you are receiving this because",
)
IMAP_HOST = os.environ.get("IMAP_HOST", "box.unbl.ink")
IMAP_USER = os.environ.get("IMAP_USER", "hungryroot@unbl.ink")
IMAP_PASS = os.environ.get("IMAP_PASS", "")
NTFY_BASE = os.environ.get("NTFY_BASE", "https://ntfy.unbl.ink")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "hroot-notifications")
NTFY_ERROR_TOPIC = os.environ.get("NTFY_ERROR_TOPIC", "errors")
POLL_SECONDS = int(os.environ.get("POLL_SECONDS", "20"))
# Optional: if your ntfy needs auth
NTFY_USER = os.environ.get("NTFY_USER")
NTFY_PASS = os.environ.get("NTFY_PASS")
NTFY_TOKEN = os.environ.get("NTFY_TOKEN") # alternative to user/pass
MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "500"))
def _decode(value: str) -> str:
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
def _extract_text(msg: email.message.Message) -> str:
"""Best-effort plain text extraction."""
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
disp = (part.get("Content-Disposition") or "").lower()
if ctype == "text/plain" and "attachment" not in disp:
payload = part.get_payload(decode=True) or b""
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
return ""
payload = msg.get_payload(decode=True) or b""
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
def _first_github_link(text: str) -> str | None:
# crude but effective; improve later if you want
for token in text.split():
if token.startswith("https://github.com/"):
return token.strip(")>.,;\"'")
return None
def _sanitize_header(value: str) -> str:
# Collapse CR/LF and excessive whitespace into single spaces
return " ".join(value.replace("\r", " ").replace("\n", " ").split())
def normalize_title(title: str) -> str:
# Unfold/sanitize any CRLF + weird spacing from forwarded headers
title = " ".join(title.replace("\r", " ").replace("\n", " ").split())
# Strip Re:/Fwd: chains like "Re: Re: Fwd:"
title = PREFIX_RE.sub("", title).strip()
# Strip ONLY hungryroot repo prefix blocks, keep [BE-####] etc.
title = HUNGRYROOT_REPOS_RE.sub("", title).strip()
return title
def strip_github_footer(text: str) -> tuple[str, str | None]:
"""
Strip GitHub's email footer and return (clean_text, github_url).
"""
lines = text.splitlines()
clean_lines = []
github_url = None
in_footer = False
for line in lines:
lower = line.lower()
if not in_footer and any(m in lower for m in FOOTER_START_MARKERS):
in_footer = True
continue
if in_footer:
if github_url is None:
match = GITHUB_URL_RE.search(line)
if match:
github_url = match.group(0)
continue
clean_lines.append(line)
clean_text = "\n".join(clean_lines).rstrip()
return clean_text, github_url
def strip_forwarded_headers(text: str) -> str:
"""
Strip a forwarded-message header block, including folded headers.
"""
lines = text.splitlines()
out = []
in_headers = True
for line in lines:
if in_headers:
# End of headers = first completely empty line
if line.strip() == "":
in_headers = False
# Still in headers: skip
continue
out.append(line)
return "\n".join(out).lstrip()
def notify_ntfy(title: str, message: str, url: str | None = None):
endpoint = f"{NTFY_BASE.rstrip('/')}/{NTFY_TOPIC}"
safe_title = normalize_title(title)
headers = {
"Title": safe_title,
"Tags": "github",
"Priority": "4",
}
if url:
headers["Click"] = url
auth = None
if NTFY_TOKEN:
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
elif NTFY_USER and NTFY_PASS:
auth = (NTFY_USER, NTFY_PASS)
r = requests.post(
endpoint,
data=message.encode("utf-8"),
headers=headers,
auth=auth,
timeout=10,
)
r.raise_for_status()
def notify_error(err: str):
endpoint = f"{NTFY_BASE.rstrip('/')}/{NTFY_ERROR_TOPIC}"
headers = {
"Title": "hungryroot-notifications error",
"Tags": "warning",
"Priority": "5",
}
auth = None
if NTFY_TOKEN:
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
elif NTFY_USER and NTFY_PASS:
auth = (NTFY_USER, NTFY_PASS)
# Keep it short so it doesn't spam
body = err.strip()
if len(body) > 1500:
body = body[:1500] + "…"
r = requests.post(endpoint, data=body.encode("utf-8"), headers=headers, auth=auth, timeout=10)
r.raise_for_status()
def fetch_unseen():
context = ssl.create_default_context()
with imaplib.IMAP4_SSL(IMAP_HOST, ssl_context=context) as M:
M.login(IMAP_USER, IMAP_PASS)
M.select("INBOX")
typ, data = M.search(None, "UNSEEN")
if typ != "OK":
return []
uids = data[0].split()
out = []
for uid in uids:
typ, msg_data = M.fetch(uid, "(RFC822)")
if typ != "OK" or not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
subject = _decode(msg.get("Subject", "GitHub notification"))
from_ = _decode(msg.get("From", ""))
body = strip_forwarded_headers(_extract_text(msg))
body, github_url = strip_github_footer(body)
out.append((uid, subject, from_, body, github_url))
# mark seen so we don't resend
M.store(uid, "+FLAGS", "\\Seen")
return out
def main():
while True:
try:
items = fetch_unseen()
for _uid, subject, from_, snippet, github_url in items:
title = subject
msg = f"{snippet}\n\nFrom: {from_}"
notify_ntfy(title=title, message=msg, url=github_url)
except Exception as e:
msg = f"[hungryroot-notifications] {type(e).__name__}: {e}"
print(msg)
try:
notify_error(msg)
except Exception as e2:
print(f"[hungryroot-notifications] failed to notify error: {e2}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()