RSS to Google Chat
Public Made by Adomby adom
Post new RSS feed items straight into your Google Chat spaces. A small always-on poller with a pinnable web editor: click the link in your space, add or edit feeds, Save — live within one poll cycle. Per-feed dedup handles both news feeds (post once) and status pages (re-post on updates).
Install?
Post new RSS feed items straight into your Google Chat spaces. A small always-on poller with a pinnable web editor: click the link in your space, add or edit feeds, Save — live within one poll cycle. Per-feed dedup handles both news feeds (post once) and status pages (re-post on updates).
adom-wiki pkg install adom/adom-rss-gchat
Latest: v1.8.9, published
Contents
- Setup
- 0. (Optional) Create a dedicated service container
- 1. Install
- 2. Expose the feed editor
- 3. Add your first feed
- Managing feeds
- Security model
- Operations
- Staying alive
- Config reference
- Filtering a noisy feed
- One story, several sites
- Watching a page that has no feed
- Digest mode: post only the sections you want
- Watchlist: sites that have no feed yet
- Running it elsewhere
README
markdownRSS to Google Chat
Post new RSS feed items straight into your Google Chat spaces, and manage which feeds post from a web editor pinned right next to them.
A small always-on service on your own Adom container polls your feeds (default every 30 minutes) and posts each newly published item to the Chat space you choose. Nothing is ever posted twice, adding a feed never floods the space with its backlog, and edits to the feed list go live without touching the server.
[Vendor News] *Product 2.0 released*
Tue, 21 Jul 2026 14:00:00 +0000
https://vendor.example.com/blog/product-2-0
- RSS and Atom: both formats work, so GitHub release feeds
(
https://github.com/<org>/<repo>/releases.atom) are a first-class source for "tell me when this project ships a version". - Per-feed routing: each feed carries its own Chat webhook, so one instance can post different feeds to different spaces.
- Two dedup modes:
guidposts once per item (news, blogs, release notes);guid+datere-posts when an item updates (status pages: you see Investigating, then Resolved). - Pinnable editor: a secret-free link you pin in the space; sign in with your Adom account to add/edit/remove feeds, live within one poll cycle.
- Self-contained: runs on your container, supervised by a watchdog. No central service, and your webhooks never leave your box.
Setup
You need an Adom account and a Google Chat space you can add webhooks to. About five minutes.
Where should it run? Anywhere that stays up, which on Adom is any container: they run 24/7 in the cloud, independent of whether your laptop is on. Most people should just use the container they already have.
| Your existing dev container (simplest) | A dedicated service container | |
|---|---|---|
| Extra containers | none | one more to manage |
| Isolation | shares the box with your dev work | fully isolated |
| Survives an admin rebuild of your dev box | no, reinstall | yes |
Both are the same install. Pick the dedicated container only if you want the isolation; otherwise skip step 0.
0. (Optional) Create a dedicated service container
Skip this if you are installing into a container you already have.
IMAGE_ID=$(adom-cli carbon containers images | jq -r '.[] | select(.name=="default-light") | .id')
adom-cli carbon user repo-create rss-gchat --private --description "RSS to Google Chat service"
REPO_ID=$(adom-cli carbon user repos | jq -r '.[] | select(.name=="rss-gchat") | .id')
adom-cli carbon containers create --image-id "$IMAGE_ID" --class small --repo-id "$REPO_ID"
Note the slug and SSH username from the response, give it ~20 seconds to
boot, then prefix the install command below with
ssh -o StrictHostKeyChecking=accept-new <ssh-username>@adom.cloud '...'.
1. Install
Run this on whichever container will host the service:
curl -fsSL https://wiki.adom.inc/download/adom/adom-rss-gchat/1.8.8/install.sh | bash
No Rust, git, or other tooling needed: the installer downloads a prebuilt binary, installs a cron watchdog, and prints the exact next steps (including your editor URL, resolved automatically when it can).
2. Expose the feed editor
The installer prints this command with your container's real slug filled in:
adom-cli carbon containers port-add <slug> --port 8842 --prefix rss-editor
Your editor URL is the returned https://<hostname>/, with no secret in it.
The first visit asks you to Sign in with Adom; only the container's owner
gets in.
3. Add your first feed
Open the editor URL. On first run it opens straight into the setup guide:

It walks you through the Google Chat side:
- In Google Chat, open the target space, click the space name, then Apps & integrations → Webhooks → Add webhook. Name it (e.g. "RSS") and copy the URL.
- In the editor: Add feed, paste the feed URL and the webhook URL, pick a label and dedup mode, Save.
- Pin the editor URL to the space (paste it in a message, hover, Pin) so feed edits are always one click away.
That's it. New items post from here on; the feed's existing backlog is marked as read silently, so your space is never flooded.
Managing feeds

Everything happens in the editor, it validates input, backs up the previous list before every save, and the service hot-reloads the file each poll cycle:
| You want to | Do |
|---|---|
| Add a feed | Add feed → URL + webhook + label → Save |
| Route a feed to another space | Give it that space's webhook URL |
| Status-page style re-posts | Set dedup to guid+date |
| Cut a noisy feed down | Filters → only / skip categories |
| Post only parts of a newsletter | digest_sections (see below) |
| Stop old articles arriving as news | Filters → max age |
| Rename a feed's label | Edit label → Save (read-history is kept) |
| Stop one feed | Remove it → Save |
| Pause everything | Remove all feeds → Save (nothing is forgotten) |
A save is live within one poll cycle (default ≤30 min). Feeds are identified by URL, so relabeling never re-posts old items.
Security model
Default: Sign in with Adom. The pinned editor URL carries no secret. It is
just the hostname. Opening it prompts a one-time "Sign in with Adom" (approve
the request in the tab that opens; the session lasts 30 days per browser).
Access is limited to the container's owner, resolved automatically with nothing to configure. Allow teammates by adding
EDITOR_ALLOWED_USERS=name1,name2 to ~/rss-gchat/env. Anyone else who
clicks the pinned link gets a named "not authorized" refusal, so the space
audience and the feed audience can safely differ, including feeds whose URLs
embed private credentials.
Extra safeguards, active in both modes:
- Webhooks are write-only. The editor never returns stored webhook URLs, it shows a masked form with the key/token stripped. (Leave the field blank to keep a saved webhook; paste to replace. Changing a feed's URL requires re-entering its webhook.)
- Changes are loud. Every save posts a short "feed configuration changed"
notice to all affected spaces, including a space a feed was just removed
from. Silently rerouting a feed is not possible.
(
EDITOR_CHANGE_NOTICE=0in~/rss-gchat/envdisables this.)
Token mode (EDITOR_AUTH=token) remains available for non-Adom
deployments or when you want a capability link anyone in the space can use:
the URL embeds a long random token, treat it like a password, and rotate a
leak with rm ~/rss-gchat/editor.token + re-run the installer + re-pin.
Fresh installs default to adom when the container can identify its owner,
token otherwise; upgrades never change an existing choice.
Webhook URLs live only in ~/rss-gchat/feeds.json (0700-permission dir) on
your container.
Operations
| Task | How |
|---|---|
| Upgrade | Re-run the install one-liner, feeds, state, and token are preserved |
| Logs | ~/rss-gchat/bridge.log and ~/rss-gchat/editor.log |
| Health | pidof adom-rss-gchat (bridge) and curl localhost:8842/healthz (editor) |
| After a full container restart | sudo service cron start once; the watchdog revives both processes within 2 min |
The watchdog (cron, every 2 minutes) relaunches the bridge or editor if either dies. State writes are atomic and recorded per successful post, so even a crash mid-batch never causes a duplicate post.
Staying alive
Adom containers run 24/7 in the cloud, so closing your laptop or your editor
changes nothing. What does matter is process parentage: anything started from a
VS Code terminal is a child of that terminal and dies when the terminal is
reaped. This service is never started that way. Cron runs the watchdog, the
watchdog launches the daemons with nohup, and they reparent to PID 1. Check
it any time:
ps -o pid,ppid,comm -p $(cat ~/rss-gchat/bridge.pid) # PPID 1 = survives
The one gap is a container restart: cron is not started by the container entrypoint, so nothing (this service or any other cron job) comes back until cron does. Start it once after a restart:
sudo service cron start
To make that automatic, add this to the top of ~/.bashrc (above its
non-interactive return guard) and to ~/.profile, so any terminal or ssh
login re-arms cron and every cron job with it:
pgrep -x cron >/dev/null 2>&1 || sudo -n service cron start >/dev/null 2>&1
Config reference
Settings live in ~/rss-gchat/env on the service container (all optional):
| Variable | Default | Meaning |
|---|---|---|
GCHAT_WEBHOOK_URL |
unset | Deployment-wide default webhook; per-feed webhooks override it. Usually unset, feeds carry their own. |
POLL_INTERVAL_MINUTES |
30 |
Poll frequency (min 1) |
RSS_EDITOR_PORT |
8842 |
Editor port |
EDITOR_AUTH |
auto | adom (Sign in with Adom, owner-only) or token (capability link) |
EDITOR_ALLOWED_USERS |
unset | Extra Adom usernames allowed in adom mode (comma-separated) |
SEED_ON_FIRST_RUN |
true |
Silently mark a new feed's backlog as read |
RUST_LOG |
info |
Log verbosity |
Filtering a noisy feed
Some feeds mix everything together, and some re-publish old articles under
fresh ids so ancient posts arrive looking like news. Open Filters on any
feed in the editor to fix both. The panel opens by itself when a feed already
has filters, and the link shows a summary like (only Product, API, 45d):
| Key | Meaning |
|---|---|
| Editor field | feeds.json key |
| --- | --- |
| only | include_categories |
| skip | exclude_categories |
| title has | include_titles |
| title skips | exclude_titles |
| (feeds.json) | include_titles + summary: include_text |
| (feeds.json) | exclude_text |
| max age | max_age_days |
include_text is for publishers who put the substance in the subtitle. a16z
titles an announcement "Endeavor Optical Networks" and only the summary says
what happened, so include_text: ["Series A", "co-leads"] catches funding
posts that a title filter cannot see. Title and summary are searched
separately, so a phrase can never match across the join between them.
A keyword may start with ^ to anchor it to the beginning of the field.
Publishers name a whole genre by the subject noun that opens the headline, so
^gamer or ^enthusiast drops hobbyist anecdotes while leaving industry news
that happens to contain the same word later in the sentence.
Matching is case-insensitive and whole-word, so short keywords stay
usable: "AI" matches "AI Act" and "AI-native" but not "Entertainment" or
"Grand Prairie". A plural still counts, so "chip" matches "chips" and
"Test Bed" matches "Test Beds". All-caps acronyms are matched exactly,
because their plural is a different word: "TIP" does not match "tips".
One story, several sites
A publisher network republishes the same article on each of its titles under a
different id and URL, so per-feed dedup posts it once per feed. Give those
feeds a shared dedup_group and they key on the headline instead, so the story
posts once from whichever feed sees it first:
{ "url": "https://www.computerworld.com/feed/", "label": "Computerworld AI",
"dedup_group": "foundry-ai", "include_categories": ["Artificial Intelligence"] }
Watching a page that has no feed
Not every publisher ships RSS. page_watch reads entries off the HTML instead,
which is how a product changelog that otherwise only arrives by marketing email
becomes a feed:
{ "url": "https://www.quilter.ai/changelog", "label": "Quilter Changelog",
"page_watch": { "headings": ["h2", "h3"] } }
| key | meaning |
|---|---|
headings |
Heading levels that mark an entry. Default h2 + h3. |
link_prefix |
Take links whose href contains this instead of headings, for index pages that list posts. |
link_exclude |
Skip hrefs containing any of these. Navigation usually shares the posts' path, so a prefix alone pulls in the menu. |
Entries dedup on their text, so an entry is posted once even though every entry
on the page shares one URL, and the first poll seeds silently rather than
posting the whole backlog. Filters apply as usual, which is what keeps a blog
that mixes product news with SEO articles readable. If a redesign moves the
headings, the log says page watch found no entries instead of the watch
quietly reporting nothing forever.
Categories only work when the publisher tags them. Many organisation-wide
feeds (most .gov ones) carry none, so title keywords are the way to follow a
single programme inside a firehose:
{ "url": "https://www.nsf.gov/rss/rss_www_funding_pgm_annc_inf.xml",
"label": "NSF TIP Funding",
"include_titles": ["SBIR", "STTR", "PESOSE", "Innovation Engine"] }
Matching is case-insensitive. An item with no date is never dropped by
max_age_days, since a missing date is not evidence of staleness. Filtered
items are recorded as seen, so changing a filter later does not backfill.
{ "url": "https://openai.com/news/rss.xml", "label": "OpenAI",
"include_categories": ["Product", "API"], "max_age_days": 45 }
That example takes OpenAI's firehose from ~1100 items down to ~10 actual product announcements.
Digest mode: post only the sections you want
Newsletters arrive as one item per issue, so a feed reader shows you a single
subject line for what is really a dozen stories mixed with sponsored blocks.
digest_sections lifts out named sections and posts nothing else.
{ "url": "https://rss.beehiiv.com/feeds/<id>.xml", "label": "Daily AI",
"digest_item_chars": 400,
"digest_sections": [
{ "name": "LATEST DEVELOPMENTS", "require": "Why it matters",
"summary_after": "The Rundown:" },
{ "name": "Trending AI Tools" },
{ "name": "Everything else in AI today" }
] }
| Key | Meaning |
|---|---|
name |
Section heading or coloured banner to match (case-insensitive substring) |
require |
Keep only sub-blocks containing this phrase |
summary_after |
Start each block's summary here; it ends with that paragraph |
digest_item_chars |
Cap per line (default 400) |
digest_max_chars |
Cap for the whole message (default 3500) |
require is the useful part. Publications label their own editorial content
("Why it matters", "The takeaway") and sponsored blocks never carry it, so one
phrase separates articles from ads without any per-advertiser list.
Sections are located by heading or by the full-width coloured banners that
newsletters use as dividers, which are plain divs that a heading scan misses.
If none of the named sections match, because the publisher restructured, the
message falls back to title and link rather than posting the whole issue.
For a simpler zero-config version, expand_headlines: true just lists the
issue's own contents list under the title.
Watchlist: sites that have no feed yet
Plenty of sites you want simply do not publish RSS. Rather than lose that research, list them and the service re-checks on a slow timer (default every 24 hours) and posts to your space the moment one goes live.
{ "feeds": [ ... ],
"watch_interval_hours": 24,
"watchlist": [
{ "label": "Some Vendor", "site": "https://example.com/",
"notes": "Webflow, RSS not enabled",
"candidates": ["https://example.com/updates.xml"] }
] }
Each check reads the site's HTML for a self-declared feed link, then tries
your candidates and a list of conventional paths (/feed, /rss.xml,
/blog/rss.xml, /index.xml, /atom.xml, ?feed=rss2 and more). A feed only
counts when it parses and has at least one item, so empty WordPress shells
and comment feeds do not trigger a false alarm.
You get one message per site, not a daily reminder. Adding the feed is still your call; the watchlist only tells you it became possible.
feeds.json format (what the editor writes):
{
"feeds": [
{ "url": "https://vendor.example.com/rss", "label": "Vendor News",
"webhook": "https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=..." },
{ "url": "https://status.example.com/history.rss", "label": "Vendor Status",
"dedup": "guid+date",
"webhook": "https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=..." }
]
}
Running it elsewhere
The binary is a plain Rust program with no Adom dependencies, it reads env
vars and a feeds file, and POSTs to webhooks. cargo build --release, then
run it under systemd, cron (RUN_ONCE=1), or Docker (see Dockerfile and
deploy/ in the source repo). DRY_RUN=1 previews what would post without
sending anything.