Initial commit
This commit is contained in:
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# cmk_telegram - Checkmk Telegram Notifications
|
||||
|
||||
Telegram notification plugin for the Checkmk monitoring platform.
|
||||
|
||||
This is a Telegram adaptation of `Berghmans/cmk_discord`. It keeps the same notification semantics as far as Telegram allows:
|
||||
|
||||
- emoji per Checkmk notification type
|
||||
- state highlighting via colored status marker
|
||||
- state transition (`previous -> current`)
|
||||
- service fields for host and service
|
||||
- host/service output
|
||||
- acknowledgement/downtime comments
|
||||
- footer with check command, Checkmk site and timestamp
|
||||
- inline button linking to the failed host/service when a Checkmk site URL is configured
|
||||
|
||||
Telegram does not support Discord-style embeds, embed sidebars/colors, webhook usernames or webhook avatars. Those parts are represented with message formatting, colored emoji markers and an inline button.
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual installation
|
||||
|
||||
On the Checkmk server, switch into the site user and install the notification script:
|
||||
|
||||
```bash
|
||||
sudo su - <site-name>
|
||||
mkdir -p ~/local/share/check_mk/notifications
|
||||
cp cmk_telegram.py ~/local/share/check_mk/notifications/cmk_telegram.py
|
||||
chmod +x ~/local/share/check_mk/notifications/cmk_telegram.py
|
||||
```
|
||||
|
||||
Then restart/refresh Checkmk if needed and create a notification rule.
|
||||
|
||||
### Package source layout
|
||||
|
||||
The `src/` folder follows the same source layout as the original MKP-style repository:
|
||||
|
||||
```text
|
||||
src/
|
||||
info.json
|
||||
notifications/
|
||||
cmk_telegram.py
|
||||
```
|
||||
|
||||
On a Checkmk site you can copy the notification file manually, or use the `src` directory as the basis for creating an MKP on your Checkmk instance.
|
||||
|
||||
## Configuration in Checkmk
|
||||
|
||||
Go to **Setup → Notifications → Add rule** and select **Telegram Notification**.
|
||||
|
||||
Parameters:
|
||||
|
||||
1. **Telegram bot token** — mandatory, e.g. `123456789:ABCDEF...`
|
||||
2. **Telegram chat ID** — mandatory, e.g. `123456789`, `-1001234567890`, or `@channelusername`
|
||||
3. **Checkmk site URL** — optional, e.g. `https://checkmkhost.mycompany.com/my_monitoring`
|
||||
4. **Telegram message thread ID** — optional, for forum topics in supergroups
|
||||
5. **Disable notification sound** — optional; use `true`, `1`, `yes`, `on` to send silently
|
||||
|
||||
## Getting Telegram values
|
||||
|
||||
1. Create a bot via `@BotFather` and copy the token.
|
||||
2. Add the bot to your target chat/group/channel.
|
||||
3. Send a message to the target chat.
|
||||
4. Retrieve the chat ID with `https://api.telegram.org/bot<TOKEN>/getUpdates`, or use an ID helper bot in private chats.
|
||||
|
||||
For channels, the bot usually must be an administrator and the chat ID can also be the public channel username, e.g. `@my_channel`.
|
||||
|
||||
## Quick local smoke test
|
||||
|
||||
This will not contact Telegram, but it verifies that the script builds a Telegram payload:
|
||||
|
||||
```bash
|
||||
python3 examples/render_payload.py
|
||||
```
|
||||
|
||||
## License and attribution
|
||||
|
||||
This work is derived from `Berghmans/cmk_discord`, which is licensed under GPL-2.0. Keep GPL-2.0 attribution when redistributing modified versions.
|
||||
414
cmk_telegram.py
Normal file
414
cmk_telegram.py
Normal file
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
# Telegram Notification for Checkmk
|
||||
# Derived from Berghmans/cmk_discord (GPL-2.0-only)
|
||||
# Version: 0.1.0
|
||||
|
||||
import datetime
|
||||
import html
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
"""Checkmk notification context."""
|
||||
|
||||
# Common fields
|
||||
what: str # SERVICE or HOST
|
||||
notification_type: str
|
||||
short_datetime: str
|
||||
omd_site: str
|
||||
hostname: str
|
||||
|
||||
# Parameters from the notification rule
|
||||
telegram_token: Optional[str] = None # PARAMETER_1
|
||||
chat_id: Optional[str] = None # PARAMETER_2
|
||||
site_url: Optional[str] = None # PARAMETER_3
|
||||
|
||||
# Optional Telegram parameters
|
||||
message_thread_id: Optional[str] = None # PARAMETER_4, for forum topics
|
||||
disable_notification: Optional[str] = None # PARAMETER_5: true/false
|
||||
|
||||
# Service-specific fields
|
||||
service_desc: Optional[str] = None
|
||||
service_state: Optional[str] = None
|
||||
previous_service_state: Optional[str] = None
|
||||
service_output: Optional[str] = None
|
||||
service_check_command: Optional[str] = None
|
||||
service_url: Optional[str] = None
|
||||
|
||||
# Host-specific fields
|
||||
host_state: Optional[str] = None
|
||||
previous_host_state: Optional[str] = None
|
||||
host_output: Optional[str] = None
|
||||
host_check_command: Optional[str] = None
|
||||
host_url: Optional[str] = None
|
||||
|
||||
# Optional comment
|
||||
notification_comment: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Context":
|
||||
"""Create Context from environment variable dictionary."""
|
||||
return cls(
|
||||
what=data.get("WHAT", ""),
|
||||
notification_type=data.get("NOTIFICATIONTYPE", ""),
|
||||
short_datetime=data.get("SHORTDATETIME", ""),
|
||||
omd_site=data.get("OMD_SITE", ""),
|
||||
hostname=data.get("HOSTNAME", ""),
|
||||
telegram_token=data.get("PARAMETER_1"),
|
||||
chat_id=data.get("PARAMETER_2"),
|
||||
site_url=data.get("PARAMETER_3"),
|
||||
message_thread_id=data.get("PARAMETER_4"),
|
||||
disable_notification=data.get("PARAMETER_5"),
|
||||
service_desc=data.get("SERVICEDESC"),
|
||||
service_state=data.get("SERVICESTATE"),
|
||||
previous_service_state=data.get("LASTSERVICESTATE")
|
||||
or data.get("PREVIOUSSERVICEHARDSTATE"),
|
||||
service_output=data.get("SERVICEOUTPUT"),
|
||||
service_check_command=data.get("SERVICECHECKCOMMAND"),
|
||||
service_url=data.get("SERVICEURL"),
|
||||
host_state=data.get("HOSTSTATE"),
|
||||
previous_host_state=data.get("LASTHOSTSTATE")
|
||||
or data.get("PREVIOUSHOSTHARDSTATE"),
|
||||
host_output=data.get("HOSTOUTPUT"),
|
||||
host_check_command=data.get("HOSTCHECKCOMMAND"),
|
||||
host_url=data.get("HOSTURL"),
|
||||
notification_comment=data.get("NOTIFICATIONCOMMENT"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Context":
|
||||
"""Create Context from environment variables (NOTIFY_* variables)."""
|
||||
env_dict = {
|
||||
var[7:]: value
|
||||
for (var, value) in os.environ.items()
|
||||
if var.startswith("NOTIFY_")
|
||||
}
|
||||
return cls.from_dict(env_dict)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate the context and raise SystemExit if invalid."""
|
||||
if not self.telegram_token:
|
||||
sys.stderr.write("Empty Telegram bot token given as parameter 1")
|
||||
sys.exit(2)
|
||||
|
||||
if self.telegram_token.startswith("http") and not self.telegram_token.startswith(
|
||||
"https://api.telegram.org/bot"
|
||||
):
|
||||
sys.stderr.write(
|
||||
"Invalid Telegram API URL given as first parameter "
|
||||
"(expected bot token or https://api.telegram.org/bot.../sendMessage)"
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if not self.chat_id:
|
||||
sys.stderr.write("Empty Telegram chat id given as parameter 2")
|
||||
sys.exit(2)
|
||||
|
||||
if self.site_url and not self.site_url.startswith("http"):
|
||||
sys.stderr.write(
|
||||
"Invalid site url given as third parameter (not starting with http): %s"
|
||||
% self.site_url
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
class AlertColor(str, Enum):
|
||||
"""Mapping of Checkmk alert states to Telegram-visible state markers."""
|
||||
|
||||
CRITICAL = "🔴"
|
||||
DOWN = "🔴"
|
||||
WARNING = "🟡"
|
||||
OK = "🟢"
|
||||
UP = "🟢"
|
||||
UNKNOWN = "🟠"
|
||||
UNREACHABLE = "⚫"
|
||||
|
||||
|
||||
class NotificationEmoji(str, Enum):
|
||||
"""Mapping of Checkmk notification types to Unicode emojis."""
|
||||
|
||||
PROBLEM = "🚨"
|
||||
RECOVERY = "✅"
|
||||
ACKNOWLEDGEMENT = "☑️"
|
||||
FLAPPINGSTART = "⁉️"
|
||||
FLAPPINGSTOP = "✅"
|
||||
DOWNTIMESTART = "⏰"
|
||||
DOWNTIMEEND = "✅"
|
||||
DOWNTIMECANCELLED = "☑️"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelegramMessage:
|
||||
"""Base class for Telegram messages that mimic the Discord embed layout."""
|
||||
|
||||
ctx: Context
|
||||
timestamp: str
|
||||
previous_state: Optional[str]
|
||||
current_state: Optional[str]
|
||||
output: Optional[str]
|
||||
title_subject: str
|
||||
footer_text: Optional[str]
|
||||
url_path: Optional[str]
|
||||
fields: Optional[list] = None
|
||||
|
||||
MAX_TEXT_LENGTH = 4096
|
||||
TRUNCATION_SUFFIX = "\n\n… truncated by cmk_telegram because Telegram messages are limited."
|
||||
|
||||
@staticmethod
|
||||
def escape(value: Optional[str]) -> str:
|
||||
"""HTML-escape a value for Telegram parse_mode=HTML."""
|
||||
return html.escape(str(value or ""), quote=False)
|
||||
|
||||
@staticmethod
|
||||
def get_alert_marker(state: Optional[str]) -> str:
|
||||
"""Get a Telegram-visible marker for a Checkmk alert state."""
|
||||
if state and state in AlertColor.__members__:
|
||||
return AlertColor[state].value
|
||||
return "⚪"
|
||||
|
||||
@staticmethod
|
||||
def get_emoji(notification_type: str) -> str:
|
||||
"""Get the emoji for the notification type."""
|
||||
# IMPORTANT: Use __members__ instead of iterating the enum directly.
|
||||
# Duplicate enum values (RECOVERY, FLAPPINGSTOP, DOWNTIMEEND) are aliases
|
||||
# and would otherwise be skipped during iteration.
|
||||
if notification_type in NotificationEmoji.__members__:
|
||||
return NotificationEmoji[notification_type].value
|
||||
|
||||
for member_name in NotificationEmoji.__members__:
|
||||
if notification_type.startswith(member_name):
|
||||
return NotificationEmoji[member_name].value
|
||||
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def from_context(cls, ctx: Context) -> "TelegramMessage":
|
||||
"""Factory method to create the appropriate message type based on context."""
|
||||
timestamp = str(datetime.datetime.fromisoformat(ctx.short_datetime).astimezone())
|
||||
message_class = ServiceMessage if ctx.what == "SERVICE" else HostMessage
|
||||
return message_class(ctx, timestamp)
|
||||
|
||||
def _build_title(self) -> str:
|
||||
marker = self.get_alert_marker(self.current_state)
|
||||
emoji = self.get_emoji(self.ctx.notification_type)
|
||||
return "%s %s <b>%s: %s</b>" % (
|
||||
marker,
|
||||
emoji,
|
||||
self.escape(self.ctx.notification_type),
|
||||
self.escape(self.title_subject),
|
||||
)
|
||||
|
||||
def _build_state_transition(self) -> str:
|
||||
return "<b>%s -> %s</b>" % (
|
||||
self.escape(self.previous_state),
|
||||
self.escape(self.current_state),
|
||||
)
|
||||
|
||||
def _build_description(self) -> str:
|
||||
parts = [self._build_state_transition()]
|
||||
|
||||
if self.output:
|
||||
parts.extend(["", self.escape(self.output)])
|
||||
|
||||
if self.ctx.notification_comment:
|
||||
parts.extend(["", "<b>Comment</b>", self.escape(self.ctx.notification_comment)])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _build_fields(self) -> str:
|
||||
if not self.fields:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
for field in self.fields:
|
||||
name = self.escape(field.get("name"))
|
||||
value = self.escape(field.get("value"))
|
||||
lines.append("<b>%s</b>: %s" % (name, value))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_footer(self) -> str:
|
||||
footer_parts = []
|
||||
|
||||
if self.footer_text:
|
||||
footer_parts.append(self.escape(self.footer_text))
|
||||
|
||||
if self.ctx.omd_site:
|
||||
footer_parts.append("Checkmk - %s" % self.escape(self.ctx.omd_site))
|
||||
|
||||
if self.timestamp:
|
||||
footer_parts.append(self.escape(self.timestamp))
|
||||
|
||||
if not footer_parts:
|
||||
return ""
|
||||
|
||||
return "<i>%s</i>" % self.escape(" • ").join(footer_parts)
|
||||
|
||||
def get_checkmk_url(self) -> Optional[str]:
|
||||
if not self.ctx.site_url or not self.url_path:
|
||||
return None
|
||||
|
||||
return "".join([self.ctx.site_url.rstrip("/"), self.url_path])
|
||||
|
||||
def _truncate(self, text: str) -> str:
|
||||
if len(text) <= self.MAX_TEXT_LENGTH:
|
||||
return text
|
||||
|
||||
keep = self.MAX_TEXT_LENGTH - len(self.TRUNCATION_SUFFIX)
|
||||
return text[:keep] + self.TRUNCATION_SUFFIX
|
||||
|
||||
def to_text(self) -> str:
|
||||
sections = [self._build_title(), "", self._build_description()]
|
||||
|
||||
fields = self._build_fields()
|
||||
if fields:
|
||||
sections.extend(["", fields])
|
||||
|
||||
footer = self._build_footer()
|
||||
if footer:
|
||||
sections.extend(["", footer])
|
||||
|
||||
return self._truncate("\n".join(sections))
|
||||
|
||||
def to_reply_markup(self) -> Optional[dict]:
|
||||
checkmk_url = self.get_checkmk_url()
|
||||
if not checkmk_url:
|
||||
return None
|
||||
|
||||
return {
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{
|
||||
"text": "Open in Checkmk",
|
||||
"url": checkmk_url,
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class ServiceMessage(TelegramMessage):
|
||||
"""Telegram message for service notifications."""
|
||||
|
||||
def __init__(self, ctx: Context, timestamp: str):
|
||||
super().__init__(
|
||||
ctx=ctx,
|
||||
timestamp=timestamp,
|
||||
previous_state=ctx.previous_service_state,
|
||||
current_state=ctx.service_state,
|
||||
output=ctx.service_output,
|
||||
title_subject=ctx.service_desc or "",
|
||||
footer_text=ctx.service_check_command,
|
||||
url_path=ctx.service_url,
|
||||
fields=[
|
||||
{"name": "Host", "value": ctx.hostname},
|
||||
{"name": "Service", "value": ctx.service_desc},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class HostMessage(TelegramMessage):
|
||||
"""Telegram message for host notifications."""
|
||||
|
||||
def __init__(self, ctx: Context, timestamp: str):
|
||||
super().__init__(
|
||||
ctx=ctx,
|
||||
timestamp=timestamp,
|
||||
previous_state=ctx.previous_host_state,
|
||||
current_state=ctx.host_state,
|
||||
output=ctx.host_output,
|
||||
title_subject="Host: %s" % ctx.hostname,
|
||||
footer_text=ctx.host_check_command,
|
||||
url_path=ctx.host_url,
|
||||
)
|
||||
|
||||
|
||||
class TelegramBotApi:
|
||||
"""Telegram Bot API sender for Checkmk notifications."""
|
||||
|
||||
def __init__(self, token_or_url: str, chat_id: str, message: TelegramMessage):
|
||||
self.token_or_url = token_or_url.strip()
|
||||
self.chat_id = chat_id.strip()
|
||||
self.message = message
|
||||
|
||||
def _api_url(self) -> str:
|
||||
if self.token_or_url.startswith("https://api.telegram.org/bot"):
|
||||
return self.token_or_url
|
||||
|
||||
token = self.token_or_url
|
||||
if token.startswith("bot"):
|
||||
token = token[3:]
|
||||
|
||||
return "https://api.telegram.org/bot%s/sendMessage" % token
|
||||
|
||||
@staticmethod
|
||||
def _as_bool(value: Optional[str]) -> bool:
|
||||
return str(value or "").strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
def _build_payload(self) -> dict:
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": self.message.to_text(),
|
||||
"parse_mode": "HTML",
|
||||
"link_preview_options": {"is_disabled": True},
|
||||
"disable_notification": self._as_bool(self.message.ctx.disable_notification),
|
||||
}
|
||||
|
||||
reply_markup = self.message.to_reply_markup()
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
|
||||
if self.message.ctx.message_thread_id:
|
||||
payload["message_thread_id"] = self.message.ctx.message_thread_id
|
||||
|
||||
return payload
|
||||
|
||||
def send(self) -> None:
|
||||
response = requests.post(url=self._api_url(), json=self._build_payload(), timeout=10)
|
||||
|
||||
if response.status_code != HTTPStatus.OK.value:
|
||||
sys.stderr.write(
|
||||
"Unexpected response when calling Telegram Bot API URL %s: %i. "
|
||||
"Response body: %s" % (self._api_url(), response.status_code, response.text)
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
sys.stderr.write(
|
||||
"Unexpected non-JSON response when calling Telegram Bot API URL %s: %s"
|
||||
% (self._api_url(), response.text)
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if body.get("ok") is not True:
|
||||
sys.stderr.write(
|
||||
"Unexpected response when calling Telegram Bot API URL %s: %i. "
|
||||
"Response body: %s" % (self._api_url(), response.status_code, response.text)
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
ctx = Context.from_env()
|
||||
ctx.validate()
|
||||
message = TelegramMessage.from_context(ctx)
|
||||
telegram = TelegramBotApi(ctx.telegram_token, ctx.chat_id, message)
|
||||
telegram.send()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
sys.stderr.write("Unhandled exception: %s\n" % e)
|
||||
sys.exit(2)
|
||||
Reference in New Issue
Block a user