import existing project
This commit is contained in:
commit
a64f82a2ea
13 changed files with 19871 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
# Environment / secrets — leave these ignored unless you've verified no secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# ====================== BUILD STAGE ======================
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files first for better caching
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy only the application code (NO config folder)
|
||||||
|
COPY index.js ./
|
||||||
|
|
||||||
|
# ====================== PRODUCTION STAGE ======================
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S -u 1001 -G nodejs nodejs
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built artifacts from builder stage
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/index.js ./
|
||||||
|
|
||||||
|
# Create logs directory (will be mounted)
|
||||||
|
RUN mkdir -p /app/logs && chown nodejs:nodejs /app/logs
|
||||||
|
|
||||||
|
# Default environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=1977
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 1977
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:1977/status || exit 1
|
||||||
|
|
||||||
|
# Start the bot
|
||||||
|
CMD ["node", "index.js"]
|
||||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# discount
|
||||||
4834
config/authorized.json
Normal file
4834
config/authorized.json
Normal file
File diff suppressed because it is too large
Load diff
23
config/config.json
Normal file
23
config/config.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"name": "discount"
|
||||||
|
},
|
||||||
|
"alerting": {
|
||||||
|
"email": "mcqueenj@ae.com",
|
||||||
|
"daysAhead": 7,
|
||||||
|
"cron": "0 6 * * *",
|
||||||
|
"timezone": "America/New_York"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"webex": {
|
||||||
|
"bot": {
|
||||||
|
"restrictedToEmailDomains": "ae.com,toddsnyder.com,quietplatforms.com",
|
||||||
|
"name": "discount",
|
||||||
|
"userName": "discount@webex.bot",
|
||||||
|
"webhookRequestJSONLocation": "body",
|
||||||
|
"removeWebhooksOnStart": true,
|
||||||
|
"removeDeviceRegistrationsOnStart": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
330
config/schedule.json
Normal file
330
config/schedule.json
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"month": "January 2025",
|
||||||
|
"start": "1/1/26",
|
||||||
|
"end": "2/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "37C59QT1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "14Q65XM1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-3me5u4",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-40",
|
||||||
|
"message": "**40% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "February 2026",
|
||||||
|
"start": "2/1/26",
|
||||||
|
"end": "3/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "56L45VR1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "36452S91",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-3me5u4",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-40",
|
||||||
|
"message": "**40% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "March 2026",
|
||||||
|
"start": "3/1/26",
|
||||||
|
"end": "4/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "46958SM1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "00324JY1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-82tc9e",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-40",
|
||||||
|
"message": "**40% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "April 2026",
|
||||||
|
"start": "4/1/26",
|
||||||
|
"end": "5/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "26J62VP1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "99N11XB1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-82tc9e",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-50",
|
||||||
|
"message": "**50% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "May 2026",
|
||||||
|
"start": "5/1/26",
|
||||||
|
"end": "6/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "46F49RE1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "50145F11",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-82tc9e",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-50",
|
||||||
|
"message": "**50% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "June 2026",
|
||||||
|
"start": "6/1/26",
|
||||||
|
"end": "7/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "71203B51",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "32L26AQ1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-82tc9e",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-50",
|
||||||
|
"message": "**50% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "July 2026",
|
||||||
|
"start": "7/1/26",
|
||||||
|
"end": "8/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "32C51LL1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "45H22MS1",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-82tc9e",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-50",
|
||||||
|
"message": "**50% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "August 2026",
|
||||||
|
"start": "8/1/26",
|
||||||
|
"end": "9/1/26",
|
||||||
|
"aeAerie": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "64V64K61",
|
||||||
|
"message": "**40% off discount**: Valid on all AE, Aerie and Tailgate presentation merchandise & **25% off discount**: Valid on Dormify merchandise, clearance (AE/Aerie/Tailgate), 3rd Party + Gift Cards"
|
||||||
|
},
|
||||||
|
"mexico": {
|
||||||
|
"code": "32E68V81",
|
||||||
|
"message": "**40% off discount**: Valid on all AE and Aerie presentation merchandise & **25% off discount**: Valid on all Tailgate merchandise. Valid on all clearance (AE/Aerie/Tailgate) + Gift Cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toddSnyder": {
|
||||||
|
"usrow": {
|
||||||
|
"tstc": {
|
||||||
|
"code": "AEO-82tc9e",
|
||||||
|
"message": "**50% off Todd Snyder & TS Collections**\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "5RNZXMV4X8AE",
|
||||||
|
"message": "**25% off 3rd Party brands** (excluding Aesop, Moscot & Nike)\nMust create profile with @ae.com email address. Free shipping with code: EMP_free_shipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsubscribed": {
|
||||||
|
"usrow": {
|
||||||
|
"code": "EMP-UNSUBSCRIBED-50",
|
||||||
|
"message": "**50% off all UNSUBSCRIBED**"
|
||||||
|
},
|
||||||
|
"thirdparty": {
|
||||||
|
"code": "EMP-THIRDPARTY-25",
|
||||||
|
"message": "**25% off all THIRD PARTY**"
|
||||||
|
},
|
||||||
|
"giftcard": {
|
||||||
|
"code": "EMP-GIFTCARD",
|
||||||
|
"message": "**40% off Gift Cards**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
discountbot:
|
||||||
|
build: .
|
||||||
|
container_name: discountbot
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1977:1977"
|
||||||
|
env_file:
|
||||||
|
- .env.production # Use .env.development when testing locally
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs # Persist logs
|
||||||
|
- ./config:/app/config # ← Configs stay on your Mac and are mounted
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:1977/status"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
597
index copy.js
Normal file
597
index copy.js
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
*****************************************************************
|
||||||
|
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
|
||||||
|
|||||||||||||||||||||||||||||
|
||||||
|
| "||,,,,. "|" .,,,,||" ____
|
||||||
|
| .d8888b. .d8888b. ( ) _____
|
||||||
|
/|\ o8' o '8o o8'o `8o | | ( )
|
||||||
|
||| o8. .8o o8. .8o | | _______ | |
|
||||||
|
`Y8888P' `Y8888P' | |( )| |
|
||||||
|
,||''|| \ / ||''||, | || || |
|
||||||
|
,|| ||, \ / .|| ||, | || || |
|
||||||
|
|| || ` || || | || || |
|
||||||
|
,|| '|| ||' ||, | || || | _
|
||||||
|
|| '|| ||' || | || || || \
|
||||||
|
|| |; ;| || | || || || '
|
||||||
|
|| ,| |, || | || || || '
|
||||||
|
||, ,|| ||, ,|| | || || || |
|
||||||
|
||, ,||| |||, ,|| | || || || |
|
||||||
|
'||,,||||,...,||||,,|| | || || ||___|_
|
||||||
|
`|||..."|||"...|||' (____)(_______)(_____)|____|
|
||||||
|
|%%%%%%%%WWWW%%%%%%WWWW%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%|
|
||||||
|
`""""""""""3$F""""#$F""""""""""""""""""""""""""""""""""""""""'
|
||||||
|
@$.... '$B
|
||||||
|
d$$$$$$$$$$:
|
||||||
|
````````````
|
||||||
|
Basebot by Joe McQueen - 2020
|
||||||
|
|
||||||
|
Purpose: Template for bots
|
||||||
|
|
||||||
|
To do:
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
//Webex Framework APIs
|
||||||
|
import fs 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';
|
||||||
|
|
||||||
|
var config = JSON.parse(fs.readFileSync('./config/config.json'));
|
||||||
|
var schedule = JSON.parse(fs.readFileSync('./config/schedule.json'));
|
||||||
|
var authorizedMembers = JSON.parse(fs.readFileSync('./config/authorized.json'));
|
||||||
|
|
||||||
|
var app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
//Main function to run on startUp
|
||||||
|
var server = app.listen(config.server.port, function () { logger("startup", config.server.name + " running on port " + config.server.port + ".") });
|
||||||
|
|
||||||
|
app.get('/status', function (req, res) {
|
||||||
|
res.status(200).send({ "status": `Alive and kicking. ${authorizedMembers.length} total names in authorizedMembers.` });
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/members', function (req, res) {
|
||||||
|
saveJSON(req.body, ('./config/authorized.json'))
|
||||||
|
|
||||||
|
res.status(200).send({ "status": "Authorized users updated." });
|
||||||
|
authorizedMembers = JSON.parse(fs.readFileSync('./config/authorized.json'));
|
||||||
|
logger(`POST /members`, `Authorized users updated.`)
|
||||||
|
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
/*app.get('/codes', (req, res) => res.json(schedule))
|
||||||
|
|
||||||
|
app.get('/code', function (req, res) {
|
||||||
|
var nowDate = Date.now();
|
||||||
|
//console.log("Now:" + nowDate);
|
||||||
|
for (var codes in schedule) {
|
||||||
|
//console.log(code);
|
||||||
|
var startDate = Date.parse(schedule[codes].start);
|
||||||
|
var endDate = Date.parse(schedule[codes].end);
|
||||||
|
//console.log("Code: " + code + " Start: " + startDate + " End: " + endDate );
|
||||||
|
if ((nowDate.valueOf() <= endDate.valueOf() && nowDate.valueOf() >= startDate.valueOf())) {
|
||||||
|
//console.log(startDate + "\t" + nowDate + "\t" + endDate);
|
||||||
|
var influxPoint = 'discount,type=api count=1';
|
||||||
|
//sendInfluxPoint(influxPoint);
|
||||||
|
buildCard(schedule[codes])
|
||||||
|
.then(discountCode => {
|
||||||
|
res.send(discountCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// init framework
|
||||||
|
var Framework = new framework(config.auth.webex.bot);
|
||||||
|
Framework.start();
|
||||||
|
console.log("Starting framework, please wait...");
|
||||||
|
|
||||||
|
Framework.on("initialized", function () {
|
||||||
|
console.log("framework is all fired up! [Press CTRL-C to quit]");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
//framework.on('log', (msg) => {
|
||||||
|
// console.log(msg);
|
||||||
|
//});
|
||||||
|
|
||||||
|
Framework.on('membershipRulesAction', (type, event, bot, id, ...args) => {
|
||||||
|
console.log(`Framework membershipRulesAction of type:${type}, event:${event} occurred in space "${bot.room.title}".`);
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case ('state-change'):
|
||||||
|
console.log(`Membership Rules forced a "${event}" event`);
|
||||||
|
break;
|
||||||
|
case ('event-swallowed'):
|
||||||
|
if (event === 'spawn') {
|
||||||
|
if (args.length >= 2) {
|
||||||
|
let actorId = args[0];
|
||||||
|
let membershipRuleChange = args[1];
|
||||||
|
let email = membershipRuleChange.membership.personEmail;
|
||||||
|
if (membershipRuleChange && membershipRuleChange.membershipRule === "restrictedToEmailDomains") {
|
||||||
|
console.log(`spawn swallowed. Dissallowed member: ${email}`);
|
||||||
|
} else {
|
||||||
|
console.log(`spawn swallowed. Space membership is missing one of ` +
|
||||||
|
'the users specified in the `guideEmails` framework config parameter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((event === 'memberExits') || (event === 'memberEnters')) {
|
||||||
|
let member = args[0];
|
||||||
|
console.log(`Ignored ${event} for ${member.personEmail} in disallowed space.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ('hears-swallowed'):
|
||||||
|
console.log(`Membership Rules swallowed a "${event}" event`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assert(true === false, `Got unexpected membershipsRules type: ${type}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed processing mebershipRulesAction event "${event}": ${e.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//Process incoming Webex Teams messages
|
||||||
|
let responded = false;
|
||||||
|
/* On mention with command
|
||||||
|
ex User enters @botname help, the bot will write back in markdown
|
||||||
|
*/
|
||||||
|
Framework.hears(/help|what can i (do|say)|what (can|do) you do/i, function (bot, trigger) {
|
||||||
|
console.log(`someone needs help! They asked ${trigger.text}`);
|
||||||
|
responded = true;
|
||||||
|
|
||||||
|
bot.say("markdown","Say the word 'code' or 'discount' and press enter.")
|
||||||
|
});
|
||||||
|
|
||||||
|
Framework.hears(/discount|code/i, function (bot, trigger) {
|
||||||
|
responded = true;
|
||||||
|
//console.log(trigger);
|
||||||
|
logger(`Framework.hears(/discount|code/i`, `${trigger.person.displayName} (${trigger.person.userName}) requested a code.`)
|
||||||
|
var nowDate = Date.now();
|
||||||
|
|
||||||
|
if (authorizedMembers.includes(trigger.person.displayName.toLowerCase()) || authorizedMembers.includes(trigger.person.userName.toLowerCase())) {
|
||||||
|
logger(`Framework.hears(/discount|code/i`, `${trigger.person.displayName} (${trigger.person.userName}) authorized.`)
|
||||||
|
var found = false;
|
||||||
|
for (var codes in schedule) {
|
||||||
|
var startDate = Date.parse(schedule[codes].start);
|
||||||
|
var endDate = Date.parse(schedule[codes].end);
|
||||||
|
if ((nowDate.valueOf() <= endDate.valueOf() && nowDate.valueOf() >= startDate.valueOf())) {
|
||||||
|
found = true;
|
||||||
|
|
||||||
|
buildCard(schedule[codes])
|
||||||
|
.then(discountCode => {
|
||||||
|
bot.sendCard(discountCode.card, discountCode.text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
bot.dm("mcqueenj@ae.com", "markdown", "No code found")
|
||||||
|
}
|
||||||
|
|
||||||
|
let d = new Date();
|
||||||
|
let fileName = "requests-" + d.getFullYear() + "-" + (d.getMonth() + 1) + ".log";
|
||||||
|
let p = path.join('./logs/', fileName);
|
||||||
|
var stampData = d.getHours() + ":" + d.getMinutes() + ":" + d.getSeconds() + " " + (d.getMonth() + 1) + "/" + d.getDate() + "/" + d.getFullYear();
|
||||||
|
fs.appendFileSync(p, stampData + ": " + trigger.person.displayName + "," + trigger.person.userName + "\n");
|
||||||
|
} else {
|
||||||
|
logger(`Framework.hears(/discount|code/i`, `${trigger.person.displayName} (${trigger.person.userName}) failed authorization.`);
|
||||||
|
bot.reply(trigger.message, 'You are not authorized to access discount codes. If you believe this is in error, please contact the support center.');
|
||||||
|
bot.dm('mcqueenj@ae.com', `${trigger.person.displayName} requested a discount code but was not authorized.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process a submitted card
|
||||||
|
Framework.on('attachmentAction', function (bot, trigger) {
|
||||||
|
responded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* On mention with unexpected bot command
|
||||||
|
Its a good practice is to gracefully handle unexpected input
|
||||||
|
*/
|
||||||
|
Framework.hears(/.*/, function (bot, trigger) {
|
||||||
|
// This will fire for any input so only respond if we haven't already
|
||||||
|
if (!responded) {
|
||||||
|
console.log(`catch-all handler fired for user input: ${trigger.text}`);
|
||||||
|
bot.say(`Sorry, I don't know how to answer that command: ${trigger.text}`)
|
||||||
|
.then(() => sendHelp(bot))
|
||||||
|
.catch((e) => console.error(`Problem in the unexepected command hander: ${e.message}`));
|
||||||
|
}
|
||||||
|
responded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendHelp(bot) {
|
||||||
|
bot.sendCard(helpCard, "Discount provides the latest discount codes for employees of AE, Aerie, Tailgate, and Todd Snyder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// gracefully shutdown (ctrl-c)
|
||||||
|
process.on('SIGINT', function () {
|
||||||
|
Framework.debug('Stopping DiscountBot...');
|
||||||
|
server.close();
|
||||||
|
Framework.stop().then(function () {
|
||||||
|
process.exit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildCard(code) {
|
||||||
|
return new Promise(async function (resolve, reject) {
|
||||||
|
|
||||||
|
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)._";
|
||||||
|
|
||||||
|
resolve({ "card": discountCard, "text": discountText })
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function logger(activeFunction, logLine) {
|
||||||
|
var d = new Date();
|
||||||
|
console.log(d.toLocaleString() + " " + activeFunction + ": " + logLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOldFiles() {
|
||||||
|
try {
|
||||||
|
var files = fs.readdirSync(config.server.logging.directory);
|
||||||
|
} catch (error) {
|
||||||
|
logger('clearnOldFiles', `Error: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var file of files) {
|
||||||
|
var fileStats = fs.statSync(config.server.logging.directory + file);
|
||||||
|
if (new Date(fileStats.mtime).getTime() <= new Date().getTime() - (config.server.logging.retensionDays * 24 * 3600 * 1000)) {
|
||||||
|
logger('cleanOldFiles', `Removing file '${config.server.logging.directory + file}'`)
|
||||||
|
fs.unlinkSync(config.server.logging.directory + file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJSONObject(obj) {
|
||||||
|
return obj !== null
|
||||||
|
&&
|
||||||
|
typeof obj === 'object'
|
||||||
|
&&
|
||||||
|
obj.constructor === Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshToken() {
|
||||||
|
logger('refreshToken', `Checking token.`)
|
||||||
|
if (new Date(new Date(config.serviceAccount.authorization.expiresOn) - 7200000) < new Date()) {
|
||||||
|
logger(`refreshToken`, `Token needs refreshed.`)
|
||||||
|
var url = "https://webexapis.com/v1/access_token";
|
||||||
|
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.append('grant_type', 'refresh_token');
|
||||||
|
params.append('client_id', config.serviceAccount.authorization.clientId);
|
||||||
|
params.append('client_secret', config.serviceAccount.authorization.clientSecret);
|
||||||
|
params.append('refresh_token', config.serviceAccount.authorization.token.refresh_token);
|
||||||
|
|
||||||
|
fetch(url, { method: 'post', body: params })
|
||||||
|
.then(res => { return res.json() })
|
||||||
|
.then(function (json) {
|
||||||
|
console.log(JSON.stringify(json))
|
||||||
|
config.serviceAccount.authorization.created = new Date().toTimeString;
|
||||||
|
config.serviceAccount.authorization.token = json;
|
||||||
|
config.serviceAccount.authorization.expiresOn = new Date(Date.now() + (json.expires_in * 1000));
|
||||||
|
config.serviceAccount.authorization.refreshBy = new Date(Date.now() + (json.refresh_token_expires_in * 1000));
|
||||||
|
logger(`refreshToken`, `Token refreshed. Expires on ${config.serviceAccount.authorization.expiresOn.toLocaleString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})}.`)
|
||||||
|
saveJSON(config, './config/config.json')
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
logger(`refreshToken`, `Error: ${error}`)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger(`refreshToken`, `Token is good until ${new Date(config.serviceAccount.authorization.expiresOn).toLocaleString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveJSON(jsonObject, configFile) {
|
||||||
|
return new Promise(async function (resolve, reject) {
|
||||||
|
|
||||||
|
const jsonData = JSON.stringify(jsonObject, null, 4);
|
||||||
|
fs.writeFileSync(configFile, jsonData, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
throw err;
|
||||||
|
} else {
|
||||||
|
logger("saveJSON", "Wrote " + configFile);
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
619
index.js
Normal file
619
index.js
Normal file
|
|
@ -0,0 +1,619 @@
|
||||||
|
#!/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();
|
||||||
13205
package-lock.json
generated
Normal file
13205
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "discountv3",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --env-file=.env.development index.js",
|
||||||
|
"start": "node index.js",
|
||||||
|
"docker:build": "docker build -t discountbot:latest .",
|
||||||
|
"docker:run": "docker run -p 1977:1977 --env-file .env.production discountbot:latest"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"webex-node-bot-framework": "^2.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/webexCache.js
Normal file
150
src/webexCache.js
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
let cachedMembers = [];
|
||||||
|
let cacheInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all members for a single Webex SCIM group, handling SCIM pagination.
|
||||||
|
* @param {string} groupId - The ID of the group to fetch.
|
||||||
|
* @param {string} accessToken - A Webex admin access token.
|
||||||
|
* @returns {Promise<Array>} A promise that resolves with an array of member objects.
|
||||||
|
*/
|
||||||
|
const getSingleGroupMembers = async (groupId, accessToken) => {
|
||||||
|
const members = [];
|
||||||
|
const maxPerPage = 500;
|
||||||
|
let startIndex = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
const url = `https://webexapis.com/v1/groups/${groupId}/members`;
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
console.log(`Fetching members for group ${groupId}, page starting at index ${startIndex}...`);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
startIndex: startIndex,
|
||||||
|
count: maxPerPage,
|
||||||
|
memberType: 'user' // Ensure you only fetch user members
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}?${params.toString()}`, { headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error fetching group ${groupId}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
//console.log(JSON.stringify(data))
|
||||||
|
// Correctly access the `members` property directly from the top-level response
|
||||||
|
const groupMembers = data.members || [];
|
||||||
|
|
||||||
|
if (groupMembers.length > 0) {
|
||||||
|
groupMembers.forEach(member => {
|
||||||
|
if (member.type === 'user' && member.value && member.display) {
|
||||||
|
members.push({
|
||||||
|
id: member.id,
|
||||||
|
displayName: member.display,
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for more pages
|
||||||
|
hasMore = (startIndex + maxPerPage) <= data.totalResults;
|
||||||
|
startIndex += maxPerPage;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing group ${groupId}:`, error.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch email addresses for each member after collecting all user IDs
|
||||||
|
if (members.length > 0) {
|
||||||
|
console.log(`Fetching email addresses for ${members.length} members in group ${groupId}...`);
|
||||||
|
const emailPromises = members.map(member =>
|
||||||
|
fetchUserEmail(member.id, accessToken).then(email => {
|
||||||
|
member.email = email;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.allSettled(emailPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
return members;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single user's email address using the People API.
|
||||||
|
* @param {string} userId - The ID of the user.
|
||||||
|
* @param {string} accessToken - A Webex admin access token.
|
||||||
|
* @returns {Promise<string|null>} A promise that resolves with the user's email or null on failure.
|
||||||
|
*/
|
||||||
|
const fetchUserEmail = async (userId, accessToken) => {
|
||||||
|
const url = `https://webexapis.com/v1/people/${userId}`;
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Could not fetch email for user ${userId}. Status: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(JSON.stringify(data))
|
||||||
|
return data.emails?.[0] || null; // Use optional chaining and get the first email
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching email for user ${userId}:`, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the cache by fetching all members from the specified groups.
|
||||||
|
* This should be called once at application startup.
|
||||||
|
* @param {string[]} groupIds - An array of Webex group IDs.
|
||||||
|
* @param {string} accessToken - A Webex admin access token.
|
||||||
|
*/
|
||||||
|
export const initializeMemberCache = async (groupIds, accessToken) => {
|
||||||
|
console.log("Initializing Webex member cache...");
|
||||||
|
const newMembers = [];
|
||||||
|
const fetchPromises = groupIds.map(groupId => getSingleGroupMembers(groupId, accessToken));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(fetchPromises);
|
||||||
|
|
||||||
|
results.forEach(result => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
newMembers.push(...result.value);
|
||||||
|
} else {
|
||||||
|
console.error("A group fetch failed:", result.reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedMembers = newMembers;
|
||||||
|
cacheInitialized = true;
|
||||||
|
console.log(`Webex member cache initialized successfully with ${cachedMembers.length} members.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize Webex member cache:", error);
|
||||||
|
cacheInitialized = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a user against the cached list of authorized members.
|
||||||
|
* @param {string} userEmail - The email of the user to validate.
|
||||||
|
* @returns {boolean} True if the user is in the cache, false otherwise.
|
||||||
|
*/
|
||||||
|
export const isUserAuthorized = (userEmail) => {
|
||||||
|
if (!cacheInitialized) {
|
||||||
|
console.warn("Attempted to validate user before cache was initialized.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cachedMembers.some(member => member.email && member.email === userEmail);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue