#!/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= 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();