Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions bot/telegram_rss_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,41 @@ 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(
"--config",
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"),
Expand All @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions configuration.toml.example
Original file line number Diff line number Diff line change
@@ -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/",
]
58 changes: 58 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""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 NOW, 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, now=NOW) == 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), now=NOW) == 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, now=NOW) == 1
31 changes: 31 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading