mirror of
https://github.com/serega404/VodokanalBot.git
synced 2026-10-30 11:29:55 +03:00
Compare commits
1
Commits
main
..
03a4ad5d8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03a4ad5d8e |
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[](https://github.com/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`:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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()
|
|
||||||
Reference in new issue
Block a user