// Pure helpers extracted from index.js. Everything here is state-free: callers // pass in whatever piece of config, tokens, or env they want to evaluate // against. That keeps the functions trivially unit-testable and lets the // server module stay the single place that owns mutable runtime state. // Default number of days that completed jobs are retained before the daily // cleanup cron prunes them. Exported so callers and tests share the constant. export const COMPLETED_RETENTION_DAYS = 30; // Composite key for the "draft job being built" bucket. Keying by cookieId + // appName means a user authorized on multiple bots can build one draft per // bot without them colliding on top of each other. export function buildingKey(cookieId, appName) { return String(cookieId) + ':' + String(appName); } // Filters one of the global job arrays (running/scheduled/completed) down to // the jobs that belong to the requested bot. Null-safe so callers can hand // in an uninitialized array. export function jobsForApp(arr, appName) { return (arr || []).filter(function (j) { return j && j.appName === appName; }); } // Returns the bot's raw access token from the botTokens map, honoring the // enabled flag. Returns null when the bot is unknown, explicitly disabled, // or missing a token. Tolerates a flat "appName -> tokenString" shape too, // which is what older configs used before the enabled flag was introduced. export function getBotToken(botTokens, appName) { if (!botTokens) return null; var entry = botTokens[appName]; if (!entry) return null; if (typeof entry === 'string') return entry; if (entry.enabled === false) return null; return entry.token || null; } export function isBotEnabled(botTokens, appName) { return getBotToken(botTokens, appName) !== null; } // Returns the bot's config block only if the bot is defined in config.json // AND has an enabled token entry. Returns null otherwise. Every caller should // go through this instead of reaching into config.webex.bot[...] directly, // so unknown or disabled bots produce a clean 404 rather than a crash. export function getBotConfig(config, botTokens, appName) { if (!appName) return null; var cfg = config && config.webex && config.webex.bot && config.webex.bot[appName]; if (!cfg) return null; if (!isBotEnabled(botTokens, appName)) return null; return cfg; } // True iff `personId` is listed under the bot's `authorized` map AND the bot // is enabled. Missing personId or missing bot config yields false. export function isAuthorized(config, botTokens, appName, personId) { var botCfg = getBotConfig(config, botTokens, appName); if (!botCfg) return false; if (!personId) return false; return !!(botCfg.authorized && botCfg.authorized[personId]); } // True iff `personId` appears in the top-level config.admins array. Admin // authority is intentionally cross-bot: a single admin manages authorization // for every bot the process serves. Missing personId or missing admins list // yields false so an unconfigured deployment fails closed. export function isAdmin(config, personId) { if (!personId) return false; var admins = config && config.admins; if (!Array.isArray(admins)) return false; return admins.indexOf(personId) !== -1; } // Replaces the `:app` placeholder in the OAuth callback URL template with the // appName. Empty template returns an empty string so callers can detect the // misconfiguration. export function getOAuthRedirectUri(template, appName) { return (template || '').replace(':app', appName); } // Builds the Webex OAuth authorize URL for a bot. Returns null when the // required inputs (clientId / template) are missing, so callers can serve an // actionable 500 instead of a malformed URL. export function buildAuthUrl({ clientId, template, appName }) { if (!clientId || !template) return null; var params = new URLSearchParams(); params.append('client_id', clientId); params.append('response_type', 'code'); params.append('redirect_uri', getOAuthRedirectUri(template, appName)); params.append('scope', 'spark:kms spark:people_read'); params.append('state', ''); return 'https://webexapis.com/v1/authorize?' + params.toString(); } // Drops completed jobs older than `retentionDays` from `jobs.completed`. // Mutates `jobs` in place (matching the pre-extraction behavior) and returns // a report so callers can log the counts without importing a logger. Falls // back through endTime → startTime → created for the age comparison, and // keeps any job that has no timestamp at all as a safety measure. export function cleanCompletedJobs(jobs, retentionDays = COMPLETED_RETENTION_DAYS, now = Date.now()) { var cutoffDate = new Date(now - (retentionDays * 24 * 60 * 60 * 1000)); if (!Array.isArray(jobs.completed)) { return { jobs, removed: 0, cutoff: cutoffDate, retentionDays }; } var originalCount = jobs.completed.length; jobs.completed = jobs.completed.filter(function (job) { var jobDateStr = job.endTime || job.startTime || job.created; if (!jobDateStr) return true; return new Date(jobDateStr) >= cutoffDate; }); return { jobs, removed: originalCount - jobs.completed.length, cutoff: cutoffDate, retentionDays, }; } // Formats a millisecond duration as "Nh Nm N.Ns" (or just "N.Ns" for < 1min, // or "Nm N.Ns" for < 1hr). Matches the original stat display in job cards. export function msToTime(duration) { var milliseconds = parseInt((duration % 1000) / 100) , seconds = parseInt((duration / 1000) % 60) , minutes = parseInt((duration / (1000 * 60)) % 60) , hours = parseInt((duration / (1000 * 60 * 60)) % 24); if (hours > 0) return hours + "h " + minutes + "m " + seconds + "." + milliseconds + "s"; if (minutes > 0) return minutes + "m " + seconds + "." + milliseconds + "s"; return seconds + "." + milliseconds + "s"; }