Initial commit: status-page bridge with Webex bot management

Bridges third-party status pages into Webex spaces via RSS polling and
inbound webhooks (Statuspage / Status.io / Uptime Kuma / generic).

Includes an interactive Webex bot (websocket transport) that lets space
members register sources with an Adaptive Card instead of hand-editing
config/feeds.json: help, add, list, webhook <key>, remove <key>.

Ships with an atomic JSON store (per-file mutex, tmp+rename), parallel
RSS polling, and unit tests via node:test. All secrets are sourced from
environment variables (see .env.example); no credentials in the repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-08 16:08:34 -04:00
commit 007086caf6
17 changed files with 21779 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
config
logs

23
.env.example Normal file
View file

@ -0,0 +1,23 @@
# Copy this file to `.env` and fill in the real values.
# Docker: pass with `--env-file .env` or via your orchestrator's secret store.
# --- Required ---
# Webex bot bearer token. Rotate at https://developer.webex.com if this value
# has ever been committed.
WEBEX_BOT_TOKEN=
# --- Optional ---
# Public base URL of this service, used only so the bot can show users the
# exact inbound webhook URL to configure in their status provider.
# Example: https://status-bridge.example.com
#PUBLIC_BASE_URL=
# Override server bind (config.json values are used if these are unset).
#SERVER_PORT=1449
#SERVER_NAME=aeStatusPage
# Disable the interactive Webex chat bot (leaving inbound status webhooks
# active). Any value other than 'true' (case-insensitive) turns the bot off.
#BOT_ENABLED=true

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
node_modules/
logs/
.env
.env.*
!.env.example
# Runtime state / caches
config/rssFeedCache.json
config/*.bak
# OS / editor
.DS_Store
*.swp
.vscode/
.idea/

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:20
# Create app directory
WORKDIR /usr/src/app
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
RUN npm install
# If you are building your code for production
# RUN npm ci --only=production
# Bundle app source
COPY . .
EXPOSE 1449
CMD [ "node", "index.js" ]

110
README.md Normal file
View file

@ -0,0 +1,110 @@
# aeStatusPage
Small Node.js service that bridges third-party status pages into Webex spaces.
It can consume:
- RSS / Atom feeds (polled every 5 minutes)
- Inbound webhooks in Atlassian Statuspage, Status.io, or Uptime Kuma format
- Anything else you point at it (raw dump)
It also runs a Webex bot so you can register new sources from inside a space
with a card-based UI instead of hand-editing `config/feeds.json`.
## Configure
Copy `.env.example` to `.env` and set the values, at minimum:
| Variable | Purpose |
| --- | --- |
| `WEBEX_BOT_TOKEN` | Bearer token for the Webex bot user (required) |
| `PUBLIC_BASE_URL` | Public https URL where this service is reachable; used to show users the correct webhook URL |
| `SERVER_PORT` | Override the port from `config/config.json` (default 1449) |
| `SERVER_NAME` | Cosmetic name used in log lines |
| `BOT_ENABLED` | Set to `false` to disable the interactive chat bot |
`config/config.json` should now only contain non-secret configuration.
## Run
**Node version.** The Docker image runs on Node 20 (`node:20`). For local
development, use Node 20.x — Node 21+ breaks `webex-node-bot-framework`'s
transitive `webex` dep, which tries to write to the now read-only global
`navigator`. The app will still start on newer Node hosts, but the interactive
chat bot will be disabled with a warning logged. Set `BOT_ENABLED=false` to
suppress the warning if you don't want the bot in dev.
```bash
npm install
npm start
```
or Docker:
```bash
docker build -t aestatuspage .
docker run --env-file .env -p 1449:1449 \
-v $(pwd)/config:/usr/src/app/config \
-v $(pwd)/logs:/usr/src/app/logs \
aestatuspage
```
## Register a source from Webex
1. Invite the bot user to the space.
2. Type `help` — you'll see the available commands.
3. Type `add` — the bot posts an Adaptive Card. Fill in:
- a short key (used as the webhook URL slug)
- a friendly name
- source type: `RSS`, `Webhook`, or `Both`
- the RSS URL and/or the webhook payload format
4. On submit the bot saves the entry to `config/feeds.json` atomically and
posts back the exact webhook URL you should configure in your upstream
status provider (Statuspage, Status.io, Uptime Kuma, ...).
Other commands:
- `list` — show sources currently posting to this space
- `webhook <key>` — echo back the inbound webhook URL for a provider
- `remove <key>` — stop posting a provider to this space
The bot uses Webex's websocket transport, so it does not require an inbound
webhook URL to work as a chat bot. `PUBLIC_BASE_URL` is only used to show
users the correct URL for **status page** webhooks.
## HTTP endpoints
| Method + path | Purpose |
| --- | --- |
| `GET /healthCheck` | Liveness probe |
| `GET /feeds` | Dumps the current `feeds.json` |
| `GET /webhookEvents` | Dumps recent webhook events (in-memory) |
| `GET /refreshFeeds` | Re-reads `feeds.json` from disk without restarting |
| `GET /checkRSSFeed?site=<url>` | Debug: fetch + parse a feed and return it |
| `GET /cleanOldFiles` | Manually trigger old-log cleanup |
| `GET /:provider/go` | Redirect to the provider's `siteUrl` |
| `POST /:provider` | Inbound status webhook (Statuspage / Status.io / Uptime Kuma / generic) |
| `POST /rssTest?provider=<key>` | Debug: send a fake RSS item as if it had come through the feed |
## Tests
```bash
npm test
```
Runs `node --test` against `test/**/*.test.js`. No test framework
dependencies — everything is Node built-ins.
## Layout
```
index.js HTTP server, cron loop, webhook processors
src/atomicJson.js Atomic JSON read/write with per-file mutex
src/providerStore.js Typed wrapper around feeds.json (add/remove/list)
src/cards.js Adaptive Card templates used by the bot
src/webexBot.js webex-node-bot-framework wiring (websocket mode)
config/config.json Non-secret configuration
config/feeds.json Registered providers (mutated at runtime)
config/rssFeedCache.json Dedup cache for RSS items already posted
logs/ Rolling per-day log files
test/ Unit tests
```

13
config/config.json Normal file
View file

@ -0,0 +1,13 @@
{
"server": {
"name": "aeStatusPage",
"port": 1449
},
"webex": {
"bot": {
"name": "AE StatusPage",
"userName": "aestatuspage@webex.bot",
"id": "Y2lzY29zcGFyazovL3VzL0FQUExJQ0FUSU9OLzg0OGY1M2JhLTkwMTUtNGE4Zi1hOGM0LWU5NjAyMzZlMGMwYw"
}
}
}

700
config/feeds.json Normal file
View file

@ -0,0 +1,700 @@
{
"providers": {
"aeo": {
"name": "AEO",
"siteUrl": "https://aeo-technology.statuspage.io",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYzNmYzRmODAtNjc1Zi0xMWVkLWE0OWMtMzM2MWEyNGU0M2Fj",
"webhook": {
"format": "statusPage"
}
},
"actioniq": {
"name": "ActionIQ",
"siteUrl": "https://status.actioniq.com/",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMjdmNDg5ZTAtNjZkOC0xMWVkLTk5NzctMTc2YzFmMGE3MTFm",
"webhook": {
"format": "statusPage"
}
},
"akamai": {
"name": "Akamai",
"siteUrl": "https://www.akamaistatus.com",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vY2FjYmY3ODAtNjZkNy0xMWVkLWIyMGItZDk4MDJjNjRiZjRj",
"rss": {
"url": "https://www.akamaistatus.com/history.atom",
"lastCheck": "2026-07-08T19:30:21.236Z"
},
"webhook": {
"format": "statusPage"
}
},
"akeneo": {
"name": "Akeneo",
"siteUrl": "https://status.akeneo.com/",
"roomId": "2da3fd80-3034-11f0-bb92-052686b30f97",
"rss": {
"url": "https://status.akeneo.com/history.atom",
"lastCheck": "2026-07-08T19:30:21.526Z"
}
},
"applenews": {
"name": "Developer News",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOTZiNTdlMTAtNjFmMi0xMWVkLThhZTQtZjFiNDg0YTVlZDA1",
"rss": {
"url": "https://developer.apple.com/news/rss/news.rss",
"lastCheck": "2026-07-08T19:30:21.786Z"
}
},
"applerelease": {
"name": "Apple Software Releases",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOTZiNTdlMTAtNjFmMi0xMWVkLThhZTQtZjFiNDg0YTVlZDA1",
"rss": {
"url": "https://developer.apple.com/news/releases/rss/releases.rss",
"lastCheck": "2026-07-08T19:30:21.812Z"
}
},
"appspace": {
"name": "AppSpace",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYjM2OWYwNzAtNjZiZC0xMWVkLTllNGYtNmJjYmUzZjlmMWVj",
"rss": {
"url": "https://status.appspace.com/history.atom",
"lastCheck": "2026-07-08T19:30:22.138Z"
},
"webhook": {
"url": "https://status.appspace.com/",
"format": "statusPage"
}
},
"aprimo": {
"name": "Aprimo",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMWE0NzQxYzAtNjZkOC0xMWVkLTllMTQtZjc3NTc1ZGQ0Yzc3",
"rss": {
"url": "https://status.aprimo.com/pages/5c34ab9f8a716f04b8fccb35/rss",
"lastCheck": "2026-07-08T19:30:22.655Z"
},
"webhook": {
"url": "https://status.aprimo.com/",
"format": "statusIO"
}
},
"jira": {
"name": "Atlassian Jira",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vODA2OGFiMzAtMTdjNy0xMWYxLTg0ZWMtMjcwMmJlMmUxOWVj",
"webhook": {
"url": "https://jira-software.status.atlassian.com/",
"format": "statusPage"
}
},
"jsm": {
"name": "Atlassian Jira Service Management",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOGYzZDVjNTAtMTdjNy0xMWYxLWJkMzktNjEwZjg5ZjRiYzEz",
"webhook": {
"url": "https://jira-service-management.status.atlassian.com/",
"format": "statusPage"
}
},
"confluence": {
"name": "Atlassian Confluence",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOWEyNzY1YzAtMTdjNy0xMWYxLTliZmYtMDMyZGIzYTk4ZDI0",
"webhook": {
"url": "https://confluence.status.atlassian.com/",
"format": "statusPage"
}
},
"aws": {
"name": "AWS",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOTUwM2RlYzAtNjc4YS0xMWVkLTlkZTAtZDM0MjdmM2ZjYzhh",
"rss": {
"url": "https://status.aws.amazon.com/rss/all.rss",
"lastCheck": "2026-07-08T19:30:22.869Z"
}
},
"awsuseast2": {
"name": "AWS US East-2",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOTUwM2RlYzAtNjc4YS0xMWVkLTlkZTAtZDM0MjdmM2ZjYzhh",
"rss": {
"url": "https://status.aws.amazon.com/rss/internetconnectivity-us-east-2.rss",
"lastCheck": "2026-07-08T19:30:22.905Z"
}
},
"azure": {
"name": "Azure",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNzZmZDlhMDAtNjc4Yi0xMWVkLTk3YmUtNGRlYTc1MmUzNTJh",
"rss": {
"url": "https://rssfeed.azure.status.microsoft/en-us/status/feed/",
"lastCheck": "2026-07-08T19:30:23.773Z"
}
},
"cloudflare": {
"name": "Cloudflare",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYzUyYzY3MTAtNjc4Yi0xMWVkLWJkY2MtMjM3MjgzMDczNjhj",
"rss": {
"url": "https://www.cloudflarestatus.com/history.atom",
"lastCheck": "2026-07-08T19:30:23.924Z"
}
},
"cisaCyberAdvisories": {
"name": "CISA Cybersecurity Advisories",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vODJiNDk2NjAtNjc0OS0xMWVkLWFiYzUtYTVmZWM2YmNlOGIy",
"rss": {
"url": "https://www.cisa.gov/cybersecurity-advisories/ics-advisories.xml",
"lastCheck": "2026-07-08T19:30:24.135Z"
}
},
"cisaBlog": {
"name": "CISA BLOG",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZDc3ZWNmYTAtMDc3Yi0xMWYxLWE3NDMtMGZiYTg2ODRmZDc3",
"rss": {
"url": "https://www.cisa.gov/cisa/blog.xml",
"lastCheck": "2026-07-08T19:30:24.281Z"
}
},
"cisaNews": {
"name": "CISA NEWS",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vY2MzOTg2ZDAtMDc3Yi0xMWYxLWEzNWItNjExYmE4NzQ1NTY2",
"rss": {
"url": "https://www.cisa.gov/news.xml",
"lastCheck": "2026-07-08T19:30:24.312Z"
}
},
"cloudgenix": {
"name": "CloudGenix",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTIxM2FmYzAtMjYzNy0xMWVlLWJhOTktNDMzMDIyZDkwNjRm",
"rss": {
"url": "https://cloudgenix.statuspage.io/history.atom",
"lastCheck": "2026-07-08T19:30:24.563Z"
}
},
"contentstack": {
"name": "ContentStack",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZDNiMDlkNjAtNjZiZS0xMWVkLThjZjItYzczYzk4MzU0YmJj",
"rss": {
"url": "https://status.contentstack.com/history.atom",
"lastCheck": "2026-07-08T19:30:24.879Z"
},
"webhook": {
"url": "https://status.contentstack.com/",
"format": "statusPage"
}
},
"delinea": {
"name": "Delinea",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZmQ0YmQxOTAtNjZiZC0xMWVkLThmNjEtYTlhZGJmNGNkMjgw",
"rss": {
"url": "https://status.delinea.com/history.atom",
"lastCheck": "2026-07-08T19:30:25.073Z"
},
"webhook": {
"url": "https://uptime.centrify.com/",
"format": "statusPage"
}
},
"docusign": {
"name": "Docusign",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNTIwOTc3ZTAtOWI0ZS0xMWVkLWJhNTMtZjllOTA0YWE4OWQx",
"siteUrl": "https://status.docusign.com/",
"rss": {
"url": "https://status.docusign.com/history.atom",
"lastCheck": "2026-07-08T19:30:25.259Z"
},
"webhook": {
"format": "statusPage"
}
},
"docusign2": {
"name": "Docusign",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNTIwOTc3ZTAtOWI0ZS0xMWVkLWJhNTMtZjllOTA0YWE4OWQx",
"siteUrl": "https://status.docusign.com/",
"rss": {
"url": "https://www.docusign.com/trust/alerts/feed",
"lastCheck": "2026-07-08T19:30:25.913Z"
}
},
"elastic": {
"name": "Elastic | Kibana",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZWRmZmNlYzAtNjZkNy0xMWVkLWFkM2YtYTE1MmFhNTAxYTFh",
"rss": {
"url": "https://status.elastic.co/history.atom",
"lastCheck": "2026-07-08T19:30:26.035Z"
},
"webhook": {
"url": "https://status.elastic.co/",
"format": "statusPage"
}
},
"envoy": {
"name": "Envoy",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vODA5YmJjMzAtNjZiZi0xMWVkLTlkODYtZGJhYjAwMDBhOGE2",
"rss": {
"url": "https://status.envoy.com/history.atom",
"lastCheck": "2026-07-08T19:30:26.157Z"
},
"webhook": {
"url": "https://status.envoy.com",
"format": "statusPage"
}
},
"equinix": {
"name": "Equinix",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZDJjOTJmNTAtNjZkOS0xMWVkLTllZGYtOTE4YTQ4NDBiZjlj",
"rss": {
"url": "https://status.equinix.com/history.atom",
"lastCheck": "2025-04-03T01:05:26.165Z"
},
"webhook": {
"url": "https://status.equinix.com",
"format": "statusPage"
}
},
"faxplus": {
"name": "Fax.Plus",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMGNkMjBiZjAtMWJiOC0xMWYwLWJiZDUtZGRiYzk0ZWQ5OThl",
"rss": {
"url": "https://status.fax.plus/history.atom",
"lastCheck": "2026-07-08T19:30:26.805Z"
},
"webhook": {
"url": "https://status.firstup.io/#",
"format": "statusPage"
}
},
"firstup": {
"name": "FirstUp",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTFjODI0MTAtNjc1Ni0xMWVkLWI5NzctZDE5MmEzM2UxOWNj",
"rss": {
"url": "https://status.firstup.io/history.atom",
"lastCheck": "2026-07-08T19:30:27.068Z"
},
"webhook": {
"url": "https://status.firstup.io/#",
"format": "statusPage"
}
},
"github": {
"name": "GitHub",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMTc1Nzg0ODAtNjc4Yi0xMWVkLTk3YmUtNGRlYTc1MmUzNTJh",
"rss": {
"url": "https://www.githubstatus.com/history.atom",
"lastCheck": "2026-07-08T19:30:27.187Z"
},
"webhook": {
"url": "https://www.githubstatus.com/",
"format": "statusPage"
}
},
"googleads": {
"name": "Google Ads",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZjdjMjFlZDAtNjc4Yy0xMWVkLTgyZmQtNWYzZGZkMmQ5ZGUw",
"siteUrl": "https://ads.google.com/status/publisher/",
"rss": {
"url": "https://ads.google.com/status/publisher/en/feed.atom",
"lastCheck": "2026-07-01T17:15:27.577Z"
}
},
"googleblog": {
"name": "Google Blog",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYjcwYWNmNDAtYTBlNC0xMWVkLTgzMDktM2Q1NGNmYTlkYzRj",
"siteUrl": "https://status.actioniq.com/",
"rss": {
"url": "https://blog.google/rss/",
"lastCheck": "2026-07-08T19:30:27.398Z"
}
},
"googlecloud": {
"name": "Google Cloud",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNDZjZjE3YjAtNjc1ZC0xMWVkLWE1OTQtNWQxNzg3ZGQ5ZDdj",
"rss": {
"url": "https://status.cloud.google.com/en/feed.atom",
"lastCheck": "2026-07-08T19:30:27.501Z"
},
"webhook": {
"url": "https://status.cloud.google.com/regional/americas",
"format": "statusPage",
"status": "not available"
}
},
"googleworkspace": {
"name": "Google Workspace",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNjIyZDhhYjAtNjZiYy0xMWVkLTk2ZWEtODk4M2I2ZDIyNjdh",
"rss": {
"url": "https://www.google.com/appsstatus/dashboard/en/feed.atom",
"lastCheck": "2026-07-08T19:30:27.611Z"
},
"webhook": {
"url": "https://www.google.com/appsstatus/dashboard/",
"format": "statusPage",
"status": "not available"
}
},
"informatica": {
"name": "Informatica",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYWQ2ZmVkZDAtNDFkNC0xMWVlLThmMjAtYjE4YzZmYzhiZTU0",
"rss": {
"url": "https://status.informatica.com/history.atom",
"lastCheck": "2025-08-03T07:40:37.117Z"
},
"webhook": {
"url": "https://status.informatica.com",
"format": "statusPage"
}
},
"jamf": {
"name": "JAMF",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNTFiOTRlZDAtNjZjNi0xMWVkLTliNTUtNDU0MzM5NjQzMmMz",
"siteUrl": "https://status.jamf.com",
"rss": {
"url": "https://status.jamf.com/history.atom",
"lastCheck": "2026-07-08T19:30:28.242Z"
},
"webhook": {
"format": "statusPage"
}
},
"krebsonsecurity": {
"name": "Krebs on Security",
"siteUrl": "https://krebsonsecurity.com",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTJjNDM1MjAtNjc0Ny0xMWVkLWIwZTctYzFiZjYwOTRlMWQw",
"rss": {
"url": "https://krebsonsecurity.com/feed/",
"lastCheck": "2026-07-08T19:30:28.466Z"
}
},
"meraki": {
"name": "Meraki Networks",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMGM0NTE3MjAtNjZiZC0xMWVkLWI0MzMtNjMwMjMwNTVhYjY0",
"rss": {
"url": "https://status.meraki.net/history.atom",
"lastCheck": "2026-07-08T19:30:28.596Z"
},
"webhook": {
"url": "https://status.meraki.net/",
"format": "statusPage",
"status": "not available"
}
},
"miro": {
"name": "Miro",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZTgyN2FkYzAtNjZiZC0xMWVkLTlkYTUtYTliYzRkMjBmOWI3",
"rss": {
"url": "https://status.miro.com/us/feed.atom",
"lastCheck": "2026-07-08T19:30:28.748Z"
},
"webhook": {
"url": "https://status.miro.com",
"format": "statusPage",
"status": "not available"
}
},
"monday": {
"name": "Monday.com",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOTYyNGY1NTAtNjZiZC0xMWVkLWI4ZGEtNzk5YTJkNTMxOTNj",
"rss": {
"url": "https://status.monday.com/history.atom",
"lastCheck": "2026-07-08T19:30:28.863Z"
},
"webhook": {
"url": "https://status.monday.com/",
"format": "statusPage"
}
},
"newrelic": {
"name": "New Relic",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNmYzYjY5MTAtNjZiZC0xMWVkLTk5ZWEtYjc4MjYzODA5MGE0",
"rss": {
"url": "https://status.newrelic.com/history.atom",
"lastCheck": "2026-07-01T17:15:29.290Z"
},
"webhook": {
"url": "https://status.newrelic.com/",
"format": "statusPage"
}
},
"onelogin": {
"name": "OneLogin",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYjU5ZjE1NjAtNjZiYy0xMWVkLWI5ZWUtYjU3OGVjZWYyOWI1",
"siteUrl": "https://www.onelogin.com/status",
"rss": {
"url": "https://status.us.onelogin.com/pages/538511e2ce5cb97537000144/rss",
"lastCheck": "2026-07-08T19:30:29.023Z"
},
"webhook": {
"format": "statusPage"
}
},
"oracle": {
"name": "Oracle Cloud",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMGE2NDVjMTAtNjc3NC0xMWVkLWEwOTQtZTEyYWRjMzg5NmQ0",
"rss": {
"url": "https://ocistatus.oraclecloud.com/api/v2/incident-summary.rss",
"lastCheck": "2026-07-08T19:30:29.233Z"
},
"webhook": {
"url": "https://ocistatus.oraclecloud.com/",
"format": "statusPage",
"status": "not available"
}
},
"pagerduty": {
"name": "PagerDuty",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZjU2M2E4NjAtNjZiYi0xMWVkLWIzZGYtNjVmZmU4ZDdlOTNm",
"webhook": {
"url": "https://status.pagerduty.com/",
"format": "statusPage"
}
},
"paloalto": {
"name": "Palo Alto Networks",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMzUwMzVjNjAtYjJiOS0xMWVkLTg4NTktYzc1N2YzYjMwNGE4",
"rss": {
"url": "https://sase.status.paloaltonetworks.com/history.atom",
"lastCheck": "2026-03-31T02:10:30.460Z"
},
"webhook": {
"url": "https://sase.status.paloaltonetworks.com/#",
"format": "statusPage"
}
},
"panopto": {
"name": "Panopto",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMjYwNTA5OTAtYjJjMS0xMWVkLWE5NjAtNjNmNmE4ZTFjOGYx",
"rss": {
"url": "https://trust.panopto.com/pages/57c1fd57097aa14d7f0021cd/rss",
"lastCheck": "2026-07-08T19:30:29.988Z"
},
"webhook": {
"url": "https://status.io/pages/57c1fd57097aa14d7f0021cd",
"format": "statusIO"
}
},
"ping": {
"name": "Ping Identity",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNWNlNzczNTAtMWJiOC0xMWYwLWE1NzktZWRkZmQ0NWQ5OWI5",
"webhook": {
"url": "https://status.pingidentity.com/",
"format": "statusPage"
}
},
"procore": {
"name": "ProCore",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZTA0NTNjYzAtNjc3Mi0xMWVkLWEwNDEtZTNhOTI3ZmM4M2Jk",
"rss": {
"url": "https://status.procore.com/history.atom",
"lastCheck": "2026-07-08T19:30:30.172Z"
},
"webhook": {
"url": "https://status.procore.com/",
"format": "statusPage",
"status": "not available"
}
},
"redsky": {
"name": "RedSky Technologies",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNmJhMDU0ODAtMWJiNy0xMWYwLWEyMzItZGY2ZDM0ZjRiNzc3",
"webhook": {
"url": "https://redskytechnologies.statuspage.io/#",
"format": "statusPage",
"status": "not available"
}
},
"sailpoint": {
"name": "SailPoint",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZDAwMWJiYTAtNjZiZC0xMWVkLWEwMzAtMDVjMzI1YWI2OTc3",
"rss": {
"url": "https://status.sailpoint.com/history.atom",
"lastCheck": "2026-07-08T19:30:30.431Z"
},
"webhook": {
"url": "https://status.sailpoint.com/",
"format": "statusPage"
}
},
"sendgrid": {
"name": "SendGrid",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMzU3YTI1YzAtNjZkOC0xMWVkLWI4ZGEtY2ZhOGU1MTRjZjIy",
"rss": {
"url": "https://status.sendgrid.com/history.atom",
"lastCheck": "2026-07-08T19:30:30.533Z"
},
"webhook": {
"url": "https://status.sendgrid.com/",
"format": "statusPage"
}
},
"smartsheet": {
"name": "SmartSheet",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMGQyYmIyNTAtNjZkOC0xMWVkLWI3Y2ItZWY3ZjkyNWJjODM3",
"rss": {
"url": "https://status.smartsheet.com/history.atom",
"lastCheck": "2026-07-08T19:30:30.815Z"
},
"webhook": {
"url": "https://status.smartsheet.com/",
"format": "statusPage"
}
},
"tealium": {
"name": "Tealium",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNDgyYTYzNjAtNjZkOC0xMWVkLWEyMzQtMDk1MGNmY2U4NTM1",
"rss": {
"url": "https://status.tealium.com/pages/56216ec56de34e1e5900016f/rss",
"lastCheck": "2026-07-01T17:15:31.039Z"
},
"webhook": {
"url": "https://status.tealium.com",
"format": "statusIO"
}
},
"tenable": {
"name": "Tenable",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vN2JiNTgyOTAtNzBlOC0xMWVkLTk3YTktNDFhOGM4YTljNDhm",
"rss": {
"url": "https://status.cloud.tenable.com/history.atom",
"lastCheck": "2026-07-08T19:30:31.271Z"
}
},
"tenableNews": {
"name": "Tenable News",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYzVkOTIxMDAtMDc3Ny0xMWYxLWFjZWQtMzEyNWZmNDQ2ZTUz",
"rss": {
"url": "https://www.tenable.com/media/in-the-news/feed",
"lastCheck": "2026-07-08T19:30:31.669Z"
}
},
"tenablePressRelease": {
"name": "Tenable Press Releases",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZDEzNWU5NzAtMDc3Ny0xMWYxLThmMzUtZWQwNjc1MWFhYjNm",
"rss": {
"url": "https://www.tenable.com/media/press-releases/feed",
"lastCheck": "2026-07-08T19:30:31.759Z"
}
},
"tenableSecurityAdvisories": {
"name": "Tenable Security Advisories",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZGMyMzljNjAtMDc3Ny0xMWYxLWE3NjQtOTVlNzYzNWM0ZGQw",
"rss": {
"url": "https://www.tenable.com/security/feed",
"lastCheck": "2026-07-08T19:30:31.797Z"
}
},
"tenableResearchAdvisories": {
"name": "Tenable Research Advisories",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZTg2OGYwMTAtMDc3Ny0xMWYxLWE2YTItMWZjMGFmNzI4MzUz",
"rss": {
"url": "https://www.tenable.com/security/research/feed",
"lastCheck": "2026-07-08T19:30:31.836Z"
}
},
"tenableCyberExposureAlerts": {
"name": "Tenable Cyber Exposure Alerts",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZjU0ZmRhZjAtMDc3Ny0xMWYxLTgwODgtNzNjYjdiM2Q3YWM1",
"rss": {
"url": "https://www.tenable.com/blog/cyber-exposure-alerts/feed",
"lastCheck": "2026-07-08T19:30:31.878Z"
}
},
"truphone": {
"name": "Truphone",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vMzU3YTI1YzAtNjZkOC0xMWVkLWI4ZGEtY2ZhOGU1MTRjZjIy",
"webhook": {
"url": "https://status.smartsheet.com/",
"format": "statusPage"
}
},
"twilio": {
"name": "Twilio",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOGU3YzZkMzAtNjZiYi0xMWVkLWIxOTQtMzU3ZTQxY2JkYTI5",
"rss": {
"url": "https://status.twilio.com/history.atom",
"lastCheck": "2026-07-08T19:30:31.981Z"
},
"webhook": {
"url": "https://status.twilio.com/",
"format": "statusPage"
}
},
"uptimeKuma": {
"name": "Uptime-Kuma",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTRjZGMyMjAtNTRiMC0xMWVmLWI1ZDItMjdmZTlmZjVlNmFj",
"webhook": {
"format": "uptimeKuma"
}
},
"vmware": {
"name": "VMware",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vOWQ3NTE5YjAtNjZiZS0xMWVkLTlkYjQtNDdmN2FiOWEzMzUx",
"webhook": {
"url": "https://status.vmware-services.io/",
"format": "statusPage",
"status": "not available"
}
},
"webexalerts": {
"name": "Webex Alerts",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNWQxYTU3YjAtZDNjYy0xMWVkLThmYmQtOTkzYjE4NjMzMTY2",
"webhook": {
"format": "webexalerts"
}
},
"webexapi": {
"name": "Webex Incidents",
"siteUrl": "https://developer.webex.com/blog",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNjliODU3ZjAtYjA2Zi0xMWVjLWJlMzUtZDc3NTUwY2IyYzk3",
"rss": {
"url": "https://developer.webex.com/api/content/blog/feed",
"lastCheck": "2026-07-08T19:30:32.643Z"
}
},
"webexincidents": {
"name": "Webex Incidents",
"siteUrl": "https://status.webex.com/incident/history?lang=en_US",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vZGY2OWIzNzAtNjZiYS0xMWVkLThjOGItYmQxMmRmZGFmMDQ2",
"rss": {
"url": "https://status.webex.com/incidents.rss",
"lastCheck": "2026-07-08T19:30:32.743Z"
}
},
"webexmaintenance": {
"name": "Webex Maintenance",
"siteUrl": "https://status.webex.com/maintenance?lang=en_US",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vM2YwNmU3ZjAtNjZiZS0xMWVkLWEyYjYtYTM0YmVkNDg3MTJi",
"rss": {
"url": "https://status.webex.com/maintenances.rss",
"lastCheck": "2026-07-08T19:30:32.786Z"
}
},
"webexupgrades": {
"name": "Webex Upgrades",
"siteUrl": "https://status.webex.com/updates/upgrades?lang=en_US",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTMwMWMxNzEtNTlhMi0xMWVmLTkxODYtN2Y1MTM4NDgyZTM0",
"rss": {
"url": "https://status.webex.com/updates-upgrades.rss",
"lastCheck": "2026-07-08T19:30:32.882Z"
}
},
"ws1": {
"name": "Workspace ONE",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vN2M5MzIxYjAtNjZiZS0xMWVkLTgzODYtMWQ5NGI1MmZmMWIy",
"rss": {
"url": "https://status.workspaceone.com/history.atom",
"lastCheck": "2026-07-08T19:30:33.260Z"
},
"webhook": {
"url": "https://status.workspaceone.com/",
"format": "statusPage"
}
},
"zoom": {
"name": "Zoom",
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNWVmYTFkYjAtNjZiZi0xMWVkLWIxMzUtNjE1MGRjOGY0NGI4",
"rss": {
"url": "https://status.zoom.us/history.atom",
"lastCheck": "2026-07-08T19:30:33.508Z"
},
"webhook": {
"url": "https://status.zoom.us/",
"format": "statusPage"
}
}
},
"disabled": {}
}

546
index.js Normal file
View file

@ -0,0 +1,546 @@
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import express from 'express';
import bodyParser from 'body-parser';
import cron from 'node-cron';
import { parseFeed } from '@rowanmanning/feed-parser';
import { readJSON, writeJSON } from './src/atomicJson.js';
import { ProviderStore } from './src/providerStore.js';
import { startBot } from './src/webexBot.js';
var config = readJSON('./config/config.json');
var store = ProviderStore.load('./config/feeds.json');
var feedProviders = store.raw;
var rssFeedCache = readJSON('./config/rssFeedCache.json');
var webhookEvents = {};
// Secrets and per-deployment overrides come from the environment, never from
// the checked-in config. Keep the tree shape (config.webex.bot.token) so the
// rest of the code can keep reading it the way it always has.
config.webex = config.webex || {};
config.webex.bot = config.webex.bot || {};
config.webex.bot.token = process.env.WEBEX_BOT_TOKEN || config.webex.bot.token;
if (!config.webex.bot.token) {
console.error("FATAL: WEBEX_BOT_TOKEN is not set. Refusing to start.");
process.exit(1);
}
config.server = config.server || {};
config.server.name = process.env.SERVER_NAME || config.server.name || "aeStatusPage";
config.server.port = Number(process.env.SERVER_PORT || config.server.port) || 1449;
// Public URL where inbound webhooks reach this service. Used only to show the
// user the URL they should configure in their upstream status provider. If
// unset the bot degrades gracefully by showing a placeholder.
config.publicBaseUrl = process.env.PUBLIC_BASE_URL || null;
// Enable/disable the Webex chat bot control channel. Defaults to on when a
// token is present. Set BOT_ENABLED=false to run in webhook-only mode.
var botEnabled = String(process.env.BOT_ENABLED || 'true').toLowerCase() !== 'false';
var app = express();
app.use(bodyParser.json({ limit: '50mb' }));
var server = app.listen(config.server.port, function () { logger("startup", `Started ${config.server.name} on *:${config.server.port}...`) });
app.get('/healthCheck', function (req, res) {
res.status(200).send({ "status": "alive" });
});
app.get('/feeds', (req, res) => {
logger(`\/feeds`, `Requested the feeds file.`)
res.status(200).send(feedProviders);
})
app.get('/webhookEvents', (req, res) => {
logger(`\/webhookEvents`, `Requested the webhookEvents data.`)
res.status(200).send(webhookEvents);
})
app.get('/:provider/go', (req, res) => {
if (feedProviders.providers[req.params.provider] && feedProviders.providers[req.params.provider].siteUrl) {
logger(`\/${req.params.provider}\/go`, `Redirect to ${feedProviders.providers[req.params.provider].siteUrl}`)
res.status(301).redirect(feedProviders.providers[req.params.provider].siteUrl)
} else {
res.status(404).send(`${req.params.provider} could not be found.`);
}
})
app.get('/checkRSSFeed', (req, res) => {
if (!req.query.site) {
return res.status(400).send({ error: "Missing required query parameter 'site'." });
}
fetch(req.query.site)
.then(response => response.text())
.then(text => parseFeed(text))
.then(feed => res.status(200).send(feed))
.catch(error => {
logger('/checkRSSFeed', `Error fetching/parsing ${req.query.site}: ${error}`);
res.status(502).send({ error: `Failed to fetch or parse feed: ${error.message}` });
});
})
app.post('/rssTest', (req, res) => {
var output = testRss(req.body, req.query.provider)
res.status(200).send(output)
})
app.get('/cleanOldFiles', (req, res) => {
cleanOldFiles()
res.status(201).send()
})
app.post('/:provider', (req, res) => {
logger(`\/${req.params.provider}`, `Received webhook.`)
res.status(201).send();
if (feedProviders.providers[req.params.provider] && feedProviders.providers[req.params.provider].webhook) {
processWebhook(req.params.provider, req.body);
logFile(req.params.provider, req.body);
} else {
logger(`\/${req.params.provider}`, `${JSON.stringify(req.body)}`)
}
})
app.get('/refreshFeeds', function (req, res) {
try {
store.reload();
logger('/refreshFeeds', 'Refreshed feeds.');
res.status(200).send('Refreshed feeds.');
} catch (error) {
logger('/refreshFeeds', `Error: ${error}`);
res.status(500).send({ error: `Failed to reload feeds: ${error.message}` });
}
});
cron.schedule('20 */5 * * * *', async () => {
var startTime = Date.now();
logger('cron', 'Checking for RSS updates.');
try {
await processRSSFeeds();
await cleanRSSCache();
await Promise.all([
store.save(),
writeJSON('./config/rssFeedCache.json', rssFeedCache),
]);
logger('cron', `processFeeds completed. (${(Date.now() - startTime) / 1000}s)`);
} catch (error) {
logger('cron', `Error in cron cycle: ${error}`);
}
cleanOldFiles();
});
async function processRSSFeeds() {
var startTime = Date.now();
var rssEntries = Object.entries(feedProviders.providers).filter(([, p]) => p && p.rss);
var results = await Promise.allSettled(rssEntries.map(async ([key, provider]) => {
var response;
try {
response = await fetch(provider.rss.url);
} catch (error) {
throw new Error(`fetch ${provider.name}: ${error.message}`);
}
if (!response.ok) {
throw new Error(`fetch ${provider.name}: HTTP ${response.status}`);
}
return parseRSSFeed(key, response);
}));
var cntTotalFeeds = 0;
for (var i = 0; i < results.length; i++) {
var r = results[i];
if (r.status === 'fulfilled') {
cntTotalFeeds += r.value || 0;
} else {
logger('processFeeds', `${r.reason.message || r.reason}`);
}
}
if (cntTotalFeeds > 0) {
logger('processFeeds', `Found ${cntTotalFeeds} new feeds total (${((Date.now() - startTime) / 1000)}s across ${rssEntries.length} providers).`);
}
}
async function parseRSSFeed(feedProvider, response) {
var cntFeeds = 0;
const feed = parseFeed(await response.text());
var startFeedTime = new Date().getTime();
var cutoff = startFeedTime - (24 * 60 * 60 * 1000);
for (var feedItem of feed.items) {
// Only consider items updated in the last 24 hours; skip anything already in cache.
if (new Date(feedItem.updated).getTime() <= cutoff) continue;
if (!rssFeedCache.providers[feedProvider]) {
rssFeedCache.providers[feedProvider] = [];
}
var rssitemFound = 0;
for (var rssItem of rssFeedCache.providers[feedProvider]) {
if (rssItem.id == feedItem.id && rssItem.content == feedItem.content) {
rssitemFound = 1;
break;
}
}
if (rssitemFound) continue;
rssFeedCache.providers[feedProvider].push(feedItem);
var msg = feedItem.url
? `## [${feedItem.title}](${feedItem.url})\n`
: `## ${feedItem.title}`;
if (feedItem.content) {
msg += `${feedItem.content}`;
} else if (feedItem.description) {
msg += `${feedItem.description}`;
}
var body = {
"roomId": feedProviders.providers[feedProvider].roomId,
"markdown": msg
}
logger('parseRSSFeed', `Found: ${feedProvider} - ${feed.id}`);
sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, feedProvider);
cntFeeds++;
}
feedProviders.providers[feedProvider].rss.lastCheck = new Date(startFeedTime);
if (cntFeeds > 0) {
logger('parseRSSFeed', `Found ${cntFeeds} new items for ${feedProviders.providers[feedProvider].name} (${(new Date().getTime() - startFeedTime)}ms).`)
}
return cntFeeds;
}
function testRss(feedItem, feedProvider) {
logger(`testRss (${feedProvider})`, `${JSON.stringify(feedItem)}`);
if (feedItem.url) {
var msg = `## [${feedItem.title}](${feedItem.url})\n`;
//var msg = `<h2><a href="${feedItem.url}">${feedItem.title}</a></h2><br>`;
} else {
var msg = `## ${feedItem.title}`;
}
if (feedItem.content) {
msg += `${feedItem.content}`;
} else if (feedItem.description) {
msg += `${feedItem.description}`;
}
var body = {
"roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTRjZGMyMjAtNTRiMC0xMWVmLWI1ZDItMjdmZTlmZjVlNmFj",
"markdown": msg
}
sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, feedProvider)
return msg;
}
function processWebhook(provider, webhook) {
logger('processWebhook', `Payload is ${feedProviders.providers[provider].webhook.format} format.`)
if (feedProviders.providers[provider].webhook.format == "statusPage") {
sendStatusPageUpdate(provider, webhook)
} else if (feedProviders.providers[provider].webhook.format == "statusIO") {
sendStatusIOUpdate(provider, webhook);
} else if (feedProviders.providers[provider].webhook.format == "uptimeKuma") {
sendUptimeKuma(provider, webhook);
} else {
logger(`processWebhook (${provider})`, `${JSON.stringify(webhook)}`)
}
}
function sendUptimeKuma(provider, webhook) {
return new Promise(async function (resolve, reject) {
if (webhook.msg) {
var body = {
"roomId": feedProviders.providers[provider].roomId,
"markdown": webhook.msg
}
sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, provider)
}
})
}
function cleanRSSCache() {
return new Promise(async function (resolve, reject) {
for (var provider in rssFeedCache.providers) {
var clearCache = [];
for (var rssItem of rssFeedCache.providers[provider]) {
if (new Date(rssItem.updated).getTime() > new Date(new Date().getTime() - (2 * 24 * 60 * 60 * 1000)).getTime()) {
clearCache.push(rssItem);
}
}
rssFeedCache.providers[provider] = clearCache;
}
resolve();
})
}
function sendStatusPageUpdate(provider, statusPage) {
return new Promise(async function (resolve, reject) {
//console.log("In sendStatusPageUpdate function");
if (statusPage.incident) {
//console.log("Doing Status Page Update.");
var statusColor = "";
var statusTextName = "";
var componentColor = "";
var statusText = "- - -\n# [" + statusPage.incident.name + "](" + statusPage.incident.shortlink + ")\n" +
"## " + statusPage.page.status_description + "\n\n";
statusText += "### Updates:\n";
for (const update of statusPage.incident.incident_updates) {
if (update.status == "completed") {
statusColor = "success";
statusTextName = "Completed";
};
if (update.status == "scheduled") {
statusColor = "info";
statusTextName = "Scheduled";
};
if (update.status == "in_progress") {
statusColor = "warning";
statusTextName = "In Progress";
};
if (update.status == "investigating") {
statusColor = "danger";
statusTextName = "Investigating";
}
if (update.status == "identified") {
statusColor = "warning";
statusTextName = "Identified";
}
if (update.status == "monitoring") {
statusColor = "info";
statusTextName = "Monitoring";
}
if (update.status == "resolved") {
statusColor = "success";
statusTextName = "Resolved";
}
var createdDate = new Date(update.created_at);
statusText += "<blockquote class='" + statusColor + "'><b><h2>" + statusTextName + "</h2></b><br>" +
update.body + "<br>" +
"<i>Created: " + createdDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + "</i>";
statusText += "</blockquote>\n\n";
/*
if (update.affected_components) {
statusText += "<h3>Affected Components:</h3><br>";
for (const affected of update.affected_components) {
statusText += affected.name + "<br>" +
affected.old_status + " -> " + affected.new_status + "<br>";
}
} */
}
statusText += "### Components:\n";
for (const component of statusPage.incident.components) {
if (component.status == "operational") {
componentColor = "success";
}
if (component.status == "major_outage") {
componentColor = "danger";
}
if (component.status == "degraded_performance") {
componentColor = "warning";
}
if (component.status == "partial_outage") {
componentColor = "warning";
}
if (component.status == "under_maintenance") {
componentColor = "info";
}
var updatedDate = new Date(component.updated_at);
statusText += "<blockquote class='" + componentColor + "'><h3>" + component.name + "</h3> (" + component.status + ")<br>";
if (component.description) {
statusText += component.description + "<br>";
}
statusText += "Updated: " + updatedDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + "</blockquote>\n\n";
}
//statusText += "[Link](" + statusPage.incident.shortlink + ")";
var body = {
"roomId": feedProviders.providers[provider].roomId,
"markdown": statusText
}
sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, provider)
}
})
}
function sendStatusIOUpdate(provider, statusIO) {
return new Promise(async function (resolve, reject) {
if (statusIO.incident_url) {
var statusColor = "";
var statusTextName = "";
var componentColor = "";
var statusText = "- - -\n# " + statusIO.title + "\n" +
"## " + statusIO.current_status + "\n\n";
statusText += "### Updates:\n";
if (statusIO.current_state == "Identified") {
statusColor = "info";
statusTextName = statusIO.current_state;
};
statusText += "<blockquote class='" + statusColor + "'><h3>" + statusTextName + "</h3><br>" + statusIO.details + "</blockquote>\n\n";
/*
if (update.affected_components) {
statusText += "<h3>Affected Components:</h3><br>";
for (const affected of update.affected_components) {
statusText += affected.name + "<br>" +
affected.old_status + " -> " + affected.new_status + "<br>";
}
} */
statusText += "### Components:\n";
for (var affected of statusIO.infrastructure_affected) {
for (var component of statusIO.components) {
if (affected.component == component._id) {
statusText += component.name;
}
}
for (var container of statusIO.containers) {
if (affected.container == container._id) {
statusText += " (" + container.name + ")\n"
}
}
}
statusText += "\n[Link](" + statusIO.incident_url + ")";
var body = {
"roomId": feedProviders.providers[provider].roomId,
"markdown": statusText
}
sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, provider)
}
})
}
function sendWebexAPI(url, method, body, feedProviderName) {
var myHeaders = {
"Authorization": "Bearer " + config.webex.bot.token,
"Content-Type": "application/json"
}
var requestOptions = {
method: method,
headers: myHeaders,
body: JSON.stringify(body),
redirect: 'follow'
};
//console.log("Request options: " + JSON.stringify(requestOptions, null, 4));
fetchWithRateLimit(url, requestOptions)
.then(response => response.json())
.then(result => logFile(feedProviderName, result))
.catch(error => logger("sendWebexAPI", `Error during send to Webex: ${error}`));
}
async function fetchWithRateLimit(url, requestOptions) {
const response = await fetch(url, requestOptions)
if (response.status === 429) {
//console.log(response.headers)
const secondsToWait = Number(response.headers.get('retry-after'))
console.log("Waiting for " + secondsToWait.toString() + " due to 429 message: " + url);
await new Promise(resolve => setTimeout(resolve, secondsToWait * 1000))
console.log("Finished waiting for " + secondsToWait.toString() + " due to 429 message: " + url);
return await fetchWithRateLimit(url, requestOptions)
}
return response
}
function cleanOldFiles() {
var logDir = './logs/';
var files;
try {
files = fs.readdirSync(logDir);
} catch (error) {
logger('cleanOldFiles', `Error reading ${logDir}: ${error}`);
return;
}
var cutoff = Date.now() - 259200000; // 3 days
for (var file of files) {
var fullPath = logDir + file;
try {
var fileStats = fs.statSync(fullPath);
if (fileStats.mtime.getTime() <= cutoff) {
logger('cleanOldFiles', `Removing file '${fullPath}'`);
fs.unlinkSync(fullPath);
}
} catch (error) {
logger('cleanOldFiles', `Error handling ${fullPath}: ${error}`);
}
}
}
function logFile(provider, jsonData) {
var d = new Date();
var year = d.getFullYear();
var month = (d.getMonth() + 1).toString().padStart(2, "0");
var day = d.getDate().toString().padStart(2, "0");
let logFile = path.join(`./logs/${provider}-${year}${month}${day}.log`);
fs.appendFileSync(logFile, d.toLocaleString() + "\n");
fs.appendFileSync(logFile, JSON.stringify(jsonData) + "\n");
}
function logger(activeFunction, logLine) {
var d = new Date();
console.log(d.toLocaleString() + " " + activeFunction + ": " + logLine);
var year = d.getFullYear();
var month = (d.getMonth() + 1).toString().padStart(2, "0");
var day = d.getDate().toString().padStart(2, "0");
let logFile = path.join(`./logs/${year}${month}${day}.log`);
fs.appendFileSync(logFile, d.toLocaleString() + " " + activeFunction + ": " + logLine + "\n");
}
// Boot the Webex chat control channel. Websocket mode: no inbound webhook
// URL required, so no additional network exposure. Users can then type
// `add`, `list`, `webhook <key>`, `remove <key>`, or `help` in any space
// the bot has been invited to.
var bot = null;
if (botEnabled) {
startBot({
token: config.webex.bot.token,
store,
publicBaseUrl: config.publicBaseUrl,
log: logger,
}).then(fw => {
bot = fw;
logger('startup', 'Webex bot enabled (websocket mode).');
}).catch(error => {
logger('startup', `Failed to start Webex bot: ${error}. Continuing without bot.`);
});
} else {
logger('startup', 'Webex bot disabled via BOT_ENABLED=false.');
}
// gracefully shutdown (ctrl-c)
process.on('SIGINT', function () {
var stopBot = bot && typeof bot.stop === 'function' ? bot.stop() : Promise.resolve();
Promise.resolve(stopBot).finally(() => {
server.close(() => {
logger('shutdown', config.server.name + ' stopped!');
process.exit();
});
});
});

19632
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "aerss",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test 'test/**/*.test.js'"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"engines": {
"node": "20.x"
},
"dependencies": {
"@rowanmanning/feed-parser": "^1.0.1",
"body-parser": "^1.20.2",
"express": "^4.19.2",
"node-cron": "^3.0.3",
"node-fetch": "^3.3.2",
"webex-node-bot-framework": "^2.5.1"
}
}

53
src/atomicJson.js Normal file
View file

@ -0,0 +1,53 @@
import fs from 'fs';
import path from 'path';
// Per-file promise chain. Any writer for a given path queues behind the previous
// one so we never have two writers renaming into the same target concurrently.
const locks = new Map();
function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
// Ignore the previous task's result/errors — every waiter still gets a chance to run.
const next = prev.then(fn, fn);
const tail = next.catch(() => {});
locks.set(key, tail);
tail.then(() => {
if (locks.get(key) === tail) locks.delete(key);
});
return next;
}
export function readJSON(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
// Write JSON atomically: serialize -> write to tmp in the same directory ->
// fsync -> rename over the target. Rename within a single filesystem is atomic
// on POSIX, so readers either see the old or new file, never a torn write.
export function writeJSON(filePath, data) {
return withLock(path.resolve(filePath), async () => {
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
const payload = JSON.stringify(data, null, 4);
const fh = await fs.promises.open(tmp, 'w');
try {
await fh.writeFile(payload);
await fh.sync();
} finally {
await fh.close();
}
try {
await fs.promises.rename(tmp, filePath);
} catch (error) {
// If rename fails, don't leave the tmp file behind.
fs.promises.unlink(tmp).catch(() => {});
throw error;
}
});
}
// Exported for tests only.
export const __test__ = { withLock, locks };

165
src/cards.js Normal file
View file

@ -0,0 +1,165 @@
// Adaptive Card v1.3 templates. Kept as plain data so they're easy to unit test
// and easy to preview in https://adaptivecards.io/designer.
import { WEBHOOK_FORMATS } from './providerStore.js';
const FORMAT_LABELS = {
statusPage: 'Statuspage (Atlassian)',
statusIO: 'Status.io',
uptimeKuma: 'Uptime Kuma',
generic: 'Generic (raw JSON dump)',
};
/**
* Card users see when they type `add`. Collects everything needed to create
* a provider entry for the current room.
*/
export function addProviderCard({ defaults = {}, publicBaseUrl } = {}) {
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.3',
body: [
{ type: 'TextBlock', size: 'Medium', weight: 'Bolder', text: 'Add a status source' },
{
type: 'TextBlock',
wrap: true,
isSubtle: true,
text: 'Register an RSS feed or an inbound webhook for this space. Updates will be posted here automatically.',
},
{
type: 'Input.Text',
id: 'key',
label: 'Short key (used in the webhook URL)',
placeholder: 'e.g. acme-status',
value: defaults.key || '',
isRequired: true,
errorMessage: 'Required. Letters, digits, dot, dash, underscore.',
regex: '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$',
},
{
type: 'Input.Text',
id: 'name',
label: 'Friendly name',
placeholder: 'e.g. Acme Status',
value: defaults.name || '',
isRequired: true,
errorMessage: 'Required.',
},
{
type: 'Input.Text',
id: 'siteUrl',
label: 'Status page URL (optional)',
placeholder: 'https://status.example.com',
value: defaults.siteUrl || '',
},
{
type: 'Input.ChoiceSet',
id: 'sourceType',
label: 'Source type',
style: 'expanded',
value: defaults.sourceType || 'rss',
choices: [
{ title: 'RSS / Atom feed', value: 'rss' },
{ title: 'Inbound webhook', value: 'webhook' },
{ title: 'Both', value: 'both' },
],
},
{
type: 'Input.Text',
id: 'rssUrl',
label: 'RSS/Atom URL (required for RSS or Both)',
placeholder: 'https://status.example.com/history.atom',
value: (defaults.rss && defaults.rss.url) || '',
},
{
type: 'Input.ChoiceSet',
id: 'webhookFormat',
label: 'Webhook payload format (required for Webhook or Both)',
value: (defaults.webhook && defaults.webhook.format) || 'statusPage',
choices: WEBHOOK_FORMATS.map(f => ({ title: FORMAT_LABELS[f] || f, value: f })),
},
{
type: 'TextBlock',
wrap: true,
isSubtle: true,
size: 'Small',
text: publicBaseUrl
? `Webhook URL will be **${publicBaseUrl.replace(/\/$/, '')}/<key>** — point your status provider at that URL.`
: '_Set the `PUBLIC_BASE_URL` env var so the bot can show you the exact webhook URL._',
},
],
actions: [
{ type: 'Action.Submit', title: 'Save', data: { action: 'addProvider' } },
],
},
};
}
/**
* Small confirmation card shown after a save.
*/
export function providerSavedCard({ key, provider, publicBaseUrl }) {
const body = [
{ type: 'TextBlock', size: 'Medium', weight: 'Bolder', text: `Saved: ${provider.name}` },
{ type: 'FactSet', facts: [
{ title: 'Key', value: key },
...(provider.rss ? [{ title: 'RSS URL', value: provider.rss.url }] : []),
...(provider.webhook ? [{ title: 'Webhook format', value: provider.webhook.format }] : []),
...(provider.siteUrl ? [{ title: 'Status page', value: provider.siteUrl }] : []),
] },
];
if (provider.webhook) {
body.push({
type: 'TextBlock',
wrap: true,
weight: 'Bolder',
text: 'Point your status provider at this webhook URL:',
});
body.push({
type: 'TextBlock',
wrap: true,
fontType: 'Monospace',
text: publicBaseUrl
? `${publicBaseUrl.replace(/\/$/, '')}/${key}`
: `<PUBLIC_BASE_URL>/${key} (set PUBLIC_BASE_URL env var to get the real URL)`,
});
}
return {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.3',
body,
},
};
}
/**
* Given raw card submission inputs, build a provider spec ready for
* ProviderStore.upsert. Throws on invalid combinations so we can surface a
* clean error back to the user.
*/
export function specFromCardInputs(inputs, roomId) {
const key = (inputs.key || '').trim();
const name = (inputs.name || '').trim();
const siteUrl = (inputs.siteUrl || '').trim();
const sourceType = inputs.sourceType || 'rss';
const rssUrl = (inputs.rssUrl || '').trim();
const webhookFormat = inputs.webhookFormat || 'statusPage';
const spec = { name, roomId };
if (siteUrl) spec.siteUrl = siteUrl;
if (sourceType === 'rss' || sourceType === 'both') {
if (!rssUrl) throw new Error('An RSS URL is required for the RSS or Both source types.');
spec.rss = { url: rssUrl };
}
if (sourceType === 'webhook' || sourceType === 'both') {
spec.webhook = { format: webhookFormat };
}
return { key, spec };
}

122
src/providerStore.js Normal file
View file

@ -0,0 +1,122 @@
import { readJSON, writeJSON } from './atomicJson.js';
// Thin wrapper around feeds.json. Keeps the in-memory copy authoritative for
// reads and lets the bot mutate it via typed operations that persist atomically.
export class ProviderStore {
constructor(filePath, initial) {
this.filePath = filePath;
this.data = initial || { providers: {} };
if (!this.data.providers) this.data.providers = {};
}
static load(filePath) {
return new ProviderStore(filePath, readJSON(filePath));
}
// Direct access for the RSS loop / HTTP handlers that were already reading
// the raw structure. Prefer the typed methods below for new code.
get raw() {
return this.data;
}
get providers() {
return this.data.providers;
}
keys() {
return Object.keys(this.data.providers);
}
get(key) {
return this.data.providers[key];
}
listByRoom(roomId) {
return Object.entries(this.data.providers)
.filter(([, p]) => p.roomId === roomId)
.map(([key, provider]) => ({ key, ...provider }));
}
/**
* Insert or replace a provider spec. Returns the persisted entry.
*
* spec = {
* name: string (required)
* roomId: string (required)
* siteUrl?: string
* rss?: { url: string }
* webhook?: { format: 'statusPage'|'statusIO'|'uptimeKuma'|'generic' }
* }
*/
async upsert(key, spec) {
assertValidKey(key);
assertValidSpec(spec);
const existing = this.data.providers[key] || {};
// Preserve rss.lastCheck if this is an edit and we're still an RSS provider.
const preservedRss = existing.rss && spec.rss
? { ...spec.rss, lastCheck: existing.rss.lastCheck }
: spec.rss;
this.data.providers[key] = {
...existing,
...spec,
...(preservedRss ? { rss: preservedRss } : {}),
};
// If the caller cleared rss/webhook, honor that.
if (!spec.rss && existing.rss && spec.hasOwnProperty('rss')) delete this.data.providers[key].rss;
if (!spec.webhook && existing.webhook && spec.hasOwnProperty('webhook')) delete this.data.providers[key].webhook;
await this.save();
return this.data.providers[key];
}
async remove(key) {
if (!this.data.providers[key]) return false;
delete this.data.providers[key];
await this.save();
return true;
}
async save() {
await writeJSON(this.filePath, this.data);
}
// Re-read from disk without swapping the wrapper object, so any consumer
// that kept a reference to `store.raw` continues to see live data.
reload() {
const fresh = readJSON(this.filePath);
for (const k of Object.keys(this.data)) delete this.data[k];
Object.assign(this.data, fresh);
if (!this.data.providers) this.data.providers = {};
return this;
}
}
const KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const VALID_FORMATS = new Set(['statusPage', 'statusIO', 'uptimeKuma', 'generic']);
export function assertValidKey(key) {
if (typeof key !== 'string' || !KEY_RE.test(key)) {
throw new Error(`Invalid provider key '${key}'. Use letters, digits, dot, dash, underscore (max 64 chars).`);
}
}
export function assertValidSpec(spec) {
if (!spec || typeof spec !== 'object') throw new Error('spec is required');
if (!spec.name || typeof spec.name !== 'string') throw new Error('name is required');
if (!spec.roomId || typeof spec.roomId !== 'string') throw new Error('roomId is required');
if (!spec.rss && !spec.webhook) throw new Error('Provider must define at least one of rss or webhook');
if (spec.rss) {
if (typeof spec.rss.url !== 'string' || !/^https?:\/\//i.test(spec.rss.url)) {
throw new Error('rss.url must be an http(s) URL');
}
}
if (spec.webhook) {
if (!VALID_FORMATS.has(spec.webhook.format)) {
throw new Error(`webhook.format must be one of ${[...VALID_FORMATS].join(', ')}`);
}
}
if (spec.siteUrl && !/^https?:\/\//i.test(spec.siteUrl)) {
throw new Error('siteUrl must be an http(s) URL');
}
}
export const WEBHOOK_FORMATS = [...VALID_FORMATS];

