RSS to Google Chat

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

README

markdown

RSS 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: guid posts once per item (news, blogs, release notes); guid+date re-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:

First-run setup guide

It walks you through the Google Chat side:

  1. 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.
  2. In the editor: Add feed, paste the feed URL and the webhook URL, pick a label and dedup mode, Save.
  3. 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

The feed editor with two feeds configured

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=0 in ~/rss-gchat/env disables 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.