This commit is contained in:
commit
7cba62f45d
9 files changed
+936
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
.venv
|
||||||
|
.ruff_cache
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
config
|
||||||
|
config.json
|
||||||
|
README.md
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.frik.su
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Install Docker
|
||||||
|
run: curl -fsSL https://get.docker.com | sh
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker tags from release
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# image-manifest=true + oci-mediatypes=true обязательны для кэша в реестре Gitea,
|
||||||
|
# иначе PUT манифеста кэша падает с 400 Bad Request (go-gitea/gitea#28973)
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.ruff_cache/
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
config/
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.14-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
CONFIG_PATH=/app/config/config.json
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY reminder.py ./
|
||||||
|
|
||||||
|
# Run as a non-root user
|
||||||
|
RUN useradd --create-home --uid 1000 app \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
|
CMD ["python", "reminder.py"]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# simple-reminder-bot
|
||||||
|
|
||||||
|
Простой бот-напоминалка: читает JSON-конфиг и отправляет сообщения в
|
||||||
|
Telegram - по расписанию из конфига.
|
||||||
|
Конфиг можно править на лету: бот сам подхватывает изменения (время обновления до минуты), рестарт не нужен.
|
||||||
|
|
||||||
|
Единственная зависимость - `requests`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
- `interval` - сообщение отправляется каждые N секунд/минут/часов/дней.
|
||||||
|
Первое сообщение уходит сразу при запуске (или в `start_at`), дальше - каждые N.
|
||||||
|
- `at` - сообщение отправляется один раз в указанный момент. Если момент уже
|
||||||
|
прошёл, напоминание пропускается с предупреждением в логе.
|
||||||
|
- `at` + `repeat: "yearly"` (или `"monthly"`) - сообщение отправляется каждый
|
||||||
|
год (месяц) в ту же календарную дату. Если дата уже прошла, бот сразу
|
||||||
|
планирует ближайшее будущее наступление.
|
||||||
|
- Когда все разовые напоминания отправлены (и в конфиге не осталось активных),
|
||||||
|
процесс завершается с кодом 0.
|
||||||
|
- Пропущенные повторные отправки не «догоняются»: после долгой паузы (сон,
|
||||||
|
перезапуск) уйдёт максимум одно сообщение.
|
||||||
|
- При получении `SIGTERM`/`SIGINT` (в том числе `docker stop`) бот завершается
|
||||||
|
корректно.
|
||||||
|
|
||||||
|
## Hot-reload конфига
|
||||||
|
- Пока бот работает, файл конфига проверяется на изменения (mtime) - не реже раза в минуту.
|
||||||
|
Если файл изменился, конфиг перечитывается на ходу.
|
||||||
|
- Мгновенное перечитывание - по `SIGHUP`:
|
||||||
|
`docker kill -s HUP simple-reminder-bot`
|
||||||
|
- Новые (или изменившиеся по расписанию) напоминания начинают работать так же,
|
||||||
|
как при запуске бота: интервальные - с отправки сразу (или в `start_at`),
|
||||||
|
разовые - в свой `at` (если он ещё не прошёл).
|
||||||
|
- Если новый конфиг невалиден, бот пишет ошибку и продолжает работать
|
||||||
|
со старым конфигом.
|
||||||
|
|
||||||
|
## Конфиг
|
||||||
|
### Верхний уровень
|
||||||
|
| Поле | Описание |
|
||||||
|
|-------------|---------------------------------------------------------|
|
||||||
|
| `bot_token` | Токен бота |
|
||||||
|
| `chat_id` | Чат по умолчанию для всех напоминаний |
|
||||||
|
| `timezone` | Таймзона для дат без указания зоны (по умолчанию `UTC`) |
|
||||||
|
| `reminders` | Массив напоминаний |
|
||||||
|
|
||||||
|
### Напоминания
|
||||||
|
| Поле | Описание |
|
||||||
|
|------------|-----------------------------------------------------------|
|
||||||
|
| `message` | Текст сообщения |
|
||||||
|
| `interval` | Число секунд или строка: `30s`, `5m`, `2h`, `1d`, `1h30m` |
|
||||||
|
| `at` | Дата/время разовой отправки |
|
||||||
|
| `repeat` | `yearly`/`monthly` - повтор по календарю (только с `at`) |
|
||||||
|
| `start_at` | Первый запуск интервального напоминания |
|
||||||
|
| `chat_id` | Переопределить чат для этого напоминания |
|
||||||
|
| `enabled` | `false` - чтобы временно отключить напоминание |
|
||||||
|
|
||||||
|
Ровно одно из `interval`/`at` обязательно.
|
||||||
|
`repeat` можно только с `at`.
|
||||||
|
Даты можно писать с таймзоной (`2026-12-31T10:00:00+03:00`) или без неё - тогда
|
||||||
|
используется таймзона из конфига.
|
||||||
|
|
||||||
|
|
||||||
|
## Env
|
||||||
|
- `CONFIG_PATH` - путь к конфигу в контейнере.
|
||||||
|
- `LOG_LEVEL` - уровень логирования (`DEBUG`, `INFO`, `WARNING`, `ERROR`)
|
||||||
|
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
### Docker compose
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локально
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python reminder.py --config config.json # обычный запуск
|
||||||
|
python reminder.py --check # проверить конфиг и показать расписание
|
||||||
|
python reminder.py --dry-run # писать только в лог (dry-run)
|
||||||
|
```
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
reminder-bot:
|
||||||
|
image: simple-reminder-bot:latest
|
||||||
|
container_name: simple-reminder-bot
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
CONFIG_PATH: /app/config/config.json
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
volumes:
|
||||||
|
- ./config.json:/app/config.json:ro
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"bot_token": "YOUR_BOT_TOKEN",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"timezone": "Europe/Moscow",
|
||||||
|
"reminders": [
|
||||||
|
{
|
||||||
|
"message": "Eat them.",
|
||||||
|
"interval": "1h"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "They are coming in 15 minutes.",
|
||||||
|
"interval": "1d",
|
||||||
|
"start_at": "2026-10-12T09:45:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "Happy birthday!",
|
||||||
|
"at": "2026-12-31T10:00:00+03:00",
|
||||||
|
"repeat": "yearly"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "Share information about them (group chat only).",
|
||||||
|
"interval": "7d",
|
||||||
|
"chat_id": -1001234567890
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+725
@@ -0,0 +1,725 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import calendar
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
LOG = logging.getLogger("reminder-bot")
|
||||||
|
|
||||||
|
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||||
|
MAX_SEND_ATTEMPTS = 5
|
||||||
|
LOOP_TICK_SECONDS = 60.0 # wake up at least this often: schedule + config check
|
||||||
|
MAX_ONESHOT_RETRIES = 3 # loop-level retries of a one-shot after a failed send
|
||||||
|
|
||||||
|
UNIT_SECONDS = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
|
||||||
|
INTERVAL_PART_RE = re.compile(r"(\d+(?:\.\d+)?)\s*([smhd])")
|
||||||
|
REPEAT_UNITS = ("yearly", "monthly")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""Raised when the JSON config is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
# Parsing
|
||||||
|
|
||||||
|
|
||||||
|
def parse_interval(value: Any) -> float:
|
||||||
|
"""Convert an interval to seconds.
|
||||||
|
|
||||||
|
Accepts a number (seconds) or a string like "90s", "5m", "2h", "1d" or "1h30m".
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||||
|
raise ConfigError(
|
||||||
|
f"invalid interval {value!r}: expected a number of seconds "
|
||||||
|
"or a string like '1h30m'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
seconds = float(value)
|
||||||
|
else:
|
||||||
|
text = value.strip().lower()
|
||||||
|
seconds = 0.0
|
||||||
|
pos = 0
|
||||||
|
for match in INTERVAL_PART_RE.finditer(text):
|
||||||
|
if text[pos : match.start()].strip():
|
||||||
|
raise ConfigError(f"invalid interval {value!r}")
|
||||||
|
seconds += float(match.group(1)) * UNIT_SECONDS[match.group(2)]
|
||||||
|
pos = match.end()
|
||||||
|
if pos == 0 or text[pos:].strip():
|
||||||
|
raise ConfigError(
|
||||||
|
f"invalid interval {value!r}: use a number of seconds "
|
||||||
|
"or a string like '1h30m'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if seconds <= 0:
|
||||||
|
raise ConfigError(f"invalid interval {value!r}: must be positive")
|
||||||
|
return seconds
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime(value: Any, default_tz: ZoneInfo, field: str) -> datetime:
|
||||||
|
"""Parse an ISO 8601 date/time; naive values are interpreted in `default_tz`."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ConfigError(f"{field}: expected an ISO 8601 string, got {value!r}")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigError(f"{field}: invalid date/time {value!r}: {exc}") from exc
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=default_tz)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
# Config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Reminder:
|
||||||
|
message: str
|
||||||
|
chat_id: int | str
|
||||||
|
interval: float | None = None
|
||||||
|
at: datetime | None = None
|
||||||
|
start_at: datetime | None = None
|
||||||
|
repeat: str | None = None
|
||||||
|
next_fire: datetime | None = None
|
||||||
|
failures: int = 0
|
||||||
|
occurrence: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
path: Path
|
||||||
|
token: str
|
||||||
|
chat_id: int | str | None
|
||||||
|
timezone: ZoneInfo
|
||||||
|
reminders: list[Reminder]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chat_id(value: Any, field: str) -> int | str:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
||||||
|
raise ConfigError(
|
||||||
|
f"{field}: chat_id must be an integer or a string, got {value!r}"
|
||||||
|
)
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ConfigError(f"{field}: chat_id must not be empty")
|
||||||
|
if stripped.lstrip("-").isdigit():
|
||||||
|
return int(stripped)
|
||||||
|
return stripped # e.g. "@channelname"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_reminder(
|
||||||
|
item: Any, index: int, default_chat: Any, tz: ZoneInfo
|
||||||
|
) -> Reminder | None:
|
||||||
|
where = f"reminders[{index}]"
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ConfigError(f"{where}: expected an object")
|
||||||
|
if not item.get("enabled", True):
|
||||||
|
LOG.info("%s: disabled, skipped", where)
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = item.get("message")
|
||||||
|
if not isinstance(message, str) or not message.strip():
|
||||||
|
raise ConfigError(f"{where}: 'message' must be a non-empty string")
|
||||||
|
|
||||||
|
chat = item.get("chat_id", default_chat)
|
||||||
|
if chat is None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: no chat_id — set it here or globally in the config"
|
||||||
|
)
|
||||||
|
chat = _normalize_chat_id(chat, where)
|
||||||
|
|
||||||
|
has_interval = "interval" in item
|
||||||
|
has_at = "at" in item
|
||||||
|
if has_interval == has_at:
|
||||||
|
raise ConfigError(f"{where}: exactly one of 'interval' or 'at' is required")
|
||||||
|
|
||||||
|
interval = parse_interval(item["interval"]) if has_interval else None
|
||||||
|
at = parse_datetime(item["at"], tz, f"{where}.at") if has_at else None
|
||||||
|
|
||||||
|
repeat = item.get("repeat")
|
||||||
|
if repeat is not None:
|
||||||
|
if not isinstance(repeat, str) or repeat.lower() not in REPEAT_UNITS:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: 'repeat' must be one of: {', '.join(REPEAT_UNITS)}"
|
||||||
|
)
|
||||||
|
if not has_at:
|
||||||
|
raise ConfigError(f"{where}: 'repeat' only makes sense together with 'at'")
|
||||||
|
repeat = repeat.lower()
|
||||||
|
|
||||||
|
start_at = None
|
||||||
|
if "start_at" in item:
|
||||||
|
if not has_interval:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: 'start_at' only makes sense together with 'interval'"
|
||||||
|
)
|
||||||
|
start_at = parse_datetime(item["start_at"], tz, f"{where}.start_at")
|
||||||
|
|
||||||
|
return Reminder(
|
||||||
|
message=message,
|
||||||
|
chat_id=chat,
|
||||||
|
interval=interval,
|
||||||
|
at=at,
|
||||||
|
start_at=start_at,
|
||||||
|
repeat=repeat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> AppConfig:
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ConfigError(f"config file not found: {path}") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"cannot read config file {path}: {exc}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ConfigError(f"invalid JSON in {path}: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ConfigError("config root must be a JSON object")
|
||||||
|
|
||||||
|
tz_name = raw.get("timezone", "UTC")
|
||||||
|
if not isinstance(tz_name, str):
|
||||||
|
raise ConfigError("'timezone' must be a string like 'Europe/Moscow'")
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||||
|
raise ConfigError(f"unknown timezone {tz_name!r}") from exc
|
||||||
|
|
||||||
|
token = raw.get("bot_token")
|
||||||
|
if not isinstance(token, str) or not token.strip():
|
||||||
|
raise ConfigError(
|
||||||
|
"'bot_token' is required: put the Telegram bot token in the config"
|
||||||
|
)
|
||||||
|
|
||||||
|
default_chat = raw.get("chat_id")
|
||||||
|
if default_chat is not None:
|
||||||
|
default_chat = _normalize_chat_id(default_chat, "chat_id")
|
||||||
|
|
||||||
|
reminders_raw = raw.get("reminders")
|
||||||
|
if not isinstance(reminders_raw, list) or not reminders_raw:
|
||||||
|
raise ConfigError("'reminders' must be a non-empty array")
|
||||||
|
|
||||||
|
reminders: list[Reminder] = []
|
||||||
|
for i, item in enumerate(reminders_raw):
|
||||||
|
reminder = _parse_reminder(item, i, default_chat, tz)
|
||||||
|
if reminder is not None:
|
||||||
|
reminders.append(reminder)
|
||||||
|
|
||||||
|
if not reminders:
|
||||||
|
LOG.warning("all reminders are disabled")
|
||||||
|
|
||||||
|
return AppConfig(
|
||||||
|
path=path, token=token, chat_id=default_chat, timezone=tz, reminders=reminders
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
|
||||||
|
|
||||||
|
def _is_recurring(reminder: Reminder) -> bool:
|
||||||
|
"""True if the reminder fires more than once."""
|
||||||
|
return reminder.interval is not None or reminder.repeat is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_years(dt: datetime, years: int) -> datetime:
|
||||||
|
try:
|
||||||
|
return dt.replace(year=dt.year + years)
|
||||||
|
except ValueError:
|
||||||
|
# Feb 29 -> Feb 28 in non-leap years
|
||||||
|
return dt.replace(year=dt.year + years, day=28)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_months(dt: datetime, months: int) -> datetime:
|
||||||
|
year, month = divmod(dt.year * 12 + (dt.month - 1) + months, 12)
|
||||||
|
month += 1
|
||||||
|
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||||
|
return dt.replace(year=year, month=month, day=day)
|
||||||
|
|
||||||
|
|
||||||
|
def _occurrence(anchor: datetime, repeat: str, index: int) -> datetime:
|
||||||
|
"""Return the index-th occurrence counted from the anchor date."""
|
||||||
|
if repeat == "yearly":
|
||||||
|
return _add_years(anchor, index)
|
||||||
|
return _add_months(anchor, index)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_occurrence_index(anchor: datetime, repeat: str, now: datetime) -> int:
|
||||||
|
"""Return the smallest index whose occurrence is not before `now`."""
|
||||||
|
if repeat == "yearly":
|
||||||
|
index = max(0, now.year - anchor.year)
|
||||||
|
else:
|
||||||
|
index = max(0, (now.year - anchor.year) * 12 + (now.month - anchor.month))
|
||||||
|
while _occurrence(anchor, repeat, index) < now:
|
||||||
|
index += 1
|
||||||
|
while index > 0 and _occurrence(anchor, repeat, index - 1) >= now:
|
||||||
|
index -= 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def build_schedule(reminders: list[Reminder], now: datetime) -> None:
|
||||||
|
"""Set the first fire time for every reminder.
|
||||||
|
|
||||||
|
Calendar-repeating reminders (`at` + `repeat`) are moved to their next
|
||||||
|
occurrence. One-shot reminders already in the past are skipped with a
|
||||||
|
warning. Interval reminders fire immediately unless `start_at` says otherwise.
|
||||||
|
"""
|
||||||
|
for reminder in reminders:
|
||||||
|
if reminder.at is not None:
|
||||||
|
if reminder.repeat is not None:
|
||||||
|
reminder.occurrence = _next_occurrence_index(
|
||||||
|
reminder.at, reminder.repeat, now
|
||||||
|
)
|
||||||
|
reminder.next_fire = _occurrence(
|
||||||
|
reminder.at, reminder.repeat, reminder.occurrence
|
||||||
|
)
|
||||||
|
elif reminder.at <= now:
|
||||||
|
LOG.warning(
|
||||||
|
"skipping one-shot reminder %r: %s is already in the past",
|
||||||
|
reminder.message,
|
||||||
|
reminder.at.isoformat(),
|
||||||
|
)
|
||||||
|
reminder.next_fire = None
|
||||||
|
else:
|
||||||
|
reminder.next_fire = reminder.at
|
||||||
|
elif reminder.interval is not None:
|
||||||
|
if reminder.start_at is not None:
|
||||||
|
next_fire = reminder.start_at
|
||||||
|
if next_fire <= now:
|
||||||
|
step = timedelta(seconds=reminder.interval)
|
||||||
|
missed = int((now - next_fire) // step) + 1
|
||||||
|
next_fire += missed * step
|
||||||
|
else:
|
||||||
|
# no start_at: send right away, then every interval
|
||||||
|
next_fire = now
|
||||||
|
reminder.next_fire = next_fire
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduled_fire(reminder: Reminder) -> datetime:
|
||||||
|
"""Return the next fire time of an active reminder.
|
||||||
|
|
||||||
|
Active reminders always have one; this helper makes that invariant
|
||||||
|
explicit for the type checker and fails loudly if it is ever broken.
|
||||||
|
"""
|
||||||
|
if reminder.next_fire is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"internal error: reminder {reminder.message!r} has no scheduled time"
|
||||||
|
)
|
||||||
|
return reminder.next_fire
|
||||||
|
|
||||||
|
|
||||||
|
def _advance(reminder: Reminder, now: datetime) -> datetime | None:
|
||||||
|
"""Return the next fire time after a send, or None if the reminder is done."""
|
||||||
|
if reminder.interval is not None:
|
||||||
|
step = timedelta(seconds=reminder.interval)
|
||||||
|
next_fire = _scheduled_fire(reminder) + step
|
||||||
|
if next_fire <= now:
|
||||||
|
# the loop was delayed (sleep/suspend): skip missed occurrences
|
||||||
|
# instead of sending a burst of messages
|
||||||
|
missed = int((now - next_fire) // step) + 1
|
||||||
|
next_fire += missed * step
|
||||||
|
return next_fire
|
||||||
|
if reminder.repeat is not None and reminder.at is not None:
|
||||||
|
reminder.occurrence += 1
|
||||||
|
next_fire = _occurrence(reminder.at, reminder.repeat, reminder.occurrence)
|
||||||
|
while next_fire <= now:
|
||||||
|
# skip occurrences missed while the loop was not running
|
||||||
|
reminder.occurrence += 1
|
||||||
|
next_fire = _occurrence(reminder.at, reminder.repeat, reminder.occurrence)
|
||||||
|
return next_fire
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Reload
|
||||||
|
|
||||||
|
|
||||||
|
def _config_stamp(path: Path) -> tuple[int, int, int] | None:
|
||||||
|
"""Return (mtime_ns, size, inode) of the config, or None if it is unreadable."""
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_key(reminder: Reminder) -> tuple[Any, ...]:
|
||||||
|
"""Identity used to carry the schedule of a reminder over a config reload.
|
||||||
|
|
||||||
|
The message text is deliberately not part of the key: fixing a typo in a
|
||||||
|
message must not restart the schedule (or trigger an immediate send).
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
reminder.chat_id,
|
||||||
|
reminder.interval,
|
||||||
|
reminder.at,
|
||||||
|
reminder.start_at,
|
||||||
|
reminder.repeat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_load(path: Path) -> AppConfig | None:
|
||||||
|
"""Load the config for a live reload; on error keep the previous one."""
|
||||||
|
try:
|
||||||
|
return load_config(path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
LOG.error("config reload failed, keeping the previous config: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_reload(
|
||||||
|
active: list[Reminder],
|
||||||
|
new_config: AppConfig,
|
||||||
|
completed: set[tuple[Any, ...]],
|
||||||
|
now: datetime,
|
||||||
|
) -> list[Reminder]:
|
||||||
|
"""Build the new active list, preserving schedules of unchanged reminders."""
|
||||||
|
pools: dict[tuple[Any, ...], list[Reminder]] = {}
|
||||||
|
for reminder in active:
|
||||||
|
pools.setdefault(_schedule_key(reminder), []).append(reminder)
|
||||||
|
|
||||||
|
kept: list[Reminder] = []
|
||||||
|
added: list[Reminder] = []
|
||||||
|
for reminder in new_config.reminders:
|
||||||
|
pool = pools.get(_schedule_key(reminder))
|
||||||
|
if pool:
|
||||||
|
old = pool.pop(0)
|
||||||
|
reminder.next_fire = old.next_fire
|
||||||
|
reminder.failures = old.failures
|
||||||
|
reminder.occurrence = old.occurrence
|
||||||
|
kept.append(reminder)
|
||||||
|
if old.message != reminder.message:
|
||||||
|
LOG.info("updated message: %r -> %r", old.message, reminder.message)
|
||||||
|
else:
|
||||||
|
added.append(reminder)
|
||||||
|
|
||||||
|
removed = [r for pool in pools.values() for r in pool]
|
||||||
|
# one-shots that already fired (or were skipped as past) are not rescheduled
|
||||||
|
added = [
|
||||||
|
r for r in added if not (r.at is not None and _schedule_key(r) in completed)
|
||||||
|
]
|
||||||
|
build_schedule(added, now)
|
||||||
|
for reminder in added:
|
||||||
|
if reminder.at is not None and reminder.next_fire is None:
|
||||||
|
completed.add(_schedule_key(reminder))
|
||||||
|
|
||||||
|
new_active = kept + [r for r in added if r.next_fire is not None]
|
||||||
|
LOG.info(
|
||||||
|
"config reloaded: %d kept, %d added, %d removed",
|
||||||
|
len(kept),
|
||||||
|
len(added),
|
||||||
|
len(removed),
|
||||||
|
)
|
||||||
|
for reminder in added:
|
||||||
|
next_send = reminder.next_fire.isoformat() if reminder.next_fire else "-"
|
||||||
|
LOG.info(
|
||||||
|
"added: %r (chat %s), next send at %s",
|
||||||
|
reminder.message,
|
||||||
|
reminder.chat_id,
|
||||||
|
next_send,
|
||||||
|
)
|
||||||
|
for reminder in removed:
|
||||||
|
LOG.info("removed: %r (chat %s)", reminder.message, reminder.chat_id)
|
||||||
|
|
||||||
|
return new_active
|
||||||
|
|
||||||
|
|
||||||
|
# Sending
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after(response: requests.Response) -> float:
|
||||||
|
try:
|
||||||
|
params = response.json().get("parameters") or {}
|
||||||
|
return float(params.get("retry_after", 5))
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
token: str,
|
||||||
|
chat_id: int | str,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
stop_event: threading.Event | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Send one message via the Telegram Bot API, retrying transient failures."""
|
||||||
|
if dry_run:
|
||||||
|
LOG.info("[dry-run] -> chat %s: %s", chat_id, text)
|
||||||
|
return True
|
||||||
|
|
||||||
|
url = f"{TELEGRAM_API_BASE}/bot{token}/sendMessage"
|
||||||
|
payload = {"chat_id": chat_id, "text": text}
|
||||||
|
|
||||||
|
for attempt in range(1, MAX_SEND_ATTEMPTS + 1):
|
||||||
|
wait = float(min(2**attempt, 60))
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, timeout=(10, 30))
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
LOG.warning(
|
||||||
|
"sending to chat %s failed (attempt %d/%d): %s",
|
||||||
|
chat_id,
|
||||||
|
attempt,
|
||||||
|
MAX_SEND_ATTEMPTS,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
ok = bool(response.json().get("ok"))
|
||||||
|
except ValueError:
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
LOG.info("sent to chat %s: %s", chat_id, text)
|
||||||
|
return True
|
||||||
|
LOG.error("unexpected Telegram response: %s", response.text[:300])
|
||||||
|
return False
|
||||||
|
if response.status_code == 429:
|
||||||
|
wait = min(_retry_after(response), 300.0)
|
||||||
|
LOG.warning("rate limited by Telegram, retrying in %.0f s", wait)
|
||||||
|
elif response.status_code >= 500:
|
||||||
|
LOG.warning(
|
||||||
|
"Telegram server error %d (attempt %d/%d)",
|
||||||
|
response.status_code,
|
||||||
|
attempt,
|
||||||
|
MAX_SEND_ATTEMPTS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOG.error(
|
||||||
|
"Telegram rejected the message (HTTP %d): %s",
|
||||||
|
response.status_code,
|
||||||
|
response.text[:300],
|
||||||
|
)
|
||||||
|
return False # bad token/chat/etc. — retrying won't help
|
||||||
|
|
||||||
|
if attempt < MAX_SEND_ATTEMPTS:
|
||||||
|
if stop_event is not None:
|
||||||
|
if stop_event.wait(wait):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
LOG.error(
|
||||||
|
"gave up sending to chat %s after %d attempts", chat_id, MAX_SEND_ATTEMPTS
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
config: AppConfig,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
wake_event: threading.Event,
|
||||||
|
reload_requested: threading.Event,
|
||||||
|
) -> int:
|
||||||
|
active = [r for r in config.reminders if r.next_fire is not None]
|
||||||
|
# one-shots that were skipped as "in the past" must not be re-warned on reload
|
||||||
|
completed: set[tuple[Any, ...]] = {
|
||||||
|
_schedule_key(r)
|
||||||
|
for r in config.reminders
|
||||||
|
if r.at is not None and r.next_fire is None
|
||||||
|
}
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
LOG.warning("no active reminders (all are disabled or already in the past)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for reminder in active:
|
||||||
|
LOG.info(
|
||||||
|
"scheduled: %r (chat %s), next send at %s",
|
||||||
|
reminder.message,
|
||||||
|
reminder.chat_id,
|
||||||
|
_scheduled_fire(reminder).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
stamp = _config_stamp(config.path)
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
if reload_requested.is_set() or _config_stamp(config.path) != stamp:
|
||||||
|
reload_requested.clear()
|
||||||
|
stamp = _config_stamp(config.path)
|
||||||
|
new_config = _try_load(config.path)
|
||||||
|
if new_config is not None:
|
||||||
|
active = _apply_reload(active, new_config, completed, datetime.now(UTC))
|
||||||
|
config = new_config
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for reminder in [r for r in active if _scheduled_fire(r) <= now]:
|
||||||
|
sent = send_message(
|
||||||
|
config.token,
|
||||||
|
reminder.chat_id,
|
||||||
|
reminder.message,
|
||||||
|
dry_run=dry_run,
|
||||||
|
stop_event=stop_event,
|
||||||
|
)
|
||||||
|
if sent or _is_recurring(reminder):
|
||||||
|
next_fire = _advance(reminder, now)
|
||||||
|
if next_fire is None:
|
||||||
|
active.remove(reminder)
|
||||||
|
completed.add(_schedule_key(reminder))
|
||||||
|
LOG.info("reminder %r is done", reminder.message)
|
||||||
|
else:
|
||||||
|
reminder.next_fire = next_fire
|
||||||
|
else:
|
||||||
|
# a one-shot send failed: retry on the next tick, then give up
|
||||||
|
reminder.failures += 1
|
||||||
|
if reminder.failures >= MAX_ONESHOT_RETRIES:
|
||||||
|
LOG.error(
|
||||||
|
"dropping one-shot reminder %r after %d failed attempts",
|
||||||
|
reminder.message,
|
||||||
|
reminder.failures,
|
||||||
|
)
|
||||||
|
active.remove(reminder)
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
LOG.info("no active reminders left, exiting")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
next_fire = min(_scheduled_fire(r) for r in active)
|
||||||
|
delay = (next_fire - datetime.now(UTC)).total_seconds()
|
||||||
|
wake_event.wait(min(max(delay, 0.0), LOOP_TICK_SECONDS))
|
||||||
|
wake_event.clear()
|
||||||
|
|
||||||
|
LOG.info("stopped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def install_signal_handlers(
|
||||||
|
stop_event: threading.Event,
|
||||||
|
wake_event: threading.Event,
|
||||||
|
reload_requested: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
def on_stop(signum: int, _frame: Any) -> None:
|
||||||
|
LOG.info("received %s, shutting down", signal.Signals(signum).name)
|
||||||
|
stop_event.set()
|
||||||
|
wake_event.set()
|
||||||
|
|
||||||
|
def on_reload(signum: int, _frame: Any) -> None:
|
||||||
|
LOG.info("received %s, reloading config", signal.Signals(signum).name)
|
||||||
|
reload_requested.set()
|
||||||
|
wake_event.set()
|
||||||
|
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
signal.signal(sig, on_stop)
|
||||||
|
if hasattr(signal, "SIGHUP"):
|
||||||
|
signal.signal(signal.SIGHUP, on_reload)
|
||||||
|
|
||||||
|
|
||||||
|
# cli
|
||||||
|
|
||||||
|
|
||||||
|
def _format_dt(dt: datetime) -> str:
|
||||||
|
return dt.isoformat(sep=" ", timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def print_plan(config: AppConfig) -> None:
|
||||||
|
print(f"Config: {config.path}")
|
||||||
|
print(f"Timezone: {config.timezone} (used for dates without an offset)")
|
||||||
|
default_chat = config.chat_id if config.chat_id is not None else "not set"
|
||||||
|
print(f"Default chat_id: {default_chat}")
|
||||||
|
print()
|
||||||
|
print(f"{len(config.reminders)} reminder(s):")
|
||||||
|
for i, reminder in enumerate(config.reminders, 1):
|
||||||
|
if reminder.interval is not None:
|
||||||
|
kind = f"every {reminder.interval:g}s"
|
||||||
|
elif reminder.repeat is not None:
|
||||||
|
kind = reminder.repeat
|
||||||
|
else:
|
||||||
|
kind = "one-shot"
|
||||||
|
print(f" {i}. [{kind}, chat {reminder.chat_id}] {reminder.message!r}")
|
||||||
|
if reminder.next_fire is None:
|
||||||
|
print(" -> will not fire (time is in the past)")
|
||||||
|
continue
|
||||||
|
fires = [reminder.next_fire]
|
||||||
|
if reminder.interval is not None:
|
||||||
|
step = timedelta(seconds=reminder.interval)
|
||||||
|
for _ in range(2):
|
||||||
|
fires.append(fires[-1] + step)
|
||||||
|
elif reminder.repeat is not None and reminder.at is not None:
|
||||||
|
index = reminder.occurrence
|
||||||
|
for _ in range(2):
|
||||||
|
index += 1
|
||||||
|
fires.append(_occurrence(reminder.at, reminder.repeat, index))
|
||||||
|
print(" -> next: " + ", ".join(_format_dt(f) for f in fires))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Telegram reminder bot: sends messages on a schedule from a JSON config"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
default=os.environ.get("CONFIG_PATH", "config.json"),
|
||||||
|
help="path to the JSON config (default: $CONFIG_PATH or ./config.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="validate the config, print the schedule and exit",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="run normally but log messages instead of sending them",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
# Main
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config(Path(args.config))
|
||||||
|
except ConfigError as exc:
|
||||||
|
LOG.error("config error: %s", exc)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
build_schedule(config.reminders, datetime.now(UTC))
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
print_plan(config)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
wake_event = threading.Event()
|
||||||
|
reload_requested = threading.Event()
|
||||||
|
install_signal_handlers(stop_event, wake_event, reload_requested)
|
||||||
|
LOG.info("reminder bot starting (config: %s)", config.path)
|
||||||
|
return run(
|
||||||
|
config,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
stop_event=stop_event,
|
||||||
|
wake_event=wake_event,
|
||||||
|
reload_requested=reload_requested,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
requests==2.34.2
|
||||||
Reference in new issue
Block a user