113
src/webexBot.js Normal file
View file

@ -0,0 +1,113 @@
// Note: `webex-node-bot-framework` (via its `webex` dep) mutates `globalThis`
// at import time — specifically it sets `navigator`, which is a read-only
// getter on Node 21+. To keep this project bootable on newer Node hosts when
// the bot is disabled, we load the framework lazily inside `startBot()`.
import { addProviderCard, providerSavedCard, specFromCardInputs } from './cards.js';
const HELP_TEXT = [
'**AE Status Page bot**',
'',
'- `add` — open a form to register an RSS feed or webhook for this space',
'- `list` — show status sources currently posting to this space',
'- `webhook <key>` — show the inbound webhook URL for a provider',
'- `remove <key>` — stop posting a provider to this space',
'- `help` — this message',
].join('\n');
/**
* Start the Webex bot. Uses websocket mode (no inbound webhook URL) so we
* don't need to expose the Webex control channel to the internet.
*
* Deps are injected so this module stays unit-friendly.
*/
export async function startBot({ token, store, publicBaseUrl, log = console.log }) {
// Dynamic import so a failure here doesn't crash the entire process at
// module-load time (see note at top of file about Node 21+ compat).
const mod = await import('webex-node-bot-framework');
const Framework = mod.default || mod;
const framework = new Framework({
token,
messageFormat: 'markdown',
removeDeviceRegistrationsOnStart: true,
});
framework.start();
framework.on('initialized', () => log('webexBot', 'Framework initialized.'));
framework.on('spawn', (bot, _id, addedById) => {
// spawn also fires on startup for every room the bot is already in.
// Only greet when a human actually adds us.
if (!addedById) return;
bot.say('markdown', `Hi! I'm the AE Status Page bot. Type \`help\` to see what I can do, or \`add\` to register a status source for this space.`);
});
framework.hears(/^\s*help\s*$/i, (bot) => {
bot.say('markdown', HELP_TEXT);
});
framework.hears(/^\s*add\s*$/i, (bot) => {
bot.sendCard(addProviderCard({ publicBaseUrl }), 'Your Webex client does not support Adaptive Cards. Type `help` for text commands.');
});
framework.hears(/^\s*list\s*$/i, (bot) => {
const roomId = bot.room.id;
const rows = store.listByRoom(roomId);
if (rows.length === 0) {
bot.say('markdown', 'No status sources are posting to this space yet. Type `add` to register one.');
return;
}
const lines = ['**Sources posting to this space:**'];
for (const r of rows) {
const kinds = [r.rss ? 'RSS' : null, r.webhook ? `webhook (${r.webhook.format})` : null].filter(Boolean).join(', ');
lines.push(`- \`${r.key}\`${r.name}${kinds}`);
}
bot.say('markdown', lines.join('\n'));
});
framework.hears(/^\s*webhook\s+(\S+)\s*$/i, (bot, trigger) => {
const key = trigger.args[1];
const provider = store.get(key);
if (!provider || provider.roomId !== bot.room.id) {
bot.say('markdown', `No provider called \`${key}\` is registered to this space. Use \`list\` to see what's here.`);
return;
}
if (!provider.webhook) {
bot.say('markdown', `\`${key}\` is registered but does not have a webhook. Use \`add\` to add one.`);
return;
}
const url = publicBaseUrl
? `${publicBaseUrl.replace(/\/$/, '')}/${key}`
: `<PUBLIC_BASE_URL>/${key} (set PUBLIC_BASE_URL env var)`;
bot.say('markdown', `Webhook URL for **${provider.name}** (\`${provider.webhook.format}\`):\n\n\`${url}\``);
});
framework.hears(/^\s*remove\s+(\S+)\s*$/i, async (bot, trigger) => {
const key = trigger.args[1];
const provider = store.get(key);
if (!provider || provider.roomId !== bot.room.id) {
bot.say('markdown', `No provider called \`${key}\` is registered to this space.`);
return;
}
try {
await store.remove(key);
bot.say('markdown', `Removed \`${key}\` (${provider.name}) from this space.`);
} catch (error) {
bot.say('markdown', `Failed to remove \`${key}\`: ${error.message}`);
}
});
framework.on('attachmentAction', async (bot, trigger) => {
const inputs = trigger.attachmentAction && trigger.attachmentAction.inputs;
if (!inputs || inputs.action !== 'addProvider') return;
try {
const { key, spec } = specFromCardInputs(inputs, bot.room.id);
const saved = await store.upsert(key, spec);
bot.sendCard(providerSavedCard({ key, provider: saved, publicBaseUrl }), `Saved ${saved.name}.`);
} catch (error) {
bot.say('markdown', `Could not save: ${error.message}`);
}
});
return framework;
}

