From 7cba62f45d1c01619ee87e8ef44436bdb7fa5203 Mon Sep 17 00:00:00 2001 From: Beesquit Date: Thu, 15 Oct 2026 14:24:06 +0300 Subject: [PATCH] Initial Commit --- .dockerignore | 13 + .gitea/workflows/docker-build-push.yaml | 55 ++ .gitignore | 7 + Dockerfile | 19 + README.md | 79 +++ compose.yml | 11 + config.example.json | 26 + reminder.py | 725 ++++++++++++++++++++++++ requirements.txt | 1 + 9 files changed, 936 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/docker-build-push.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 config.example.json create mode 100755 reminder.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a3e5d41 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.gitea +.venv +.ruff_cache +__pycache__ +*.py[cod] +.env +config +config.json +README.md +docker-compose.yml +Dockerfile diff --git a/.gitea/workflows/docker-build-push.yaml b/.gitea/workflows/docker-build-push.yaml new file mode 100644 index 0000000..627269c --- /dev/null +++ b/.gitea/workflows/docker-build-push.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d1b61a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.py[cod] +.ruff_cache/ +.env +config.json +config/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd97d8f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..156e700 --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c3fbff9 --- /dev/null +++ b/compose.yml @@ -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 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..acfe3de --- /dev/null +++ b/config.example.json @@ -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 + } + ] +} diff --git a/reminder.py b/reminder.py new file mode 100755 index 0000000..e4f6451 --- /dev/null +++ b/reminder.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a258782 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests==2.34.2