# Telegram news bot: RSS feeds to a daily digest: build brief

Assembled by AgentGid's starter kit builder from recipe `news-bot` v0.3 (https://agentgid.com/build/kits/news-bot/). Data snapshot: 2026-10-12.
Constraints: commercial use; free parts only; crediting authors is fine.
Recipe status: test-built on 2026-10-12 (2 builds) with exactly these parts. The fixes from the last build are in this version, which has not been built again as a whole.
Licenses and prices change. Before you ship, open the linked AgentGid pages and confirm.

## What to build

A Python bot: in a chat, `/add <feed URL>` follows a feed, `/time 09:00` sets the digest time, and every day the bot sends the new items since the last digest, grouped by feed, with links.

## Definition of done

- [ ] `python -m newsbot digest --dry-run --feeds tests/fixtures/*.xml` prints the digest that would be sent, with no token and no network.
- [ ] Tests drive the bot through `Dispatcher.feed_update` with a fake session that records every Bot API call: `/start`, `/add`, `/remove`, `/list`, `/time`, `/digest` each produce the expected message, and a non-admin in a group cannot change the feeds.
- [ ] A feed item is sent once per chat: a second digest with the same feeds sends nothing new, and that survives a restart (SQLite).
- [ ] Every message is valid Telegram HTML: titles and text are escaped, tags from feed descriptions are stripped, and no message is longer than 4096 characters (long digests are split between items).
- [ ] Broken feeds (malformed XML, 404, timeout) are reported in `/list` and skipped; the other feeds still go out.
- [ ] Feeds are fetched at most every 15 minutes with `ETag` and `Last-Modified`, and a 304 answer costs no parsing.
- [ ] Digests go out at each chat's own time in its own time zone (`/time 09:00 Europe/Berlin`); a test checks the next run time across a daylight-saving change in that zone.
- [ ] The token comes from `.env` (`BOT_TOKEN`), `.env.example` lists it, `.env` is in `.gitignore`, and no log line contains it.
- [ ] Tests pass with `python -m pytest`.

Left for the person, because no check can do it:

- Create the bot with @BotFather, put the token into `.env`, start the bot and send `/start` to it once: only then can it write to you.
- To post into a channel, add the bot to the channel as an administrator.
- Choose where it runs: your own computer while it is on, or any small server; the bot uses long polling, so no public address is needed.

## Before you start

These files are already in this folder. Keep them, and never let a scaffolder overwrite them:

- `AGENTS.md`: this brief.
- `CLAUDE.md`: one line that imports AGENTS.md for Claude Code.
- `.mcp.json`: the MCP servers listed under "Tools you have".
- `CREDITS.md`: the license record. Add to it as you add files.
- `kit.json`: this kit as data (parts, versions, checks). `install.txt`: the install commands below. `first-prompt.txt`: the message the person starts you with.

You may do these without asking:

- Install the packages in the Stack section.
- Read the documentation this brief links to.
- Work without a bot token: build and test everything against the fake session and the fixture feeds, and report the live run as not verified.
- Use `git` without asking.

Stop and ask the person for:

- an account, a key or a payment;
- a runtime dependency, asset source or API that this brief does not list;
- a check that still fails after two real fixes (say what you tried).

## Stack

Use these parts and versions. Ask before swapping one.

| Part | Choice | Version | Kind |
|---|---|---|---|
| Runtime | Python 3.12 | - | runtime |
| Telegram library | aiogram | 3.31.0 | Python package |
| Feed reading | feedparser | 6.0.14 | Python package |
| Scheduling | APScheduler | 3.11.3 up to 4 | Python package |
| Also | HTTPX (HTTP client) | 0.28.1 | Python package |
| Also | python-dotenv (reads keys from .env) | 1.2.4 | Python package |
| Also | tzdata (time zones on Windows) | 2026.5 | Python package |
| Also | pytest (unit tests) | 9.1.1 | Python package |

Install exactly these versions (the same lines are in `install.txt`):

```sh
uv venv --seed --python 3.12 .venv && source .venv/bin/activate   # uv fetches Python 3.12 if it is missing; --seed puts pip into the venv, so the pip line below installs there and not into the system Python
# without uv: python3.12 -m venv .venv && source .venv/bin/activate
python --version   # must print 3.12.x
pip install aiogram==3.31.0 feedparser==6.0.14 "apscheduler>=3.11.3,<4" httpx==0.28.1 python-dotenv==1.2.4 tzdata==2026.5 pytest==9.1.1
```

## Rules for this project

- Keep fetching, formatting and sending apart: `feeds.py` returns new items, `digest.py` turns items into message texts, `bot.py` only sends. Tests cover the first two without a Bot, a session or the network.
- Never write the bot token into a log or an error message; the token is part of every Bot API URL.
- Send at most one message per second to a chat and 20 a minute to a group, as Telegram's bot FAQ asks; wait and retry on a 429 answer for the number of seconds it gives.

### APScheduler, from its AgentGid page

- Do not use the v4.0 series in production. The README marks it as a pre-release that may break compatibility without a migration path.
- When reading the README or docs, check whether they describe v4 or the 3.x line (latest release 3.11.3).
- If schedules must survive restarts or be shared between instances, configure a persistent data store: PostgreSQL, MySQL, SQLite or MongoDB.
- With more than one scheduler or worker, also configure an event broker: PostgreSQL, Redis or MQTT.
- Pick the synchronous flavor for thread-based apps and the asynchronous flavor for asyncio or Trio apps.
- Use the built-in limits on simultaneous jobs per function and on how late a job may start, instead of writing your own guards.
- If you run sub-minute interval jobs with a <code>ZoneInfo</code> time zone, use 3.11.3 or later to avoid the DST spring-forward stall.
- More: https://agentgid.com/tools/apscheduler/

## Tools you have

These MCP servers are in the config file listed under "Before you start".

- Context7: Up-to-date library docs for the agent
- AgentGid: Live data for choosing tools, assets and APIs while building

If a server's tools are missing from your session, either the person has not approved them yet (Claude Code holds the servers of a project `.mcp.json` as "Pending approval" until the person accepts them), or the config was added after the session started. Ask the person to approve the servers or restart the agent in this folder; until then, use the links in this brief.

## Plan

Work one step at a time. Do not start a step until the previous one meets its "Done when" lines.

### 1. Scaffold

- Create the virtual environment with Python 3.12 explicitly, as the install lines of the Stack section show, and check `python --version` inside it. If Python 3.12 is missing, `uv python install 3.12` gets it.
- Install the packages from the Stack section.
- `git init`, with a `.gitignore` that holds `.env`, `.venv/`, `data/` and `.check/`, a `.env.example` with `BOT_TOKEN=`, and a README.md that says how to create the bot and run it.
- List the packages with their versions in `requirements.txt` (or under `dependencies` in pyproject.toml), so the README's install step works in a fresh copy.
- Load `.env` with `load_dotenv(find_dotenv(usecwd=True))`: a bare `load_dotenv()` searches from the folder of the calling module, not from where the bot was started.
- Lay out `newsbot/` with `feeds.py`, `digest.py`, `store.py` (SQLite), `bot.py`, `schedule.py` and a `__main__.py` with the commands `run` and `digest`. Put `pythonpath = ["."]` under `[tool.pytest.ini_options]` in pyproject.toml, and run async code in tests with `asyncio.run`, without extra test plugins.
- Done when:
  - `python -m newsbot --help` lists the commands and `python -m pytest` runs.
  - The kit files listed under "Before you start" are still there.

### 2. Feeds

- Download each feed with httpx (timeout 15 s, a User-Agent that names the bot and, when `NEWSBOT_CONTACT_URL` is set in `.env`, that URL; `If-None-Match` and `If-Modified-Since` from the last answer), then parse the body with `feedparser.parse(io.BytesIO(content), response_headers=dict(r.headers))`. Never pass the bytes or text directly: feedparser first tries them as a file name or URL, so a server that answers `/etc/passwd` makes the bot read a local file.
- feedparser never raises on a broken feed: it sets `d.bozo` and keeps the error in `d.bozo_exception`. Treat a bozo feed with no entries as broken; keep the entries of a bozo feed that has some.
- An item's identity is `entry.id`, else `entry.link`, else a hash of title and summary. Its time is `published_parsed`, else `updated_parsed`, else the time it was first seen: many feeds leave the dates out.
- Write three fixture feeds into `tests/fixtures/`: a normal RSS feed, an Atom feed, and a broken one; give titles an `&` and a `<`, and descriptions HTML with `<p>` and `<b>`.
- Read first: `https://feedparser.readthedocs.io/en/stable/bozo.html`
- Done when: Tests parse the three fixtures, find the broken one, and return each item once across two runs.

### 3. Digest text

