Initial Commit
Build and Push Docker Image / build-and-push (release) Failing after 5m59s

This commit is contained in:
Beesquit committed 2026-10-15 16:39:55 +03:00
commit 7cba62f45d
9 files changed
+936

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
.gitea
.venv
.ruff_cache
__pycache__
*.py[cod]
.env
config
config.json
README.md
docker-compose.yml
Dockerfile
+55
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.py[cod]
.ruff_cache/
.env
config.json
config/
+19
View File
@@ -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"]
+79
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
requests==2.34.2