52
test/atomicJson.test.js Normal file
View file

@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { readJSON, writeJSON } from '../src/atomicJson.js';
function tmpFile(name) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atomicjson-'));
return path.join(dir, name);
}
test('writeJSON persists and readJSON reads it back', async () => {
const file = tmpFile('a.json');
await writeJSON(file, { hello: 'world', n: 1 });
assert.deepEqual(readJSON(file), { hello: 'world', n: 1 });
});
test('concurrent writeJSON calls all land, none corrupt the target', async () => {
const file = tmpFile('b.json');
const writers = [];
for (let i = 0; i < 25; i++) {
writers.push(writeJSON(file, { i, at: Date.now() }));
}
await Promise.all(writers);
// File must be valid JSON with one of the written objects.
const parsed = readJSON(file);
assert.equal(typeof parsed.i, 'number');
assert.ok(parsed.i >= 0 && parsed.i < 25);
});
test('writeJSON removes tmp files on rename failure', async () => {
const file = tmpFile('c.json');
await writeJSON(file, { ok: true });
// Force a rename failure by pointing at a path whose parent does not exist.
const bad = path.join(file, 'nested', 'not', 'there.json');
await assert.rejects(writeJSON(bad, { x: 1 }));
// No tmp files should be left in the parent dir of `file`.
const dir = path.dirname(file);
const stray = fs.readdirSync(dir).filter(n => n.endsWith('.tmp'));
assert.deepEqual(stray, []);
});
test('writes to different paths do not block each other', async () => {
const f1 = tmpFile('d.json');
const f2 = tmpFile('e.json');
await Promise.all([writeJSON(f1, { a: 1 }), writeJSON(f2, { b: 2 })]);
assert.deepEqual(readJSON(f1), { a: 1 });
assert.deepEqual(readJSON(f2), { b: 2 });
});

86
test/cards.test.js Normal file
View file

@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { addProviderCard, providerSavedCard, specFromCardInputs } from '../src/cards.js';
test('addProviderCard has the required inputs and a submit action', () => {
const card = addProviderCard({ publicBaseUrl: 'https://status.example.com' });
assert.equal(card.contentType, 'application/vnd.microsoft.card.adaptive');
const ids = card.content.body.filter(b => b.type.startsWith('Input.')).map(b => b.id);
assert.deepEqual(ids.sort(), ['key', 'name', 'rssUrl', 'siteUrl', 'sourceType', 'webhookFormat'].sort());
assert.equal(card.content.actions[0].type, 'Action.Submit');
assert.equal(card.content.actions[0].data.action, 'addProvider');
// The URL hint should include the public base.
const hint = card.content.body.find(b => b.type === 'TextBlock' && b.text && b.text.includes('Webhook URL'));
assert.ok(hint.text.includes('status.example.com'));
});
test('addProviderCard falls back to a friendly warning when PUBLIC_BASE_URL is unset', () => {
const card = addProviderCard({});
const hint = card.content.body.find(b => b.type === 'TextBlock' && b.text && b.text.includes('PUBLIC_BASE_URL'));
assert.ok(hint, 'expected a PUBLIC_BASE_URL hint when the base URL is unset');
});
test('specFromCardInputs builds an RSS-only spec', () => {
const { key, spec } = specFromCardInputs({
key: 'acme',
name: 'Acme',
sourceType: 'rss',
rssUrl: 'https://acme.example/rss',
}, 'room-123');
assert.equal(key, 'acme');
assert.equal(spec.name, 'Acme');
assert.equal(spec.roomId, 'room-123');
assert.equal(spec.rss.url, 'https://acme.example/rss');
assert.equal(spec.webhook, undefined);
});
test('specFromCardInputs builds a webhook-only spec', () => {
const { spec } = specFromCardInputs({
key: 'acme',
name: 'Acme',
sourceType: 'webhook',
webhookFormat: 'uptimeKuma',
}, 'room-123');
assert.equal(spec.webhook.format, 'uptimeKuma');
assert.equal(spec.rss, undefined);
});
test('specFromCardInputs builds a both spec and copies siteUrl', () => {
const { spec } = specFromCardInputs({
key: 'acme',
name: 'Acme',
siteUrl: 'https://status.acme.com',
sourceType: 'both',
rssUrl: 'https://acme.example/rss',
webhookFormat: 'statusPage',
}, 'room-123');
assert.equal(spec.rss.url, 'https://acme.example/rss');
assert.equal(spec.webhook.format, 'statusPage');
assert.equal(spec.siteUrl, 'https://status.acme.com');
});
test('specFromCardInputs errors when RSS is selected but no URL was provided', () => {
assert.throws(() =>
specFromCardInputs({ key: 'acme', name: 'Acme', sourceType: 'rss', rssUrl: '' }, 'r'),
/RSS URL is required/);
});
test('providerSavedCard shows the webhook URL when applicable', () => {
const card = providerSavedCard({
key: 'acme',
provider: { name: 'Acme', webhook: { format: 'statusPage' } },
publicBaseUrl: 'https://status.example.com/',
});
const monospace = card.content.body.find(b => b.fontType === 'Monospace');
assert.equal(monospace.text, 'https://status.example.com/acme');
});
test('providerSavedCard omits webhook block for RSS-only providers', () => {
const card = providerSavedCard({
key: 'acme',
provider: { name: 'Acme', rss: { url: 'https://a/rss' } },
publicBaseUrl: 'https://x',
});
const monospace = card.content.body.find(b => b.fontType === 'Monospace');
assert.equal(monospace, undefined);
});

