Phase 7: polish for production readiness
- Add README with local setup, add-a-bot walkthrough, Docker run
recipe, and a rundown of committed vs. runtime state.
- Wrap the cron schedules with an explicit timezone (default
America/New_York, override via CRON_TIMEZONE) so cadence is
independent of the host/container clock.
- Route saveServiceAccountToken through a try/catch so a disk hiccup
no longer bubbles up as an unhandled exception during token refresh,
and keep the in-memory copy usable even on write failure.
- Silence per-message queue.on('active') / on('completed') logs that
also registered a new listener on every job invocation (accumulating
on the shared queue over time).
- Fix logIt reference (undefined; would have thrown if
checkScheduledJobs ever rejected) and a broken JSON.stringify.result
debug log that always evaluated to undefined.
- Dockerfile: switch to node:20-slim, install from the lockfile via
npm ci --omit=dev, set NODE_ENV=production, and document that env
and volumes are supplied at runtime.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e604c7e9c9
commit
b4e5ca3f33
4 changed files with 191 additions and 32 deletions
|
|
@ -1,6 +1,11 @@
|
|||
# --- Server ----------------------------------------------------------------
|
||||
SERVER_PORT=1450
|
||||
|
||||
# Timezone used for both cron schedules (daily jobs cleanup and per-minute
|
||||
# token refresh / scheduled-job dispatch). Any IANA tz name is accepted.
|
||||
# Defaults to America/New_York when unset.
|
||||
CRON_TIMEZONE=America/New_York
|
||||
|
||||
# --- OAuth callback URL template ------------------------------------------
|
||||
# The `:app` placeholder is replaced per-request with the bot's appName.
|
||||
# IMPORTANT: every bot's resolved URL must be registered as an allowed
|
||||
|
|
|
|||
24
Dockerfile
24
Dockerfile
|
|
@ -1,19 +1,23 @@
|
|||
FROM node:20
|
||||
# Create app directory
|
||||
FROM node:20-slim
|
||||
|
||||
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+)
|
||||
# Install dependencies from the lockfile for reproducible builds.
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
RUN npm install
|
||||
# If you are building your code for production
|
||||
# RUN npm ci --only=production
|
||||
|
||||
# Bundle app source
|
||||
# Bundle app source. `.dockerignore` deliberately excludes `config/`, `.env`,
|
||||
# `uploads/`, and other runtime state so the image is stateless.
|
||||
COPY . .
|
||||
|
||||
# Env vars are supplied at runtime, either via `docker run --env-file .env`,
|
||||
# `-e KEY=VALUE`, or compose/K8s. `config/` and `uploads/` should be mounted
|
||||
# as volumes so tokens, jobs, and uploaded CSVs persist across restarts.
|
||||
#
|
||||
# The listening port is controlled by the SERVER_PORT env var; EXPOSE below
|
||||
# is informational and should be aligned with whatever SERVER_PORT is set to
|
||||
# at deploy time.
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 1450
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
|
|
|
|||
151
README.md
Normal file
151
README.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# CollabCentral
|
||||
|
||||
A multi-bot mass-messaging service for Webex. Each bot has its own token,
|
||||
avatar, label, and per-user access list, and users can only see the bots
|
||||
and jobs they're authorized on.
|
||||
|
||||
Under one server you can host any number of bots (Novi, TechUpdates, …) —
|
||||
each one exposes the same UI at:
|
||||
|
||||
- `/CollabCentral/<botName>/sendMessage.html` — compose and send.
|
||||
- `/CollabCentral/<botName>/monitorJobs.html` — track running / scheduled / completed jobs.
|
||||
- `/CollabCentral/<botName>/jobDetail.html?jobId=…` — detailed per-job view.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+.
|
||||
- A single Webex integration used for OAuth on all bots.
|
||||
- A Webex service account (used server-side for group/people lookups; its
|
||||
refresh token is stored in `config/token.json` and rotated automatically).
|
||||
- One Webex bot per app you want to host.
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in the values:
|
||||
```
|
||||
SERVER_PORT=1450
|
||||
CRON_TIMEZONE=America/New_York
|
||||
OAUTH_CALLBACK_URL_TEMPLATE=https://bot.example.com/CollabCentral/:app/oauth
|
||||
WEBEX_INTEGRATION_CLIENT_ID=...
|
||||
WEBEX_INTEGRATION_CLIENT_SECRET=...
|
||||
WEBEX_SERVICE_ACCOUNT_CLIENT_ID=...
|
||||
WEBEX_SERVICE_ACCOUNT_CLIENT_SECRET=...
|
||||
GOOGLE_TRANSLATE_API_KEY=...
|
||||
```
|
||||
|
||||
2. Copy `config/botTokens.example.json` to `config/botTokens.json` and put
|
||||
in the bot tokens for each app:
|
||||
```json
|
||||
{
|
||||
"novi": { "token": "…", "enabled": true },
|
||||
"techupdates": { "token": "…", "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
3. Create `config/token.json` with the initial service-account OAuth tokens
|
||||
(access + refresh). After the first refresh, the cron job rewrites this
|
||||
file automatically.
|
||||
|
||||
4. Install dependencies and start:
|
||||
```
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
`npm start` uses `node --env-file=.env`, so no external process manager is
|
||||
required to load env vars during local development.
|
||||
|
||||
## Adding a new bot
|
||||
|
||||
Say you want to add a bot called `alerts`.
|
||||
|
||||
1. **Webex bot** — create the bot at developer.webex.com. Copy its access token.
|
||||
2. **Bot token** — add an entry to `config/botTokens.json`:
|
||||
```json
|
||||
"alerts": { "token": "<bot access token>", "enabled": true }
|
||||
```
|
||||
Setting `enabled: false` will keep the bot listed but return 404 for all
|
||||
`/CollabCentral/alerts/*` routes.
|
||||
3. **Bot metadata + authorized users** — edit `config/config.json` and add
|
||||
an entry under `webex.bot`:
|
||||
```json
|
||||
"alerts": {
|
||||
"label": "Ops Alerts",
|
||||
"authorized": {
|
||||
"<webexPersonId>": {
|
||||
"id": "<webexPersonId>",
|
||||
"displayName": "…",
|
||||
"email": "…",
|
||||
"avatar": "…",
|
||||
"groups": [ { "name": "…", "alias": "…", "id": "<webexGroupId>" } ]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Only person IDs listed under `authorized` can log into that bot's UI.
|
||||
4. **Icons** — drop `alerts.png` and `alerts.ico` into `html/` (they're
|
||||
served by the per-bot static mount).
|
||||
5. **OAuth redirect URI** — in the Webex integration used for OAuth, register
|
||||
`https://<your-host>/CollabCentral/alerts/oauth` as an allowed redirect
|
||||
URI. Without this step, users get an OAuth error on login.
|
||||
6. Restart the server (or the container). The bot's avatar is fetched once
|
||||
at startup via Webex `/people/me` using the token you supplied in step 2.
|
||||
|
||||
## Docker
|
||||
|
||||
`Dockerfile` builds a stateless image. Config, tokens, uploads, and jobs
|
||||
data are expected to be mounted at runtime.
|
||||
|
||||
```
|
||||
docker build -t collabcentral .
|
||||
docker run -d \
|
||||
--name collabcentral \
|
||||
--env-file /path/to/.env \
|
||||
-v /path/to/config:/usr/src/app/config \
|
||||
-v /path/to/uploads:/usr/src/app/uploads \
|
||||
-p 1450:1450 \
|
||||
collabcentral
|
||||
```
|
||||
|
||||
The `config/` mount should contain at minimum `config.json`, `botTokens.json`,
|
||||
`token.json`, `languages.json`, and (if you want to preserve state)
|
||||
`jobs.json` and `userPrefs.json`.
|
||||
|
||||
## File layout
|
||||
|
||||
Committed to the repo:
|
||||
|
||||
- `index.js` — Express server, OAuth, cron jobs, Webex API integration,
|
||||
and the send queue.
|
||||
- `html/` — frontend (sendMessage / monitorJobs / jobDetail pages, per-bot
|
||||
icons, shared JS libraries).
|
||||
- `config/config.json` — server settings, per-bot labels, and the
|
||||
authorized-user table for each bot.
|
||||
- `config/languages.json` — supported translation languages.
|
||||
- `config/botTokens.example.json` — template for `botTokens.json`.
|
||||
- `.env.example` — template for `.env`.
|
||||
- `Dockerfile`, `.dockerignore`, `.gitignore`, `package.json`,
|
||||
`package-lock.json`.
|
||||
|
||||
Runtime state (gitignored, not committed):
|
||||
|
||||
- `.env` — secrets and deployment-specific URLs.
|
||||
- `config/botTokens.json` — per-bot access tokens.
|
||||
- `config/token.json` — service-account OAuth tokens (rotated by the
|
||||
refresh cron).
|
||||
- `config/jobs.json` — building / running / scheduled / completed jobs.
|
||||
- `config/userPrefs.json` — per-user preferences (language, etc.).
|
||||
- `uploads/` — CSVs of recipient IDs and any attached images.
|
||||
|
||||
## Behavior notes
|
||||
|
||||
- **Completed-job retention**: the daily cleanup cron (default `01:10` in
|
||||
`CRON_TIMEZONE`) drops entries from `jobs.completed` older than 30 days.
|
||||
Change `COMPLETED_RETENTION_DAYS` in `index.js` to adjust.
|
||||
- **Send concurrency**: a shared `p-queue` limits outbound Webex sends to
|
||||
10 in-flight requests.
|
||||
- **Rate limiting**: `fetchWithRateLimit` transparently retries on Webex 429
|
||||
responses honoring `Retry-After`.
|
||||
- **Per-bot job isolation**: draft jobs are keyed by `cookieId + appName`,
|
||||
and job list / detail endpoints filter by `appName`, so authorized users
|
||||
of one bot never see another bot's jobs.
|
||||
41
index.js
41
index.js
|
|
@ -146,8 +146,14 @@ function loadServiceAccountToken() {
|
|||
}
|
||||
|
||||
function saveServiceAccountToken(tokenObj) {
|
||||
fs.writeFileSync('./config/token.json', JSON.stringify(tokenObj, null, 4));
|
||||
// Keep the in-memory copy in sync even if the disk write fails so the
|
||||
// refreshed token is still usable for the rest of the process lifetime.
|
||||
serviceAccountToken = tokenObj;
|
||||
try {
|
||||
fs.writeFileSync('./config/token.json', JSON.stringify(tokenObj, null, 4));
|
||||
} catch (err) {
|
||||
logger("saveServiceAccountToken", "Failed to persist token.json: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceAccountAccessToken() {
|
||||
|
|
@ -199,16 +205,24 @@ const upload = multer({ limits: { fileSize: 4000000 } }).fields(
|
|||
]
|
||||
);
|
||||
|
||||
// Anchor both crons to a specific timezone so the schedule is deterministic
|
||||
// regardless of the host/container clock. Override with CRON_TIMEZONE if the
|
||||
// deployment moves to a different region.
|
||||
var cronTimezone = process.env.CRON_TIMEZONE || 'America/New_York';
|
||||
|
||||
// Daily at 01:10 local time: prune completed jobs older than the retention
|
||||
// window and rewrite jobs.json.
|
||||
cron.schedule('0 10 1 * * *', async function () {
|
||||
var jobFile = JSON.parse(fs.readFileSync('./config/jobs.json'));
|
||||
var cleanJobs = await cleanCompletedJobs(jobFile);
|
||||
saveConfig(cleanJobs, './config/jobs.json');
|
||||
jobs = JSON.parse(fs.readFileSync('./config/jobs.json'));
|
||||
|
||||
})
|
||||
}, { timezone: cronTimezone })
|
||||
|
||||
// Every minute: refresh the service-account token if it's inside the renewal
|
||||
// window, and dispatch any scheduled jobs whose time has arrived.
|
||||
cron.schedule('0 * * * * *', () => {
|
||||
var startRefreshTime = new Date();
|
||||
var renewBy = serviceAccountToken && serviceAccountToken.renewBy;
|
||||
if (renewBy && new Date(new Date(renewBy) - 7200000) < new Date()) {
|
||||
logger("refreshToken", "Renew by: " + new Date(renewBy).toLocaleString())
|
||||
|
|
@ -218,14 +232,11 @@ cron.schedule('0 * * * * *', () => {
|
|||
.then(response => {
|
||||
logger("tokenRefresh", response);
|
||||
})
|
||||
.catch(error => console.log("refreshToken: " + error));
|
||||
.catch(error => logger("refreshToken", error));
|
||||
}
|
||||
checkScheduledJobs()
|
||||
.then((result) => {
|
||||
console.log(result)
|
||||
})
|
||||
.catch(error => logIt("checkScheduledJobs: " + error));
|
||||
})
|
||||
.catch(error => logger("checkScheduledJobs", error));
|
||||
}, { timezone: cronTimezone })
|
||||
|
||||
//Routes to be used
|
||||
// Validate the :app segment for every /CollabCentral/:app/* request. This
|
||||
|
|
@ -853,17 +864,6 @@ function sendQueueJobMessages(queueNumber) {
|
|||
return new Promise(async function (resolve, reject) {
|
||||
logger("sendQueueJobMessages", "Starting JobId:" + jobs.running[queueNumber].jobId + " Queue#" + queueNumber);
|
||||
|
||||
|
||||
var count = 0;
|
||||
queue.on('active', () => {
|
||||
console.log(`Working on item #${++count}. Size: ` + queue.size + ` Pending: ` + queue.pending);
|
||||
});
|
||||
|
||||
queue.on('completed', async function (result) {
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
});
|
||||
|
||||
for (var x = 0; x < jobs.running[queueNumber].memberList.length; x++) {
|
||||
await queue.onSizeLessThan(2);
|
||||
if (userPrefs[jobs.running[queueNumber].memberList[x].id]) {
|
||||
|
|
@ -1220,7 +1220,6 @@ function translateMessage(message, language, format, messageType) {
|
|||
fetchWithRateLimit(translateUrl, { "method": "POST" })
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
console.log(JSON.stringify.result);
|
||||
var languageResult = {
|
||||
"name": language.name,
|
||||
"key": language.key,
|
||||
|
|
|
|||
Loading…
Reference in a new issue