- Use Telegram's HTML parse mode (`DefaultBotProperties(parse_mode=ParseMode.HTML)`). Escape every title and text with `html.quote` (`from aiogram import html`; there is no module `aiogram.html`) and build the message from `<b>`, `<a href>` and plain text only. `html.quote` leaves `"` alone, so escape link targets with Python's `html.escape(url, quote=True)`.
- Feed descriptions arrive as HTML (feedparser returns `<p>Hello <b>world</b></p>` as is). Strip the tags and decode entities first, then cut to two sentences or 300 characters, then escape. Telegram rejects tags it does not support, such as `<p>`.
- Group items by feed, newest first, at most 10 items per feed per digest, with a line per feed for the rest ("and 7 more"). The items behind that line count as sent, so a backlog does not come back every day.
- A feed added with `/add` contributes the items it shows at that moment to the next digest (with the same cap); older items stored for other chats do not.
- Split at item boundaries so no message exceeds 4096 characters, counted after the HTML is parsed (the visible text). Count UTF-16 code units (`len(text.encode('utf-16-le')) // 2`, an emoji counts 2), as Telegram does for entity offsets; that is never less than Python's `len`.
- Read first: `https://core.telegram.org/bots/api#formatting-options`
- Done when: Unit tests cover escaping, tag stripping, the 4096 limit with a digest of 60 items, and the "and N more" line.

### 4. Bot commands

- aiogram 3: handlers live on a `Router` (`@router.message(Command('add'))`), the `Dispatcher()` takes no bot, and polling starts with `await dp.start_polling(bot)`. Version 2 code (`executor.start_polling`, `Dispatcher(bot)`, `@dp.message_handler`) no longer exists.
- Build the `Router` inside a function (`def make_router(store) -> Router`), not at module level: a router can be attached to one Dispatcher only, so a module-level router fails the second test with "Router is already attached".
- Commands: `/start` (help and registers the chat), `/add <url>` (fetches once to check it is a feed), `/remove <number>`, `/list` (feeds with their last status), `/time HH:MM [Area/City]`, `/digest` (send now). In groups only administrators may change feeds or time.
- Commands posted in a channel arrive as `channel_post` updates, not `message`: register each handler for both (`@router.channel_post(Command('add'))` too), and poll with `allowed_updates=dp.resolve_used_update_types()`.
- For tests, give the `Bot` a fake session: subclass `aiogram.client.session.base.BaseSession`, record each method in `make_request` and return a plausible result (`getChatMember` must return a ChatMember such as `ChatMemberAdministrator` or `ChatMemberMember`; the `Command` filter may call `getMe`), implement `close` and `stream_content` too, since they are abstract, and feed updates with `await dp.feed_update(bot, Update.model_validate({...}, context={'bot': bot}))`. A token must look like `123:ABC` even in tests: `Bot('bad')` raises TokenValidationError.
- Before polling, call `delete_webhook()`: `getUpdates` does not work while a webhook is set.
- Read first: `https://docs.aiogram.dev/en/latest/migration_2_to_3.html`
- Done when: Every command has a test through `feed_update` with the fake session, including a refused change by a non-admin in a group.

### 5. Schedule and storage

- SQLite in `data/newsbot.db`: chats (id, time, time zone, language), feeds (url, etag, last_modified, last_status), subscriptions, and sent items per chat.
- One APScheduler 3 `AsyncIOScheduler` job per chat with a `CronTrigger(hour, minute, timezone=ZoneInfo(...))`, rebuilt when `/time` changes. Keep no APScheduler job store: at start-up, rebuild the jobs from the `chats` table, which already holds each chat's time. Pass the coroutine function itself or a `functools.partial` of it, never `lambda: send_digest(chat)`: APScheduler runs a plain function in a worker thread, the coroutine is never awaited and the digest silently never goes out. Fetch feeds every 15 minutes in a separate job and store new items; the digest job only reads the store.
- To change a chat's time, `remove_job` the old job (if `get_job` finds it) and add the new one. Before `scheduler.start()`, jobs are only pending: `add_job(..., replace_existing=True)` then queues a second job with the same id, and `get_job` still returns the old one.
- Mark items as sent only after Telegram accepted the message that holds them.
- Done when: A test computes the next digest time for `09:00 Europe/Berlin` on the day of a daylight-saving change and gets 09:00 local time.

### 6. Verify

- Commit, make a clean copy of the repository in a temporary folder (`git clone . /tmp/check`, or a git-ignored `.check/` inside the project if you may not write outside it) and follow only the README there: install, run the tests, run the dry-run digest.
- If a token is in `.env`, run the bot, send it the commands from a phone and write what happened into PROGRESS.md. Without a token, say that the live run was not verified.
- Done when: Every line under Definition of done is true, and anything that could not be verified is named in PROGRESS.md.

## How to check your work

- Run `digest --dry-run` after every change to the digest text and read the output.
- Keep tests offline: fixtures for feeds, the fake session for Telegram, a recorded reply for the model.

## Known traps

- aiogram 3 is not aiogram 2: `executor` is gone, `Dispatcher()` takes no bot, handlers sit on a `Router` without the `_handler` suffix, and keyword filters became explicit filters. (aiogram migration guide)
- A message may hold 1 to 4096 characters after entities are parsed. aiogram does not check that before sending: a 5,000-character text leaves the client and fails at Telegram. (measured with aiogram 3.31.0 and feedparser 6.0.14, Oct 12, 2026)
- In HTML mode every `<`, `>` and `&` that is not part of a tag must be escaped, and only Telegram's short list of tags is accepted. feedparser hands descriptions over as HTML (`<p>`, `<div>`, `<img>`), which Telegram rejects. (Bot API, and measured with aiogram 3.31.0 and feedparser 6.0.14, Oct 12, 2026)
- feedparser does not raise on malformed feeds: it sets `bozo` and stores the error. Code that only catches exceptions never notices a broken feed. (feedparser docs)
- `getUpdates` (long polling) does not work while a webhook is set for the bot; delete the webhook before polling. (Bot API)
- Telegram's limits: about one message a second per chat, 20 a minute per group, about 30 a second in total. Above that the API answers 429 with a wait time. (Telegram bot FAQ)
- `feedparser.parse()` treats a `str` or `bytes` argument as a file name or URL first and reads it if it exists; only a file-like object such as `io.BytesIO(content)` is always parsed as the feed itself. (feedparser 6.0.14 docstring, and measured with aiogram 3.31.0 and feedparser 6.0.14, Oct 12, 2026)
- APScheduler 3's `AsyncIOScheduler` awaits a job only when the job function is a coroutine function. A `lambda` that returns a coroutine runs in a thread pool; Python then warns "coroutine ... was never awaited" and nothing is sent. (measured with APScheduler 3.11.3, Oct 12, 2026)
- There is no module `aiogram.html`: `import aiogram.html` fails with ModuleNotFoundError. Use `from aiogram import html`. Its `quote()` escapes `<`, `>` and `&` but not `"`. (measured with aiogram 3.31.0 and feedparser 6.0.14, Oct 12, 2026)
- In APScheduler 3, jobs added before `start()` are pending. A second `add_job` with the same id and `replace_existing=True` queues a duplicate, and `get_job` returns the first one until the scheduler starts. (measured with APScheduler 3.11.3, Oct 12, 2026)
- APScheduler's 4.0 series is a pre-release with a different API (data stores, event brokers); the Stack pins 3.x, which needs none of that. (AgentGid rules for APScheduler)

## Limits

- Send headlines, a short description and the link, not whole articles: the articles belong to their publishers.
- Use feeds that publishers offer for reading. Do not scrape sites that have no feed.
- Never read or print the contents of `.env` files, and never commit keys.

## When you need something that is not listed

Do not pick a library, asset source or API from memory. Ask the AgentGid MCP server first:

- `find_tools(goal)` for libraries, APIs and agents that fit a task.
- `find_assets(category, query, free_commercial_only=true)` for media sources and their license terms. It knows sources such as Kenney or Pixabay, not single packs, models or fonts.
- `get_api(api)` for prices, free tiers and limits of entries in AgentGid's API catalog. Media sources are not in it: use `find_assets`.
- `check_status(names)` when a service seems down.

Then tell the person what you want to add and why, cite the AgentGid URL, and wait for a yes before you add a runtime dependency.

## When this brief is wrong

Keep a list of what in this brief was wrong, missing or out of date, with the line and what worked instead (in PROGRESS.md). When you finish, send it to AgentGid's editors with `report_kit_issue(recipe="news-bot", version="0.3", text=...)` on the AgentGid MCP server: only they read it. Leave out keys, personal data and the person's code.

## Sources

- https://www.python.org/downloads/
- https://agentgid.com/tools/aiogram/
- https://agentgid.com/tools/feedparser/
- https://agentgid.com/tools/apscheduler/
- https://agentgid.com/mcp/servers/io-github-upstash-context7/
- https://agentgid.com/connect/
- https://feedparser.readthedocs.io/en/stable/bozo.html
- https://core.telegram.org/bots/api#formatting-options
- https://docs.aiogram.dev/en/latest/migration_2_to_3.html
- https://core.telegram.org/bots/api#sendmessage
- https://core.telegram.org/bots/api#getupdates
- https://core.telegram.org/bots/faq
- https://feedparser.readthedocs.io/en/stable/introduction.html
