- Require ADMIN_TOKEN bearer auth on /members and new /schedule CRUD routes - Validate JSON body shape and reject empty/malformed writes so a bad multipart POST can no longer wipe authorized.json - Make saveJSON atomic (tmp -> rename) with a one-generation .bak file - Serve a self-contained admin SPA at /admin (public/admin.html) for editing the discount schedule and authorized members without hand- editing JSON, with a /preview endpoint that renders the bot's fallback text for draft entries - Dockerfile now copies public/ into the image Co-authored-by: Cursor <cursoragent@cursor.com>
884 lines
No EOL
31 KiB
JavaScript
884 lines
No EOL
31 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
*****************************************************************
|
|
Basebot / DiscountBot by Joe McQueen - Updated 2026
|
|
Purpose: Template for Webex bots with discount code delivery
|
|
*/
|
|
import dotenv from 'dotenv';
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
dotenv.config({ path: '.env.development' });
|
|
} else {
|
|
dotenv.config();
|
|
}
|
|
|
|
import fs from 'fs/promises';
|
|
import fsSync from 'fs';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import framework from 'webex-node-bot-framework';
|
|
import express from 'express';
|
|
import bodyParser from 'body-parser';
|
|
import fetch from 'node-fetch';
|
|
import cron from 'node-cron';
|
|
|
|
let config = {};
|
|
let schedule = [];
|
|
let authorizedMembers = [];
|
|
let responded = false;
|
|
let Framework = null;
|
|
|
|
const CONFIG_DIR = './config';
|
|
const SCHEDULE_PATH = path.join(CONFIG_DIR, 'schedule.json');
|
|
const AUTHORIZED_PATH = path.join(CONFIG_DIR, 'authorized.json');
|
|
const PUBLIC_DIR = path.resolve('./public');
|
|
|
|
const app = express();
|
|
app.disable('x-powered-by');
|
|
app.use(bodyParser.json({ limit: '512kb' }));
|
|
app.use(bodyParser.urlencoded({ extended: false, limit: '512kb' }));
|
|
|
|
// Helper: Safe async JSON load
|
|
async function loadJSON(filePath) {
|
|
try {
|
|
const data = await fs.readFile(filePath, 'utf8');
|
|
return JSON.parse(data);
|
|
} catch (err) {
|
|
logger('loadJSON', `Error loading ${filePath}: ${err.message}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Helper: Safe async JSON save (atomic + one-generation .bak rollback)
|
|
async function saveJSON(jsonObject, filePath) {
|
|
const jsonData = JSON.stringify(jsonObject, null, 4);
|
|
const tmpPath = `${filePath}.tmp`;
|
|
const bakPath = `${filePath}.bak`;
|
|
try {
|
|
await fs.writeFile(tmpPath, jsonData, 'utf8');
|
|
try {
|
|
await fs.copyFile(filePath, bakPath);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
await fs.rename(tmpPath, filePath);
|
|
logger('saveJSON', `Successfully wrote ${filePath} (backup at ${bakPath})`);
|
|
} catch (err) {
|
|
logger('saveJSON', `Error writing ${filePath}: ${err.message}`, 'ERROR');
|
|
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ====================== VALIDATION ======================
|
|
const MAX_MEMBERS = 5000;
|
|
const MAX_MEMBER_LEN = 320;
|
|
const MAX_SCHEDULE_ENTRIES = 240;
|
|
const MAX_STRING_LEN = 2000;
|
|
|
|
function isPlainString(v, { allowEmpty = false, max = MAX_STRING_LEN } = {}) {
|
|
if (typeof v !== 'string') return false;
|
|
if (!allowEmpty && v.trim() === '') return false;
|
|
if (v.length > max) return false;
|
|
// no NUL / control chars other than \n \r \t
|
|
return !/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(v);
|
|
}
|
|
|
|
function validateAuthorized(input) {
|
|
if (!Array.isArray(input)) return { ok: false, error: 'Body must be a JSON array of usernames/emails.' };
|
|
if (input.length === 0) return { ok: false, error: 'Refusing to save an empty authorized list.' };
|
|
if (input.length > MAX_MEMBERS) return { ok: false, error: `Too many entries (max ${MAX_MEMBERS}).` };
|
|
const cleaned = [];
|
|
const seen = new Set();
|
|
for (let i = 0; i < input.length; i++) {
|
|
const item = input[i];
|
|
if (typeof item !== 'string') return { ok: false, error: `Entry ${i} is not a string.` };
|
|
const norm = item.trim().toLowerCase();
|
|
if (!norm) return { ok: false, error: `Entry ${i} is empty.` };
|
|
if (norm.length > MAX_MEMBER_LEN) return { ok: false, error: `Entry ${i} exceeds ${MAX_MEMBER_LEN} chars.` };
|
|
if (!isPlainString(norm, { max: MAX_MEMBER_LEN })) return { ok: false, error: `Entry ${i} has invalid characters.` };
|
|
if (!seen.has(norm)) { seen.add(norm); cleaned.push(norm); }
|
|
}
|
|
cleaned.sort();
|
|
return { ok: true, value: cleaned };
|
|
}
|
|
|
|
// Shape required by buildCard() — every code/message field must be present.
|
|
const SCHEDULE_SHAPE = [
|
|
['aeAerie', 'usrow'],
|
|
['aeAerie', 'mexico'],
|
|
['toddSnyder', 'usrow', 'tstc'],
|
|
['toddSnyder', 'usrow', 'thirdparty'],
|
|
['unsubscribed', 'usrow'],
|
|
['unsubscribed', 'thirdparty'],
|
|
['unsubscribed', 'giftcard']
|
|
];
|
|
|
|
function getAt(obj, pathArr) {
|
|
return pathArr.reduce((acc, k) => (acc == null ? acc : acc[k]), obj);
|
|
}
|
|
|
|
function validateScheduleEntry(entry, i = 0) {
|
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
return { ok: false, error: `Entry ${i} must be an object.` };
|
|
}
|
|
if (!isPlainString(entry.month, { max: 60 })) return { ok: false, error: `Entry ${i}: "month" is required.` };
|
|
if (!isPlainString(entry.start, { max: 40 })) return { ok: false, error: `Entry ${i}: "start" is required.` };
|
|
if (!isPlainString(entry.end, { max: 40 })) return { ok: false, error: `Entry ${i}: "end" is required.` };
|
|
const startMs = Date.parse(entry.start);
|
|
const endMs = Date.parse(entry.end);
|
|
if (isNaN(startMs)) return { ok: false, error: `Entry ${i}: "start" is not a parseable date (${entry.start}).` };
|
|
if (isNaN(endMs)) return { ok: false, error: `Entry ${i}: "end" is not a parseable date (${entry.end}).` };
|
|
if (endMs < startMs) return { ok: false, error: `Entry ${i}: "end" is before "start".` };
|
|
|
|
for (const shape of SCHEDULE_SHAPE) {
|
|
const node = getAt(entry, shape);
|
|
const label = shape.join('.');
|
|
if (!node || typeof node !== 'object') {
|
|
return { ok: false, error: `Entry ${i}: missing "${label}".` };
|
|
}
|
|
if (!isPlainString(node.code, { max: 200 })) {
|
|
return { ok: false, error: `Entry ${i}: "${label}.code" is required.` };
|
|
}
|
|
if (typeof node.message !== 'string' || node.message.length > MAX_STRING_LEN) {
|
|
return { ok: false, error: `Entry ${i}: "${label}.message" must be a string ≤ ${MAX_STRING_LEN} chars.` };
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function validateScheduleArray(input) {
|
|
if (!Array.isArray(input)) return { ok: false, error: 'Schedule must be a JSON array.' };
|
|
if (input.length === 0) return { ok: false, error: 'Refusing to save an empty schedule.' };
|
|
if (input.length > MAX_SCHEDULE_ENTRIES) return { ok: false, error: `Too many schedule entries (max ${MAX_SCHEDULE_ENTRIES}).` };
|
|
for (let i = 0; i < input.length; i++) {
|
|
const r = validateScheduleEntry(input[i], i);
|
|
if (!r.ok) return r;
|
|
}
|
|
return { ok: true, value: input };
|
|
}
|
|
|
|
// ====================== ADMIN AUTH ======================
|
|
function safeEqual(a, b) {
|
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
const ab = Buffer.from(a);
|
|
const bb = Buffer.from(b);
|
|
if (ab.length !== bb.length) return false;
|
|
return crypto.timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
const expected = process.env.ADMIN_TOKEN;
|
|
if (!expected) {
|
|
return res.status(503).json({ error: 'Admin endpoints are disabled: ADMIN_TOKEN is not configured on the server.' });
|
|
}
|
|
const header = req.get('authorization') || '';
|
|
const m = header.match(/^Bearer\s+(.+)$/i);
|
|
const provided = m ? m[1].trim() : (req.get('x-admin-token') || '').trim();
|
|
if (!provided || !safeEqual(provided, expected)) {
|
|
return res.status(401).json({ error: 'Unauthorized.' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Logger
|
|
function logger(section, message, level = 'INFO') {
|
|
const now = new Date().toLocaleString();
|
|
console.log(`${now} [${level}] ${section}: ${message}`);
|
|
}
|
|
|
|
// Load configs on startup
|
|
async function loadConfigs() {
|
|
try {
|
|
config = await loadJSON(path.join(CONFIG_DIR, 'config.json'));
|
|
const rawSchedule = await loadJSON(SCHEDULE_PATH);
|
|
schedule = Array.isArray(rawSchedule) ? rawSchedule : Object.values(rawSchedule || {});
|
|
const rawAuthorized = await loadJSON(AUTHORIZED_PATH);
|
|
authorizedMembers = (rawAuthorized || []).map(item =>
|
|
typeof item === 'string' ? item.toLowerCase() : item
|
|
);
|
|
logger('startup', `Configs loaded. ${authorizedMembers.length} authorized members, ${schedule.length} schedule entries.`);
|
|
} catch (err) {
|
|
logger('startup', 'Failed to load one or more config files. Exiting.', 'ERROR');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ====================== EXPRESS ROUTES ======================
|
|
app.get('/status', (req, res) => {
|
|
res.status(200).json({
|
|
status: `Alive and kicking. ${authorizedMembers.length} authorized members, ${schedule.length} schedule entries.`
|
|
});
|
|
});
|
|
|
|
// ---------- Members ----------
|
|
app.get('/members', requireAdmin, (req, res) => {
|
|
res.status(200).json(authorizedMembers);
|
|
});
|
|
|
|
app.post('/members', requireAdmin, async (req, res) => {
|
|
if (!req.is('application/json')) {
|
|
return res.status(415).json({ error: 'Content-Type must be application/json.' });
|
|
}
|
|
const check = validateAuthorized(req.body);
|
|
if (!check.ok) {
|
|
logger('POST /members', `Rejected: ${check.error}`, 'WARN');
|
|
return res.status(400).json({ error: check.error });
|
|
}
|
|
try {
|
|
await saveJSON(check.value, AUTHORIZED_PATH);
|
|
authorizedMembers = check.value;
|
|
logger('POST /members', `Authorized users updated (${authorizedMembers.length} entries).`);
|
|
res.status(200).json({ status: 'Authorized users updated.', count: authorizedMembers.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to update authorized users.' });
|
|
}
|
|
});
|
|
|
|
// ---------- Schedule ----------
|
|
app.get('/schedule', requireAdmin, (req, res) => {
|
|
res.status(200).json(schedule);
|
|
});
|
|
|
|
// Replace whole schedule
|
|
app.put('/schedule', requireAdmin, async (req, res) => {
|
|
if (!req.is('application/json')) {
|
|
return res.status(415).json({ error: 'Content-Type must be application/json.' });
|
|
}
|
|
const check = validateScheduleArray(req.body);
|
|
if (!check.ok) {
|
|
logger('PUT /schedule', `Rejected: ${check.error}`, 'WARN');
|
|
return res.status(400).json({ error: check.error });
|
|
}
|
|
try {
|
|
await saveJSON(check.value, SCHEDULE_PATH);
|
|
schedule = check.value;
|
|
logger('PUT /schedule', `Schedule replaced (${schedule.length} entries).`);
|
|
res.status(200).json({ status: 'Schedule replaced.', count: schedule.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to save schedule.' });
|
|
}
|
|
});
|
|
|
|
// Append a single entry
|
|
app.post('/schedule', requireAdmin, async (req, res) => {
|
|
if (!req.is('application/json')) {
|
|
return res.status(415).json({ error: 'Content-Type must be application/json.' });
|
|
}
|
|
const check = validateScheduleEntry(req.body, schedule.length);
|
|
if (!check.ok) {
|
|
return res.status(400).json({ error: check.error });
|
|
}
|
|
if (schedule.length + 1 > MAX_SCHEDULE_ENTRIES) {
|
|
return res.status(400).json({ error: `Schedule is full (max ${MAX_SCHEDULE_ENTRIES}).` });
|
|
}
|
|
const next = [...schedule, req.body];
|
|
try {
|
|
await saveJSON(next, SCHEDULE_PATH);
|
|
schedule = next;
|
|
logger('POST /schedule', `Appended "${req.body.month}" (index ${schedule.length - 1}).`);
|
|
res.status(201).json({ status: 'Entry appended.', index: schedule.length - 1, count: schedule.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to save schedule.' });
|
|
}
|
|
});
|
|
|
|
// Replace a single entry
|
|
app.put('/schedule/:index', requireAdmin, async (req, res) => {
|
|
if (!req.is('application/json')) {
|
|
return res.status(415).json({ error: 'Content-Type must be application/json.' });
|
|
}
|
|
const idx = Number.parseInt(req.params.index, 10);
|
|
if (!Number.isInteger(idx) || idx < 0 || idx >= schedule.length) {
|
|
return res.status(404).json({ error: `Schedule index ${req.params.index} not found.` });
|
|
}
|
|
const check = validateScheduleEntry(req.body, idx);
|
|
if (!check.ok) return res.status(400).json({ error: check.error });
|
|
const next = schedule.slice();
|
|
next[idx] = req.body;
|
|
try {
|
|
await saveJSON(next, SCHEDULE_PATH);
|
|
schedule = next;
|
|
logger('PUT /schedule/:index', `Updated index ${idx} ("${req.body.month}").`);
|
|
res.status(200).json({ status: 'Entry updated.', index: idx });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to save schedule.' });
|
|
}
|
|
});
|
|
|
|
// Delete a single entry
|
|
app.delete('/schedule/:index', requireAdmin, async (req, res) => {
|
|
const idx = Number.parseInt(req.params.index, 10);
|
|
if (!Number.isInteger(idx) || idx < 0 || idx >= schedule.length) {
|
|
return res.status(404).json({ error: `Schedule index ${req.params.index} not found.` });
|
|
}
|
|
if (schedule.length <= 1) {
|
|
return res.status(400).json({ error: 'Refusing to delete the last remaining schedule entry.' });
|
|
}
|
|
const removed = schedule[idx];
|
|
const next = schedule.slice();
|
|
next.splice(idx, 1);
|
|
try {
|
|
await saveJSON(next, SCHEDULE_PATH);
|
|
schedule = next;
|
|
logger('DELETE /schedule/:index', `Removed index ${idx} ("${removed.month}").`);
|
|
res.status(200).json({ status: 'Entry deleted.', count: schedule.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to save schedule.' });
|
|
}
|
|
});
|
|
|
|
// Preview the exact card text the bot would send for an entry (handy for the admin UI).
|
|
app.post('/preview', requireAdmin, async (req, res) => {
|
|
if (!req.is('application/json')) {
|
|
return res.status(415).json({ error: 'Content-Type must be application/json.' });
|
|
}
|
|
const check = validateScheduleEntry(req.body, 0);
|
|
if (!check.ok) return res.status(400).json({ error: check.error });
|
|
try {
|
|
const preview = await buildCard(req.body);
|
|
res.status(200).json(preview);
|
|
} catch (err) {
|
|
res.status(500).json({ error: `Failed to build preview: ${err.message}` });
|
|
}
|
|
});
|
|
|
|
// Admin UI (public HTML shell — all data endpoints still require the bearer token)
|
|
app.get('/admin', (req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, 'admin.html'), (err) => {
|
|
if (err) {
|
|
logger('GET /admin', `Failed to serve admin.html: ${err.message}`, 'ERROR');
|
|
if (!res.headersSent) res.status(500).send('Admin UI unavailable.');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ====================== WEBEX FRAMEWORK ======================
|
|
// IMPORTANT: We initialize the framework INSIDE main() AFTER loadConfigs()
|
|
|
|
|
|
// Build Adaptive Card + fallback text
|
|
async function buildCard(code) {
|
|
// Your original card + text logic (kept almost identical, minor cleanups)
|
|
var discountCard = {
|
|
"type": "AdaptiveCard",
|
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
"version": "1.3",
|
|
"body": [
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "Associate Discount Code for " + code.month,
|
|
"wrap": true,
|
|
"size": "Medium",
|
|
"horizontalAlignment": "Center",
|
|
"weight": "Bolder"
|
|
},
|
|
{
|
|
"type": "Container",
|
|
"items": [
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "[AE & Aerie](https://www.ae.com)",
|
|
"wrap": true,
|
|
"weight": "Bolder",
|
|
"size": "ExtraLarge",
|
|
"separator": true,
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "None"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "US, Canada, & ROW",
|
|
"wrap": true,
|
|
"weight": "Lighter",
|
|
"size": "Small",
|
|
"separator": true,
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "None"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.aeAerie.usrow.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"size": "ExtraLarge",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.aeAerie.usrow.message,
|
|
"wrap": true,
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"weight": "Lighter",
|
|
"spacing": "Small"
|
|
}
|
|
],
|
|
"separator": true
|
|
},
|
|
{
|
|
"type": "Container",
|
|
"items": [
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "[AE & Aerie](https://www.ae.com/mx/es)",
|
|
"wrap": true,
|
|
"weight": "Bolder",
|
|
"size": "ExtraLarge",
|
|
"separator": true,
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "None"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "Mexico",
|
|
"wrap": true,
|
|
"weight": "Lighter",
|
|
"size": "Small",
|
|
"separator": true,
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "None"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.aeAerie.mexico.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"size": "ExtraLarge",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.aeAerie.mexico.message,
|
|
"wrap": true,
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"weight": "Lighter",
|
|
"spacing": "Small"
|
|
}
|
|
],
|
|
"separator": true
|
|
},
|
|
|
|
{
|
|
"type": "Container",
|
|
"items": [
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "[Todd Snyder](https://www.toddsnyder.com)",
|
|
"wrap": true,
|
|
"weight": "Bolder",
|
|
"size": "ExtraLarge",
|
|
"separator": true,
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "None"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.toddSnyder.usrow.tstc.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"size": "ExtraLarge",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.toddSnyder.usrow.tstc.message,
|
|
"wrap": true,
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"weight": "Lighter",
|
|
"spacing": "Small"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.toddSnyder.usrow.thirdparty.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"size": "ExtraLarge",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.toddSnyder.usrow.thirdparty.message,
|
|
"wrap": true,
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"weight": "Lighter",
|
|
"spacing": "Small"
|
|
}
|
|
],
|
|
"separator": true
|
|
},
|
|
{
|
|
"type": "Container",
|
|
"items": [
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "[Unsubscribed](https://www.unsubscribed.com)",
|
|
"wrap": true,
|
|
"weight": "Bolder",
|
|
"size": "ExtraLarge",
|
|
"separator": true,
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "None"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.unsubscribed.usrow.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"size": "ExtraLarge",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.unsubscribed.usrow.message,
|
|
"wrap": true,
|
|
"weight": "Lighter",
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Small"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.unsubscribed.thirdparty.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"size": "ExtraLarge",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.unsubscribed.thirdparty.message,
|
|
"wrap": true,
|
|
"weight": "Lighter",
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Small"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.unsubscribed.giftcard.code,
|
|
"wrap": true,
|
|
"fontType": "Monospace",
|
|
"weight": "Bolder",
|
|
"horizontalAlignment": "Center",
|
|
"size": "ExtraLarge",
|
|
"spacing": "Padding"
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": code.unsubscribed.giftcard.message,
|
|
"wrap": true,
|
|
"weight": "Lighter",
|
|
"size": "Small",
|
|
"color": "Light",
|
|
"horizontalAlignment": "Center",
|
|
"spacing": "Small"
|
|
}
|
|
],
|
|
"separator": true
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": "[Associate Discount Policies & Codes](https://onfirstup.com/AEO/AEO/contents/25437784) \n\nThe online discount is a benefit to all AEO Corporate and Distribution Center associates as well as Field leadership (RD, DTL, Regional Assistants, Field Visual, Field Auditors, Field Real Estate, and Field HR).",
|
|
"wrap": true,
|
|
"horizontalAlignment": "Center",
|
|
"separator": true
|
|
}
|
|
]
|
|
}
|
|
|
|
var discountText = "# Discount Code for " + code.month + "\n" +
|
|
"## AE/Aerie US, Canada, & ROW [Website](https://www.ae.com/)" + "\n" +
|
|
"`" + code.aeAerie.usrow.code + "`\n" +
|
|
"_" + code.aeAerie.usrow.message + "_\n" +
|
|
"## AE/Aerie Mexico [Website](https://www.ae.com/mx/es)\n" +
|
|
"`" + code.aeAerie.mexico.code + "`\n" +
|
|
"_" + code.aeAerie.mexico.message + "_\n" +
|
|
"- - -\n" +
|
|
"## Todd Snyder [Website](https://www.toddsnyder.com)\n" +
|
|
"`" + code.toddSnyder.usrow.tstc.code + "`\n" +
|
|
"_" + code.toddSnyder.usrow.tstc.message + "_\n" +
|
|
"`" + code.toddSnyder.usrow.thirdparty.code + "`\n" +
|
|
"_" + code.toddSnyder.usrow.thirdparty.message + "_\n" +
|
|
"- - -\n" +
|
|
"## Unsubscribed [Website](https://www.unsubscribed.com)\n" +
|
|
"`" + code.unsubscribed.usrow.code + "`\n" +
|
|
"_" + code.unsubscribed.usrow.message + "_\n" +
|
|
"`" + code.unsubscribed.thirdparty.code + "`\n" +
|
|
"_" + code.unsubscribed.thirdparty.message + "_\n" +
|
|
"`" + code.unsubscribed.giftcard.code + "`\n" +
|
|
"_" + code.unsubscribed.giftcard.message + "_\n\n" +
|
|
"_hese employee discount codes cannot be combined with any other discounts or affiliate marketing links. The online discount is a benefit to all AEO Corporate and Distribution Center associates as well as Field leadership (RD, DTL, Regional Assistants, Field Visual, Field Auditors, Field Real Estate, and Field HR)._";
|
|
|
|
return { card: discountCard, text: discountText };
|
|
}
|
|
|
|
/// Cleanup old logs
|
|
cron.schedule('0 0 * * *', () => {
|
|
cleanOldFiles();
|
|
});
|
|
|
|
// Look ahead in the schedule and alert if no discount code covers the target date.
|
|
// Runs every morning so the user keeps getting pinged until a new code is added.
|
|
async function checkExpiringDiscounts() {
|
|
try {
|
|
const alerting = config.alerting || {};
|
|
const alertEmail = alerting.email || 'mcqueenj@ae.com';
|
|
const daysAhead = Number.isFinite(alerting.daysAhead) ? alerting.daysAhead : 7;
|
|
|
|
if (!Framework || !Framework.webex) {
|
|
logger('checkExpiringDiscounts', 'Framework not ready yet; skipping check.', 'WARN');
|
|
return;
|
|
}
|
|
|
|
const targetDate = new Date(Date.now() + daysAhead * 24 * 3600 * 1000);
|
|
const targetMs = targetDate.getTime();
|
|
const targetLabel = targetDate.toLocaleDateString('en-US');
|
|
|
|
let covering = null;
|
|
for (const entry of schedule) {
|
|
const start = Date.parse(entry.start);
|
|
const end = Date.parse(entry.end);
|
|
if (!isNaN(start) && !isNaN(end) && targetMs >= start && targetMs <= end) {
|
|
covering = entry;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (covering) {
|
|
logger('checkExpiringDiscounts',
|
|
`Code for "${covering.month}" covers ${targetLabel} (${daysAhead}d ahead). No alert needed.`);
|
|
return;
|
|
}
|
|
|
|
const markdown =
|
|
`**DiscountBot alert:** No discount code is loaded for **${targetLabel}** ` +
|
|
`(${daysAhead} days from now). Add the next entry to \`config/schedule.json\` ` +
|
|
`before it lapses. You'll keep getting this reminder every morning until it's added.`;
|
|
|
|
await Framework.webex.messages.create({
|
|
toPersonEmail: alertEmail,
|
|
markdown
|
|
});
|
|
|
|
logger('checkExpiringDiscounts',
|
|
`Alerted ${alertEmail}: no code covers ${targetLabel} (${daysAhead}d ahead).`, 'WARN');
|
|
} catch (err) {
|
|
logger('checkExpiringDiscounts', `Error running check: ${err.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
function cleanOldFiles() {
|
|
try {
|
|
const loggingDir = config.server?.logging?.directory || './logs/';
|
|
|
|
if (!fsSync.existsSync(loggingDir)) {
|
|
logger('cleanOldFiles', `Directory ${loggingDir} does not exist. Skipping cleanup.`, 'WARN');
|
|
return;
|
|
}
|
|
|
|
const files = fsSync.readdirSync(loggingDir);
|
|
|
|
const retentionDays = config.server?.logging?.retensionDays || 30;
|
|
const cutoffTime = Date.now() - (retentionDays * 24 * 3600 * 1000);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(loggingDir, file);
|
|
const fileStats = fsSync.statSync(filePath);
|
|
|
|
if (fileStats.mtime.getTime() <= cutoffTime) {
|
|
logger('cleanOldFiles', `Removing old log: ${file}`);
|
|
fsSync.unlinkSync(filePath);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger('cleanOldFiles', `Error during cleanup: ${error.message}`, 'WARN');
|
|
}
|
|
}
|
|
|
|
// ====================== START EVERYTHING ======================
|
|
async function main() {
|
|
try {
|
|
await loadConfigs();
|
|
|
|
// === Inject BOT_ACCESS_TOKEN from .env ===
|
|
if (!process.env.BOT_ACCESS_TOKEN) {
|
|
logger('startup', 'ERROR: BOT_ACCESS_TOKEN is missing from .env file!', 'ERROR');
|
|
console.error('→ Create .env.development with BOT_ACCESS_TOKEN=your_dev_token_here');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Safely build the framework options
|
|
if (!config.auth) config.auth = {};
|
|
if (!config.auth.webex) config.auth.webex = {};
|
|
if (!config.auth.webex.bot) config.auth.webex.bot = {};
|
|
|
|
config.auth.webex.bot.token = process.env.BOT_ACCESS_TOKEN;
|
|
|
|
logger('startup', `Bot token injected successfully (${process.env.NODE_ENV || 'development'} mode)`);
|
|
|
|
if (!process.env.ADMIN_TOKEN) {
|
|
logger('startup',
|
|
'ADMIN_TOKEN is not set — /admin, /members, and /schedule endpoints will return 503. ' +
|
|
'Add ADMIN_TOKEN=<random> to .env to enable.', 'WARN');
|
|
} else {
|
|
logger('startup', 'ADMIN_TOKEN detected — admin endpoints enabled.');
|
|
}
|
|
|
|
// Optional: Support webhook URL from env (great for ngrok in dev)
|
|
if (process.env.WEBHOOK_URL) {
|
|
config.auth.webex.bot.webhookUrl = process.env.WEBHOOK_URL;
|
|
logger('startup', `Using webhook URL: ${process.env.WEBHOOK_URL}`);
|
|
}
|
|
|
|
// Now it's safe to create the framework (module-scoped so scheduled jobs can use it)
|
|
Framework = new framework(config.auth.webex.bot);
|
|
|
|
Framework.start();
|
|
logger('startup', 'Starting Webex framework...');
|
|
|
|
Framework.on('initialized', () => {
|
|
logger('framework', 'Initialized and ready! [Press CTRL-C to quit]');
|
|
checkExpiringDiscounts();
|
|
});
|
|
|
|
// Daily discount-expiration check (defaults to 6am America/New_York).
|
|
const alertingCfg = config.alerting || {};
|
|
const alertCron = alertingCfg.cron || '0 6 * * *';
|
|
const alertTz = alertingCfg.timezone || 'America/New_York';
|
|
cron.schedule(alertCron, checkExpiringDiscounts, { timezone: alertTz });
|
|
logger('startup',
|
|
`Discount-expiration alerts scheduled (${alertCron} ${alertTz}, ` +
|
|
`${alertingCfg.daysAhead ?? 7}d ahead → ${alertingCfg.email || 'mcqueenj@ae.com'}).`);
|
|
|
|
Framework.on('membershipRulesAction', (type, event, bot, id, ...args) => {
|
|
logger('membershipRules', `Type: ${type}, Event: ${event} in space "${bot.room?.title || 'unknown'}"`);
|
|
});
|
|
|
|
Framework.hears(/discount|code/i, async (bot, trigger) => {
|
|
responded = true;
|
|
const person = trigger.person;
|
|
const nameOrUsername = (person.displayName || '').toLowerCase();
|
|
const username = (person.userName || '').toLowerCase();
|
|
|
|
logger('hears/discount', `${person.displayName} (${username}) requested a code.`);
|
|
|
|
if (!authorizedMembers.includes(nameOrUsername) && !authorizedMembers.includes(username)) {
|
|
logger('hears/discount', `${person.displayName} failed authorization.`, 'WARN');
|
|
bot.reply(trigger.message, 'You are not authorized to access discount codes. Contact support if this is an error.');
|
|
bot.dm('mcqueenj@ae.com', `${person.displayName} requested a discount code but was not authorized.`);
|
|
return;
|
|
}
|
|
|
|
logger('hears/discount', `${person.displayName} is authorized.`);
|
|
|
|
const now = Date.now();
|
|
let found = false;
|
|
|
|
for (let idx = 0; idx < schedule.length; idx++) {
|
|
const entry = schedule[idx];
|
|
const start = Date.parse(entry.start);
|
|
const end = Date.parse(entry.end);
|
|
|
|
if (!isNaN(start) && !isNaN(end) && now >= start && now <= end) {
|
|
found = true;
|
|
try {
|
|
const discount = await buildCard(entry);
|
|
await bot.sendCard(discount.card, discount.text);
|
|
} catch (err) {
|
|
logger('buildCard', `Error for entry ${idx}: ${err.message}`, 'ERROR');
|
|
}
|
|
break; // Send only the first matching period
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
logger('hears/discount', 'No active code found for current date.');
|
|
bot.dm('mcqueenj@ae.com', 'No active discount code found for current date.');
|
|
}
|
|
|
|
// Log request to monthly file
|
|
try {
|
|
const d = new Date();
|
|
const fileName = `requests-${d.getFullYear()}-${d.getMonth() + 1}.log`;
|
|
const logPath = path.join('./logs/', fileName);
|
|
const stamp = `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()} ${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
|
await fs.appendFile(logPath, `${stamp}: ${person.displayName},${username}\n`);
|
|
} catch (err) {
|
|
logger('logRequest', `Failed to write request log: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
// Attachment actions (if you add buttons later)
|
|
Framework.on('attachmentAction', (bot, trigger) => {
|
|
logger('attachmentAction', `Received from ${trigger.person?.displayName}`);
|
|
// Add logic here if your cards ever have Action.Submit buttons
|
|
});
|
|
|
|
// Help command
|
|
Framework.hears(/help|what can i (do|say)|what (can|do) you do/i, (bot) => {
|
|
responded = true;
|
|
sendHelp(bot);
|
|
});
|
|
|
|
// Catch-all - must be the LAST hears() handler
|
|
Framework.hears(/.*/, (bot, trigger) => {
|
|
if (!responded) {
|
|
logger('catch-all', `Unknown command: ${trigger.text}`);
|
|
bot.say(`Sorry, I don't understand that command: "${trigger.text}". Try "help" or "code".`)
|
|
.then(() => sendHelp(bot))
|
|
.catch(e => logger('catch-all', e.message, 'ERROR'));
|
|
}
|
|
responded = false; // reset for next message
|
|
});
|
|
|
|
function sendHelp(bot) {
|
|
bot.say('markdown', 'Say **code** or **discount** to get the current associate discount codes.');
|
|
}
|
|
|
|
// Start Express server
|
|
const serverPort = process.env.PORT || config.server.port || 1977;
|
|
app.listen(serverPort, () => {
|
|
logger('startup', `${config.server.name} running on port ${serverPort}.`);
|
|
});
|
|
|
|
// Optional one-time cleanup on start
|
|
cleanOldFiles();
|
|
|
|
} catch (err) {
|
|
logger('startup', `Fatal error: ${err.message}`, 'ERROR');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
logger('shutdown', 'Stopping DiscountBot...');
|
|
try {
|
|
await Framework.stop();
|
|
} catch (e) { }
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
main(); |