discount/index.js
2026-07-01 13:27:52 -04:00

619 lines
No EOL
20 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 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 app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// 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
async function saveJSON(jsonObject, filePath) {
try {
const jsonData = JSON.stringify(jsonObject, null, 4);
await fs.writeFile(filePath, jsonData, 'utf8');
logger('saveJSON', `Successfully wrote ${filePath}`);
} catch (err) {
logger('saveJSON', `Error writing ${filePath}: ${err.message}`);
throw err;
}
}
// 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('./config/config.json');
schedule = await loadJSON('./config/schedule.json');
authorizedMembers = (await loadJSON('./config/authorized.json')).map(item =>
typeof item === 'string' ? item.toLowerCase() : item
);
logger('startup', `Configs loaded. ${authorizedMembers.length} authorized members.`);
} 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.`
});
});
app.post('/members', async (req, res) => {
try {
await saveJSON(req.body, './config/authorized.json');
authorizedMembers = (req.body || []).map(item =>
typeof item === 'string' ? item.toLowerCase() : item
);
logger('POST /members', 'Authorized users updated.');
res.status(200).json({ status: 'Authorized users updated.' });
} catch (err) {
res.status(500).json({ error: 'Failed to update authorized users.' });
}
});
// ====================== 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 key in schedule) {
const entry = schedule[key];
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)`);
// 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 (const key in schedule) {
const entry = schedule[key];
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 ${key}: ${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();