---
title: "Telegram News Bot in Python (2026): aiogram + APScheduler"
description: "Build a Telegram news bot in Python with aiogram 3.27, APScheduler, and APITube. Async, sentiment-filtered, channel auto-post — full code + Docker."
source: https://apitube.io/blog/post/telegram-news-bot-python
---

# Building a Telegram News Bot with Python (2026 Edition)

**A Telegram news bot is a Python program that fetches articles from a news API, filters them by topic or sentiment, and posts them to a Telegram chat or channel automatically.** In 2026 the fast way to build one is `aiogram` 3.27 for the async Telegram client, `APScheduler` for in-process scheduling, and a news API that exposes sentiment and entities so you can post curated streams instead of a firehose.


> **Key takeaways**
> - Use `aiogram` 3.27 (async) over `python-telegram-bot` for channel auto-posting bots above ~50 concurrent users.
> - Filter the news API response by `sentiment.overall.polarity` and `entities[]` so the channel posts curated content, not a firehose.
> - `APScheduler`'s `AsyncIOScheduler` runs inside the aiogram event loop — one process for polling, fetching, and scheduled posting.
> - Ship as a multi-stage Python 3.12 Docker image with `restart: unless-stopped`; Fly.io, Railway, or a $5/month VPS all work.


This tutorial walks through a production-shaped channel bot in about 100 lines: it pulls articles every 15 minutes, drops anything that doesn't match the sentiment and entity filter, deduplicates by article ID, and survives a container restart. By the end you'll have a Dockerized service you can drop on any VPS.


## What you'll build

- An async Telegram bot that posts news to a channel automatically
- Sentiment + entity filter (e.g., only post negative articles mentioning Tesla)
- APScheduler running inside the aiogram event loop — no cron, no second process
- Deduplication by article ID and retry on Telegram rate limits
- A multi-stage Dockerfile with `restart: unless-stopped` you can ship today


## aiogram vs python-telegram-bot — quick decision

This is the question every Python dev asks first. Both libraries are maintained in 2026; the right pick depends on workload.

| Criterion | aiogram 3.27 | python-telegram-bot 21.x |
|---|---|---|
| Concurrency model | Async (asyncio) only | Async since v20; sync wrapper still available |
| Latency at 100 concurrent updates | ~80 ms p95 | ~600 ms p95 (sync) / ~120 ms (async) |
| Channel auto-post + scheduler in one process | Native fit (AsyncIOScheduler) | Works but more boilerplate |
| FSM, middleware, filters | First-class | First-class |
| Best for | Channel/group bots, news/notification streams, >500 users | Personal bots, command-driven workflows, gradual sync→async migration |
| Community trajectory in 2026 | Faster moving, channel-bot focus | Larger ecosystem, more legacy examples |

**Unlike python-telegram-bot, aiogram has been async-only since v3**, which means it shares a single event loop with `AsyncIOScheduler` and `httpx.AsyncClient` — no bridging, no thread pools, no callback wrapping. Practical rule: under 50 concurrent updates either library is fine; above 500, or for any channel auto-poster, pick aiogram. We'll use aiogram for the rest of this tutorial.


## Prerequisites

- Python 3.10 or newer (3.12 recommended)
- A Telegram bot token from `@BotFather`
- A Telegram channel where your bot is added as an administrator with "Post messages" permission
- An APITube API key from apitube.io — free tier includes sentiment and entities on every article (disclosure: I work on APITube)
- Familiarity with `async`/`await` — if `await client.get(...)` looks alien, skim the Python asyncio tutorial first


## How to build a Telegram news bot in Python (6 steps)

1. Get a bot token from BotFather
2. Set up the project and install dependencies
3. Fetch news from APITube (async)
4. Filter by sentiment and entities
5. Schedule channel posts with APScheduler
6. Add `/start`, `/subscribe`, `/filter` commands


### Step 1 — Get a bot token from BotFather

Open Telegram, message `@BotFather`, send `/newbot`, pick a name and a username. You'll get a token that looks like `8123456789:AAH...`. Save it.

Then create a channel (or use an existing one), open its settings → Administrators → Add Administrator → search your bot's username → grant "Post messages". Get the channel ID by forwarding any channel message to `@JsonDumpBot` — you want the `chat.id`, which for channels is a negative number like `-1001234567890`.


### Step 2 — Set up the project

```bash
mkdir telegram-news-bot && cd telegram-news-bot
python -m venv .venv && source .venv/bin/activate
pip install aiogram==3.27.0 apscheduler==3.10.4 httpx==0.27.0 python-dotenv==1.0.1
```

Create `.env`:

```
BOT_TOKEN=8123456789:AAH...
CHANNEL_ID=-1001234567890
APITUBE_KEY=your_apitube_key
```


### Step 3 — Fetch news from APITube

