#!/usr/bin/env node import fs from 'fs'; import fetch from 'node-fetch'; import { Headers } from 'node-fetch'; import express from 'express'; import bodyParser from 'body-parser'; import multer from 'multer'; import sharp from 'sharp'; import { v4 as uuid } from 'uuid'; import cookieParser from 'cookie-parser'; import FormData from 'form-data'; import cron from "node-cron"; import PQueue from 'p-queue'; import * as helpers from './lib/helpers.js'; //import pLimit from "p-limit"; //const limit = pLimit(10); const queue = new PQueue({ concurrency: 10 }); //Load the config files from storage. // config.json = committed, structural (server port, bot labels, integration ids). // authorized.json = gitignored, mutable per-user data: // { admins: [personId...], bot: { : { : { ...profile, groups: [...] } } } } // jobs.json = gitignored, runtime queue state. // userPrefs.json = gitignored, per-recipient preferences (language, ...). var config = JSON.parse(fs.readFileSync('./config/config.json')); var jobs = JSON.parse(fs.readFileSync('./config/jobs.json')); var userPrefs = JSON.parse(fs.readFileSync('./config/userPrefs.json')); var authorized = loadAuthorized(); function loadAuthorized() { try { var doc = JSON.parse(fs.readFileSync('./config/authorized.json')); doc.admins = Array.isArray(doc.admins) ? doc.admins : []; doc.bot = (doc.bot && typeof doc.bot === 'object') ? doc.bot : {}; return doc; } catch (err) { // Missing file (fresh deploy) is fine — we start empty and the admin // page can populate. Any other parse error is fatal because otherwise // the whole authorization layer silently permits nothing. if (err && err.code === 'ENOENT') { console.log('loadAuthorized: authorized.json not found, starting empty.'); return { admins: [], bot: {} }; } throw err; } } // Returns the mutable per-user record for (appName, personId), creating the // bot bucket if this is the first write. Never returns null when personId is // truthy — callers use this to add favorites / users without null-checking. // Read-only callers should prefer helpers.getAuthorizedEntry(authorized, ...). function getOrCreateAuthorizedEntry(appName, personId) { if (!authorized.bot[appName]) authorized.bot[appName] = {}; return authorized.bot[appName][personId] || null; } // Defensive init so code below can safely `.push`, `.filter`, and index into // these regardless of what the on-disk jobs.json happens to contain. jobs.building = jobs.building || {}; jobs.running = Array.isArray(jobs.running) ? jobs.running : []; jobs.scheduled = Array.isArray(jobs.scheduled) ? jobs.scheduled : []; jobs.completed = Array.isArray(jobs.completed) ? jobs.completed : []; if (typeof jobs.lastJobNumber !== 'number') jobs.lastJobNumber = 0; // Per-bot tokens live in their own gitignored file so adding a new bot is one // JSON entry + an env-free restart. Shape: // { "": { "token": "...", "enabled": true } } var botTokens = loadBotTokens(); function loadBotTokens() { try { return JSON.parse(fs.readFileSync('./config/botTokens.json')); } catch (err) { console.log('loadBotTokens failed: ' + err.message); return {}; } } function getBotToken(appName) { return helpers.getBotToken(botTokens, appName); } function isBotEnabled(appName) { return helpers.isBotEnabled(botTokens, appName); } // Returns the bot's config block if the bot is both defined in config.json AND // has an enabled token entry. Returns null otherwise. Use this everywhere // instead of reaching into config.webex.bot[...] directly so unknown or // disabled bots can't crash the request. function getBotConfig(appName) { return helpers.getBotConfig(config, botTokens, appName); } // Cached bot profiles populated from Webex /people/me at startup so the // front-end and the completion card can use the bot's actual displayName and // avatar without hardcoding either in config.json. // botProfiles[appName] = { displayName, avatar } var botProfiles = {}; async function loadBotProfile(appName) { var token = getBotToken(appName); if (!token) return; try { var response = await fetchWithRateLimit('https://webexapis.com/v1/people/me', { method: 'GET', headers: { 'Authorization': 'Bearer ' + token } }); if (!response.ok) { logger('loadBotProfile', appName + ' /people/me failed: ' + response.status + ' ' + response.statusText); return; } var data = await response.json(); botProfiles[appName] = { displayName: data.displayName, avatar: data.avatar, personId: data.id, emails: data.emails }; logger('loadBotProfile', appName + ' = ' + data.displayName); } catch (err) { logger('loadBotProfile', appName + ' error: ' + (err && err.message || err)); } } function loadBotProfiles() { var apps = Object.keys((config.webex && config.webex.bot) || {}); return Promise.allSettled(apps.filter(isBotEnabled).map(loadBotProfile)); } function getBotProfile(appName) { return botProfiles[appName] || null; } // Cached list of every Webex group in the org, used by the "Additional groups" // dropdown on the compose page. findWebexGroup() paginates through ~10k groups // 500 at a time, which used to run on every /user/groups/find request; we now // refresh once at startup and once a day and serve everything else from // memory. `refreshing` is a shared in-flight Promise so a concurrent refresh // (e.g. the cron firing during a slow startup fetch) can't stampede. var groupsCache = { data: null, lastRefreshed: null, refreshing: null, }; function refreshGroupsCache() { if (groupsCache.refreshing) return groupsCache.refreshing; var startedAt = Date.now(); logger('groupsCache', 'Refreshing group list from Webex...'); groupsCache.refreshing = findWebexGroup() .then(function (groups) { groupsCache.data = groups; groupsCache.lastRefreshed = new Date(); logger('groupsCache', 'Cached ' + groups.length + ' groups in ' + (Date.now() - startedAt) + 'ms.'); return groups; }) .catch(function (err) { logger('groupsCache', 'Refresh failed: ' + (err && err.message || err)); throw err; }) .finally(function () { groupsCache.refreshing = null; }); return groupsCache.refreshing; } // Returns the cached groups if we have any; otherwise waits for whatever // refresh is currently in flight (or kicks one off). Requests only ever block // on the very first call after startup — every subsequent call resolves // synchronously from memory. function getGroupsFromCache() { if (groupsCache.data) return Promise.resolve(groupsCache.data); return refreshGroupsCache(); } // Express middleware: 404s any /CollabCentral/:app/* request whose `:app` is // not a known + enabled bot. Attaches the bot config to req.botConfig for // downstream handlers. function requireBot(req, res, next) { var botCfg = getBotConfig(req.params.app); if (!botCfg) { logger("requireBot", "Unknown or disabled bot: " + req.params.app); return res.status(404).send("Unknown bot: " + req.params.app); } req.botConfig = botCfg; next(); } // Building (draft) jobs are keyed by cookieId AND appName so a user who is // authorized for more than one bot can have one independent draft per bot. function buildingKey(req) { return helpers.buildingKey(req.cookies && req.cookies.id, req.params.app); } function jobsForApp(arr, appName) { return helpers.jobsForApp(arr, appName); } // Service-account OAuth tokens are rewritten in place by refreshToken(), so // they live in their own file rather than mixed into config.json. var serviceAccountToken = loadServiceAccountToken(); function loadServiceAccountToken() { try { return JSON.parse(fs.readFileSync('./config/token.json')); } catch (err) { console.log('loadServiceAccountToken failed: ' + err.message); return {}; } } function saveServiceAccountToken(tokenObj) { // 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() { return serviceAccountToken && serviceAccountToken.access_token; } // Builds the Webex OAuth authorize URL for a given bot. The redirect_uri must // match one of the URIs registered with the Webex integration in the Webex // developer portal (one URI per bot). Returns null if required env vars are // missing so callers can return an actionable error instead of a malformed URL. function buildAuthUrl(appName) { var url = helpers.buildAuthUrl({ clientId: process.env.WEBEX_INTEGRATION_CLIENT_ID, template: process.env.OAUTH_CALLBACK_URL_TEMPLATE, appName: appName, }); if (!url) logger('buildAuthUrl', 'Missing WEBEX_INTEGRATION_CLIENT_ID or OAUTH_CALLBACK_URL_TEMPLATE env var.'); return url; } function getOAuthRedirectUri(appName) { return helpers.getOAuthRedirectUri(process.env.OAUTH_CALLBACK_URL_TEMPLATE, appName); } //Load Express Server var app = express(); app.use(bodyParser.json()); app.use(cookieParser()); var serverPort = process.env.SERVER_PORT || config.server.port; var server = app.listen(serverPort, function () { logger("startup", config.server.name + " running on port " + serverPort + "."); loadBotProfiles() .then(() => logger("startup", "Loaded " + Object.keys(botProfiles).length + " bot profile(s).")) .catch(err => logger("startup", "loadBotProfiles error: " + (err && err.message || err))); // Warm the groups cache in the background so the very first user hitting // /user/groups/find gets an instant response. Failures are non-fatal: // getGroupsFromCache() will simply try again on the first request. refreshGroupsCache().catch(function () { /* logged inside */ }); }); server.setTimeout(3000000); const upload = multer({ limits: { fileSize: 4000000 } }).fields( [ { name: 'uploadImage' }, { name: 'uploadCSV' } ] ); // 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 renewBy = serviceAccountToken && serviceAccountToken.renewBy; if (renewBy && new Date(new Date(renewBy) - 7200000) < new Date()) { logger("refreshToken", "Renew by: " + new Date(renewBy).toLocaleString()) logger("refreshToken", "Renew after: " + new Date(new Date(renewBy) - 7200000).toLocaleString()); logger("refreshToken", "Refreshing token.") refreshToken() .then(response => { logger("tokenRefresh", response); }) .catch(error => logger("refreshToken", error)); } checkScheduledJobs() .catch(error => logger("checkScheduledJobs", error)); }, { timezone: cronTimezone }) // Daily at 03:00 local time: rebuild the org-wide groups cache. Offset from // the 01:10 jobs cleanup so the two crons don't fight over the event loop. cron.schedule('0 0 3 * * *', function () { refreshGroupsCache().catch(function () { /* logged inside */ }); }, { timezone: cronTimezone }) //Routes to be used // Validate the :app segment for every /CollabCentral/:app/* request. This // runs before the static mount and all the per-route handlers below, so any // unknown or disabled bot gets a clean 404 instead of crashing on undefined. app.use('/CollabCentral/:app', requireBot) app.use('/CollabCentral/:app', express.static('html')) app.get('/status', function (req, res) { res.status(200).send({ "status": "I'm alive!'" }); }); app.get('/CollabCentral/:app/authUrl', function (req, res) { var url = buildAuthUrl(req.params.app); if (!url) { return res.status(500).send('OAuth is not configured. Check WEBEX_INTEGRATION_CLIENT_ID and OAUTH_CALLBACK_URL_TEMPLATE.'); } res.send(url); }); // Returns the bot's display metadata + whether the current cookie is authorized. // The front-end uses this to drive page labels, icons, and the // "you don't have access" view, so adding a new bot doesn't require any code // changes in the HTML/JS. app.get('/CollabCentral/:app/info', function (req, res) { var appName = req.params.app; var botCfg = req.botConfig; // populated by requireBot var profile = getBotProfile(appName) || {}; var iconBase = botCfg.iconBase || appName; var personId = req.cookies && req.cookies.id; res.status(200).send({ appName: appName, label: botCfg.label || appName, displayName: profile.displayName || botCfg.label || appName, iconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.png', faviconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.ico', avatarUrl: profile.avatar || null, authorized: isAuthorized(appName, personId), isAdmin: isAdmin(personId) }); }); app.post('/CollabCentral/:app/jobs/:action', (req, res) => { if (isAuthorized(req.params.app, req.cookies.id)) { req.setTimeout(3000000); if (req.params.action == "edit") { logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/edit"); buildJob(req, res) .then(job => { sendDirectMessage(job.senderId, job.message.raw, job.imageName, req.params.app) .then(async function (result) { jobs.building[buildingKey(req)].message.english = { "text": result.text, "markdown": result.markdown, "html": result.html } // Remember the preview DM's Webex message id so // /jobs/cancel can retract it and /jobs/runNow can // treat it as "already delivered" (future). if (result && result.id) { jobs.building[buildingKey(req)].previewMessageId = result.id; } await buildTranslations(jobs.building[buildingKey(req)].message.english) .then(result => { jobs.building[buildingKey(req)].message = result; }) .catch(error => console.log("Error translating: " + error)); res.status(200).send(job) saveConfig(jobs, "./config/jobs.json") }) .catch(error => console.log("Error sending message: " + error)); }) .catch(error => console.log("Error buildJob: " + error)); } else if (req.params.action == "runNow") { logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/runNow"); jobs.running.push(jobs.building[buildingKey(req)]); delete jobs.building[buildingKey(req)]; processRunningQueue() .catch(error => console.log("sendRunningMessages error: " + error)) res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html"); } else if (req.params.action == "schedule") { logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/schedule"); jobs.scheduled.push(jobs.building[buildingKey(req)]); delete jobs.building[buildingKey(req)]; saveConfig(jobs, './config/jobs.json') res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html"); } else if (req.params.action == "cancel") { // Abandon the caller's in-progress "building" job for this bot. // A building entry exists as soon as the user clicks "Review & // send" (POST /jobs/edit) and lingers on the server until the // user promotes it to running/scheduled — or until now, where // this route just drops it on the floor. Idempotent: 200 whether // or not a building entry actually existed for (personId, app), // so the client can always fire this on "cancel" without needing // to check state first. // // If the building job has a previewMessageId (captured during // /jobs/edit), also retract that DM from the sender's own space // so the review message doesn't hang around after they discarded // the draft. Best-effort — a failed Webex delete is logged but // does not fail the cancel. logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel"); var key = buildingKey(req); var job = jobs.building[key]; var hadJob = !!job; if (hadJob) { var previewId = job.previewMessageId; delete jobs.building[key]; try { saveConfig(jobs, './config/jobs.json'); } catch (err) { logger('apiEndpoint(' + req.params.app + ')', 'jobs/cancel save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to cancel job.'); } if (previewId) { // Fire-and-forget; we've already committed the cancel // to disk and we don't want a slow Webex round trip to // hold the response. deleteWebexMessage(previewId, req.params.app); } } return res.status(200).send({ cancelled: hadJob }); } else { res.status(404) } } else { res.status(401).send("You are not authorized.") } }) app.get('/CollabCentral/:app/jobs/list/:scope', (req, res) => { if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) { return res.status(401).send("You are not authorized."); } var appName = req.params.app; if (req.params.scope == "all") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/all"); return res.status(200).send({ building: jobs.building[buildingKey(req)], running: jobsForApp(jobs.running, appName), scheduled: jobsForApp(jobs.scheduled, appName), completed: jobsForApp(jobs.completed, appName) }); } if (req.params.scope == "building") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/building"); return res.status(200).send(jobs.building[buildingKey(req)]); } if (req.params.scope == "running") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/running"); var runningJobs = []; for (var job of jobsForApp(jobs.running, appName)) { var completedSends = 0; for (var member of (job.memberList || [])) { if (member.results) completedSends++; } runningJobs.push({ jobId: job.jobId, senderDisplayName: job.senderDisplayName, appName: job.appName, startTime: job.startTime, totalRecipients: (job.memberList || []).length, completedRecipients: completedSends }); } return res.status(200).send(runningJobs); } if (req.params.scope == "scheduled") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/scheduled"); var scheduledJobs = []; for (var job of jobsForApp(jobs.scheduled, appName)) { scheduledJobs.push({ senderDisplayName: job.senderDisplayName, appName: job.appName, message: job.message && job.message.english && job.message.english.html, totalRecipients: (job.memberList || []).length, scheduledFor: job.scheduledFor }); } return res.status(200).send(scheduledJobs); } if (req.params.scope == "completed") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/completed"); return res.status(200).send(jobsForApp(jobs.completed, appName)); } return res.status(404).send(); }); // Returns the full job object for a given jobId, but only if the job belongs // to the requested bot. Looks across completed/running/scheduled so the same // URL keeps working as a job moves through its lifecycle. app.get('/CollabCentral/:app/jobs/detail/:jobId', (req, res) => { if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) { return res.status(401).send("You are not authorized."); } var appName = req.params.app; var jobId = req.params.jobId; logger("apiEndpoint(" + appName + ")", "GET /jobs/detail/" + jobId); var all = (jobs.completed || []).concat(jobs.running || [], jobs.scheduled || []); var match = all.find(function (j) { return j && j.appName === appName && String(j.jobId) === String(jobId); }); if (!match) return res.status(404).send("Job not found."); res.status(200).send(match); }); app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => { console.log("Got /user/:scope/:action request."); //console.log(req.cookies); if (isAuthorized(req.params.app, req.cookies.id)) { //console.log("Person is authorized for the request.") if (req.params.scope == "groups") { if (req.params.action == "list") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/list"); var listEntry = helpers.getAuthorizedEntry(authorized, req.params.app, req.cookies.id); res.status(200).send((listEntry && listEntry.groups) || []); } else if (req.params.action == "find") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find"); getGroupsFromCache() .then(function (groups) { res.status(200).send(groups); }) .catch(function (err) { logger('apiEndpoint(' + req.params.app + ')', 'groups/find failed: ' + (err && err.message || err)); res.status(502).send('Failed to load groups from Webex.'); }); } } else { res.status(404).send('Not found.'); } } else { res.status(401).send('Unauthorized.'); } }) // Add / remove a favorite group for the calling user. Favorites live inside // authorized.json under `bot[app][personId].groups` so they survive restarts // and are picklable in the compose page's Favorite Groups selector. Both // endpoints validate that: // - the caller is authorized for :app // - :id looks like a Webex SCIM group id we know about (present in the // cached org-wide group list) — this stops a bad actor from stuffing // arbitrary strings into the config. // Because index.js is single-process and node is single-threaded, concurrent // requests here can't corrupt authorized.json — every read/mutate/write // executes atomically within one handler. app.post('/CollabCentral/:app/user/groups/add', async function (req, res) { if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.'); var appName = req.params.app; var personId = req.cookies.id; var groupId = req.body && req.body.id; if (!groupId) return res.status(400).send('Missing group id.'); var group; try { var allGroups = await getGroupsFromCache(); group = allGroups.find(function (g) { return g.id === groupId; }); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/add cache lookup failed: ' + (err && err.message || err)); return res.status(502).send('Group list unavailable.'); } if (!group) return res.status(404).send('Unknown group id.'); var entry = helpers.getAuthorizedEntry(authorized, appName, personId); // isAuthorized guarantees the entry exists but be defensive; a race // between an admin removal and this request could otherwise crash. if (!entry) return res.status(401).send('Unauthorized.'); var favorites = Array.isArray(entry.groups) ? entry.groups : []; if (favorites.find(function (g) { return g.id === groupId; })) { return res.status(200).send(favorites); } favorites.push({ name: group.displayName, id: group.id }); entry.groups = favorites; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/add save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to save favorite.'); } logger('apiEndpoint(' + appName + ')', 'groups/add ' + personId.slice(-8) + ' + "' + group.displayName + '"'); res.status(200).send(favorites); }); app.post('/CollabCentral/:app/user/groups/remove', function (req, res) { if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.'); var appName = req.params.app; var personId = req.cookies.id; var groupId = req.body && req.body.id; if (!groupId) return res.status(400).send('Missing group id.'); var entry = helpers.getAuthorizedEntry(authorized, appName, personId); if (!entry) return res.status(401).send('Unauthorized.'); var favorites = Array.isArray(entry.groups) ? entry.groups : []; var before = favorites.length; var removed = favorites.find(function (g) { return g.id === groupId; }); favorites = favorites.filter(function (g) { return g.id !== groupId; }); if (favorites.length === before) return res.status(200).send(favorites); entry.groups = favorites; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/remove save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to remove favorite.'); } logger('apiEndpoint(' + appName + ')', 'groups/remove ' + personId.slice(-8) + ' - "' + (removed && removed.name || groupId) + '"'); res.status(200).send(favorites); }); // ---- Admin: manage the authorized-user list for a bot --------------------- // // Admin authority lives in authorized.admins (personIds). Every admin route // rejects with 403 for non-admin callers — 403 rather than 401 so the client // can tell an admin-only route apart from a signed-out state (which would // 401). Admin actions are cross-bot in concept but the routes are still // :app-scoped because the resource being edited is per-bot (authorized users // on THAT bot) and it keeps the OAuth session model unchanged (admin uses // the same session cookie as any other page). // // requireAdmin returns null on success or an Express-response-sending // function on failure, so each handler stays a straight-line function. function requireAdmin(req, res) { if (!req.cookies || !req.cookies.id) return res.status(401).send('Unauthorized.'); if (!isAdmin(req.cookies.id)) return res.status(403).send('Admin only.'); return null; } // Shape of a single row returned by the admin users endpoints. Kept in one // helper so add / delete / list all produce identical output — the client // can treat every response as an authoritative "here's the current state". function adminUserRow(entry) { var groups = Array.isArray(entry && entry.groups) ? entry.groups : []; return { id: entry.id, displayName: entry.displayName || '', email: entry.email || '', avatar: entry.avatar || null, groupCount: groups.length }; } function adminUsersList(appName) { var bucket = (authorized.bot && authorized.bot[appName]) || {}; return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); }); } app.get('/CollabCentral/:app/admin/users', function (req, res) { if (requireAdmin(req, res)) return; logger('apiEndpoint(' + req.params.app + ')', 'GET /admin/users'); res.status(200).send(adminUsersList(req.params.app)); }); app.post('/CollabCentral/:app/admin/users', async function (req, res) { if (requireAdmin(req, res)) return; var appName = req.params.app; var email = req.body && req.body.email && String(req.body.email).trim(); if (!email) return res.status(400).send('Missing email.'); var person; try { person = await findPersonByEmail(email); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users lookup failed for "' + email + '": ' + (err && err.message || err)); return res.status(502).send('Failed to reach Webex directory.'); } if (!person) return res.status(404).send('No Webex user found for that email.'); if (!authorized.bot[appName]) authorized.bot[appName] = {}; var bucket = authorized.bot[appName]; if (!bucket[person.id]) { bucket[person.id] = { id: person.id, displayName: person.displayName, email: person.email, avatar: person.avatar, groups: [] }; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to save.'); } logger('apiEndpoint(' + appName + ')', 'admin/users + "' + person.displayName + '" <' + person.email + '>'); } res.status(200).send({ user: adminUserRow(bucket[person.id]), users: adminUsersList(appName) }); }); app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) { if (requireAdmin(req, res)) return; var appName = req.params.app; var targetId = req.params.id; var bucket = (authorized.bot && authorized.bot[appName]) || {}; if (!bucket[targetId]) { return res.status(200).send({ removed: false, users: adminUsersList(appName) }); } var name = bucket[targetId].displayName || targetId; delete bucket[targetId]; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users delete save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to save.'); } logger('apiEndpoint(' + appName + ')', 'admin/users - "' + name + '"'); res.status(200).send({ removed: true, users: adminUsersList(appName) }); }); // Options for the session cookies set after a successful OAuth round-trip. // // httpOnly is deliberately false: js/app.js reads `id` (to decide whether to // redirect to OAuth) and `displayName` (to render the user chip in the header) // via document.cookie. Enabling httpOnly here hides the cookies from JS, which // causes an infinite auth loop where every page load thinks the user is not // signed in and kicks off a fresh OAuth exchange — burning Webex tokens until // the CTS token limit is reached. // // sameSite is 'lax' rather than 'strict' so the cookie survives the OAuth // redirect chain webexapis.com -> /oauth -> /sendMessage.html on all browsers. const SESSION_COOKIE_OPTIONS = { httpOnly: false, secure: true, sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 }; app.get(`/CollabCentral/:app/oauth`, async function (req, res) { var appName = req.params.app; var authCode = req.query.code; if (!process.env.WEBEX_INTEGRATION_CLIENT_ID || !process.env.WEBEX_INTEGRATION_CLIENT_SECRET || !process.env.OAUTH_CALLBACK_URL_TEMPLATE) { logger("oauth", "Missing Webex integration env vars; cannot complete OAuth."); return res.status(500).send("OAuth is not configured on the server."); } if (!authCode) { return res.status(400).send("Missing OAuth `code` query parameter."); } var url = new URL("https://webexapis.com/v1/access_token"); var params = new URLSearchParams(); params.append('grant_type', 'authorization_code'); params.append('client_id', process.env.WEBEX_INTEGRATION_CLIENT_ID); params.append('client_secret', process.env.WEBEX_INTEGRATION_CLIENT_SECRET); params.append('code', authCode); params.append('redirect_uri', getOAuthRedirectUri(appName)); var response; try { response = await fetchWithRateLimit(url, { method: 'POST', body: params }); } catch (error) { logger("oauth", "Token exchange fetch failed: " + (error && error.message || error)); return res.status(502).send("Failed to reach Webex to complete OAuth."); } if (!response.ok) { var errText = await response.text().catch(() => ''); logger("oauth", "Token exchange returned " + response.status + " " + response.statusText + " " + errText); return res.status(401).send(response.statusText || "OAuth token exchange failed."); } var jsonData = await response.json(); var whoami; try { whoami = await whoAmI(jsonData.access_token); } catch (err) { logger("oauth", "whoAmI failed: " + (err && err.message || err)); return res.status(502).send("Failed to read your Webex profile after OAuth."); } logger("oauth", whoami.displayName + " successfully authed for " + appName + "."); // Only the two cookies that the server (req.cookies.id, .displayName) and // the client (getCookie('id'), getCookie('displayName')) actually read. // Access/refresh tokens deliberately stay out of the browser: they never // need to leave the server, and putting them in cookies would expose them // to any XSS that might slip in later. res .cookie('id', whoami.id, SESSION_COOKIE_OPTIONS) .cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS) .redirect(301, '/CollabCentral/' + appName + '/sendMessage.html'); }); function buildJob(req, res) { return new Promise(async function (resolve, reject) { res.setTimeout(3000000) var memberList = []; var memberListPromises = []; upload(req, res, async function (err) { //console.log(req.files); //console.log(req.body); jobs.building[buildingKey(req)] = { "appName": req.body.appName, "senderId": req.cookies.id, "senderDisplayName": req.cookies.displayName, "groups": req.body.groups, "message": { "raw": req.body.message, "english": {} }, "memberList": [] } // check for error thrown by multer- file size etc if (err || req.files === undefined) { //no file } else { if (req.files["uploadImage"]) { for (var file of req.files["uploadImage"]) { let fileName = uuid() + ".jpeg" var image = await sharp(file.buffer).jpeg({ quality: 40, }).toFile('./uploads/' + fileName).catch(err => { console.log('error: ', err) }) jobs.building[buildingKey(req)].imageName = fileName; logger("buildJob", "uploadImage " + file.originalname + " to " + fileName) } } if (req.files["uploadCSV"]) { for (var file of req.files["uploadCSV"]) { let fileName = uuid() + ".csv"; fs.writeFileSync('./uploads/' + fileName, file.buffer) jobs.building[buildingKey(req)].csv = fileName; logger("buildJob", "uploadCSV " + file.originalname + " to " + fileName) memberListPromises.push(buildPeopleList(fileName)); } } } if (req.body.groups) { logger("buildJob", "Locating groups: " + req.body.groups); memberListPromises.push(collectGroupMembers(req.body.groups)); } if (req.body.scheduledFor) { logger("buildJob", "Scheduling for " + new Date(req.body.scheduledFor).toLocaleString()) jobs.building[buildingKey(req)].scheduledFor = req.body.scheduledFor; saveConfig(jobs, "./config/jobs.json") } Promise.allSettled(memberListPromises) .then(peoplePromises => { console.log(JSON.stringify(peoplePromises)) for (var peopleList of peoplePromises) { if (peopleList.status == "fulfilled") { for (var person of peopleList.value) { memberList.push(person); } } } //Make user members unique var uniqueMembers = [...new Map(memberList.map((m) => [m.id, m])).values()]; jobs.building[buildingKey(req)].memberList = uniqueMembers; var numSending = uniqueMembers.length.toString(); saveConfig(jobs, "./config/jobs.json") resolve(jobs.building[buildingKey(req)]); }) }) }) } function collectGroupMembers(groupList) { return new Promise(async function (resolve, reject) { var groups = groupList.split(","); var memberList = []; for (var group of groups) { try { var memberCnt = 0; var groupCnt = 0; var groupData = await getGroupMembers(group); for (var member of groupData.members) { memberList.push(member); memberCnt++; } if (groupData.groups.length > 0) { for (var subGroup of groupData.groups) { logger("collectGroupMembers", "Found new group: " + subGroup.name); groupCnt++; groups.push(subGroup.id); } } logger("collectGroupMembers", "Found " + groupData.displayName + " Members: " + memberCnt) } catch (error) { console.log("Error groupData: " + error) } } //console.log("Groups: " + groupList); var uniqueMembers = [...new Map(memberList.map((m) => [m.id, m])).values()]; resolve(uniqueMembers); }) } function getGroupMembers(groupId) { return new Promise(async function (resolve, reject) { var groupMembers = { "members": [], "groups": [] }; var startIndex = 1; var count = 500; var memberSize = 10000; var requestOptions = { method: 'GET', headers: { 'Authorization': `Bearer ` + getServiceAccountAccessToken(), 'Content-Type': `application/json` } }; while (memberSize > (groupMembers.members.length + groupMembers.groups.length)) { var findGroupByIdUrl = 'https://webexapis.com/v1/groups/' + groupId + '/members?startIndex=' + startIndex + '&count=' + count; try { var groupResponse = await fetchWithRateLimit(findGroupByIdUrl, requestOptions); if (groupResponse.ok) { var groupData = await groupResponse.json(); groupMembers.displayName = groupData.displayName; memberSize = groupData.memberSize; for (var member of groupData.members) { if (member.type == "group") { groupMembers.groups.push({ "id": member.id, "name": member.displayName }) } else { groupMembers.members.push(member) } } startIndex = startIndex + count; } else { memberSize = 0; reject(groupResponse.status + ": " + groupResponse.statusText); } } catch (error) { console.log("Error findGroupById: " + error); } } resolve(groupMembers); }) } function findWebexGroup() { return new Promise(async function (resolve, reject) { var groups = []; var startIndex = 1; var count = 500; var groupSize = 10000; var url = new URL("https://webexapis.com/v1/groups") //var params = new URLSearchParams(); //url.searchParams.append("filter", "displayName contains " + searchTerm); url.searchParams.append("attributes", "displayName"); url.searchParams.append("sortBy", "displayName"); url.searchParams.append("sortOrder", "ascending"); url.searchParams.append("includeMembers", "false"); url.searchParams.append("startIndex", startIndex); url.searchParams.append("count", count); //console.log(params.toString()) var requestOptions = { method: 'GET', headers: { 'Authorization': `Bearer ` + getServiceAccountAccessToken(), 'Content-Type': `application/json` } }; while (groupSize > groups.length) { try { var fetchedGroups = await fetchWithRateLimit(url, requestOptions); if (fetchedGroups.ok) { var groupData = await fetchedGroups.json(); groupSize = groupData.totalResults; for (var group of groupData.groups) { groups.push(group); } startIndex = startIndex + count; url.searchParams.delete("startIndex"); url.searchParams.append("startIndex", startIndex); } else { return reject(fetchedGroups.status + ": " + fetchedGroups.statusText); } } catch (error) { logger('findWebexGroup', 'page fetch failed: ' + (error && error.message || error)); return reject(error); } } resolve(groups); }) } function whoAmI(bearerToken) { return new Promise(async function (resolve, reject) { logger("whoAmI", "Person is having an existential crisis of identity.") var myHeaders = { "Authorization": "Bearer " + bearerToken, "Content-Type": "application/json" } var requestOptions = { method: 'GET', headers: myHeaders, redirect: 'follow' }; fetchWithRateLimit("https://webexapis.com/v1/people/me", requestOptions) .then(async function (response) { var userInfo = await response.json(); resolve(userInfo); }) .catch(error => reject(error)); }) } function buildPeopleList(fileName) { return new Promise(async function (resolve, reject) { var peoplePromises = []; var peopleList = []; if (fileName) { var csvPeople = fs.readFileSync("./uploads/" + fileName, 'utf-8'); console.info(csvPeople); csvPeople.split(/\r?\n/).forEach(line => { peoplePromises.push(getPersonInfo(line)); }) Promise.allSettled(peoplePromises) .then(peopleData => { //console.log(JSON.stringify(peopleData)) var cnt = 0; for (var person of peopleData) { console.log(JSON.stringify(person)) cnt++; if (person.status == "fulfilled") { if (person.value) { console.log("Found " + person.value.displayName) peopleList.push({ "id": person.value.id, "type": "peopleList", "displayName": person.value.displayName }) } else { console.log(JSON.stringify(person)) } } else { console.log(cnt) console.log(JSON.stringify(person)) } } console.log(JSON.stringify(peopleList)) resolve(peopleList); }) .catch(error => reject(error)); } }) } function getPersonInfo(email) { return new Promise(async function (resolve, reject) { var myHeaders = { "Authorization": "Bearer " + getServiceAccountAccessToken(), "Content-Type": "application/json" } var requestOptions = { method: 'GET', headers: myHeaders, redirect: 'follow' }; fetchWithRateLimit("https://webexapis.com/v1/people?email=" + encodeURIComponent(email) + "&max=100", requestOptions) .then(async function (response) { console.log(email + ": " + response.status + ":" + response.statusText) if (response.ok) { var userInfo = await response.json(); resolve(userInfo.items[0]); } else { console.log(response.status + ": " + response.statusText) try { var stuff = await response.text(); } catch (error) { console.log(error) console.log(stuff) } reject(); } }) .catch(error => { reject(error) }); }) } function checkScheduledJobs() { return new Promise(async function (resolve, reject) { if (jobs.scheduled.length > 0) { for (var i = 0; i < jobs.scheduled.length; i++) { if (new Date(jobs.scheduled[i].scheduledFor) < new Date()) { jobs.running.push(jobs.scheduled[i]); delete jobs.scheduled[i]; } else { } } jobs.scheduled = jobs.scheduled.filter(elements => { return (elements != null && elements !== undefined && elements !== ""); }); saveConfig(jobs, "./config/jobs.json"); processRunningQueue(); } else { //console.log("No jobs scheduled."); } }) } function processRunningQueue() { return new Promise(async function (resolve, reject) { var runningQueuePromises = []; for (var i = 0; i < jobs.running.length; i++) { jobs.lastJobNumber++; jobs.running[i].jobId = jobs.lastJobNumber; jobs.running[i].startTime = new Date(); jobs.running[i].queueNumber = i; runningQueuePromises.push(sendQueueJobMessages(i)); } Promise.allSettled(runningQueuePromises) .then(promises => { logger("processRunningQueue", "Completed"); jobs.running = jobs.running.filter(elements => { return (elements != null && elements !== undefined && elements !== ""); }); saveConfig(jobs, "./config/jobs.json") resolve(); }) .catch(error => reject(error)); }) } function sendQueueJobMessages(queueNumber) { return new Promise(async function (resolve, reject) { logger("sendQueueJobMessages", "Starting JobId:" + jobs.running[queueNumber].jobId + " Queue#" + queueNumber); for (var x = 0; x < jobs.running[queueNumber].memberList.length; x++) { await queue.onSizeLessThan(2); if (userPrefs[jobs.running[queueNumber].memberList[x].id]) { queue.add(() => sendRunningQueueMessage(jobs.running[queueNumber].memberList[x].id, jobs.running[queueNumber].message[userPrefs[jobs.running[queueNumber].memberList[x].id].language].markdown, jobs.running[queueNumber].imageName, queueNumber, x, jobs.running[queueNumber].appName)); } else { queue.add(() => sendRunningQueueMessage(jobs.running[queueNumber].memberList[x].id, jobs.running[queueNumber].message.english.markdown, jobs.running[queueNumber].imageName, queueNumber, x, jobs.running[queueNumber].appName)); } } //console.log(membershipsPromise); await queue.onIdle(); var endTime = new Date(); var startTime = jobs.running[queueNumber].startTime; var successCnt = 0; var msgTime = 0; for (var z = 0; z < jobs.running[queueNumber].memberList.length; z++) { if (jobs.running[queueNumber].memberList[z].msgTime) { successCnt++; msgTime += jobs.running[queueNumber].memberList[z].msgTime; } } jobs.running[queueNumber].endTime = new Date(); jobs.running[queueNumber].stats = { "totalTime": msToTime((endTime - startTime)), "webexTime": msToTime(msgTime), "averageTime": msToTime((msgTime / successCnt)), "succeededMsgs": successCnt, "totalMsgs": jobs.running[queueNumber].memberList.length, "succeededMsgPct": (successCnt / jobs.running[queueNumber].memberList.length * 100) } jobs.running[queueNumber].endTime = new Date(); var cardDetails = buildJobCompletedCard(jobs.running[queueNumber]); jobs.completed.push(jobs.running[queueNumber]); logger("sendQueueJobMessages", "Completed JobId:" + jobs.running[queueNumber].jobId + " Queue#" + queueNumber) await sendDirectCard(jobs.running[queueNumber].senderId, cardDetails, jobs.running[queueNumber].appName) .then(msgResults => { delete jobs.running[queueNumber]; }) .catch(error => { //jobs.running[i].memberList[x].error = error; reject(error); }) resolve(queueNumber); }) } function sendRunningQueueMessage(toPersonId, message, imageName, jobNumber, memberNumber, appName) { return new Promise(async function (resolve, reject) { //logger("sendRunningQueueMessage", toPersonId + "\n" + message + "\n" + imageName + "\n" + jobNumber + "\n" + memberNumber + "\n" + appName) logger("sendRunningQueueMessage", "Sending to " + toPersonId); var startTime = new Date(); var myHeaders = new Headers(); myHeaders.append("Authorization", "Bearer " + getBotToken(appName)); var formdata = new FormData(); formdata.append("toPersonId", toPersonId); formdata.append("markdown", message); if (imageName) { var stream = fs.createReadStream("./uploads/" + imageName); //console.log(file); formdata.append("files", stream, imageName); }; //var myBody = JSON.stringify(myBodyJSON); var requestOptions = { method: 'POST', headers: myHeaders, body: formdata, redirect: 'follow' }; try { const response = await fetchAndRetryIfNecessary(async () => ( await fetch("https://webexapis.com/v1/messages", requestOptions) )) if (response.ok) { var result = await response.json(); jobs.running[jobNumber].memberList[memberNumber].results = result; jobs.running[jobNumber].memberList[memberNumber].msgTime = new Date() - startTime; logger("sendRunningQueueMessage", "Sent successfully to " + toPersonId); resolve(result) } else { var result = await response.text(); jobs.running[jobNumber].memberList[memberNumber].results = JSON.parse(result); jobs.running[jobNumber].memberList[memberNumber].msgTime = new Date() - startTime; resolve(JSON.parse(result)); } } catch (error) { logger("sendRunningQueueMessage", "Failed sending to " + toPersonId); reject(error) } }) } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function buildJobCompletedCard(job) { var appName = job.appName; var botCfg = (config.webex && config.webex.bot && config.webex.bot[appName]) || {}; var profile = getBotProfile(appName) || {}; // Per-bot identity: prefer the live profile from Webex /people/me, fall // back to the configured label / appName so the card renders even before // the profile loader finishes. var botLabel = botCfg.label || profile.displayName || appName; var botDisplayName = profile.displayName || botLabel; var avatarUrl = profile.avatar || null; var title = botLabel + " — Job Summary"; var stats = job.stats || {}; var successPct = (typeof stats.succeededMsgPct === "number") ? stats.succeededMsgPct.toFixed(1) + "%" : (stats.succeededMsgPct + "%"); var startedStr = job.startTime ? new Date(job.startTime).toLocaleString() : "—"; var completedStr = job.endTime ? new Date(job.endTime).toLocaleString() : "—"; // Header columns: bot avatar (if known) + job summary title. var headerColumns = []; if (avatarUrl) { headerColumns.push({ "type": "Column", "width": "auto", "items": [ { "type": "Image", "url": avatarUrl, "altText": botDisplayName, "style": "Person", "size": "Small", "spacing": "None" } ] }); } headerColumns.push({ "type": "Column", "width": "auto", "items": [ { "type": "TextBlock", "text": title, "wrap": true, "size": "ExtraLarge", "weight": "Bolder", "spacing": "None", "horizontalAlignment": "Left" } ], "verticalContentAlignment": "Center" }); var jobCompletedCard = { "type": "AdaptiveCard", "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "version": "1.3", "body": [ { "type": "ColumnSet", "columns": headerColumns, "spacing": "None" }, { "type": "TextBlock", "text": (job.message && job.message.english && job.message.english.markdown) || "", "wrap": true }, { "type": "FactSet", "facts": [ { "title": "JobID:", "value": (job.jobId !== undefined && job.jobId !== null) ? job.jobId.toString() : "—" }, { "title": "Started:", "value": startedStr }, { "title": "Completed:", "value": completedStr }, { "title": "Duration:", "value": stats.totalTime || "—" }, { "title": "Avg Message:", "value": stats.averageTime || "—" }, { "title": "Webex Time:", "value": stats.webexTime || "—" }, { "title": "Success", "value": (stats.succeededMsgs !== undefined ? (stats.succeededMsgs + "/" + stats.totalMsgs + " (" + successPct + ")") : "—") } ], "separator": true } ] }; var message = "### " + title + "\n"; message += "**JobId:** " + (job.jobId !== undefined ? job.jobId : "—") + "\n"; message += "**Started:** " + startedStr + "\n"; message += "**Completed:** " + completedStr + "\n"; message += "**Duration:** " + (stats.totalTime || "—") + "\n"; message += "**Avg Message:** " + (stats.averageTime || "—") + "\n"; message += "**Webex Time:** " + (stats.webexTime || "—") + "\n"; message += "**Success:** " + (stats.succeededMsgs !== undefined ? (stats.succeededMsgs + "/" + stats.totalMsgs + " (" + successPct + ")") : "—") + "\n"; return { "card": jobCompletedCard, "message": message }; } 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); console.log(JSON.stringify(requestOptions)); await new Promise(resolve => setTimeout(resolve, secondsToWait * 1000)) console.log("Finished waiting for " + secondsToWait.toString() + " due to 429 message: " + url); console.log(JSON.stringify(requestOptions)) return await fetchWithRateLimit(url, requestOptions) } return response } function getMillisToSleep(retryHeaderString) { let millisToSleep = Math.round(parseFloat(retryHeaderString) * 1000) if (isNaN(millisToSleep)) { millisToSleep = Math.max(0, new Date(retryHeaderString) - new Date()) } return millisToSleep } async function fetchAndRetryIfNecessary(callAPIFn) { const response = await callAPIFn() if (response.status === 429) { const retryAfter = response.headers.get('retry-after') const millisToSleep = getMillisToSleep(retryAfter) await sleep(millisToSleep) return fetchAndRetryIfNecessary(callAPIFn) } return response } // Look up a Webex person by email via /v1/people. Used by the admin UI so // authorizing someone on a bot only requires their email — the id, display // name, and avatar come from Webex. Resolves to null when the email doesn't // match any account or when the Webex request fails; the caller distinguishes // with its own 404/502 as appropriate. function findPersonByEmail(email) { return new Promise(function (resolve, reject) { var url = 'https://webexapis.com/v1/people?email=' + encodeURIComponent(email); var requestOptions = { method: 'GET', headers: { 'Authorization': 'Bearer ' + getServiceAccountAccessToken(), 'Content-Type': 'application/json' } }; fetchWithRateLimit(url, requestOptions) .then(function (response) { if (!response.ok) { return reject(new Error('HTTP ' + response.status + ' from Webex /people')); } return response.json(); }) .then(function (body) { var items = body && body.items; if (!items || !items.length) return resolve(null); var person = items[0]; resolve({ id: person.id, displayName: person.displayName, email: (person.emails && person.emails[0]) || email, avatar: person.avatar || null }); }) .catch(reject); }); } // Best-effort DELETE on a Webex message we previously sent (right now used // only to retract the preview DM when the user cancels a draft). Never // throws: the caller doesn't want a Webex hiccup here to bounce the whole // cancel flow, so we swallow + log and let the caller move on. function deleteWebexMessage(messageId, appName) { return new Promise(function (resolve) { var url = "https://webexapis.com/v1/messages/" + encodeURIComponent(messageId); var requestOptions = { method: 'DELETE', headers: { "Authorization": "Bearer " + getBotToken(appName) } }; fetchWithRateLimit(url, requestOptions) .then(function (response) { if (!response.ok) { logger('deleteWebexMessage', 'HTTP ' + response.status + ' for ' + messageId); } resolve(response.ok); }) .catch(function (err) { logger('deleteWebexMessage', 'error for ' + messageId + ': ' + (err && err.message || err)); resolve(false); }); }); } function sendDirectMessage(toPersonId, message, imageName, appName) { return new Promise(async function (resolve, reject) { var startTime = new Date(); var myHeaders = new Headers(); myHeaders.append("Authorization", "Bearer " + getBotToken(appName)); var formdata = new FormData(); formdata.append("toPersonId", toPersonId); formdata.append("markdown", message); if (imageName) { //var file = await fs.readFileSync("./uploads/" + imageName); var stream = await fs.createReadStream("./uploads/" + imageName); //console.log(file); formdata.append("files", stream, imageName); //formdata.set("files", new BlobFromStream(stream, content.length), imageName) }; //var myBody = JSON.stringify(myBodyJSON); var requestOptions = { method: 'POST', headers: myHeaders, body: formdata, redirect: 'follow' }; fetchWithRateLimit("https://webexapis.com/v1/messages", requestOptions) .then(response => response.text()) .then(result => { var msgResult = JSON.parse(result); resolve(msgResult) }) .catch(error => reject(error)); }); } function sendDirectCard(toPersonId, cardInfo, appName) { return new Promise(async function (resolve, reject) { var myHeaders = { "Authorization": "Bearer " + getBotToken(appName), "Content-Type": "application/json" } var body = { toPersonId: toPersonId, markdown: cardInfo.message, attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", content: cardInfo.card }] } var requestOptions = { method: 'POST', headers: myHeaders, body: JSON.stringify(body), redirect: 'follow' }; //console.log("RequestOptions:" + JSON.stringify(requestOptions)); fetchWithRateLimit("https://webexapis.com/v1/messages", requestOptions) .then(async function (response) { var subData = await response.text(); //console.log(subData); resolve(response); }) .catch(error => reject(error)); }); } function buildTranslations(message) { return new Promise(async function (resolve, reject) { logger("buildTranslations", "Started"); var translationPromises = []; var translations = { "english": { "text": message.text, "markdown": message.markdown, "html": message.html } }; for (var language of config.languages) { translationPromises.push(translateMessage(message.text, language, "text", "text")) translationPromises.push(translateMessage(message.markdown, language, "text", "markdown")) translationPromises.push(translateMessage(message.html, language, "html", "html")) } Promise.allSettled(translationPromises) .then(results => { //console.log(JSON.stringify(results)) for (var translation of results) { if (translation.status == "fulfilled") { if (translations[translation.value.name]) { translations[translation.value.name][translation.value.messageType] = translation.value.translatedText; } else { translations[translation.value.name] = {}; translations[translation.value.name][translation.value.messageType] = translation.value.translatedText; } } } //console.log(JSON.stringify(translations)); logger("buildTranslations", "Completed") resolve(translations); }) }) } function translateMessage(message, language, format, messageType) { return new Promise(async function (resolve, reject) { var urlSearchParams = { q: message, target: language.key, format: format, source: "en", model: "nmt", key: process.env.GOOGLE_TRANSLATE_API_KEY || '' } var translateUrl = "https://translation.googleapis.com/language/translate/v2?" + new URLSearchParams(urlSearchParams); //console.log(translateUrl); fetchWithRateLimit(translateUrl, { "method": "POST" }) .then(response => response.json()) .then(result => { var languageResult = { "name": language.name, "key": language.key, "format": format, "messageType": messageType, "translatedText": result.data.translations[0].translatedText } resolve(languageResult); }) .catch(error => reject(error)) }) } function refreshToken() { return new Promise(async function (resolve, reject) { var url = "https://webexapis.com/v1/access_token"; var params = new URLSearchParams(); params.append('grant_type', 'refresh_token'); params.append('client_id', process.env.WEBEX_SERVICE_ACCOUNT_CLIENT_ID || ''); params.append('client_secret', process.env.WEBEX_SERVICE_ACCOUNT_CLIENT_SECRET || ''); params.append('refresh_token', serviceAccountToken.refresh_token); fetchWithRateLimit(url, { method: 'post', body: params }) .then(res => { console.log(res.status); return res.json(); }) .then(function (json) { var updated = Object.assign({}, json, { created: new Date().toLocaleString("en-US", { timeZoneName: "short", timeZone: "America/New_York" }), renewBy: new Date(Date.now() + (json.expires_in * 1000)).toLocaleString("en-US", { timeZoneName: "short", timeZone: "America/New_York" }), refreshBy: new Date(Date.now() + (json.refresh_token_expires_in * 1000)).toLocaleString("en-US", { timeZoneName: "short", timeZone: "America/New_York" }) }); saveServiceAccountToken(updated); resolve("Refresh Success!\nExpires: " + updated.renewBy + "\nRefreshBy: " + updated.refreshBy); }) .catch(err => reject(err)); }) } function saveConfig(jsonObject, configFile) { const jsonData = JSON.stringify(jsonObject, null, 4); fs.writeFileSync(configFile, jsonData, (err) => { if (err) { throw err; } else { logger("saveConfig", "Wrote " + configFile); } }) } // Retention window for completed jobs (re-exported from lib/helpers.js so // index.js has a single symbol name callers can reason about). Bump this by // editing helpers.js if you want a longer / shorter review window. const COMPLETED_RETENTION_DAYS = helpers.COMPLETED_RETENTION_DAYS; async function cleanCompletedJobs(jobs) { var result = helpers.cleanCompletedJobs(jobs, COMPLETED_RETENTION_DAYS); if (!Array.isArray(result.jobs.completed)) { logger('cleanCompletedJobs', 'No completed array found – nothing to do.'); } else { logger('cleanCompletedJobs', 'Removed ' + result.removed + ' job(s) older than ' + COMPLETED_RETENTION_DAYS + ' days (cutoff ' + result.cutoff.toISOString().split('T')[0] + '). Remaining: ' + result.jobs.completed.length); } return result.jobs; } function isAuthorized(appName, personId) { logger("isAuthorized", appName + " " + personId) return helpers.isAuthorized(config, botTokens, authorized, appName, personId); } function isAdmin(personId) { return helpers.isAdmin(authorized, personId); } function msToTime(duration) { return helpers.msToTime(duration); } function logger(activeFunction, logLine) { var d = new Date(); console.log(d.toLocaleString() + " " + activeFunction + ": " + logLine); } // gracefully shutdown (ctrl-c) process.on('SIGINT', function () { server.close(() => { logger("shutdown", config.server.name + ' stopped!') process.exit(); }); })