Skip to content

Commit dd03c09

Browse files
committed
New version and tests
1 parent 235a506 commit dd03c09

18 files changed

Lines changed: 642 additions & 172 deletions

.gitignore

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,30 @@
1-
flaskAlert.log
1+
# Logs
2+
*.log
3+
4+
# Python
5+
__pycache__/
6+
*.py[cod]
7+
*.pyo
8+
*.pyd
9+
.Python
10+
11+
# Virtual envs
12+
.venv/
13+
venv/
14+
env/
15+
16+
# pytest
17+
.pytest_cache/
18+
.coverage
19+
htmlcov/
20+
21+
# Distribution
22+
*.egg-info/
23+
dist/
24+
build/
25+
26+
# IDE
27+
.venv/
28+
.idea/
29+
.vscode/
30+
*.swp

README.md

Lines changed: 115 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,119 @@
1-
# Alertmanager webhook for Telegram (Python Version)
1+
# Alertmanager webhook for Telegram (Python version)
22

33
![Docker Image CI](https://github.com/nopp/alertmanager-webhook-telegram-python/workflows/Docker%20Image%20CI/badge.svg)
44
![Code scanning - action](https://github.com/nopp/alertmanager-webhook-telegram-python/workflows/Code%20scanning%20-%20action/badge.svg)
55

6-
GO Version (https://github.com/nopp/alertmanager-webhook-telegram-go)
7-
8-
Python version 3
9-
10-
## INSTALL
11-
12-
* pip install -r requirements.txt
13-
14-
Change on flaskAlert.py
15-
=======================
16-
* botToken
17-
* chatID
18-
19-
If you'll use with authentication, change too
20-
21-
* XXXUSERNAME
22-
* XXXPASSWORD
23-
24-
Disabling authentication
25-
========================
26-
On flaskAlert.py change app.config['BASIC_AUTH_FORCE'] = True to app.config['BASIC_AUTH_FORCE'] = False
27-
28-
Alertmanager configuration example
29-
==================================
30-
31-
receivers:
32-
- name: 'telegram-webhook'
33-
webhook_configs:
34-
- url: http://ipFlaskAlert:9119/alert
35-
send_resolved: true
36-
http_config:
37-
basic_auth:
38-
username: 'admin'
39-
password: 'password'
40-
41-
One way to get the chat ID
42-
==========================
43-
1) Access https://web.telegram.org/
44-
2) Click to specific chat to the left
45-
3) At the url, you can get the chat ID(Ex: https://web.telegram.org/#/im?p=g1234567, so the chatID is 1234567)
46-
47-
Running
48-
=======
49-
* python flaskAlert.py
50-
51-
Running on docker
52-
=================
53-
git clone https://github.com/nopp/alertmanager-webhook-telegram.git
54-
cd alertmanager-webhook-telegram/docker/
55-
docker build -t alertmanager-webhook-telegram:1.0 .
56-
57-
docker run -d --name telegram-bot \
58-
-e "bottoken=telegramBotToken" \
59-
-e "chatid=telegramChatID" \
60-
-e "username=<username>" \
61-
-e "password=<password>" \
62-
-p 9119:9119 alertmanager-webhook-telegram:1.0
63-
64-
Example to test
65-
===============
66-
curl -XPOST --data '{"status":"resolved","groupLabels":{"alertname":"instance_down"},"commonAnnotations":{"description":"i-0d7188fkl90bac100 of job ec2-sp-node_exporter has been down for more than 2 minutes.","summary":"Instance i-0d7188fkl90bac100 down"},"alerts":[{"status":"resolved","labels":{"name":"olokinho01-prod","instance":"i-0d7188fkl90bac100","job":"ec2-sp-node_exporter","alertname":"instance_down","os":"linux","severity":"page"},"endsAt":"2019-07-01T16:16:19.376244942-03:00","generatorURL":"http://pmts.io:9090","startsAt":"2019-07-01T16:02:19.376245319-03:00","annotations":{"description":"i-0d7188fkl90bac100 of job ec2-sp-node_exporter has been down for more than 2 minutes.","summary":"Instance i-0d7188fkl90bac100 down"}}],"version":"4","receiver":"infra-alert","externalURL":"http://alm.io:9093","commonLabels":{"name":"olokinho01-prod","instance":"i-0d7188fkl90bac100","job":"ec2-sp-node_exporter","alertname":"instance_down","os":"linux","severity":"page"}}' http://username:password@flaskAlert:9119/alert
6+
GO version: https://github.com/nopp/alertmanager-webhook-telegram-go
7+
8+
## Project structure
9+
10+
```
11+
├── app/
12+
│ ├── __init__.py # app factory (create_app)
13+
│ ├── config.py # config from env vars
14+
│ ├── alerts.py # alert message builder
15+
│ ├── telegram.py # Telegram HTTP client
16+
│ └── routes.py # Flask blueprint (/alert)
17+
├── wsgi.py # gunicorn / dev entrypoint
18+
├── requirements.txt
19+
└── docker/
20+
├── Dockerfile
21+
└── run.sh
22+
```
23+
24+
## Requirements
25+
26+
- Python 3.11+
27+
- pip packages: see `requirements.txt`
28+
29+
## Configuration
30+
31+
All config via environment variables — no hardcoded secrets.
32+
33+
| Variable | Required | Default | Description |
34+
|---|---|---|---|
35+
| `TELEGRAM_BOT_TOKEN` ||| Bot token from @BotFather |
36+
| `TELEGRAM_CHAT_ID` ||| Target chat/group ID |
37+
| `BASIC_AUTH_USERNAME` || `""` | HTTP basic auth username |
38+
| `BASIC_AUTH_PASSWORD` || `""` | HTTP basic auth password |
39+
| `BASIC_AUTH_FORCE` || `true` | Set `false` to disable auth |
40+
| `SECRET_KEY` || random | Flask secret key |
41+
| `MAX_RETRIES` || `3` | Telegram send retries |
42+
43+
## Install & run locally
44+
45+
```bash
46+
pip install -r requirements.txt
47+
48+
export TELEGRAM_BOT_TOKEN=your_token
49+
export TELEGRAM_CHAT_ID=your_chat_id
50+
export BASIC_AUTH_USERNAME=admin
51+
export BASIC_AUTH_PASSWORD=secret
52+
53+
# dev
54+
python wsgi.py
55+
56+
# production
57+
gunicorn -w 4 -b 0.0.0.0:9119 wsgi:app
58+
```
59+
60+
## Docker
61+
62+
```bash
63+
cd docker/
64+
docker build -t alertmanager-webhook-telegram .
65+
66+
docker run -d --name telegram-webhook \
67+
-e TELEGRAM_BOT_TOKEN=your_token \
68+
-e TELEGRAM_CHAT_ID=your_chat_id \
69+
-e BASIC_AUTH_USERNAME=admin \
70+
-e BASIC_AUTH_PASSWORD=secret \
71+
-p 9119:9119 \
72+
alertmanager-webhook-telegram
73+
```
74+
75+
## Alertmanager config example
76+
77+
```yaml
78+
receivers:
79+
- name: telegram-webhook
80+
webhook_configs:
81+
- url: http://your-host:9119/alert
82+
send_resolved: true
83+
http_config:
84+
basic_auth:
85+
username: admin
86+
password: secret
87+
```
88+
89+
## Get your Telegram chat ID
90+
91+
1. Go to https://web.telegram.org/
92+
2. Open the target chat
93+
3. Copy the numeric ID from the URL (e.g. `https://web.telegram.org/#/im?p=g1234567` → ID is `-1234567` for groups)
94+
95+
## Test
96+
97+
```bash
98+
curl -XPOST \
99+
-u admin:secret \
100+
-H 'Content-Type: application/json' \
101+
--data '{
102+
"status": "resolved",
103+
"alerts": [{
104+
"status": "resolved",
105+
"labels": {
106+
"alertname": "instance_down",
107+
"instance": "i-0d7188fkl90bac100",
108+
"name": "prod-server"
109+
},
110+
"annotations": {
111+
"summary": "Instance i-0d7188fkl90bac100 down",
112+
"description": "Host has been down for more than 2 minutes."
113+
},
114+
"endsAt": "2024-01-01T16:16:19Z",
115+
"startsAt": "2024-01-01T16:02:19Z"
116+
}]
117+
}' \
118+
http://localhost:9119/alert
119+
```

app/__init__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import logging
2+
3+
from flask import Flask
4+
from flask_basicauth import BasicAuth
5+
6+
from .config import Config
7+
from .routes import bp
8+
from .telegram import TelegramClient
9+
10+
logging.basicConfig(
11+
level=logging.INFO,
12+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
13+
)
14+
15+
16+
def create_app(config: Config | None = None) -> Flask:
17+
app = Flask(__name__)
18+
19+
cfg = config or Config()
20+
21+
if not cfg.TELEGRAM_BOT_TOKEN:
22+
raise ValueError("TELEGRAM_BOT_TOKEN is required")
23+
if not cfg.TELEGRAM_CHAT_ID:
24+
raise ValueError("TELEGRAM_CHAT_ID is required")
25+
app.secret_key = cfg.SECRET_KEY
26+
app.config["BASIC_AUTH_FORCE"] = cfg.BASIC_AUTH_FORCE
27+
app.config["BASIC_AUTH_USERNAME"] = cfg.BASIC_AUTH_USERNAME
28+
app.config["BASIC_AUTH_PASSWORD"] = cfg.BASIC_AUTH_PASSWORD
29+
30+
BasicAuth(app)
31+
32+
app.telegram = TelegramClient( # type: ignore[attr-defined]
33+
bot_token=cfg.TELEGRAM_BOT_TOKEN,
34+
chat_id=cfg.TELEGRAM_CHAT_ID,
35+
max_retries=cfg.MAX_RETRIES,
36+
)
37+
38+
app.register_blueprint(bp)
39+
40+
return app

app/alerts.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from dateutil import parser
2+
3+
4+
def build_message(alert: dict) -> str:
5+
"""Build a human-readable Telegram message from a single Alertmanager alert."""
6+
lines = [f"*Status:* {alert['status'].upper()}"]
7+
8+
labels = alert.get("labels", {})
9+
if "instance" in labels:
10+
name_suffix = f" ({labels['name']})" if "name" in labels else ""
11+
lines.append(f"*Instance:* {labels['instance']}{name_suffix}")
12+
13+
annotations = alert.get("annotations", {})
14+
for key in ("info", "summary", "description"):
15+
if key in annotations:
16+
lines.append(f"*{key.capitalize()}:* {annotations[key]}")
17+
18+
status = alert["status"]
19+
if status == "resolved":
20+
date_str = parser.parse(alert["endsAt"]).strftime("%Y-%m-%d %H:%M:%S")
21+
lines.append(f"*Resolved at:* {date_str}")
22+
elif status == "firing":
23+
date_str = parser.parse(alert["startsAt"]).strftime("%Y-%m-%d %H:%M:%S")
24+
lines.append(f"*Started at:* {date_str}")
25+
26+
return "\n".join(lines)

app/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import os
2+
3+
4+
class Config:
5+
SECRET_KEY: str | bytes = os.environ.get("SECRET_KEY", os.urandom(24))
6+
BASIC_AUTH_FORCE: bool = os.environ.get("BASIC_AUTH_FORCE", "true").lower() == "true"
7+
BASIC_AUTH_USERNAME: str = os.environ.get("BASIC_AUTH_USERNAME", "")
8+
BASIC_AUTH_PASSWORD: str = os.environ.get("BASIC_AUTH_PASSWORD", "")
9+
TELEGRAM_BOT_TOKEN: str = os.environ.get("TELEGRAM_BOT_TOKEN", "")
10+
TELEGRAM_CHAT_ID: str = os.environ.get("TELEGRAM_CHAT_ID", "")
11+
MAX_RETRIES: int = int(os.environ.get("MAX_RETRIES", "3"))

app/routes.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import json
2+
import logging
3+
4+
from flask import Blueprint, current_app, request
5+
6+
from .alerts import build_message
7+
8+
logger = logging.getLogger(__name__)
9+
10+
bp = Blueprint("alerts", __name__)
11+
12+
13+
@bp.route("/alert", methods=["POST"])
14+
def post_alertmanager():
15+
try:
16+
payload = json.loads(request.get_data())
17+
except json.JSONDecodeError as exc:
18+
logger.error("Invalid JSON payload: %s", exc)
19+
return "Bad Request", 400
20+
21+
alerts = payload.get("alerts", [])
22+
if not alerts:
23+
return "No alerts", 200
24+
25+
telegram = current_app.telegram
26+
errors = []
27+
28+
for alert in alerts:
29+
alert_name = alert.get("labels", {}).get("alertname", "unknown")
30+
try:
31+
message = build_message(alert)
32+
telegram.send(message)
33+
logger.info("Sent alert: %s", alert_name)
34+
except Exception as exc:
35+
logger.exception("Failed to send alert '%s': %s", alert_name, exc)
36+
errors.append(f"{alert_name}: {exc}")
37+
38+
if errors:
39+
try:
40+
telegram.send(f"⚠️ Failed to forward {len(errors)} alert(s):\n" + "\n".join(errors))
41+
except Exception:
42+
pass
43+
return "Partial failure", 207
44+
45+
return "OK", 200

app/telegram.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
import time
3+
4+
import requests
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class TelegramClient:
10+
def __init__(self, bot_token: str, chat_id: str, max_retries: int = 3) -> None:
11+
self.chat_id = chat_id
12+
self.max_retries = max_retries
13+
self._url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
14+
15+
def send(self, text: str) -> None:
16+
"""Send a message to Telegram with retry on transient errors."""
17+
payload = {"chat_id": self.chat_id, "text": text, "parse_mode": "Markdown"}
18+
19+
for attempt in range(1, self.max_retries + 1):
20+
try:
21+
resp = requests.post(self._url, json=payload, timeout=10)
22+
resp.raise_for_status()
23+
return
24+
except requests.exceptions.HTTPError:
25+
if resp.status_code == 429:
26+
wait = int(resp.headers.get("Retry-After", 30))
27+
logger.warning("Rate limited, waiting %ds (attempt %d/%d)…", wait, attempt, self.max_retries)
28+
time.sleep(wait)
29+
else:
30+
raise
31+
except requests.exceptions.Timeout:
32+
logger.warning("Timeout (attempt %d/%d), retrying in 60s…", attempt, self.max_retries)
33+
time.sleep(60)
34+
except requests.exceptions.ConnectionError as exc:
35+
logger.warning("Network error (attempt %d/%d): %s", attempt, self.max_retries, exc)
36+
time.sleep(60)
37+
38+
raise RuntimeError(f"Failed to reach Telegram after {self.max_retries} attempts")

0 commit comments

Comments
 (0)