diff --git a/README.md b/README.md index d8635da..3b3be88 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,209 @@ -# aastatus-to-discord +# πŸ›°οΈ AAISP Status Feed β†’ Discord Webhook Notifier + +Monitors the **Andrews & Arnold (AAISP)** status Atom feed and posts **new incidents, updates, and closures** to a Discord channel using a webhook. + +This script: + +βœ” Detects new incidents +βœ” Detects updates to existing incidents +βœ” Detects severity changes +βœ” Detects status changes (Open β†’ Closed) +βœ” Tracks each incident separately +βœ” Sends color-coded Discord embeds + +--- + +## πŸ“Œ Features + +### πŸ” Intelligent Feed Monitoring + +The script constantly fetches the official AAISP Atom feed: + +``` +https://aastatus.net/atom.cgi +``` + +It tracks each incident by ID and detects: + +* New incidents +* Updated incidents +* Edited content +* Status changes +* Severity changes +* Updated timestamps + +--- + +### 🎨 Color-Coded Notifications + +The Discord embed color reflects the current **status** and **severity**: +|----------------------------------------------------------| +| Status / Severity | Color | Meaning | +|------------------ | --------- | -------------------------| +| **Closed** | 🟩 Green | Issue resolved | +| **Open** | πŸ”΅ Blue | Active incident | +| **Minor** | 🟨 Yellow | Low-impact issue | +| **MSO** | πŸ”΄ Red | Major service outage | +| **PEW** | 🟦 Cyan | Planned engineering work | +| Default | βšͺ Grey | Unknown/other | +|----------------------------------------------------------| + + +### πŸ“¦ Persistent State Tracking + +Unlike simple "new entry only" scripts, this version uses: + +``` +/tmp/aaisp_atom_state.json +``` + +The state file stores: + +```json +{ + "": { + "status": "...", + "severity": "...", + "updated": "...", + "content": "..." + } +} +``` + +This allows the script to detect: + +* β€œOpen β†’ Closed” +* Severity changes (Minor β†’ MSO) +* New updates in the AAISP feed content +* Edits made to existing entries + +--- + +### πŸ“£ Clean Discord Notifications + +Each message includes: + +* Title with status/severity +* Full link to the AAISP page +* Markdown-formatted update content +* Timestamps +* Status, severity, and categories + +Example: + +``` +[Closed] BT: Some BT lines dropped (Status changed Open β†’ Closed) +``` + +--- + +## πŸ› οΈ Installation + +### 1. Clone or download the script + +``` +git clone https://git.ncltech.co.uk/phil/aastatus-to-discord +cd aastatus-to-discord +``` + +### 2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +Requirements: + +* `requests` + +### 3. Edit your Discord webhook URL + +Open the script and set: + +```python +WEBHOOK_URL = "https://discord.com/api/webhooks/XXXXXXX" +``` + +--- + +## πŸš€ Running the Script + +### Manual run: + +``` +python3 aastatus_to_discord.py +``` + +### Run automatically every 5 minutes (cron) + +``` +*/5 * * * * /usr/bin/python3 /path/to/aastatus_to_discord.py +``` + +State is preserved between runs because it stores incident data in: + +``` +/tmp/aaisp_atom_state.json +``` + +--- + +## πŸ”§ Configuration + +If you want to override the state file path, edit: + +``` +STATE_FILE = Path("/tmp/aaisp_atom_state.json") +``` + +You can place it anywhereβ€”e.g., `/var/lib/aaisp/state.json`. + + +--- + +## πŸ§ͺ Testing + +You can simulate feed updates by: + +* Editing timestamps in the XML +* Changing "Open" β†’ "Closed" +* Adding a dummy category/status element +* Modifying content + +The script will immediately detect the change and fire a Discord message. + +--- + +## πŸ›‘οΈ Error Handling & Logging + +The script logs all operations: + +* Feed fetch attempts +* Parsing failures +* State changes +* Discord webhook failures + +Logs appear on stdout and are suitable for systemd or cron. + +--- + +## ❀️ Contributing + +Pull requests welcome! + +Ideas to add: + +* Support for multiple Discord channels +* HTML β†’ Markdown improvements +* Option to track *all* entries, not just the newest +* A simple GUI or web dashboard + +--- + +## πŸ“œ License + +GPL β€” free to modify and use. + +--- + -Monitors the Andrews & Arnold (AAISP) status Atom feed and posts new incidents, updates, and closures to a Discord channel using a webhook. \ No newline at end of file diff --git a/aastatus_to_webhook.py b/aastatus_to_webhook.py new file mode 100644 index 0000000..83ce9ba --- /dev/null +++ b/aastatus_to_webhook.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +import requests +import xml.etree.ElementTree as ET +import html +import json +import re +from pathlib import Path +import logging +from datetime import datetime + +# Configure logging +logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +FEED_URL = "https://aastatus.net/atom.cgi" +WEBHOOK_URL = "https://discord.com/api/webhooks/XXXXXXX" # Discord webhook URL +STATE_FILE = Path("/tmp/aaisp_atom_state.json") + +# Severity colors +COLOR_MINOR = 16763904 +COLOR_MSO = 16711680 +COLOR_PEW = 65535 +COLOR_DEFAULT = 3092790 + +# Status colors +COLOR_STATUS_OPEN = 0x3498DB # blue +COLOR_STATUS_CLOSED = 0x2ECC71 # green +COLOR_STATUS_UNKNOWN = 0x95A5A6 # grey + + +### STATE MANAGEMENT ### + +def load_state(): + if not STATE_FILE.exists(): + return {} + try: + return json.loads(STATE_FILE.read_text()) + except Exception: + logger.error("State file corrupted, resetting...") + return {} + +def save_state(state): + try: + STATE_FILE.write_text(json.dumps(state, indent=2)) + except Exception as e: + logger.error(f"State save failed: {e}") + + +### FEED PARSING ### + +def fetch_feed(url): + try: + resp = requests.get(url, timeout=10) + resp.raise_for_status() + return resp.text + except requests.RequestException as e: + logger.error(f"Error fetching feed: {e}") + raise + + +def get_first_valid_entry(feed_xml): + try: + root = ET.fromstring(feed_xml) + atom_ns = "{http://www.w3.org/2005/Atom}" + + for entry in root.findall(f"{atom_ns}entry"): + + id_elem = entry.find(f"{atom_ns}id") + if id_elem is None: + continue + + title_elem = entry.find(f"{atom_ns}title") + title_text = title_elem.text.strip() if title_elem is not None and title_elem.text else "No Title" + + content_elem = entry.find(f"{atom_ns}content") + summary_elem = entry.find(f"{atom_ns}summary") + + if content_elem is not None and content_elem.text and content_elem.text.strip(): + content_text = content_elem.text.strip() + elif summary_elem is not None and summary_elem.text and summary_elem.text.strip(): + content_text = summary_elem.text.strip() + else: + content_text = title_text + + # Link + link_elem = entry.find(f"{atom_ns}link[@rel='alternate']") + if link_elem is not None: + link = link_elem.get("href", "") + else: + first_link = entry.find(f"{atom_ns}link") + link = first_link.get("href", "") if first_link is not None else "" + + # Categories (severity, type, status) + severity = "Unknown" + categories = [] + status = "Unknown" + + for cat in entry.findall(f".//{atom_ns}category"): + label = cat.get("label", "") or cat.get("term", "") + scheme = cat.get("scheme", "") + + if scheme == "https://aastatus.net/severity": + severity = label + elif scheme == "https://aastatus.net/type": + categories.append(label) + elif scheme == "https://aastatus.net/status": + status = label + + updated = entry.findtext(f"{atom_ns}updated", "") + published = entry.findtext(f"{atom_ns}published", "") + + return { + "id": id_elem.text.strip(), + "title": html.unescape(title_text), + "link": link, + "updated": updated, + "published": published, + "categories": ",".join(categories), + "severity": severity, + "status": status, + "content": html.unescape(content_text) + } + + return None + + except ET.ParseError as e: + logger.error(f"XML parsing error: {e}") + raise + + +### MARKDOWN CLEANER ### + +def html_to_markdown(html_content): + md = html_content + md = re.sub(r"", "\n", md, flags=re.IGNORECASE) + md = re.sub(r"", "**", md, flags=re.IGNORECASE) + md = re.sub(r"", "\n", md, flags=re.IGNORECASE) + md = re.sub(r"<[^>]+>", "", md) + return md.strip()[:3500] + + +### COLOR LOGIC ### + +def get_embed_color(severity): + return { + "Minor": COLOR_MINOR, + "MSO": COLOR_MSO, + "PEW": COLOR_PEW + }.get(severity, COLOR_DEFAULT) + + +def get_status_color(status): + return { + "Open": COLOR_STATUS_OPEN, + "Closed": COLOR_STATUS_CLOSED + }.get(status, COLOR_STATUS_UNKNOWN) + + +### DISCORD FORMAT ### + +def build_discord_payload(entry, change_type="New entry"): + content_md = html_to_markdown(entry["content"]) + + try: + updated_ts = datetime.fromisoformat(entry["updated"].replace("Z", "+00:00")).isoformat() + except Exception: + updated_ts = entry["updated"] + + # PRIORITY: Status color > Severity color + color = get_status_color(entry["status"]) + if color == COLOR_STATUS_UNKNOWN: + color = get_embed_color(entry["severity"]) + + embed = { + "title": f"{entry['title']} ({change_type})", + "url": entry["link"], + "description": content_md, + "timestamp": updated_ts, + "color": color, + "fields": [ + {"name": "Published", "value": entry["published"], "inline": True}, + {"name": "Severity", "value": entry["severity"], "inline": True}, + {"name": "Status", "value": entry["status"], "inline": True}, + {"name": "Categories", "value": entry["categories"], "inline": False}, + ] + } + return {"embeds": [embed]} + + +def post_to_discord(webhook_url, payload): + if not webhook_url: + logger.error("Discord webhook URL is not set!") + return + try: + resp = requests.post(webhook_url, json=payload) + resp.raise_for_status() + logger.info("Posted to Discord") + except requests.RequestException as e: + logger.error(f"Discord post failed: {e}") + + +### MAIN LOGIC ### + +def main(): + logger.info("[*] Fetching feed...") + feed_xml = fetch_feed(FEED_URL) + + logger.info("[*] Parsing...") + entry = get_first_valid_entry(feed_xml) + if not entry: + logger.warning("No entry found") + return + + incident_id = entry["id"] + state = load_state() + + prev = state.get(incident_id) + + # Determine if an update is needed + must_post = False + change_type = "New entry" + + if prev is None: + must_post = True + change_type = "New Incident Detected" + + else: + # Compare important fields + if entry["status"] != prev.get("status"): + must_post = True + change_type = f"Status changed: {prev.get('status')} β†’ {entry['status']}" + + elif entry["severity"] != prev.get("severity"): + must_post = True + change_type = f"Severity changed: {prev.get('severity')} β†’ {entry['severity']}" + + elif entry["updated"] != prev.get("updated"): + must_post = True + change_type = "Feed updated" + + elif entry["content"] != prev.get("content"): + must_post = True + change_type = "Content updated" + + if must_post: + logger.info(f"[+] Posting update: {change_type}") + payload = build_discord_payload(entry, change_type) + post_to_discord(WEBHOOK_URL, payload) + + # Save state no matter what + state[incident_id] = { + "status": entry["status"], + "severity": entry["severity"], + "updated": entry["updated"], + "content": entry["content"] + } + save_state(state) + + +if __name__ == "__main__": + main() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests