Two features stitched together because they touch the same building-
job data path.
--- 1. Retract preview DM on cancel -----------------------------------
The /jobs/edit flow DMs a preview of the composed message to the
sender's own Webex space. Until now that DM lingered even if the
sender then hit Cancel or Discard.
- /jobs/edit now stores the returned message id as
jobs.building[key].previewMessageId.
- /jobs/cancel captures that id before deleting the building entry,
saves the cancel first, then fires a best-effort
DELETE /v1/messages/<id> against Webex.
- New deleteWebexMessage(messageId, appName) helper wraps the DELETE.
Uses the bot token (bots own their messages) and never throws — a
Webex hiccup logs but doesn't fail the cancel that already
succeeded on our side. Called fire-and-forget so the HTTP response
isn't blocked on a slow Webex round trip.
--- 2. Admin: manage authorized users -------------------------------
Admin authority lives in a new top-level config.admins array of
personIds (seeded with Joe's id). Admin actions are cross-bot in
concept but the routes are :app-scoped because the resource being
edited is per-bot and it lets admin reuse the existing OAuth session
without a separate auth surface.
Helpers
- lib/helpers.js: new pure isAdmin(config, personId) that fails
closed when admins is missing, not-an-array, or config is null.
- test/helpers.test.js: 5 new assertions covering the happy path,
the "not in list" case, missing personId, non-array admins, and
missing config. Total suite is now 48 assertions across 11 groups.
Server-side (index.js)
- New findPersonByEmail(email) helper hits Webex /v1/people?email=
using the service account token, returns
{ id, displayName, email, avatar } or null.
- /info now returns isAdmin so the client can decide whether to
render the admin dropdown.
- New requireAdmin(req, res) gate returns 401 for signed-out and
403 for signed-in-but-not-admin (distinct codes so the frontend
can render distinct panels).
- GET /CollabCentral/:app/admin/users → list users
- POST /CollabCentral/:app/admin/users → lookup + add
- DELETE /CollabCentral/:app/admin/users/:id → remove
- Shared adminUserRow / adminUsersList shape so every response is an
authoritative snapshot the client can render without merging.
- DELETE of an unknown id is idempotent — returns 200 removed:false
without rewriting config.json.
Frontend
- New html/admin.html + html/admin.js on the shared layout. Panels
swap between not-signed-in / not-admin / admin. Add-user form
takes an email; user list renders as rows with Webex avatar
(fallback initials), name, email, favorite-group count, and a
Remove button that confirm()s before firing DELETE.
- html/js/app.js: renderUserChip() replaces the plain-text top-right
user label with a proper button + dropdown menu when the caller
is an admin. Menu is keyboard-friendly (Escape to close), closes
on outside-click, and currently exposes one item ("Admin" →
admin.html). Non-admins get the plain-text label unchanged, so
the existing pages are visually identical for them.
- html/css/app.css: new .appHeader__userBtn / .appHeader__userMenu
dropdown, .inlineFieldRow for the email-plus-button pattern, and
.userRow* rules for the admin user list.
Config
- Add config.admins array seeded with Joe McQueen's personId.
- config.json also picks up an in-app state change from the running
instance (an "AV Team" favorite removed from Joe's techupdates
authorized entry via the favorites UI). Rolling that into this
commit so the file stops drifting from origin.
Co-authored-by: Cursor <cursoragent@cursor.com>
1642 lines
67 KiB
JavaScript
1642 lines
67 KiB
JavaScript
#!/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.
|
||
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'));
|
||
|
||
// 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:
|
||
// { "<appName>": { "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");
|
||
res.status(200).send(config.webex.bot[req.params.app].authorized[req.cookies.id].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) }
|
||
} else { res.status(401) }
|
||
})
|
||
|
||
// Add / remove a favorite group for the calling user. Favorites live inside
|
||
// config.json under `webex.bot[app].authorized[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 config.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 favorites = config.webex.bot[appName].authorized[personId].groups || [];
|
||
if (favorites.find(function (g) { return g.id === groupId; })) {
|
||
return res.status(200).send(favorites);
|
||
}
|
||
favorites.push({ name: group.displayName, id: group.id });
|
||
config.webex.bot[appName].authorized[personId].groups = favorites;
|
||
try {
|
||
saveConfig(config, './config/config.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 favorites = config.webex.bot[appName].authorized[personId].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);
|
||
|
||
config.webex.bot[appName].authorized[personId].groups = favorites;
|
||
try {
|
||
saveConfig(config, './config/config.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 a top-level config.admins array (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 authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {};
|
||
return Object.keys(authorized).map(function (id) { return adminUserRow(authorized[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.');
|
||
|
||
var authorized = config.webex.bot[appName].authorized = config.webex.bot[appName].authorized || {};
|
||
if (!authorized[person.id]) {
|
||
authorized[person.id] = {
|
||
id: person.id,
|
||
displayName: person.displayName,
|
||
email: person.email,
|
||
avatar: person.avatar,
|
||
groups: []
|
||
};
|
||
try {
|
||
saveConfig(config, './config/config.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(authorized[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 authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {};
|
||
if (!authorized[targetId]) {
|
||
return res.status(200).send({ removed: false, users: adminUsersList(appName) });
|
||
}
|
||
var name = authorized[targetId].displayName || targetId;
|
||
delete authorized[targetId];
|
||
try {
|
||
saveConfig(config, './config/config.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, appName, personId);
|
||
}
|
||
|
||
function isAdmin(personId) {
|
||
return helpers.isAdmin(config, 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();
|
||
});
|
||
})
|