Build an RSS-to-Discord Feed Bot That Doesn't Spam Your Channel

·5 min read·Updated
discordtelegramrssbotsvolumeshow-to

An RSS-to-Discord bot is about the smallest useful thing you can host: a process with no inbound port that wakes on a timer, fetches a feed, and posts anything it hasn't seen before. You don't even need a bot account — a Discord webhook URL accepts posts with no authentication, so the whole thing can be thirty lines. The one mistake that turns it from useful into unbearable is keeping "what I've already posted" in the wrong place, because then every redeploy re-posts the entire feed into your channel.

Why this fits a small container so well

A feed relay is idle almost all of the time. It wakes every few minutes, makes one HTTP request, usually finds nothing new, and goes back to sleep. There's no inbound traffic, so there's nothing to route and no health check to satisfy — it's a worker, which is the shape this kind of platform handles best.

It's also a good first project for a container, because it exercises the three things every long-running app needs: a schedule, some persistent state, and an outbound credential.

Posting without a bot account

For a one-way relay you don't need a bot at all. A Discord webhook is a low-effort way to post into a channel without a bot user, and executing one requires no authentication — the token is part of the URL (Discord docs).

requests.post(WEBHOOK_URL, json={"content": f"**{title}**\n{link}"})

Create the webhook in Channel Settings → Integrations, and treat the URL as a secret: anyone holding it can post to your channel. It belongs in an environment variable, never in the repo.

For Telegram the equivalent is sendMessage with your bot token and a chat ID — a bot token there, but still no inbound port and no webhook to receive.

Use a real bot instead only if you need to react to anything — commands, reactions, threads. For "post new items into a channel", the webhook is simpler and has less to break.

The part that spams your channel

Your bot needs to remember which items it has already posted. The obvious place to keep that is a file next to your code:

# don't
SEEN_FILE = "seen.json"

That file lives in the container's writable layer, which is discarded every time the container is recreated — which happens on every redeploy. So the sequence is: you ship a small fix, the container is replaced, your bot starts with an empty "seen" set, decides all forty items in the feed are new, and posts all forty.

It works perfectly right up until the first time you change anything, which is exactly the failure mode that's hardest to catch in testing.

Put the state on a volume:

SEEN_FILE = os.environ.get("SEEN_FILE", "/data/seen.json")

Mount a volume at /data and the file survives redeploys. The same rule as any bot database — and here the consequence of getting it wrong is unusually visible.

Two refinements worth building in from the start:

  • Store IDs, not the whole entry. A feed's <guid> or entry id is the stable identifier. Titles and links can change.
  • Bound the set. Keep the most recent few hundred IDs rather than growing the file forever — disk that grows without limit is a problem on a small quota, and total disk overruns stop every app in the account.

Seed it on first run. The very first start also has an empty seen-set, so a brand-new bot will post the entire feed once. Mark everything currently in the feed as seen on first run and only post from then on — one flag, and it saves an embarrassing debut.

Scheduling the poll

There's no cron service, so the loop lives inside your process — which is free, since the process is already running:

scheduler = AsyncIOScheduler(timezone="UTC")
scheduler.add_job(check_feed, "interval", minutes=10, max_instances=1)
scheduler.start()

max_instances=1 matters: if a fetch hangs, you don't want a second one starting on top of it and double-posting. More on in-process scheduling, including why the container clock is UTC.

Note that a redeploy resets the schedule, so a poll due during the gap simply doesn't happen. For a feed reader that's harmless — the next poll catches up, because your seen-set is the source of truth rather than the timer.

Be polite to the feed

You're hitting someone else's server on a loop. Three things cost nothing and matter:

  • Send a real User-Agent identifying your bot. Some feeds block generic clients.
  • Use conditional requests. Store the ETag and Last-Modified from each response and send them back as If-None-Match / If-Modified-Since. A 304 Not Modified costs the publisher almost nothing and tells you there's nothing new without parsing anything.
  • Poll at a sane interval. Every 10–15 minutes is plenty for almost any feed. Every 30 seconds is how you get blocked.

Conditional requests also make the bot cheaper to run, since most polls become a tiny 304 rather than a full download and parse.

Handle failures quietly

Feeds go down, return malformed XML, and occasionally serve an HTML error page with a 200. Wrap the fetch-and-parse in a try/except that logs and moves on — an unhandled exception exits the process, and while the platform will restart it, a crash loop still reports as running and you'll be debugging a bot that looks healthy.

The failure to be loudest about is the one that produces silence: if the feed has been unreachable for hours, that's worth a log line you'd actually notice.

FAQ

Do I need a bot account to post RSS items to Discord?

No. A channel webhook accepts posts without authentication — the token is in the URL. Use a bot only if you need to respond to commands or reactions.

Why does my feed bot repost everything after a deploy?

Its "already seen" state is stored in the container's writable layer, which is discarded when the container is recreated. Move the file to a mounted volume.

Where should I store which items I've posted?

On a volume, as a bounded set of stable entry IDs — a few hundred is plenty. Don't let the file grow without limit.

How often should I poll a feed?

Every 10–15 minutes for most feeds, using ETag and Last-Modified conditional requests so unchanged feeds cost a 304.

Do I need a port for a feed bot?

No. It only makes outbound requests, so it's a worker with nothing exposed.

What happens if a poll is due while I'm redeploying?

It's skipped. That's harmless here — the next poll picks up anything new, because the seen-set determines what gets posted, not the schedule.

Wrapping up

A feed relay is a good project precisely because it's small enough to finish and still teaches the three things that decide whether a long-running app is pleasant to operate: where state lives, how work gets scheduled, and what happens when something fails. Get the state onto a volume before you deploy the second time and the rest is a loop and an HTTP POST.