feedparser, requests, python-dotenv, a Telegram bot token from @BotFather, a chat ID, and optionally a self-hosted RSSHub instance for sources that have no native feed
cron, so a Unix-like shell is the path of least resistance.pip and venv. Check the currently supported version in the Python documentation before installing./newbot). The bot returns a token shaped like <digits>:<token-string>.The pipeline you are building has four stages:
1. Source list — a YAML file listing RSS feeds and GitHub repositories you care about.
2. Poller — a Python script that fetches each source, extracts new items and compares them against a local SQLite table of already-seen IDs.
3. Notifier — the same script posts each unseen item to Telegram through the Bot API (https://api.telegram.org/bot<token>/sendMessage).
4. Scheduler — cron runs the poller on a fixed interval, so you never poll manually again.
| Source type | What you poll | Auth |
|---|---|---|
| RSS / Atom feed | Feed URL, one entry per post or release note | None |
| GitHub Releases | `https://api.github.com/repos/<owner>/<repo>/releases` | Optional token; strongly recommended |
| Telegram delivery | Bot API `sendMessage` method | Bot token |
Example: a vendor blog that publishes https://<vendor-domain>/feed.xml and a repository https://github.com/<org>/<repo> both end up in one sources.yaml, and both arrive in the same Telegram chat.
If you are still deciding *which* models and repositories are worth watching, start from Open-weight AI models: how to run them for free in 2026 and Best AI models and neural networks in 2026.
What to do: create the working directory and install dependencies.
Why: an isolated virtualenv keeps the poller's packages away from system Python, so a later OS upgrade cannot break it.
How to check: pip list shows the three packages.
mkdir -p /opt/model-watch && cd /opt/model-watch
python3 -m venv .venv
. .venv/bin/activate
pip install feedparser requests python-dotenv
pip list | grep -E "feedparser|requests|python-dotenv"
Example output:
feedparser <version>
python-dotenv <version>
requests <version>
What to do: write sources.yaml with the feeds and repositories you follow, and create the SQLite table that remembers what you have already sent.
Why: RSS (Really Simple Syndication — a standard XML format where a site lists its latest posts) and the GitHub Releases API both return the *most recent* N items on every call. Without a store of seen IDs, you would be re-notified about the same release forever.
rss:
- name: vendor-blog
url: https://<vendor-domain>/feed.xml
- name: research-lab
url: https://<lab-domain>/blog/rss.xml
github:
- owner: <org-or-user>
repo: <repository>
- owner: <org-or-user>
repo: <second-repository>
python - <<'PY'
import sqlite3
db = sqlite3.connect("watch.db")
db.execute("""CREATE TABLE IF NOT EXISTS seen (
fp TEXT PRIMARY KEY,
title TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP)""")
db.commit()
print(db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall())
PY
Example: if you follow one lab's blog and two repositories, sources.yaml has three entries and the first run writes three rows into seen.
How to check: the script above prints [('seen',)]. If it prints [], the CREATE TABLE did not run — check that you are inside /opt/model-watch and that the file is writable.
Not every AI vendor publishes a feed. Test the candidate URLs before adding them: curl -sI https://<vendor-domain>/feed.xml | head -n 1 should return a 200; common alternative paths are /rss, /feed and /blog/feed.xml. If none work, generate a feed with a self-hosted RSSHub instance at http://<your-host>:<rsshub-port>/… — confirm the route syntax and default port in the RSSHub documentation.

What to do: put credentials in .env, then write watch.py that loops over both source types and sends unseen items.
Why: the Bot API is a plain HTTPS endpoint, so a single requests.post per new item is enough — no webhook, no inbound port, no server exposure.
cat > .env <<'EOF'
TELEGRAM_BOT_TOKEN=<token-from-botfather>
TELEGRAM_CHAT_ID=<your-chat-id>
GITHUB_TOKEN=<github-pat>
EOF
chmod 600 .env
import os, sqlite3, hashlib, requests, feedparser, yaml
from dotenv import load_dotenv
load_dotenv()
TOKEN, CHAT = os.environ["TELEGRAM_BOT_TOKEN"], os.environ["TELEGRAM_CHAT_ID"]
DB = sqlite3.connect("watch.db")
def fp(value): # stable fingerprint for any item
return hashlib.sha256(str(value).encode()).hexdigest()
def known(key):
return DB.execute("SELECT 1 FROM seen WHERE fp=?", (key,)).fetchone() is not None
def remember(key, title):
DB.execute("INSERT OR IGNORE INTO seen(fp,title) VALUES (?,?)", (key, title))
DB.commit()
def notify(text):
r = requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage",
json={"chat_id": CHAT, "text": text,
"disable_web_page_preview": True}, timeout=20)
r.raise_for_status()
return r.json()
def poll_rss(name, url):
feed = feedparser.parse(url)
if feed.bozo:
print(f"[{name}] bozo: {feed.get('bozo_exception')}")
for e in feed.entries[:10]:
key = fp(e.get("id") or e.link)
if not known(key):
notify(f"[{name}] {e.title}\n{e.link}")
remember(key, e.title)
def poll_github(owner, repo):
h = {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"}
r = requests.get(f"https://api.github.com/repos/{owner}/{repo}/releases",
headers=h, params={"per_page": 5}, timeout=20)
r.raise_for_status()
for rel in r.json():
key = fp(f"{owner}/{repo}:{rel['id']}")
if not known(key):
notify(f"[github] {owner}/{repo} {rel['tag_name']}\n{rel['html_url']}")
remember(key, rel["tag_name"])
with open("sources.yaml") as f:
cfg = yaml.safe_load(f)
for s in cfg.get("rss", []):
poll_rss(s["name"], s["url"])
for s in cfg.get("github", []):
poll_github(s["owner"], s["repo"])
print("done")
Schedule it with cron (a Unix daemon that runs commands on a timetable):
crontab -e
# add:
*/15 * * * * cd /opt/model-watch && .venv/bin/python watch.py >> watch.log 2>&1
Example: the first run sends one Telegram message per unseen item; the second run, executed a minute later, sends nothing and prints done — that is deduplication working, because every item's fingerprint is already in seen.
How to check: tail -n 3 watch.log shows done with no traceback lines.

What to do: confirm the bot identity, send a test message, then confirm the database and log agree.
Why: three independent things can fail silently — the token, the chat ID and the fingerprint logic. Testing them separately tells you which one broke.
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe"
curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
-d "chat_id=$TELEGRAM_CHAT_ID" -d "text=pipeline-test"
sqlite3 watch.db "SELECT COUNT(*) FROM seen;" 2>/dev/null || \
python -c "import sqlite3;print(sqlite3.connect('watch.db').execute('select count(*) from seen').fetchone())"
Checklist for a working pipeline:
getMe returns "ok":true and "is_bot":truepipeline-test appears in the target Telegram chatSELECT COUNT(*) returns a number that grows after the first run and stays flat on a repeat runwatch.log contains no Tracebackcrontab -l lists the */15 lineExample: expected first line of getMe output is {"ok":true,"result":{"id":<bot-id>,"is_bot":true, .... If ok is false, the token in .env is wrong — nothing downstream will work.
Problem. Bot reports it cannot find the chat
{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}TELEGRAM_CHAT_ID is wrong (for example, you copied the bot's own ID), or you never opened a conversation with the bot, or the bot was not added to the channel as an administrator.curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates" | grep -o '"chat":{"id":-\?[0-9]*' and put the returned number into .env. Channel IDs are negative and typically start with -100.Problem. GitHub returns no releases and the log shows a rate-limit complaint
{"message":"API rate limit exceeded for <ip>. ..."}GITHUB_TOKEN set, or load_dotenv() never loaded .env because the script was started from a different working directory.cd /opt/model-watch (so .env is found), regenerate a PAT with read-only repository access, and verify with curl -s -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/rate_limit. Check the current quota numbers in the GitHub documentation.Problem. Feed parsing succeeds but no entries arrive
[vendor-blog] bozo: <XML or SAX parse error text>feedparser flags the document as bozo.curl -s https://<vendor-domain>/feed.xml | head -n 5. If it starts with <!DOCTYPE html>, the feed path is wrong — try /rss, /feed, or generate one with RSSHub. If the feed is valid but empty, the item count in feed.entries[:10] may be lower than you expect; check the feed's own <item> count.You now have a scheduled poller that turns RSS feeds and GitHub releases into deduplicated Telegram messages, with a local SQLite record proving what was already sent. Next, add a per-repository filter for release tags (for example, ignore pre-releases) and route different source groups to different chats — for related tooling see AI Assistants for Learning English: Best Bots and Tools in Telegram and How to build an AI shopping assistant on a local model.
How often should the poller run?
Every 15 minutes is a safe default for most feeds and stays well inside Telegram's posting limits; on a shared IP, increase the interval or add the GitHub token. Check the current limits in the Telegram Bot API and GitHub REST documentation.
Do I need a GitHub token?
Not strictly — the public releases endpoint answers without one — but the unauthenticated hourly quota is small, and a token is what keeps a 15-minute schedule from exhausting it. Create a read-only token and keep it in .env with chmod 600.
Can the bot post to a channel instead of a private chat?
Yes. Add the bot as a channel administrator, use the channel ID (negative, usually starting with -100) as TELEGRAM_CHAT_ID, and the same sendMessage call works unchanged.
Why not just follow the model authors on Telegram?
You can, but feeds and releases are machine-readable and timestamped, so the same script covers every vendor regardless of whether they post to Telegram at all — and it gives you a searchable SQLite history of what changed and when.