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:
commit
007086caf6
17 changed files with 21779 additions and 0 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
config
|
||||||
|
logs
|
||||||
23
.env.example
Normal file
23
.env.example
Normal 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
15
.gitignore
vendored
Normal 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
19
Dockerfile
Normal 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
110
README.md
Normal 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
13
config/config.json
Normal 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
700
config/feeds.json
Normal 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
546
index.js
Normal 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
19632
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
package.json
Normal file
25
package.json
Normal 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
53
src/atomicJson.js
Normal 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
165
src/cards.js
Normal 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
122
src/providerStore.js
Normal 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
113
src/webexBot.js
Normal 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
52
test/atomicJson.test.js
Normal 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
86
test/cards.test.js
Normal 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
102
test/providerStore.test.js
Normal 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' } }));
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue