Added script, requirements and README.md
This commit is contained in:
parent
9bda8239c8
commit
bd5d74d8fd
210
README.md
210
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
|
||||
{
|
||||
"<incident-id>": {
|
||||
"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.
|
||||
263
aastatus_to_webhook.py
Normal file
263
aastatus_to_webhook.py
Normal file
@ -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"<br\s*/?>", "\n", md, flags=re.IGNORECASE)
|
||||
md = re.sub(r"</?b>", "**", md, flags=re.IGNORECASE)
|
||||
md = re.sub(r"</?p>", "\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()
|
||||
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
requests
|
||||
Loading…
Reference in New Issue
Block a user