From 3972863199986b70cda3018f9c975fbc408fca66 Mon Sep 17 00:00:00 2001 From: ornariece <25489980+ornariece@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:05:24 +0200 Subject: [PATCH 1/6] fix: use dict key lookup instead of hasattr for annotation access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasattr(dict, 'key') is always False — dicts expose keys via __getitem__, not as attributes. This meant the description annotation was never read, always falling back to summary. --- alertbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alertbot.py b/alertbot.py index 705e0f2..dad9c4c 100644 --- a/alertbot.py +++ b/alertbot.py @@ -223,7 +223,7 @@ def prometheus_alert_to_markdown(alert_data: dict) -> str: messages = [] known_labels = ['alertname', 'instance', 'job'] for alert in alert_data["alerts"]: - title = alert['annotations']['description'] if hasattr(alert['annotations'], 'description') else \ + title = alert['annotations']['description'] if 'description' in alert['annotations'] else \ alert['annotations']['summary'] message = f"""**{alert['status']}** {'💚' if alert['status'] == 'resolved' else '🔥'}: {title}""" for label_name in known_labels: From 22f2563c81a90c06b76682a812d1e6fab05d4a6b Mon Sep 17 00:00:00 2001 From: ornariece <25489980+ornariece@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:05:33 +0200 Subject: [PATCH 2/6] fix: harden webhook detection and dispatcher error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Catch IndexError and TypeError in addition to KeyError when probing webhook payloads, since malformed data may have wrong types or empty arrays rather than missing keys. - Fix if/elif chain in get_alert_messages — the slack-webhook branch used 'if' instead of 'elif', causing unintended fall-through. - Fix error message: e.with_traceback() returns None, use str(e). --- alertbot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/alertbot.py b/alertbot.py index dad9c4c..c1ff082 100644 --- a/alertbot.py +++ b/alertbot.py @@ -73,7 +73,7 @@ def get_alert_type(data): return "uptime-kuma-alert" elif data["heartbeat"]["status"] == 1: return "uptime-kuma-resolved" - except KeyError: + except (KeyError, TypeError): pass # Grafana @@ -83,7 +83,7 @@ def get_alert_type(data): return "grafana-alert" else: return "grafana-resolved" - except KeyError: + except (KeyError, IndexError, TypeError): pass # Prometheus @@ -93,7 +93,7 @@ def get_alert_type(data): return "prometheus-alert" else: return "prometheus-resolved" - except KeyError: + except (KeyError, IndexError, TypeError): pass return "not-found" @@ -118,7 +118,7 @@ def get_alert_messages(alert_data: dict, raw_mode=False) -> list: try: if alert_type == "slack-webhook": messages = convert_slack_webhook_to_markdown(alert_data) - if alert_type == "grafana-alert": + elif alert_type == "grafana-alert": messages = grafana_alert_to_markdown(alert_data) elif alert_type == "grafana-resolved": messages = grafana_alert_to_markdown(alert_data) @@ -132,7 +132,7 @@ def get_alert_messages(alert_data: dict, raw_mode=False) -> list: messages = uptime_kuma_resolved_to_markdown(alert_data) except KeyError as e: messages = ["**Data received**\n```\n" + str(alert_data).strip( - "\n").strip() + f"\n```\nThe data was detected as {alert_type} but was not in an expected format. If you want to help the development of this bot, file a bug report [here](https://github.com/moan0s/alertbot/issues)\n{e.with_traceback()}"] + "\n").strip() + f"\n```\nThe data was detected as {alert_type} but was not in an expected format. If you want to help the development of this bot, file a bug report [here](https://github.com/moan0s/alertbot/issues)\n{e}"] return messages From 10292c6fdb6d7b2fbf1048ebc1f724762969fdc4 Mon Sep 17 00:00:00 2001 From: ornariece <25489980+ornariece@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:06:32 +0200 Subject: [PATCH 3/6] feat: replace Prometheus formatter with generic Alertmanager support The upstream Prometheus detection required a "job" label, which is only present in Prometheus-originated alerts. Replace with "alertname" detection, which matches ALL Alertmanager webhook payloads regardless of the alert source (Prometheus, custom rules, Thanos, Mimir, etc.). New alertmanager_to_markdown() produces structured messages optimized for mobile notification clients: - Emoji status line (firing/resolved) with alert name and severity - Description from annotations (with correct dict key lookup) - Label metadata as list items (all labels except alertname/severity) - Duration calculation for resolved alerts - Pre-filled silence URL from Alertmanager's externalURL - Source link from generatorURL Move request text reading into send_alert, reducing the webhook handler to routing and error handling. --- alertbot.py | 202 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 158 insertions(+), 44 deletions(-) diff --git a/alertbot.py b/alertbot.py index c1ff082..bac2c17 100644 --- a/alertbot.py +++ b/alertbot.py @@ -1,4 +1,5 @@ import json +from urllib.parse import quote import dateutil.parser from aiohttp.web import Request, Response, json_response @@ -58,10 +59,15 @@ def convert_slack_webhook_to_markdown(data): def get_alert_type(data): - """ - Currently supported are ["grafana-alert", "grafana-resolved", "prometheus-alert", "not-found"] - - :return: alert type + """Detect the type of incoming webhook payload. + + Supported types: + - slack-webhook: Slack-format payload with text + attachments + - uptime-kuma-alert / uptime-kuma-resolved: Uptime Kuma heartbeat + - grafana-alert / grafana-resolved: Grafana alert with grafana_folder label + - alertmanager-alert / alertmanager-resolved: Any Alertmanager webhook + (includes Prometheus-originated alerts — the "job" label is NOT required) + - not-found: Unrecognized format """ if ("text" in data) and ("attachments" in data): @@ -86,19 +92,154 @@ def get_alert_type(data): except (KeyError, IndexError, TypeError): pass - # Prometheus + # Generic Alertmanager webhook: has alerts[] with labels.alertname. + # This covers Prometheus-originated alerts, custom alerts, and any other + # source that sends through Alertmanager. No "job" label required. try: - if data["alerts"][0]["labels"]["job"]: + if data["alerts"][0]["labels"]["alertname"]: if data['status'] == "firing": - return "prometheus-alert" + return "alertmanager-alert" else: - return "prometheus-resolved" + return "alertmanager-resolved" except (KeyError, IndexError, TypeError): pass return "not-found" +def _format_duration(start_str, end_str): + """Calculate human-readable duration between two ISO 8601 timestamps.""" + try: + start = dateutil.parser.isoparse(start_str) + end = dateutil.parser.isoparse(end_str) + delta = end - start + total_seconds = int(delta.total_seconds()) + if total_seconds < 0: + return None + + days, remainder = divmod(total_seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes or not parts: + parts.append(f"{minutes}m") + return " ".join(parts) + except (ValueError, TypeError): + return None + + +def _format_timestamp(iso_str): + """Format an ISO 8601 timestamp as 'YYYY-MM-DD HH:MM UTC'.""" + try: + dt = dateutil.parser.isoparse(iso_str) + return dt.strftime("%Y-%m-%d %H:%M UTC") + except (ValueError, TypeError): + return iso_str + + +def _build_silence_url(external_url, alertname): + """Build a pre-filled Alertmanager silence URL for a given alertname.""" + if not external_url: + return None + base = external_url.rstrip("/") + filter_param = quote('{alertname="' + alertname + '"}') + return f"{base}/#/silences/new?filter={filter_param}" + + +# Labels already shown in the title line — no need to repeat in metadata +_SKIP_LABELS = {"alertname", "severity"} + + +def alertmanager_to_markdown(alert_data: dict) -> list: + """Convert an Alertmanager webhook payload to formatted markdown messages. + + Produces one message per alert in the payload. Each message follows + a structured format optimized for mobile notification clients: + + 1. Status emoji + alert name + severity (= push notification text) + 2. Description + 3. Label metadata (one per line) + 4. Timestamps + 5. Source + silence links + + Args: + alert_data: The full Alertmanager webhook payload dict. + + Returns: + List of markdown-formatted message strings. + """ + messages = [] + external_url = alert_data.get("externalURL", "") + + for alert in alert_data.get("alerts", []): + status = alert.get("status", "unknown") + labels = alert.get("labels", {}) + annotations = alert.get("annotations", {}) + + alertname = labels.get("alertname", "Unknown") + severity = labels.get("severity", "") + + # --- Title line --- + emoji = "\u2705" if status == "resolved" else "\U0001f525" + severity_text = f" \u00b7 {severity}" if severity and status != "resolved" else "" + status_text = " \u00b7 resolved" if status == "resolved" else "" + title = f"{emoji} **{alertname}**{severity_text}{status_text}" + + # --- Description --- + description = annotations.get("description") or annotations.get("summary", "") + + # --- Label metadata (as list items for proper line breaks) --- + meta_lines = [] + for key, value in labels.items(): + if key not in _SKIP_LABELS: + display_key = key.replace("_", " ").title() + meta_lines.append(f"- **{display_key}:** {value}") + + # --- Timestamps --- + starts_at = alert.get("startsAt", "") + ends_at = alert.get("endsAt", "") + + if status == "resolved": + time_parts = [] + duration = _format_duration(starts_at, ends_at) + if duration: + time_parts.append(f"**Duration:** {duration}") + time_parts.append(f"**Resolved:** {_format_timestamp(ends_at)}") + meta_lines.append("- " + " \u00b7 ".join(time_parts)) + elif starts_at: + meta_lines.append(f"- **Since:** {_format_timestamp(starts_at)}") + + # --- Links --- + links = [] + generator_url = alert.get("generatorURL", "") + if generator_url: + links.append(f"[Source]({generator_url})") + silence_url = _build_silence_url(external_url, alertname) + if silence_url and status != "resolved": + links.append(f"[Silence]({silence_url})") + link_line = " \u00b7 ".join(links) + + # --- Assemble message --- + parts = [title, ""] + if description: + parts.append(description) + parts.append("") + for ml in meta_lines: + parts.append(ml) + if link_line: + parts.append("") + parts.append(link_line) + + messages.append("\n".join(parts).strip()) + + return messages + + def get_alert_messages(alert_data: dict, raw_mode=False) -> list: """ Returns a list of messages in markdown format @@ -118,14 +259,10 @@ def get_alert_messages(alert_data: dict, raw_mode=False) -> list: try: if alert_type == "slack-webhook": messages = convert_slack_webhook_to_markdown(alert_data) - elif alert_type == "grafana-alert": + elif alert_type in ("grafana-alert", "grafana-resolved"): messages = grafana_alert_to_markdown(alert_data) - elif alert_type == "grafana-resolved": - messages = grafana_alert_to_markdown(alert_data) - elif alert_type == "prometheus-alert": - messages = prometheus_alert_to_markdown(alert_data) - elif alert_type == "prometheus-resolved": - messages = prometheus_alert_to_markdown(alert_data) + elif alert_type in ("alertmanager-alert", "alertmanager-resolved"): + messages = alertmanager_to_markdown(alert_data) elif alert_type == "uptime-kuma-alert": messages = uptime_kuma_alert_to_markdown(alert_data) elif alert_type == "uptime-kuma-resolved": @@ -213,35 +350,13 @@ def grafana_alert_to_markdown(alert_data: dict) -> list: return messages -def prometheus_alert_to_markdown(alert_data: dict) -> str: - """ - Converts a prometheus alert json to markdown - - :param alert_data: - :return: Alert as fomatted markdown - """ - messages = [] - known_labels = ['alertname', 'instance', 'job'] - for alert in alert_data["alerts"]: - title = alert['annotations']['description'] if 'description' in alert['annotations'] else \ - alert['annotations']['summary'] - message = f"""**{alert['status']}** {'💚' if alert['status'] == 'resolved' else '🔥'}: {title}""" - for label_name in known_labels: - try: - message += "\n* **{0}**: {1}".format(label_name.capitalize(), alert["labels"][label_name]) - except: - pass - messages.append(message) - return messages - - class AlertBot(Plugin): raw_mode = False - async def send_alert(self, text, room): + async def send_alert(self, req, room): + text = await req.text() self.log.info(text) content = json.loads(text) - for message in get_alert_messages(content, self.raw_mode): self.log.debug(f"Sending alert to {room}") await self.client.send_markdown(room, message) @@ -249,15 +364,14 @@ async def send_alert(self, text, room): @web.post("/webhook/{room_id}") async def webhook_room(self, req: Request) -> Response: room_id = req.match_info["room_id"].strip() - text = await req.text() try: - await self.send_alert(text, room=room_id) + await self.send_alert(req, room=room_id) except MForbidden: self.log.error(f"Could not send to {room_id}: Forbidden. Most likely the bot is not invited in the room.") return json_response('{"status": "forbidden", "error": "forbidden"}', status=403) - except json.decoder.JSONDecodeError: - self.log.error(f"Decoding the data failed. {text}") - return json_response({"status": "failed", "error": "JSON decoding failed", "data": text}, status=400) + except json.JSONDecodeError: + self.log.error(f"Decoding the data failed for {room_id}") + return json_response({"status": "failed", "error": "JSON decoding failed"}, status=400) return json_response({"status": "ok"}) @command.new() From 3fad92b37ab7b359442f39560e829bf751061cca Mon Sep 17 00:00:00 2001 From: ornariece <25489980+ornariece@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:06:40 +0200 Subject: [PATCH 4/6] fix: use bullet char for metadata to avoid Element X list rendering bug CommonMark converts '- ' prefixed lines into