102
test/providerStore.test.js Normal file
View file

@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { ProviderStore, assertValidKey, assertValidSpec } from '../src/providerStore.js';
function seed(initial) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'store-'));
const file = path.join(dir, 'feeds.json');
fs.writeFileSync(file, JSON.stringify(initial ?? { providers: {} }));
return file;
}
test('load + listByRoom returns only providers for that room', () => {
const file = seed({
providers: {
a: { name: 'A', roomId: 'r1', rss: { url: 'https://a.example/rss' } },
b: { name: 'B', roomId: 'r2', webhook: { format: 'statusPage' } },
c: { name: 'C', roomId: 'r1', webhook: { format: 'uptimeKuma' } },
},
});
const store = ProviderStore.load(file);
const inR1 = store.listByRoom('r1').map(p => p.key).sort();
assert.deepEqual(inR1, ['a', 'c']);
assert.deepEqual(store.listByRoom('r2').map(p => p.key), ['b']);
assert.deepEqual(store.listByRoom('missing'), []);
});
test('upsert adds a new provider and persists to disk', async () => {
const file = seed();
const store = ProviderStore.load(file);
await store.upsert('acme', {
name: 'Acme',
roomId: 'room-1',
rss: { url: 'https://acme.example/rss' },
});
const reloaded = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.equal(reloaded.providers.acme.name, 'Acme');
assert.equal(reloaded.providers.acme.rss.url, 'https://acme.example/rss');
});
test('upsert preserves rss.lastCheck on edit', async () => {
const stamp = '2026-01-01T00:00:00.000Z';
const file = seed({
providers: {
acme: { name: 'Acme', roomId: 'r', rss: { url: 'https://a/rss', lastCheck: stamp } },
},
});
const store = ProviderStore.load(file);
await store.upsert('acme', {
name: 'Acme (renamed)',
roomId: 'r',
rss: { url: 'https://a/rss2' },
});
assert.equal(store.get('acme').rss.lastCheck, stamp);
assert.equal(store.get('acme').rss.url, 'https://a/rss2');
assert.equal(store.get('acme').name, 'Acme (renamed)');
});
test('remove deletes and persists', async () => {
const file = seed({ providers: { x: { name: 'X', roomId: 'r', rss: { url: 'https://x/rss' } } } });
const store = ProviderStore.load(file);
assert.equal(await store.remove('x'), true);
assert.equal(store.get('x'), undefined);
const reloaded = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.equal(reloaded.providers.x, undefined);
assert.equal(await store.remove('x'), false);
});
test('reload picks up out-of-band edits without breaking existing refs', () => {
const file = seed({ providers: { a: { name: 'A', roomId: 'r', webhook: { format: 'statusPage' } } } });
const store = ProviderStore.load(file);
const rawRef = store.raw;
fs.writeFileSync(file, JSON.stringify({
providers: { b: { name: 'B', roomId: 'r', webhook: { format: 'statusIO' } } },
}));
store.reload();
assert.equal(store.get('a'), undefined);
assert.equal(store.get('b').name, 'B');
// The reference the caller took earlier must still point at live data.
assert.equal(rawRef.providers.b.name, 'B');
});
test('assertValidKey rejects garbage', () => {
assert.throws(() => assertValidKey(''), /Invalid provider key/);
assert.throws(() => assertValidKey('has spaces'), /Invalid provider key/);
assert.throws(() => assertValidKey('..'), /Invalid provider key/);
assert.doesNotThrow(() => assertValidKey('valid-key_1.a'));
});
test('assertValidSpec enforces required fields and formats', () => {
assert.throws(() => assertValidSpec({}), /name is required/);
assert.throws(() => assertValidSpec({ name: 'x' }), /roomId is required/);
assert.throws(() => assertValidSpec({ name: 'x', roomId: 'r' }), /at least one of rss or webhook/);
assert.throws(() => assertValidSpec({ name: 'x', roomId: 'r', rss: { url: 'not-a-url' } }), /http\(s\) URL/);
assert.throws(() => assertValidSpec({ name: 'x', roomId: 'r', webhook: { format: 'nope' } }), /webhook\.format must be one of/);
assert.doesNotThrow(() => assertValidSpec({ name: 'x', roomId: 'r', rss: { url: 'https://a/rss' } }));
assert.doesNotThrow(() => assertValidSpec({ name: 'x', roomId: 'r', webhook: { format: 'statusPage' } }));
});