From 954ed8e0890f294737e719bbf3714d2d1759ff10 Mon Sep 17 00:00:00 2001 From: SebastienPittet Date: Mon, 26 Oct 2026 09:54:23 +0100 Subject: [PATCH 1/3] test(cli): specify --once single-cycle behaviour (red) --- tests/test_cli.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..eca71a6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,56 @@ +"""Command line interface: the ``--once`` switch and its single-cycle contract.""" + +from __future__ import annotations + +from pathlib import Path + +from bot.config import Config +from bot.telegram_rss_bot import _parse_args, run_once +from tests.conftest import FakeResponse, FakeSession, make_rss + +CONFIG = Config( + time_interval_min=1, + entry_max_time_old=3600, + bot_token="token", + channel_id="@chan", + rss_urls=("https://example.com/feed",), +) + +ONE_ITEM = make_rss([{"title": "New", "link": "https://example.com/n", "guid": "n", "age_seconds": 60}]) + + +def test_once_flag_defaults_to_off() -> None: + assert _parse_args([]).once is False + + +def test_once_flag_is_recognised() -> None: + assert _parse_args(["--once"]).once is True + + +def test_run_once_publishes_exactly_one_cycle() -> None: + session = FakeSession() + session.get_responses.append(FakeResponse(200, ONE_ITEM)) + session.post_responses.append(FakeResponse(200)) + + assert run_once(CONFIG, session) == 0 + assert len(session.get_calls) == 1 + assert len(session.post_calls) == 1 + + +def test_run_once_writes_the_heartbeat(tmp_path: Path) -> None: + session = FakeSession() + session.get_responses.append(FakeResponse(200, ONE_ITEM)) + session.post_responses.append(FakeResponse(200)) + heartbeat = tmp_path / "heartbeat" + + assert run_once(CONFIG, session, str(heartbeat)) == 0 + assert heartbeat.exists() + + +def test_run_once_reports_failure_instead_of_raising() -> None: + """A crashed cycle must surface as a non-zero exit code, not a traceback.""" + session = FakeSession() + session.get_responses.append(FakeResponse(200, ONE_ITEM)) + session.post_responses.append(RuntimeError("boom")) + + assert run_once(CONFIG, session) == 1 From 97e93093f98eaea97ba9b307863dff75baecc6ad Mon Sep 17 00:00:00 2001 From: SebastienPittet Date: Mon, 26 Oct 2026 09:55:10 +0100 Subject: [PATCH 2/3] feat(cli): add --once to run a single feed cycle and exit Validating a bot token or a new feed meant starting the scheduler and watching logs for minutes. --once runs exactly one cycle and returns: 0 on success, 1 when the cycle crashed, 2 on a configuration error (unchanged). It short-circuits before signal and scheduler setup, so it never leaves a stray timer behind, and it honours HEARTBEAT_FILE like the scheduled job. --- bot/telegram_rss_bot.py | 31 +++++++++++++++++++++++++++++++ tests/test_cli.py | 12 +++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/bot/telegram_rss_bot.py b/bot/telegram_rss_bot.py index 9a9abbe..1bbe5d2 100644 --- a/bot/telegram_rss_bot.py +++ b/bot/telegram_rss_bot.py @@ -135,6 +135,29 @@ def job() -> None: return job +def run_once( + config: Config, + session: requests.Session, + heartbeat_file: str = "", + *, + now: datetime | None = None, +) -> int: + """Run exactly one feed cycle and return a process exit code. + + Used by ``--once`` to validate a token or a feed without waiting for the + schedule. A broken cycle is reported as exit code 1 rather than raised, + so callers get a usable status instead of a traceback. + """ + try: + sent = run_cycle(config, session, {}, now=now) + except Exception: + LOGGER.exception("Single run failed.") + return 1 + LOGGER.info("Single run finished: %d new message(s) published.", sent) + write_heartbeat(heartbeat_file) + return 0 + + def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Telegram RSS bot") parser.add_argument( @@ -142,6 +165,11 @@ def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: default="configuration.toml", help="Path to a TOML configuration file (default: %(default)s).", ) + parser.add_argument( + "--once", + action="store_true", + help="Run a single cycle, then exit (exit code 1 if the cycle failed).", + ) parser.add_argument( "--log-level", default=os.getenv("LOG_LEVEL", "INFO"), @@ -160,6 +188,9 @@ def main(argv: Sequence[str] | None = None) -> int: LOGGER.critical("Configuration error: %s", exc) return 2 + if args.once: + return run_once(config, requests.Session(), os.getenv(HEARTBEAT_ENV, "")) + stop_event = threading.Event() def request_stop(signum: int, _frame: object) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index eca71a6..e21a1d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ from bot.config import Config from bot.telegram_rss_bot import _parse_args, run_once -from tests.conftest import FakeResponse, FakeSession, make_rss +from tests.conftest import NOW, FakeResponse, FakeSession, make_rss CONFIG = Config( time_interval_min=1, @@ -16,7 +16,9 @@ rss_urls=("https://example.com/feed",), ) -ONE_ITEM = make_rss([{"title": "New", "link": "https://example.com/n", "guid": "n", "age_seconds": 60}]) +ONE_ITEM = make_rss( + [{"title": "New", "link": "https://example.com/n", "guid": "n", "age_seconds": 60}] +) def test_once_flag_defaults_to_off() -> None: @@ -32,7 +34,7 @@ def test_run_once_publishes_exactly_one_cycle() -> None: session.get_responses.append(FakeResponse(200, ONE_ITEM)) session.post_responses.append(FakeResponse(200)) - assert run_once(CONFIG, session) == 0 + assert run_once(CONFIG, session, now=NOW) == 0 assert len(session.get_calls) == 1 assert len(session.post_calls) == 1 @@ -43,7 +45,7 @@ def test_run_once_writes_the_heartbeat(tmp_path: Path) -> None: session.post_responses.append(FakeResponse(200)) heartbeat = tmp_path / "heartbeat" - assert run_once(CONFIG, session, str(heartbeat)) == 0 + assert run_once(CONFIG, session, str(heartbeat), now=NOW) == 0 assert heartbeat.exists() @@ -53,4 +55,4 @@ def test_run_once_reports_failure_instead_of_raising() -> None: session.get_responses.append(FakeResponse(200, ONE_ITEM)) session.post_responses.append(RuntimeError("boom")) - assert run_once(CONFIG, session) == 1 + assert run_once(CONFIG, session, now=NOW) == 1 From 3bb780f9f8b9452c63a941b04653d0efad4241d7 Mon Sep 17 00:00:00 2001 From: SebastienPittet Date: Mon, 26 Oct 2026 09:55:59 +0100 Subject: [PATCH 3/3] docs(config): ship commented config templates and document the entry point configuration.toml and .env are git-ignored by design (they hold the bot token), so a fresh clone had no template at all and the README only showed the TOML inline. Adds configuration.toml.example and .env.example, both carrying the feed list from the README as a TOML array (and the comma-separated form for env), plus a 'Running the bot' section covering python -m bot, --config resolution and --once. Two tests parse both committed examples and assert they yield the identical 8-feed list, so the templates rot loudly instead of quietly. --- .env.example | 27 +++++++++++++++++++++ README.md | 45 +++++++++++++++++++++++++++++++++++ configuration.toml.example | 48 ++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 31 ++++++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 .env.example create mode 100644 configuration.toml.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e16c70 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Example environment for the Telegram RSS bot. +# +# Copy this file to `.env` at the repository root (git-ignored): +# +# cp .env.example .env +# +# These variables are the fallback: they are only read when configuration.toml is +# missing or invalid. Injecting them directly (`docker run --env-file`, a +# Kubernetes Secret/ConfigMap) is the recommended path in production; the .env +# loader exists as a development convenience. + +# General settings +TIME_INTERVAL_MIN=20 +ENTRY_MAX_TIME_OLD=1200 + +# Bot configuration (token from @BotFather; the bot must be a channel admin) +BOT_TOKEN=YOUR BOT TOKEN +CHANNEL_ID=@YourChannel + +# News, RSS feeds: URLs separated by commas and/or whitespace. +# Keep them on one line. A quoted multi-line value is accepted too, but is far +# easier to break when a shell or a ConfigMap renders it. +RSS_URLS=https://korben.info/feed,https://www.ictjournal.ch/taxonomy/term/404/feed,https://www.ictjournal.ch/taxonomy/term/31/feed,https://www.ictjournal.ch/taxonomy/term/406/feed,https://www.newsd.admin.ch/newsd/feeds/rss?lang=fr&org-nr=1&offer-nr=308,https://feeds.feedburner.com/TheHackersNews,https://mspoweruser.com/feed/,https://www.numerama.com/feed/ + +# Optional +# LOG_LEVEL=DEBUG +# HEARTBEAT_FILE=/tmp/telegram-rss-bot.heartbeat diff --git a/README.md b/README.md index 32872cc..619219d 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,51 @@ This chapter guides you through creating your Telegram channel and setting up a 2. Give your bot permissions to post messages. +## Running the bot + +`bot/` is a package, so it is started as a module **from the repository root** — the same +command the Docker `ENTRYPOINT` and the Kubernetes manifest run: + +```bash +cd Telegram-RSS-Bot +python -m bot # run on the configured schedule +python -m bot --once # single cycle then exit: ideal to test a token or a feed +python -m bot --config /path/to/config.toml --log-level DEBUG +``` + +Starting the script itself (`python bot/telegram_rss_bot.py`) fails with +`ModuleNotFoundError: No module named 'bot'`: from inside the package, the package is not +importable. Exit codes are `0` success, `1` a cycle crashed (`--once` only), `2` bad configuration. + +### Configuration templates + +Two commented templates are versioned — copy one and fill in your token: + +| Template | How to use it | +| ----------------------------- | ---------------------------------------------------------------- | +| `configuration.toml.example` | `cp` to `configuration.toml` at the repository root | +| `.env.example` | `cp` to `.env`; read only when no TOML file is present | + +Both copies are git-ignored, so the bot token cannot be committed. They declare the same +feed list, and a test asserts that both stay parseable and identical. + +`--config` is resolved against your **current directory**, not the repository, and an invalid +TOML file makes the bot fall back to environment variables. To keep secrets out of the working +tree, store the file elsewhere and pass an absolute path: + +```bash +mkdir -p ~/.config/telegram-rss-bot && chmod 700 ~/.config/telegram-rss-bot +cp configuration.toml.example ~/.config/telegram-rss-bot/config.toml +chmod 600 ~/.config/telegram-rss-bot/config.toml +python -m bot --config ~/.config/telegram-rss-bot/config.toml --once +``` + +Two more environment variables are available: `LOG_LEVEL` (DEBUG, INFO, WARNING, ERROR) and +`HEARTBEAT_FILE`, which touches a file after each cycle for the Kubernetes liveness probe. + +The Kubernetes manifests inject the same keys through a ConfigMap and a Secret, so no file is +needed there. See `kubernetes/kube-manifest.yaml`. + # Configuration This code supports a TOML formatted configuration file: ```toml diff --git a/configuration.toml.example b/configuration.toml.example new file mode 100644 index 0000000..cd6ad44 --- /dev/null +++ b/configuration.toml.example @@ -0,0 +1,48 @@ +# Example configuration for the Telegram RSS bot. +# +# Copy this file to `configuration.toml` at the repository root: +# +# cp configuration.toml.example configuration.toml +# +# Both `configuration.toml` and `configuration.*.toml` are git-ignored, so your +# token can never be committed by accident. To keep secrets entirely outside the +# source tree, put the file anywhere you like and point at it: +# +# python -m bot --config ~/.config/telegram-rss-bot/config.toml +# +# Every key below is required. If this file is missing or invalid, the bot falls +# back to environment variables (see .env.example). + +# General settings ------------------------------------------------------------- + +# How often to poll the feeds, in minutes. +TIME_INTERVAL_MIN = 20 + +# Maximum age of a feed entry still worth publishing, in seconds (1200 = 20 min). +# Keep it close to TIME_INTERVAL_MIN: entries older than this are skipped. +ENTRY_MAX_TIME_OLD = 1200 + +# Bot configuration ------------------------------------------------------------ + +# Token issued by @BotFather. Treat it as a password. +BOT_TOKEN = "YOUR BOT TOKEN" + +# Target channel: "@YourChannel" for a public channel, or its numeric id +# (e.g. "-1001234567890"). The bot must be an admin of the channel. +CHANNEL_ID = "@YourChannel" + +# News, RSS feeds -------------------------------------------------------------- + +# One URL per line is the clearest form. A plain string also works; URLs may be +# separated by commas and/or whitespace: +# RSS_URLS = "https://korben.info/feed, https://www.numerama.com/feed/" +RSS_URLS = [ + "https://korben.info/feed", + "https://www.ictjournal.ch/taxonomy/term/404/feed", + "https://www.ictjournal.ch/taxonomy/term/31/feed", + "https://www.ictjournal.ch/taxonomy/term/406/feed", + "https://www.newsd.admin.ch/newsd/feeds/rss?lang=fr&org-nr=1&offer-nr=308", + "https://feeds.feedburner.com/TheHackersNews", + "https://mspoweruser.com/feed/", + "https://www.numerama.com/feed/", +] diff --git a/tests/test_config.py b/tests/test_config.py index f1c2d44..21eedd1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,10 +2,12 @@ from __future__ import annotations +import tomllib from pathlib import Path from typing import Any import pytest +from dotenv import dotenv_values from bot.config import ( Config, @@ -175,3 +177,32 @@ def test_config_is_immutable() -> None: config = parse_env_config(VALID_ENV) with pytest.raises(dataclasses.FrozenInstanceError): config.time_interval_min = 99 # type: ignore[misc] + + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXPECTED_FEED_COUNT = 8 + + +def test_toml_example_committed_in_repo_is_valid() -> None: + """`configuration.toml.example` must stay parseable as feeds are edited.""" + with (REPO_ROOT / "configuration.toml.example").open("rb") as handle: + config = parse_toml_config(tomllib.load(handle)) + + assert config.time_interval_min > 0 + assert config.entry_max_time_old > 0 + assert len(config.rss_urls) == EXPECTED_FEED_COUNT + assert all(url.startswith("https://") for url in config.rss_urls) + + +def test_env_example_committed_in_repo_is_valid() -> None: + """`.env.example` must yield the same feeds as the TOML example.""" + raw = dotenv_values(REPO_ROOT / ".env.example") + config = parse_env_config({key: value for key, value in raw.items() if value}) + + assert ( + config.rss_urls + == parse_toml_config( + tomllib.loads((REPO_ROOT / "configuration.toml.example").read_text(encoding="utf-8")) + ).rss_urls + ) + assert len(config.rss_urls) == EXPECTED_FEED_COUNT