Compare commits

..
1 Commits
Author SHA1 Message Date
serega404 03a4ad5d8e Добавил поддержку MeshCore через HA 2026-05-29 01:41:01 +03:00
10 changed files with 134 additions and 213 deletions

No files matched your search

+10 -12
View File
@@ -11,6 +11,13 @@ env:
jobs: jobs:
build-and-push-image: build-and-push-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
image:
- suffix: ''
start_file: start_telegram.py
- suffix: '-meshcore-ha'
start_file: start_meshcore_ha.py
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -19,17 +26,6 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies and run tests
run: |
python -m pip install -r requirements.txt
python -m py_compile parser.py start_telegram.py
python -m unittest discover -s tests -v
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with: with:
@@ -41,12 +37,14 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image.suffix }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with: with:
context: . context: .
build-args: |
START_FILE=${{ matrix.image.start_file }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+3 -2
View File
@@ -1,8 +1,9 @@
FROM python:3.11.2-alpine3.17 FROM python:3.12-alpine
LABEL Maintainer="serega404" LABEL Maintainer="serega404"
WORKDIR /app WORKDIR /app
ARG START_FILE=start_telegram.py
COPY requirements.txt requirements.txt COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt RUN pip3 install -r requirements.txt
@@ -11,7 +12,7 @@ RUN pip3 install -r requirements.txt
COPY crontab /tmp/crontab COPY crontab /tmp/crontab
RUN cat /tmp/crontab > /etc/crontabs/root RUN cat /tmp/crontab > /etc/crontabs/root
COPY start_telegram.py start_telegram.py COPY ${START_FILE} start.py
COPY parser.py parser.py COPY parser.py parser.py
# run crond as main process of container # run crond as main process of container
+29 -12
View File
@@ -2,7 +2,7 @@
[![MIT License](https://img.shields.io/github/license/serega404/VodokanalBot)](https://github.com/serega404/VodokanalBot) [![MIT License](https://img.shields.io/github/license/serega404/VodokanalBot)](https://github.com/serega404/VodokanalBot)
## Запуск в Docker ## Запуск TG интеграции в Docker
``` Docker ``` Docker
docker volume create vodokanal_bot_data docker volume create vodokanal_bot_data
@@ -15,7 +15,7 @@ docker run -d --name VodokanalBot \
ghcr.io/serega404/vodokanalbot:main ghcr.io/serega404/vodokanalbot:main
``` ```
## Запуск в Docker Compose ### Запуск в Docker Compose
Укажи `TELEGRAM_TOKEN` и `TELEGRAM_CHANNEL` в [`docker-compose.yml`](./docker-compose.yml), затем запусти: Укажи `TELEGRAM_TOKEN` и `TELEGRAM_CHANNEL` в [`docker-compose.yml`](./docker-compose.yml), затем запусти:
@@ -23,18 +23,35 @@ docker run -d --name VodokanalBot \
docker compose up -d --build docker compose up -d --build
``` ```
## Запуск с MeshCore + Home Assistant
Вариант для MeshCore отправляет новые сообщения в webhook Home Assistant, а Home Assistant пересылает их в канал MeshCore.
1. Добавь в Home Assistant автоматизацию [`Webhook to MeshCore channel`](https://github.com/serega404/HA-MeshCore#webhook-to-meshcore-channel).
2. Замени `CHANGE_ME_RANDOM_WEBHOOK_ID` в автоматизации на случайную строку.
``` Docker
docker volume create vodokanal_bot_data
docker run -d --name VodokanalBot \
--restart=always \
-v vodokanal_bot_data:/app/data \
-e TZ='Europe/Moscow' \
-e HOME_ASSISTANT_WEBHOOK_ID='CHANGE_ME_RANDOM_WEBHOOK_ID' \
-e HOME_ASSISTANT_URL='http://homeassistant.local:8123' \
-e HOME_ASSISTANT_WEBHOOK_CHANNEL='0' \
ghcr.io/serega404/vodokanalbot-meshcore-ha:main
```
### Запуск в Docker Compose
Укажи `HOME_ASSISTANT_WEBHOOK_ID` и `HOME_ASSISTANT_WEBHOOK_CHANNEL` в [`docker-compose.ha.yml`](./docker-compose.ha.yml), затем запусти:
``` Docker
docker compose up -d --build -f docker-compose.ha.yml
```
## Интеграции ## Интеграции
Бот проверяет сообщения каждые 10 минут. Параллельные запуски защищены
файловой блокировкой: если предыдущий запуск ещё работает, очередная проверка
пропускается, не помечая сообщения обработанными. Следующий успешный запуск
снова сравнит сообщения за текущий день с базой.
Один запуск ограничен 9 минутами. Запрос к сайту водоканала имеет таймаут
10 секунд на подключение и 60 секунд ожидания данных, запросы к Telegram —
60 секунд. База `data/db.json` заменяется атомарно, чтобы прерывание записи
не повредило предыдущие данные.
Общая логика парсинга, работы с `data/db.json` и поиска новых сообщений вынесена в [`parser.py`](./parser.py). Общая логика парсинга, работы с `data/db.json` и поиска новых сообщений вынесена в [`parser.py`](./parser.py).
Для новой интеграции достаточно создать свой адаптер отправки и передать его в `publish_new_posts`: Для новой интеграции достаточно создать свой адаптер отправки и передать его в `publish_new_posts`:
+2 -2
View File
@@ -1,2 +1,2 @@
@reboot cd /app && python3 /app/start_telegram.py @reboot cd /app && python3 /app/start.py
*/10 * * * * cd /app && python3 /app/start_telegram.py */10 * * * * cd /app && python3 /app/start.py
+22
View File
@@ -0,0 +1,22 @@
services:
vodokanalbot-ha:
image: ghcr.io/serega404/vodokanalbot-meshcore-ha:main
build:
context: .
args:
START_FILE: start_meshcore_ha.py
container_name: VodokanalBotMeshCoreHA
restart: always
environment:
TZ: Europe/Moscow
HOME_ASSISTANT_URL: https://your-home-assistant-url
HOME_ASSISTANT_WEBHOOK_ID: CHANGE_ME_RANDOM_WEBHOOK_ID
HOME_ASSISTANT_WEBHOOK_CHANNEL: "0"
# VODOKANAL_URL: http://www.tgnvoda.ru/avarii.php
# PROXY_URL: socks5h://user:password@proxy-host:1080
# PROXY_URL: http://user:password@proxy-host:3128
volumes:
- vodokanal_bot_data:/app/data
volumes:
vodokanal_bot_data:
+5
View File
@@ -1,12 +1,17 @@
services: services:
vodokanalbot: vodokanalbot:
image: ghcr.io/serega404/vodokanalbot:main image: ghcr.io/serega404/vodokanalbot:main
build:
context: .
args:
START_FILE: start_telegram.py
container_name: VodokanalBot container_name: VodokanalBot
restart: always restart: always
environment: environment:
TZ: Europe/Moscow TZ: Europe/Moscow
TELEGRAM_TOKEN: TOKEN TELEGRAM_TOKEN: TOKEN
TELEGRAM_CHANNEL: CHAT_ID TELEGRAM_CHANNEL: CHAT_ID
# VODOKANAL_URL: http://www.tgnvoda.ru/avarii.php
# PROXY_URL: socks5h://user:password@proxy-host:1080 # PROXY_URL: socks5h://user:password@proxy-host:1080
# PROXY_URL: http://user:password@proxy-host:3128 # PROXY_URL: http://user:password@proxy-host:3128
volumes: volumes:
+6 -10
View File
@@ -8,8 +8,7 @@ from bs4 import BeautifulSoup
DEFAULT_DB_PATH = "data/db.json" DEFAULT_DB_PATH = "data/db.json"
SOURCE_TIMEOUT = (10, 60) DEFAULT_VODOKANAL_URL = "http://www.tgnvoda.ru/avarii.php"
@dataclass(frozen=True) @dataclass(frozen=True)
class Post: class Post:
@@ -32,7 +31,6 @@ def create_session(proxy_url=""):
return session return session
def load_database(path=DEFAULT_DB_PATH): def load_database(path=DEFAULT_DB_PATH):
if not os.path.isfile(path): if not os.path.isfile(path):
print("Database not loaded") print("Database not loaded")
@@ -47,15 +45,13 @@ def save_database(posts, path=DEFAULT_DB_PATH):
if directory and not os.path.exists(directory): if directory and not os.path.exists(directory):
os.makedirs(directory) os.makedirs(directory)
temporary_path = os.fspath(path) + ".tmp" with open(path, "w", encoding="utf-8") as f:
with open(temporary_path, "w", encoding="utf-8") as f:
json.dump([post.key for post in posts], f, ensure_ascii=False) json.dump([post.key for post in posts], f, ensure_ascii=False)
os.replace(temporary_path, path) print("Database updated")
print("Database updated")
def fetch_posts(session, url, today=None): def fetch_posts(session, url, today=None):
req = session.get(url, timeout=SOURCE_TIMEOUT) req = session.get(url)
if req.status_code != 200: if req.status_code != 200:
raise RuntimeError("Request error: " + str(req.status_code)) raise RuntimeError("Request error: " + str(req.status_code))
@@ -92,9 +88,9 @@ def get_new_posts(posts, database):
return [post for post in posts if post.key not in database_keys] return [post for post in posts if post.key not in database_keys]
def publish_new_posts(send_message, session, url, db_path=DEFAULT_DB_PATH): def publish_new_posts(send_message, session, db_path=DEFAULT_DB_PATH):
database = load_database(db_path) database = load_database(db_path)
posts = fetch_posts(session, url) posts = fetch_posts(session, DEFAULT_VODOKANAL_URL)
if not posts: if not posts:
print("No posts") print("No posts")
+56
View File
@@ -0,0 +1,56 @@
import os
from parser import create_session, publish_new_posts
PROXY_URL = os.environ.get('PROXY_URL', '')
HOME_ASSISTANT_URL = os.environ.get('HOME_ASSISTANT_URL', '')
HOME_ASSISTANT_WEBHOOK_ID = os.environ.get('HOME_ASSISTANT_WEBHOOK_ID', '')
HOME_ASSISTANT_WEBHOOK_CHANNEL = os.environ.get('HOME_ASSISTANT_WEBHOOK_CHANNEL', '0')
def create_webhook_url():
return (
HOME_ASSISTANT_URL.rstrip('/')
+ "/api/webhook/"
+ HOME_ASSISTANT_WEBHOOK_ID
)
def send_webhook_message(session, message):
req = session.get(
create_webhook_url(),
params={
'channel': HOME_ASSISTANT_WEBHOOK_CHANNEL,
'message': message,
},
)
if not 200 <= req.status_code < 300:
print("Home Assistant webhook request error: " + str(req.status_code))
exit()
else:
print("Home Assistant webhook message sent")
def main():
if HOME_ASSISTANT_URL == '':
print("Home Assistant URL is not set")
exit()
if HOME_ASSISTANT_WEBHOOK_ID == '':
print("Home Assistant webhook id is not set")
exit()
session = create_session(PROXY_URL)
try:
publish_new_posts(
send_message=lambda message: send_webhook_message(session, message),
session=session,
)
except RuntimeError as error:
print(error)
exit()
if __name__ == "__main__":
main()
+1 -28
View File
@@ -1,13 +1,8 @@
import fcntl
import os import os
import signal
from parser import create_session, publish_new_posts from parser import create_session, publish_new_posts
URL = os.environ.get('VODOKANAL_URL', 'http://www.tgnvoda.ru/avarii.php')
RUN_TIMEOUT_SECONDS = 9 * 60
TELEGRAM_TIMEOUT = 60
SEND_SILENT = os.environ.get('SEND_SILENT', False) SEND_SILENT = os.environ.get('SEND_SILENT', False)
TELEGRAM_TOKEN = os.environ.get('TELEGRAM_TOKEN', '') TELEGRAM_TOKEN = os.environ.get('TELEGRAM_TOKEN', '')
TELEGRAM_CHANNEL = os.environ.get('TELEGRAM_CHANNEL', '') TELEGRAM_CHANNEL = os.environ.get('TELEGRAM_CHANNEL', '')
@@ -22,7 +17,6 @@ def send_telegram_message(session, message):
'disable_notification': str(SEND_SILENT), 'disable_notification': str(SEND_SILENT),
'text': message, 'text': message,
}, },
timeout=TELEGRAM_TIMEOUT,
) )
if (req.status_code != 200): if (req.status_code != 200):
print("Telegram request error: " + str(req.status_code)) print("Telegram request error: " + str(req.status_code))
@@ -45,32 +39,11 @@ def main():
publish_new_posts( publish_new_posts(
send_message=lambda message: send_telegram_message(session, message), send_message=lambda message: send_telegram_message(session, message),
session=session, session=session,
url=URL,
) )
except RuntimeError as error: except RuntimeError as error:
print(error) print(error)
exit() exit()
def _stop_overdue_run(signum, frame):
raise SystemExit(f"Bot run exceeded time limit ({RUN_TIMEOUT_SECONDS} seconds)")
def run_once(lock_path="/tmp/vodokanalbot.lock"):
with open(lock_path, "a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("Previous run is still active; skipping")
return
previous_handler = signal.signal(signal.SIGALRM, _stop_overdue_run)
try:
signal.alarm(RUN_TIMEOUT_SECONDS)
main()
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, previous_handler)
if __name__ == "__main__": if __name__ == "__main__":
run_once() main()
-147
View File
@@ -1,147 +0,0 @@
import fcntl
import signal
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
import requests
import parser
import start_telegram
class RunSafetyTests(unittest.TestCase):
def test_next_run_delivers_new_posts_after_skipped_check(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "bot.lock"
db_path = str(Path(directory) / "db.json")
old = parser.Post("01.01", "old")
new = parser.Post("01.01", "new")
parser.save_database([old], db_path)
sent = []
with patch.object(parser, "fetch_posts", return_value=[old, new]), patch.object(
start_telegram, "main", side_effect=lambda: parser.publish_new_posts(
sent.append, None, "http://source", db_path=db_path
)
):
with path.open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
start_telegram.run_once(path)
self.assertEqual([], sent)
self.assertEqual([old.key], parser.load_database(db_path))
start_telegram.run_once(path)
self.assertEqual([new.text], sent)
self.assertEqual([old.key, new.key], parser.load_database(db_path))
def test_unresponsive_telegram_times_out(self):
class SlowHandler(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(0.2)
self.send_response(200)
self.end_headers()
try:
self.wfile.write(b'{"result": {"message_id": 42}}')
except BrokenPipeError:
pass
def log_message(self, *args):
pass
with ThreadingHTTPServer(("127.0.0.1", 0), SlowHandler) as server:
class LocalTelegramSession(requests.Session):
def get(self, url, **kwargs):
return super().get(f"http://127.0.0.1:{server.server_port}", **kwargs)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
with LocalTelegramSession() as session, patch.object(
start_telegram, "TELEGRAM_TIMEOUT", (0.05, 0.05), create=True
):
session.trust_env = False
with self.assertRaises(requests.exceptions.Timeout):
start_telegram.send_telegram_message(session, "test")
finally:
server.shutdown()
worker.join(timeout=2)
def test_interrupted_database_save_preserves_previous_data(self):
def interrupt_save(value, file, **kwargs):
file.write("[")
raise SystemExit("time limit")
with tempfile.TemporaryDirectory() as directory:
path = str(Path(directory) / "db.json")
parser.save_database([parser.Post("01.01", "previous")], path)
with patch.object(parser.json, "dump", side_effect=interrupt_save):
with self.assertRaisesRegex(SystemExit, "time limit"):
parser.save_database([parser.Post("01.01", "new")], path)
self.assertEqual(["01.01$previous"], parser.load_database(path))
def test_unresponsive_source_times_out(self):
class SlowHandler(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(0.2)
self.send_response(200)
self.end_headers()
def log_message(self, *args):
pass
with ThreadingHTTPServer(("127.0.0.1", 0), SlowHandler) as server:
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
with requests.Session() as session, patch.object(
parser, "SOURCE_TIMEOUT", (0.05, 0.05), create=True
):
session.trust_env = False
with self.assertRaises(requests.exceptions.Timeout):
parser.fetch_posts(session, f"http://127.0.0.1:{server.server_port}")
finally:
server.shutdown()
worker.join(timeout=2)
def test_overlapping_run_is_skipped(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "bot.lock"
with path.open("a") as lock, patch.object(start_telegram, "main") as main:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
start_telegram.run_once(path)
main.assert_not_called()
def test_completed_run_releases_lock(self):
with tempfile.TemporaryDirectory() as directory, patch.object(start_telegram, "main") as main:
path = Path(directory) / "bot.lock"
start_telegram.run_once(path)
start_telegram.run_once(path)
self.assertEqual(2, main.call_count)
def test_failed_run_releases_lock(self):
with tempfile.TemporaryDirectory() as directory, patch.object(
start_telegram, "main", side_effect=[RuntimeError("source failed"), None]
) as main:
path = Path(directory) / "bot.lock"
with self.assertRaisesRegex(RuntimeError, "source failed"):
start_telegram.run_once(path)
start_telegram.run_once(path)
self.assertEqual(2, main.call_count)
def test_overdue_run_stops_and_allows_next_run(self):
previous_handler = signal.getsignal(signal.SIGALRM)
with tempfile.TemporaryDirectory() as directory, patch.object(
start_telegram, "RUN_TIMEOUT_SECONDS", 1, create=True
):
path = Path(directory) / "bot.lock"
with patch.object(start_telegram, "main", side_effect=lambda: time.sleep(2)):
with self.assertRaisesRegex(SystemExit, "time limit"):
start_telegram.run_once(path)
self.assertEqual(previous_handler, signal.getsignal(signal.SIGALRM))
self.assertEqual(0, signal.alarm(0))
with patch.object(start_telegram, "main") as main:
start_telegram.run_once(path)
main.assert_called_once_with()