Initial commit: multi-bot CollabCentral

Extends the single-Novi codebase into a multi-bot mass-messenger where
each bot has its own token, avatar, label, and per-user authorization.

- Secrets moved out of config.json: per-bot tokens in gitignored
  config/botTokens.json (with enabled flag), service-account OAuth in
  gitignored config/token.json (rewritten by refresh cron), integration
  and Google keys in .env.
- Single Webex integration handles OAuth for all bots via a per-app
  redirect URI derived from OAUTH_CALLBACK_URL_TEMPLATE.
- New requireBot middleware and getBotConfig helper reject requests for
  unknown or disabled bots at the /CollabCentral/:app boundary.
- New /info endpoint plus dynamic frontend loading (sendMessage,
  monitorJobs) so pages self-describe per bot, including bot avatar
  fetched from Webex /people/me at startup.
- Job draft state keyed by cookieId + appName so each bot has its own
  building queue; job list/detail endpoints filter by appName so users
  only see jobs from bots they are authorized on.
- New jobDetail page for a readable per-job view; completed jobs are
  retained for 30 days by the cleanup cron.
- Completion adaptive cards use per-bot avatar and label.
- Miscellaneous fixes: off-by-two in the send loop, removed three dead
  send/process variants, added defensive init for jobs.* on load,
  dropped the deprecated crypto npm shim, and cleaned up stray logger
  labels and typos.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph B. McQueen 2026-07-01 17:53:07 -04:00
commit e604c7e9c9
27 changed files with 4454 additions and 0 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
node_modules
logs
uploads
# Config is mounted at runtime (-v ./config:/usr/src/app/config). Never bake it
# into the image, especially secrets like .env / botTokens.json / token.json.
.env
config
# Misc
testData.json
.DS_Store
*.log
.git

26
.env.example Normal file
View file

@ -0,0 +1,26 @@
# --- Server ----------------------------------------------------------------
SERVER_PORT=1450
# --- OAuth callback URL template ------------------------------------------
# The `:app` placeholder is replaced per-request with the bot's appName.
# IMPORTANT: every bot's resolved URL must be registered as an allowed
# redirect URI in the Webex integration portal
# (developer.webex.com -> My Apps -> <integration> -> Redirect URIs).
#
# Example: with the template below and bots `novi` and `techupdates`, register:
# https://bot.example.com/CollabCentral/novi/oauth
# https://bot.example.com/CollabCentral/techupdates/oauth
#
# When you add a new bot, register its redirect URI before users try to log in.
OAUTH_CALLBACK_URL_TEMPLATE=https://bot.example.com/CollabCentral/:app/oauth
# --- Webex integration (single integration used by all bots' OAuth) -------
WEBEX_INTEGRATION_CLIENT_ID=your-integration-client-id
WEBEX_INTEGRATION_CLIENT_SECRET=your-integration-client-secret
# --- Webex service account (used for group/people lookups) ----------------
WEBEX_SERVICE_ACCOUNT_CLIENT_ID=your-service-account-client-id
WEBEX_SERVICE_ACCOUNT_CLIENT_SECRET=your-service-account-client-secret
# --- Google Translate -----------------------------------------------------
GOOGLE_TRANSLATE_API_KEY=your-google-translate-api-key

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
node_modules/
# Secrets
.env
config/botTokens.json
config/token.json
# Runtime data
config/jobs.json
config/jobs copy.json
config/userPrefs.json
uploads/*
!uploads/.gitkeep
# Misc
testData.json
.DS_Store
*.log
logs/

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:20
# Create app directory
WORKDIR /usr/src/app
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
RUN npm install
# If you are building your code for production
# RUN npm ci --only=production
# Bundle app source
COPY . .
EXPOSE 1450
CMD [ "node", "index.js" ]

View file

@ -0,0 +1,10 @@
{
"novi": {
"token": "REPLACE-WITH-NOVI-BOT-TOKEN",
"enabled": true
},
"techupdates": {
"token": "REPLACE-WITH-TECHUPDATES-BOT-TOKEN",
"enabled": false
}
}

265
config/config.json Normal file
View file

@ -0,0 +1,265 @@
{
"server": {
"name": "CollabCentral",
"port": "1450"
},
"languages": [],
"webex": {
"bot": {
"novi": {
"label": "Novi Communicator",
"authorized": {
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU",
"displayName": "Joe McQueen",
"email": "mcqueenj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600",
"groups": [
{
"name": "Group: Audio Visual",
"alias": "AV Team",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZTQ3NmY3MjktZWViYi00MDI3LWFlMTctNzc1YWI1ZDgzYmNhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: South Side Campus",
"alias": "SSW Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: IT Warrendale",
"alias": "Warrendale",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: New York Design Office Personnel",
"alias": "NY Design Center",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: 34th Street Office Personnel",
"alias": "34th Street",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: San Francisco Personnel",
"alias": "San Francisco Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus",
"alias": "Ottawa Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Campus",
"alias": "Hazleton Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Canada Distribution Center",
"alias": "Canada DC",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Mexico Office Personnel",
"alias": "Mexico Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hong Kong Office",
"alias": "Hong Kong Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Mgmt",
"alias": "Hazleton Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus Mgmt",
"alias": "Ottawa Campus Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: MDC DC Mgmt",
"alias": "Canada DC Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS84MWUzMzExMi1jYWZlLTQ0NWYtOGE5OS01MDI3Mzg4ZDQwNGM": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS84MWUzMzExMi1jYWZlLTQ0NWYtOGE5OS01MDI3Mzg4ZDQwNGM",
"displayName": "Megan Reinecker",
"email": "reineckerm@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~81e33112-cafe-445f-8a99-5027388d404c~c5234572b50e49ff884c45b3ea0e3fea~1600",
"groups": [
{
"name": "Group: South Side Campus",
"alias": "SSW Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: IT Warrendale",
"alias": "Warrendale",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: New York Design Office Personnel",
"alias": "NY Design Center",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: 34th Street Office Personnel",
"alias": "34th Street",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: San Francisco Personnel",
"alias": "San Francisco Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus",
"alias": "Ottawa Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Campus",
"alias": "Hazleton Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Canada Distribution Center",
"alias": "Canada DC",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Mexico Office Personnel",
"alias": "Mexico Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hong Kong Office",
"alias": "Hong Kong Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Mgmt",
"alias": "Hazleton Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus Mgmt",
"alias": "Ottawa Campus Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: MDC DC Mgmt",
"alias": "Canada DC Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
},
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NmZkYWEzYy03YTNkLTRjY2QtYWU4Ny0xYzIzNmIyNGE0Zjg": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NmZkYWEzYy03YTNkLTRjY2QtYWU4Ny0xYzIzNmIyNGE0Zjg",
"displayName": "Jayna Catalina",
"email": "catalinaj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~cf803b9485e536eece6e2f2e541f8ab8691867a0765656a0ebb28ba4b74d7bca~8afff234e4f045ad9fa92af6f8d082c0~1600",
"groups": [
{
"name": "Group: South Side Campus",
"alias": "SSW Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: IT Warrendale",
"alias": "Warrendale",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: New York Design Office Personnel",
"alias": "NY Design Center",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: 34th Street Office Personnel",
"alias": "34th Street",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: San Francisco Personnel",
"alias": "San Francisco Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus",
"alias": "Ottawa Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Campus",
"alias": "Hazleton Campus",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Canada Distribution Center",
"alias": "Canada DC",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Mexico Office Personnel",
"alias": "Mexico Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hong Kong Office",
"alias": "Hong Kong Office",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Hazleton Mgmt",
"alias": "Hazleton Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: Ottawa Campus Mgmt",
"alias": "Ottawa Campus Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
},
{
"name": "Group: MDC DC Mgmt",
"alias": "Canada DC Mgmt",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
}
}
},
"techupdates": {
"label": "Technology Updates",
"authorized": {
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU": {
"id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU",
"displayName": "Joe McQueen",
"email": "mcqueenj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600",
"groups": [
{
"name": "Group: Audio Visual",
"alias": "AV Team",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZTQ3NmY3MjktZWViYi00MDI3LWFlMTctNzc1YWI1ZDgzYmNhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
}
}
}
},
"integration": {
"integrationId": "Y2lzY29zcGFyazovL3VzL0FQUExJQ0FUSU9OL0MzZTQ5NDUwZGQ0ZGMxNmY0OWQxNjEyYTVjNGRiZWNhZjY2M2I1ODdmZWEyOGYzYjMwY2QwOGQzNThkNTNjMDQ1",
"integrationName": "CollabCentral"
},
"serviceAccount": {
"appName": "CollabCentral"
}
}
}

18
config/languages.json Normal file
View file

@ -0,0 +1,18 @@
[
{
"name": "spanish",
"key": "es"
},
{
"name": "french",
"key": "fr"
},
{
"name": "japanese",
"key": "ja"
},
{
"name": "chinese",
"key": "zh"
}
]

BIN
html/DirectoryConnector.zip Normal file

Binary file not shown.

BIN
html/aeoPropsBot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

57
html/auth.js Normal file
View file

@ -0,0 +1,57 @@
//console.log(window.location.pathname);
var pathArray = window.location.pathname.split('/');
//console.log(pathArray);
//console.log(pathArray);
var appName = pathArray[2];
//console.log("appName=" + appName);
/*
if (appName == "novi") {
setCookie("appLabel", "Novi Communicator");
} else if (appName == "techupdates") {
setCookie("appLabel", "Technology Updates");
} else {
setCookie("appLabel", "Unknown");
}
setCookie("appName",appName,1);
*/
if (getCookie("id")) {
console.log("Found ID Cookie: " + getCookie("id"));
window.location.href = "/CollabCentral/" + appName + "/sendMessage.html";
} else {
console.log("No ID Cookie found.");
var randomNumber = Math.random().toString();
randomNumber = randomNumber.substring(2, randomNumber.length);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(res => res.text())
.then((res) => {
window.location.href = res + randomNumber;
});
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
function setCookie(name, value, days) {
var expires = "";
if (days) {
var date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}

5
html/index.html Normal file
View file

@ -0,0 +1,5 @@
<html>
<body>
<script src="auth.js"></script>
</body>
</html>

165
html/jobDetail.html Normal file
View file

@ -0,0 +1,165 @@
<html>
<head>
<title>CollabCentral</title>
<link id="appName" rel="icon" type="image/x-icon" style="border-radius: 50%;" href="favicon.ico">
<style>
* { box-sizing: border-box; }
body {
background-color: #FFFFFF;
color: #2F3941;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 10px;
}
.header {
display: block;
background-color: tan;
width: 90%;
height: auto;
text-align: center;
margin: 0 auto;
}
#appInfo {
display: flex;
text-align: center;
align-content: center;
}
.name {
display: block;
text-align: right;
}
.container {
max-width: 1100px;
margin: 1em auto;
padding: 0 1em;
}
h2.jobTitle {
margin: 0.5em 0 0.2em;
}
.subtitle {
color: #6c757d;
margin: 0 0 1em;
}
.summary-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.45em 1.5em;
margin: 1em 0 1.5em;
padding: 1em 1.2em;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.summary-grid dt {
font-weight: 600;
color: #555;
}
.summary-grid dd { margin: 0; }
.section-title {
font-size: 1.05em;
font-weight: 700;
margin: 1.5em 0 0.4em;
color: #2F3941;
}
.message-preview {
border: 1px solid #ddd;
border-radius: 6px;
padding: 1em 1.2em;
background: #fafafa;
margin: 0.4em 0 1em;
}
.message-preview img { max-width: 100%; height: auto; }
.pill {
display: inline-block;
padding: 0.15em 0.7em;
border-radius: 999px;
font-size: 0.85em;
font-weight: 600;
}
.pill.ok { background: #d4edda; color: #155724; }
.pill.err { background: #f8d7da; color: #721c24; }
.pill.pending { background: #fff3cd; color: #856404; }
td { white-space: pre-wrap !important; word-wrap: break-word; }
.hidden { display: none; }
.panel-empty {
padding: 2em;
max-width: 600px;
margin: 2em auto;
text-align: center;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
<script src="https://code.jquery.com/jquery-1.11.2.min.js"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.css" />
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.js"></script>
</head>
<body>
<div class="header">
<div id="appInfo">
<img id="appIcon" style="border-radius: 50%; padding: 10pt;">
<div id="appLabel" style="font-size: 40pt;margin: auto;"></div>
</div>
<div style="text-align: center;"><a id="jobLink" href=''>Back to Jobs</a></div>
<div id="name" style="text-align: right;"></div>
</div>
<div id="unauthorizedPanel" class="panel-empty hidden">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list. Contact the bot's administrator.</p>
</div>
<div id="notFoundPanel" class="panel-empty hidden">
<h2>Job not found</h2>
<p>The job you're looking for doesn't exist, has been purged, or belongs to a different bot.</p>
</div>
<div id="detailPanel" class="container hidden">
<h2 class="jobTitle" id="jobTitle"></h2>
<p class="subtitle" id="subtitle"></p>
<dl class="summary-grid" id="summaryGrid"></dl>
<div class="section-title">Message</div>
<div class="message-preview" id="messagePreview"></div>
<div class="section-title">Recipients (<span id="recipientCount">0</span>)</div>
<table id="recipients" style="width: 100%;">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Send Time</th>
<th>Error</th>
</tr>
</thead>
</table>
</div>
<script src="jobDetail.js"></script>
</body>
</html>

179
html/jobDetail.js Normal file
View file

@ -0,0 +1,179 @@
var pathArray = window.location.pathname.split('/');
var appName = pathArray[2];
var urlParams = new URLSearchParams(window.location.search);
var jobId = urlParams.get('jobId');
if (!getCookie("id")) {
var randomNumber = Math.random().toString().substring(2);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(res => res.text())
.then(res => { window.location.href = res + randomNumber; });
} else {
bootstrap();
}
function bootstrap() {
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label + " — Job #" + (jobId || "?");
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/monitorJobs.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
loadJobDetail();
})
.catch(err => console.error("Failed to load /info:", err));
}
function loadJobDetail() {
if (!jobId) {
document.getElementById("notFoundPanel").classList.remove("hidden");
return;
}
fetch('/CollabCentral/' + appName + '/jobs/detail/' + encodeURIComponent(jobId))
.then(res => {
if (res.status === 404) {
document.getElementById("notFoundPanel").classList.remove("hidden");
return null;
}
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
})
.then(job => {
if (!job) return;
renderJob(job);
document.getElementById("detailPanel").classList.remove("hidden");
})
.catch(err => {
console.error("Failed to load job detail:", err);
document.getElementById("notFoundPanel").classList.remove("hidden");
});
}
function renderJob(job) {
document.getElementById("jobTitle").textContent = "Job #" + (job.jobId || "?");
var subtitle = "Sent by " + (job.senderDisplayName || "unknown");
if (job.startTime) subtitle += " · started " + formatDate(job.startTime);
document.getElementById("subtitle").textContent = subtitle;
var dl = document.getElementById("summaryGrid");
dl.innerHTML = "";
var stats = job.stats || {};
var rows = [
["Job ID", job.jobId],
["Submitted by", job.senderDisplayName],
["Scheduled for", job.scheduledFor ? formatDate(job.scheduledFor) : "Sent immediately"],
["Started", formatDate(job.startTime)],
["Completed", job.endTime ? formatDate(job.endTime) : "In progress"],
["Total duration", stats.totalTime],
["Webex time", stats.webexTime],
["Average per message", stats.averageTime],
["Recipients", (job.memberList || []).length],
["Delivered", formatSuccess(stats)]
];
for (var i = 0; i < rows.length; i++) {
var key = rows[i][0], val = rows[i][1];
if (val === undefined || val === null || val === "") continue;
var dt = document.createElement("dt"); dt.textContent = key;
var dd = document.createElement("dd"); dd.textContent = val;
dl.appendChild(dt); dl.appendChild(dd);
}
var preview = document.getElementById("messagePreview");
var msg = (job.message && job.message.english) || job.message || {};
if (msg.html) {
preview.innerHTML = msg.html;
} else if (msg.markdown) {
preview.textContent = msg.markdown;
} else if (msg.text) {
preview.textContent = msg.text;
} else if (typeof msg.raw === "string") {
preview.textContent = msg.raw;
} else {
preview.innerHTML = "<em>No message content available.</em>";
}
var members = job.memberList || [];
document.getElementById("recipientCount").textContent = members.length;
$('#recipients').dataTable({
data: members.map(formatRecipientRow),
columns: [
{ data: 'displayName' },
{ data: 'email' },
{ data: 'status' },
{ data: 'time' },
{ data: 'error' }
],
pageLength: 50,
lengthMenu: [25, 50, 100, 250],
order: [[2, 'asc']],
searching: true,
info: true,
paging: true
});
}
function formatRecipientRow(m) {
var result = m.results || null;
var status;
var email = "—";
var err = "";
if (!result) {
status = '<span class="pill pending">Pending</span>';
} else if (result.id && !result.message && !result.errors) {
status = '<span class="pill ok">Delivered</span>';
email = result.toPersonEmail || email;
} else {
status = '<span class="pill err">Failed</span>';
email = result.toPersonEmail || email;
if (result.message) err = result.message;
else if (result.errors && result.errors.length) {
err = result.errors.map(function (e) { return e.description || e.code || JSON.stringify(e); }).join("; ");
}
}
return {
displayName: m.displayName || "—",
email: email,
status: status,
time: m.msgTime ? (m.msgTime / 1000).toFixed(2) + " s" : "—",
error: err
};
}
function formatSuccess(stats) {
if (!stats || stats.succeededMsgs === undefined) return null;
var pct = (typeof stats.succeededMsgPct === "number") ? stats.succeededMsgPct.toFixed(1) : stats.succeededMsgPct;
return stats.succeededMsgs + " / " + stats.totalMsgs + " (" + pct + "%)";
}
function formatDate(d) {
if (!d) return null;
try { return new Date(d).toLocaleString(); } catch (e) { return d; }
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1);
if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
}
return "";
}

13
html/js/virtual-select.min.css vendored Normal file

File diff suppressed because one or more lines are too long

5
html/js/virtual-select.min.js vendored Normal file

File diff suppressed because one or more lines are too long

126
html/monitorJobs.html Normal file
View file

@ -0,0 +1,126 @@
<html>
<head>
<title>CollabCentral</title>
<style>
* {
box-sizing: border-box;
}
body {
background-color: #FFFFFF;
color: #2F3941;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 10px;
}
#table {
width: 80%;
}
td {
white-space: pre-wrap !important;
word-wrap:break-word;
}
.header {
display: block;
background-color: tan;
width: 80%;
height: auto;
text-align: center;
}
.name {
display: block;
text-align: right;
align-content: right;
}
#appInfo {
display: flex;
text-align: center;
align-content: center;
}
.hidden {
display: none;
}
</style>
<script src="https://code.jquery.com/jquery-1.11.2.min.js"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.css" />
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.js"></script>
</head>
<body>
<link id="appName" rel="icon" type="image/x-icon" style="border-radius: 50%;" href="favicon.ico">
<div class="header">
<div id="appInfo">
<img id="appIcon" style="border-radius: 50%; padding: 10pt;">
<div id="appLabel" style="font-size: 40pt;margin: auto;"></div>
</div>
<div style="text-align: center;"><a id="jobLink" href=''>Send Message</a></div>
<div id="name" style="text-align: right;"></div>
</div>
<div id="unauthorizedPanel" class="hidden" style="padding: 2em; max-width: 600px; margin: 2em auto; text-align: center; border: 1px solid #ddd; border-radius: 8px;">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list for this bot. If you believe this is a mistake, contact the bot's administrator.</p>
</div>
<div id="jobsPanel" class="hidden">
<h3>Running Jobs</h3>
<div class="table"></div>
<table id="runningJobs" class="table">
<thead>
<tr>
<th>Submitter</th>
<th>App</th>
<th>Start Time</th>
<th>Total Recipients</th>
<th>Completed Recipients</th>
</tr>
</thead>
</table>
<h3>Scheduled Jobs</h3>
<table id="scheduledJobs">
<thead>
<tr>
<th>Submitter</th>
<th>App</th>
<th>Message</th>
<th>Recipients</th>
<th>Scheduled for</th>
</tr>
</thead>
</table>
<h3>Completed Jobs</h3>
<table id="completedJobs">
<thead>
<tr>
<th>jobId</th>
<th>Submitter</th>
<th>App</th>
<th>Message</th>
<th>Start Time</th>
<th>End Time</th>
<th>Total Time</th>
<th>Webex Time</th>
<th>Average Message Time</th>
<th>Messages Delivered</th>
<th>Total Messages</th>
<th>Success Rate</th>
</tr>
</thead>
</table>
</div>
<script src="monitorJobs.js"></script>
</body>
</html>

