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