APITube's `GET /v1/news/everything` endpoint takes a search title, language, category, and date range, and returns articles enriched with sentiment scores and named entities. Auth goes in the `X-API-Key` header.

Quick sanity check from the shell:

```bash
curl "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2" \
  -H "X-API-Key: $APITUBE_KEY"
```

Response — one full article object, every field APITube returns per result:

```json
{
  "status": "ok",
  "limit": 2,
  "path": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1",
  "page": 1,
  "has_next_pages": true,
  "next_page": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=2",
  "has_previous_page": false,
  "previous_page": "",
  "export": {
    "json": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1&export=json",
    "xlsx": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1&export=xlsx",
    "csv": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1&export=csv",
    "tsv": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1&export=tsv",
    "xml": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1&export=xml",
    "rss": "https://api.apitube.io/v1/news/everything?title=Tesla&language.code=en&per_page=2&page=1&export=rss"
  },
  "request_id": "5978d89c-46f6-4ab4-b65c-8b4a6000d4ac",
  "results": [
    {
      "id": 982451653,
      "href": "https://example.com/tesla-recall",
      "published_at": "2026-05-02T08:14:00Z",
      "title": "Tesla recalls 1.2M vehicles over autopilot flaw",
      "description": "The recall covers most Model 3 and Y units...",
      "body": "Tesla on Friday recalled roughly 1.2 million vehicles over a defect in its autopilot driver-assistance system. The recall covers most Model 3 and Model Y units built since 2023.",
      "body_html": "<p>Tesla on Friday recalled roughly 1.2 million vehicles over a defect in its autopilot driver-assistance system. The recall covers most Model 3 and Model Y units built since 2023.</p>",
      "language": "en",
      "author": {
        "id": 4521,
        "name": "Jane Doe"
      },
      "image": "https://example.com/images/tesla-recall.jpg",
      "categories": [
        {
          "id": 211,
          "name": "automotive",
          "score": 0.91,
          "taxonomy": "iptc_mediatopics",
          "links": {
            "self": "https://api.apitube.io/v1/news/category/iptc_mediatopics/medtop:20000266"
          }
        }
      ],
      "topics": [
        {
          "id": "automotive.recalls",
          "name": "Vehicle Recalls",
          "score": 0.34,
          "links": {
            "self": "https://api.apitube.io/v1/news/topic/automotive.recalls"
          }
        }
      ],
      "industries": [
        {
          "id": 88,
          "name": "Automotive",
          "links": {
            "self": "https://api.apitube.io/v1/news/industry/88"
          }
        }
      ],
      "entities": [
        {
          "id": 8821,
          "name": "Tesla",
          "type": "organization",
          "links": {
            "self": "https://api.apitube.io/v1/news/entity/8821",
            "wikipedia": "https://en.wikipedia.org/wiki/Tesla,_Inc.",
            "wikidata": "https://www.wikidata.org/wiki/Q478214"
          },
          "frequency": 5,
          "title": {
            "pos": [
              {"start": 0, "end": 5}
            ]
          },
          "body": {
            "pos": [
              {"start": 0, "end": 5},
              {"start": 142, "end": 147}
            ]
          },
          "metadata": {
            "name": "Tesla",
            "type": "business",
            "country": {"code": "US", "name": "United States"},
            "description": "Electric vehicle manufacturer"
          }
        },
        {
          "id": 19233,
          "name": "Model 3",
          "type": "product",
          "links": {
            "self": "https://api.apitube.io/v1/news/entity/19233"
          },
          "frequency": 2,
          "title": {
            "pos": []
          },
          "body": {
            "pos": [
              {"start": 58, "end": 65}
            ]
          },
          "metadata": {}
        }
      ],
      "source": {
        "id": 311,
        "domain": "reuters.com",
        "home_page_url": "https://www.reuters.com",
        "type": "news",
        "bias": "center",
        "rankings": {
          "opr": 9
        },
        "location": {
          "country_name": "United States",
          "country_code": "us"
        },
        "favicon": "https://www.reuters.com/favicon.ico"
      },
      "sentiment": {
        "overall": {"score": -0.62, "polarity": "negative"},
        "title": {"score": -0.55, "polarity": "negative"},
        "body": {"score": -0.64, "polarity": "negative"}
      },
      "summary": [
        {
          "sentence": "Tesla recalled roughly 1.2 million vehicles over an autopilot defect.",
          "sentiment": {"score": -0.6, "polarity": "negative"}
        }
      ],
      "keywords": ["Tesla", "recall", "autopilot", "Model 3"],
      "links": [
        {"url": "https://example.com/tesla-recall-followup", "type": "link"}
      ],
      "media": [
        {"url": "https://example.com/images/tesla-recall.jpg", "type": "image"}
      ],
      "story": {
        "id": 3019419820,
        "uri": "https://api.apitube.io/v1/news/story/3019419820"
      },
      "shares": {
        "total": 412,
        "facebook": 220,
        "twitter": 150,
        "reddit": 42
      },
      "is_duplicate": false,
      "is_free": true,
      "is_breaking": true,
      "read_time": 2,
      "sentences_count": 8,
      "paragraphs_count": 4,
      "words_count": 320,
      "characters_count": 1820
    }
  ]
}
```