132
html/monitorJobs.js Normal file
View file

@ -0,0 +1,132 @@
var pathArray = window.location.pathname.split('/');
var appName = pathArray[2];
if (getCookie("id")) {
console.log("Found ID Cookie: " + getCookie("id"));
// Fetch bot metadata + authorization state, then render the page.
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label;
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/sendMessage.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
document.getElementById("jobsPanel").classList.remove("hidden");
initJobTables();
})
.catch(err => console.error("Failed to load /info:", err));
} else {
console.log("No ID Cookie found.");
var randomNumber = Math.random().toString();
randomNumber = randomNumber.substring(2, randomNumber.length);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(res => res.text())
.then((res) => {
window.location.href = res + randomNumber;
});
}
function initJobTables() {
$('#runningJobs').dataTable({
ajax: {
url: '/CollabCentral/' + appName + '/jobs/list/running',
dataSrc: ''
},
columns: [
{ data: 'senderDisplayName' },
{ data: 'appName' },
{ data: 'startTime' },
{ data: 'totalRecipients' },
{
data: 'completedRecipients',
render: function (val, type, row) {
if (type !== 'display' || row.jobId === undefined || row.jobId === null) return val;
return val + ' <a href="/CollabCentral/' + appName + '/jobDetail.html?jobId=' + encodeURIComponent(row.jobId) + '" style="margin-left: 0.5em; font-size: 0.85em;">view</a>';
}
}
],
searching: false,
info: false,
ordering: false,
paging: false
});
$('#scheduledJobs').dataTable({
ajax: {
url: '/CollabCentral/' + appName + '/jobs/list/scheduled',
dataSrc: ''
},
columns: [
{ data: 'senderDisplayName' },
{ data: 'appName' },
{ data: 'message' },
{ data: 'totalRecipients' },
{ data: 'scheduledFor' }
],
searching: false,
info: false,
ordering: false,
paging: false
});
$('#completedJobs').dataTable({
ajax: {
url: '/CollabCentral/' + appName + '/jobs/list/completed',
dataSrc: ''
},
columns: [
{
data: 'jobId',
render: function (jobId) {
if (jobId === undefined || jobId === null) return '';
return '<a href="/CollabCentral/' + appName + '/jobDetail.html?jobId=' + encodeURIComponent(jobId) + '">#' + jobId + '</a>';
}
},
{ data: 'senderDisplayName' },
{ data: 'appName' },
{ data: 'message.english.html' },
{ data: 'startTime' },
{ data: 'endTime' },
{ data: 'stats.totalTime' },
{ data: 'stats.webexTime' },
{ data: 'stats.averageTime' },
{ data: 'stats.succeededMsgs' },
{ data: 'stats.totalMsgs' },
{ data: 'stats.succeededMsgPct' }
],
searching: false,
info: false,
ordering: false,
paging: false
});
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}

