diff --git a/.env.example b/.env.example index 508a8b6..a0b0fba 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 8de75f8..358387a 100644 --- a/Dockerfile +++ b/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" ] +CMD ["node", "index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cab8cec --- /dev/null +++ b/README.md @@ -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//sendMessage.html` — compose and send. +- `/CollabCentral//monitorJobs.html` — track running / scheduled / completed jobs. +- `/CollabCentral//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": "", "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": { + "": { + "id": "", + "displayName": "…", + "email": "…", + "avatar": "…", + "groups": [ { "name": "…", "alias": "…", "id": "" } ] + } + } + } + ``` + 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:///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. diff --git a/index.js b/index.js index f41e24e..e2f2d15 100644 --- a/index.js +++ b/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,