The bot only touches a handful of these — `title`, `description`, `href`, `source.domain`, `sentiment.overall.polarity`, and `entities[].name` — but everything else (topics, industries, source bias, per-sentence summary, share counts) is there if you want richer posts later.

In Python, async `httpx`:

```python
import httpx, os

API_URL = "https://api.apitube.io/v1/news/everything"

async def fetch_news(query: str) -> list[dict]:
    params = {
        "title": query,
        "language.code": "en",
        "per_page": 20,
        "sort.by": "published_at",
        "sort.order": "desc",
    }
    headers = {"X-API-Key": os.environ["APITUBE_KEY"]}
    async with httpx.AsyncClient(timeout=15) as client:
        r = await client.get(API_URL, params=params, headers=headers)
        r.raise_for_status()
        return r.json().get("results", [])
```


### Step 4 — Filter by sentiment and entities

This is the step that turns a firehose into a curated channel. Drop articles that don't match the configured polarity, and keep only ones whose entities include at least one watched name. None of the popular tutorials do this — they post everything they fetch.

```python
WATCHED_ENTITIES = {"Tesla", "SpaceX", "OpenAI"}
WANTED_POLARITY = {"negative"}  # use {"positive", "negative", "neutral"} to allow all

def passes_filter(article: dict) -> bool:
    polarity = article.get("sentiment", {}).get("overall", {}).get("polarity")
    if polarity not in WANTED_POLARITY:
        return False
    names = {e.get("name") for e in article.get("entities", [])}
    return bool(names & WATCHED_ENTITIES)
```

A 30-second filter, but it's the difference between a bot that posts 200 articles a day and one that posts 6 useful ones.


### Step 5 — Schedule channel posts with APScheduler

`AsyncIOScheduler` runs inside the same event loop aiogram is using, so the whole bot is one process. Dedup posted IDs in an in-memory `set`; for persistence across restarts, swap in Redis or SQLite.

```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from aiogram.exceptions import TelegramRetryAfter
import asyncio, html

posted_ids: set[int] = set()

async def post_news(bot, channel_id: int):
    articles = await fetch_news("Tesla OR SpaceX OR OpenAI")
    for art in articles:
        if art["id"] in posted_ids or not passes_filter(art):
            continue
        text = (
            f"<b>{html.escape(art['title'])}</b>\n"
            f"{html.escape(art['description'])}\n\n"
            f"{art['source']['domain']} · "
            f"sentiment: {art['sentiment']['overall']['polarity']}\n"
            f"<a href=\"{art['href']}\">Read more</a>"
        )
        try:
            await bot.send_message(channel_id, text, disable_web_page_preview=False)
            posted_ids.add(art["id"])
        except TelegramRetryAfter as e:
            await asyncio.sleep(e.retry_after)
```

The `TelegramRetryAfter` catch is what keeps the bot alive when Telegram throttles you for posting too fast — it sleeps the exact requested interval, then the next scheduler tick retries.


### Step 6 — Commands and main loop

Three small commands so subscribers can interact with the bot directly:

- `/start` — welcome message
- `/subscribe` — explain that the bot auto-posts to the channel
- `/filter` — show current filter config

The dispatcher wires them up, and `start_polling` runs alongside the scheduler.


## Complete code (`bot.py`)

```python
import asyncio, html, logging, os
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramRetryAfter
from aiogram.filters import Command, CommandStart
from aiogram.types import Message
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from dotenv import load_dotenv
import httpx

load_dotenv()
TOKEN = os.environ["BOT_TOKEN"]
CHANNEL_ID = int(os.environ["CHANNEL_ID"])
APITUBE_KEY = os.environ["APITUBE_KEY"]
API_URL = "https://api.apitube.io/v1/news/everything"

WATCHED_ENTITIES = {"Tesla", "SpaceX", "OpenAI"}
WANTED_POLARITY = {"negative"}
posted_ids: set[int] = set()

dp = Dispatcher()

async def fetch_news(query: str) -> list[dict]:
    params = {
        "title": query,
        "language.code": "en",
        "per_page": 20,
        "sort.by": "published_at",
        "sort.order": "desc",
    }
    headers = {"X-API-Key": APITUBE_KEY}
    async with httpx.AsyncClient(timeout=15) as client:
        r = await client.get(API_URL, params=params, headers=headers)
        r.raise_for_status()
        return r.json().get("results", [])

def passes_filter(article: dict) -> bool:
    polarity = article.get("sentiment", {}).get("overall", {}).get("polarity")
    if polarity not in WANTED_POLARITY:
        return False
    names = {e.get("name") for e in article.get("entities", [])}
    return bool(names & WATCHED_ENTITIES)

async def post_news(bot: Bot):
    articles = await fetch_news("Tesla OR SpaceX OR OpenAI")
    for art in articles:
        if art["id"] in posted_ids or not passes_filter(art):
            continue
        text = (
            f"<b>{html.escape(art['title'])}</b>\n"
            f"{html.escape(art.get('description') or '')}\n\n"
            f"{art['source']['domain']} · "
            f"sentiment: {art['sentiment']['overall']['polarity']}\n"
            f"<a href=\"{art['href']}\">Read more</a>"
        )
        try:
            await bot.send_message(CHANNEL_ID, text)
            posted_ids.add(art["id"])
            await asyncio.sleep(1)
        except TelegramRetryAfter as e:
            await asyncio.sleep(e.retry_after)
        except Exception:
            logging.exception("send_message failed for %s", art["id"])

@dp.message(CommandStart())
async def cmd_start(m: Message):
    await m.answer("News bot online. Curated negative-sentiment news on Tesla, SpaceX, OpenAI.")

@dp.message(Command("subscribe"))
async def cmd_subscribe(m: Message):
    await m.answer("Posts go to the channel automatically every 15 minutes.")

@dp.message(Command("filter"))
async def cmd_filter(m: Message):
    await m.answer(
        f"Polarity: {sorted(WANTED_POLARITY)}\nEntities: {sorted(WATCHED_ENTITIES)}"
    )

async def main():
    logging.basicConfig(level=logging.INFO)
    bot = Bot(TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
    scheduler = AsyncIOScheduler()
    scheduler.add_job(post_news, IntervalTrigger(minutes=15), args=[bot])
    scheduler.start()
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())
```

Run it:

```bash
python bot.py
```

The first scheduler tick fires 15 minutes after start. To trigger immediately, add `next_run_time=datetime.utcnow()` to `add_job`.


## Deploy with Docker

A multi-stage Python 3.12 slim image keeps the final container small and the build deterministic.

`Dockerfile`:

```dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY bot.py .
CMD ["python", "-u", "bot.py"]
```

`requirements.txt`:

```
aiogram==3.27.0
apscheduler==3.10.4
httpx==0.27.0
python-dotenv==1.0.1
```

`docker-compose.yml`:

```yaml
services:
  bot:
    build: .
    env_file: .env
    restart: unless-stopped
```

For free or near-free hosting in 2026: Fly.io's hobby tier handles a single bot container fine; Railway gives you logs and one-click redeploys; or grab a $5/month VPS (Hetzner, Vultr, DigitalOcean) and run `docker compose up -d`. Whatever you pick, `restart: unless-stopped` ensures a crash doesn't take the bot offline overnight.


## FAQ

### How do I make a Telegram news bot in Python?

Register a bot with `@BotFather`, install `aiogram` 3.27 and a news client like `httpx`, fetch articles from a news API such as APITube, filter the response, and post each item to a Telegram chat or channel using `bot.send_message`. Wrap the fetch-and-post step in `APScheduler` to make it run on an interval.

### Which library is better, aiogram or python-telegram-bot?

Unlike python-telegram-bot, aiogram is async-only since v3, which means it pairs cleanly with `AsyncIOScheduler` for channel auto-posting in a single event loop. For news/channel bots above ~50 concurrent users, pick aiogram 3.27. For command-driven personal bots, sync-codebase teams, or gradual migration paths, python-telegram-bot 21.x remains a solid choice. Both are actively maintained in 2026.

### Can a Telegram bot post to a channel automatically?

Yes. Add the bot to the channel as an administrator with the "Post messages" permission, grab the channel's numeric chat ID, and call `bot.send_message(chat_id, text)` from a scheduled task. `APScheduler`'s `AsyncIOScheduler` runs inside the bot's event loop, so one process handles both polling and scheduled posting.

### Is python-telegram-bot still maintained in 2026?

Yes — python-telegram-bot is still actively maintained. Version 21.x ships an async API and ongoing fixes; the project sees regular releases on GitHub. The "is it maintained" question gets asked because the v13 sync API is deprecated, but v20 onwards is async and current.


## Try it

Clone the snippets above, drop them in a folder, fill `.env`, `docker compose up -d`. APITube's free tier returns sentiment and entities on every article — get a key at apitube.io and replace the placeholder in step 3. If you want to filter on category instead of entities, read `article["categories"]` and match on each category's `name` (e.g. keep only articles whose category name contains `"technology"`).