BIN
html/novi.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
html/novi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

308
html/sendMessage.html Normal file
View file

@ -0,0 +1,308 @@
<html>
<head>
<title>CollabCentral</title>
<link id="appName" rel="icon" type="image/x-icon" style="border-radius: 50%;" href="favicon.ico">
<script>
let cookie = document.cookie;
if (cookie) {
} else {
window.location.href = "index.html";
}
</script>
<style>
* {
box-sizing: border-box;
}
input {
width: 60%;
padding: 12px 20px;
margin: 8px 0;
margin-bottom: 10px;
box-sizing: border-box;
display: inline-block;
border-radius: 4px;
}
.form-field input {
border: 1px solid #87929D;
border-radius: 4px;
padding: 10px;
width: 75%;
}
.form-field label {
display: block;
font-size: 13px;
}
label {
margin-top: 10px;
display: inline-block;
}
body {
background-color: #FFFFFF;
color: #2F3941;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 10px;
}
.header {
display: block;
background-color: tan;
width: 75%;
height: auto;
}
form {
display: block;
margin-top: 0em;
}
#file-ip-1-preview {
width: auto;
height: auto;
padding: 10px;
}
.groups {
width: 1000px;
height: auto;
}
#my-text-area {
height: 250px;
width: 75%;
}
.EasyMDEContainer {
display: block;
width: 75%;
border: 1px solid #87929D;
border-radius: 4px;
}
.submit {
width: 75%;
height: auto;
margin: 10px;
}
.modal {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.4rem;
width: 450px;
padding: 1.3rem;
min-height: 250px;
position: absolute;
top: 20%;
background-color: white;
border: 1px solid #ddd;
border-radius: 15px;
z-index: 2;
}
.modal .flex {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal input {
padding: 0.7rem 1rem;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 0.9em;
}
.modal p {
font-size: 0.9rem;
color: #777;
margin: 0.4rem 0 0.2rem;
}
.submit button {
display: inline-block;
padding: 0.8rem 1.4rem;
font-weight: 700;
background-color: black;
color: white;
border-radius: 5px;
text-align: center;
font-size: 1em;
}
.btn {
display: inline-block;
padding: 0.8rem 1.4rem;
font-weight: 700;
background-color: black;
color: white;
border-radius: 5px;
text-align: center;
font-size: 1em;
}
.btn-open {
position: absolute;
bottom: 150px;
}
.btn-close {
transform: translate(10px, -20px);
padding: 0.5rem 0.7rem;
background: #eee;
border-radius: 50%;
}
.overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
z-index: 1;
}
.hidden {
display: none;
}
.name {
display: block;
text-align: right;
align-content: right;
}
#appInfo {
display: flex;
text-align: center;
align-content: center;
}
#multi_option {
max-width: 100%;
width: 350px;
}
vscomp-toggle-button {
padding: 10px 30px 10px 10px;
border-radius: 5px;
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<link rel="stylesheet" href="js/virtual-select.min.css" />
<script src="js/virtual-select.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
</head>
<body>
<div class="header">
<div id="appInfo">
<img id="appIcon" style="border-radius: 50%; padding: 10pt;">
<div id="appLabel" style="font-size: 40pt;margin: auto;"></div>
</div>
<div style="text-align: center;"><a id="jobLink" href=''>Monitor Jobs</a></div>
<div id="name" style="text-align: right;"></div>
</div>
<div id="unauthorizedPanel" class="hidden" style="padding: 2em; max-width: 600px; margin: 2em auto; text-align: center; border: 1px solid #ddd; border-radius: 8px;">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list for this bot. If you believe this is a mistake, contact the bot's administrator.</p>
</div>
<div id="formPanel" class="form-input hidden">
<form action="" id="sendMessage" enctype="multipart/form-data">
<div class="form-field">
<img id="file-ip-1-preview">
<label for="uploadImage">Upload Image</label>
<input type="file" id="uploadImage" accept="image/*" name="uploadImage"
onchange="showImagePreview(event);">
</div>
<label for="my-text-area">Message</label>
<textarea id="my-text-area"></textarea>
<div class="form-field">
<div class="groups">
<label for="favGroups">Favorite Groups:</label>
<select id="favGroups" multiple name="favGroups" placeholder="Favorite Groups"
data-silent-initial-value-set="false">
</select>
</div>
<div class="groups">
<label>New Groups:</label>
<select id="newGroups" multiple name="newGroups" placeholder="New Groups"
data-silent-initial-value-set="false">
</select>
</div>
<div class="people">
<label for="uploadCSV">Upload CSV:</label>
<input type="file" id="uploadCSV" name="uploadCSV" accept="text/csv">
</div>
</div>
<div class="form-field">
<div class="when">
<label for="meeting-time">Schedule for:</label>
<input type="datetime-local" id="scheduledFor" name="scheduledFor" />
</div>
</div>
<div class="submit">
<button id="submit" type="submit">Submit</button>
</div>
</form>
<section class="modal hidden">
<div class="flex">
<button class="btn-close" onclick="continueEditing()">X</button>
</div>
<div>
<h3>Ready to send?</h3>
<p id="modalMessage">
This message will be sent to XX people.<br>
Ready to send?
</p>
</div>
<button class="btn" onclick="continueEditing()">Continue Editing</button>
<button class="btn" onclick="submit()">Submit</button>
</section>
<div class="overlay hidden"></div>
</div>
<script src="sendMessage.js"></script>
</body>
</html>

222
html/sendMessage.js Normal file
View file

@ -0,0 +1,222 @@
const listbox = document.querySelector('#list');
const modal = document.querySelector(".modal");
const overlay = document.querySelector(".overlay");
const openModalBtn = document.querySelector(".btn-open");
const closeModalBtn = document.querySelector(".btn-close");
var pathArray = window.location.pathname.split('/'); //Gets the path of the URL called
var appName = pathArray[2]; //Gets the appName from the path.
document.getElementById('sendMessage').onsubmit = function (event) {
event.preventDefault() // prevent form from posting without JS
var xhttp = new XMLHttpRequest(); // create new AJAX request
xhttp.onreadystatechange = function () {
if (this.readyState == this.DONE && this.status == 200) { // sucess from server
result = JSON.parse(xhttp.response);
console.log(xhttp.responseText)
console.log(xhttp.response);
var message = "I sent you a test message in your Webex Client for review.<br><br>";
message += "Recipients: " + result.memberList.length + "<br><br>";
if (document.getElementById("scheduledFor").value) {
message += "Scheduled for " + document.getElementById("scheduledFor").value;
} else {
message += "Message will be sent immediately.";
}
document.getElementById("modalMessage").innerHTML = message;
openModal();
} else { // errors occured
}
}
var formData = new FormData()
formData.append('uploadImage', document.getElementById('uploadImage').files[0]) // since inputs allow multi files submission, therefore files are in array
formData.append('message', message.value())
formData.append('uploadCSV', document.getElementById('uploadCSV').files[0]) // since inputs allow multi files submission, therefore files are in array
formData.append('appName', appName);
var selectedGroups = [];
for (var group of document.querySelector('#favGroups').getSelectedOptions()) {
selectedGroups.push(group.value);
}
for (var group of document.querySelector('#newGroups').getSelectedOptions()) {
selectedGroups.push(group.value);
}
formData.append('groups', selectedGroups);
formData.append('scheduledFor', document.getElementById("scheduledFor").value);
xhttp.open("POST", "/CollabCentral/" + appName + "/jobs/edit")
console.log(xhttp);
xhttp.send(formData)
}
function showImagePreview(event) {
if (event.target.files.length > 0) {
var src = URL.createObjectURL(event.target.files[0]);
var preview = document.getElementById("file-ip-1-preview");
preview.src = src;
preview.style.display = "block";
preview.style.maxHeight = "350px";
preview.style.maxWidth = "500px";
}
}
let id = getCookie("id");
// Fetch bot metadata + authorization state, then either render the form or
// show the "not authorized" panel. All bot-specific labels and icons come from
// /info so adding a new bot doesn't require any front-end changes.
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label;
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/monitorJobs.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
document.getElementById("formPanel").classList.remove("hidden");
loadFavoriteGroups();
loadNewGroups();
})
.catch(err => {
console.error("Failed to load /info:", err);
});
function loadFavoriteGroups() {
var x = document.getElementById("favGroups");
fetch('/CollabCentral/' + appName + '/user/groups/list')
.then(res => res.json())
.then((res) => {
for (var group of res) {
var option = document.createElement("option");
option.text = group.name;
option.value = group.id;
x.add(option);
}
VirtualSelect.init({ ele: '#favGroups' });
});
}
function loadNewGroups() {
var y = document.getElementById("newGroups");
fetch('/CollabCentral/' + appName + '/user/groups/find')
.then(res => res.json())
.then((res) => {
for (var group of res) {
var option = document.createElement("option");
option.text = group.displayName;
option.value = group.id;
y.add(option);
}
VirtualSelect.init({ ele: '#newGroups' });
});
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
const message = new EasyMDE({
element: document.getElementById('my-text-area'),
toolbar: ["bold", "italic", "|", "heading-1", "heading-2", "heading-3", "|", "code", "unordered-list", "ordered-list", "link", "quote", "|", "preview"],
maxHeight: "200px"
});
function submit() {
if (document.getElementById("scheduledFor").value) {
fetch("/CollabCentral/" + appName + "/jobs/schedule", {
method: "POST",
redirect: "follow"
})
.then(response => {
clearForm();
// HTTP 301 response
if (response.redirected) {
window.location.href = response.url;
}
})
.catch(function(err) {
console.info(err + " url: " + url);
});
} else {
console.log("RunNow selected.");
fetch("/CollabCentral/" + appName + "/jobs/runNow", {
method: "POST",
redirect: "follow"
})
.then(response => {
clearForm();
// HTTP 301 response
if (response.redirected) {
window.location.href = response.url;
}
})
.catch(function(err) {
console.info(err);
});
}
closeModal();
}
function continueEditing() {
closeModal();
}
const openModal = function () {
modal.classList.remove("hidden");
overlay.classList.remove("hidden");
};
const closeModal = function () {
modal.classList.add("hidden");
overlay.classList.add("hidden");
};
overlay.addEventListener("click", closeModal);
function clearForm() {
document.getElementById('uploadImage').innerHTML = "";
message.value("");
document.getElementById('uploadCSV').innerHTML = "";
document.getElementById('favGroups').selected = null;
document.getElementById('newGroups').selected = null;
document.getElementById('scheduledFor').innerHTML = "";
}

BIN
html/techupdates.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
html/techupdates.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

1346
index.js Normal file

File diff suppressed because it is too large Load diff

1500
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "collabcentral",
"version": "1.0.0",
"description": "Micro-service for sending messages to the organization.",
"main": "index.js",
"scripts": {
"start": "node --env-file=.env index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Joseph B. McQueen",
"license": "ISC",
"type": "module",
"dependencies": {
"body-parser": "^1.20.2",
"cookie-parser": "^1.4.6",
"express": "^4.18.2",
"form-data": "^4.0.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.2",
"node-fetch": "^3.3.2",
"p-queue": "^7.4.1",
"sharp": "^0.32.6",
"uuid": "^9.0.1"
}
}

0
uploads/.gitkeep Normal file
View file