test
This commit is contained in:
@@ -65,7 +65,7 @@ DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
|
|||||||
# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production)
|
# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production)
|
||||||
|
|
||||||
# --- Database ---
|
# --- Database ---
|
||||||
MONGODB_URI= # MongoDB connection string (e.g. mongodb+srv://user:pass@cluster/dbname)
|
MONGODB_URI= # MongoDB connection string (e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db)
|
||||||
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
|
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
|
||||||
|
|
||||||
# --- Branding & copy ---
|
# --- Branding & copy ---
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ DISCORD_ONLY_PORT=5000
|
|||||||
# BOSSCORD_CORS_ORIGIN=*
|
# BOSSCORD_CORS_ORIGIN=*
|
||||||
|
|
||||||
# --- Database (test cluster or local) ---
|
# --- Database (test cluster or local) ---
|
||||||
MONGODB_URI=
|
MONGODB_URI= # e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db_test?authSource=broccoli_db_test
|
||||||
# MONGODB_DATABASE=
|
# MONGODB_DATABASE=
|
||||||
|
|
||||||
# --- Branding & copy ---
|
# --- Branding & copy ---
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ When the user asks for direct fixes, make them — but still avoid unsolicited r
|
|||||||
## Project
|
## Project
|
||||||
- **broccolini-bot** — Discord ticketing + support bot for Indifferent Broccoli (game hosting).
|
- **broccolini-bot** — Discord ticketing + support bot for Indifferent Broccoli (game hosting).
|
||||||
- **Repo:** `/opt/broccolini-bot/` · **Gitea:** `ssh://git@100.114.205.53:2222/indifferentketchup/broccolini-bot.git`
|
- **Repo:** `/opt/broccolini-bot/` · **Gitea:** `ssh://git@100.114.205.53:2222/indifferentketchup/broccolini-bot.git`
|
||||||
- **DB:** MongoDB Atlas, database `broccoli_db`.
|
- **DB:** Self-hosted MongoDB on same host as bot, database `broccoli_db`. Dedicated user per DB.
|
||||||
- **Host port 8892 → container port 5000** (`CONFIG.PORT`, env `DISCORD_ONLY_PORT`).
|
- **Host port 8892 → container port 5000** (`CONFIG.PORT`, env `DISCORD_ONLY_PORT`).
|
||||||
- **Deploy:** `cd /opt/broccolini-bot && git pull && docker compose up --build -d` · tail: `docker logs broccolini-bot --tail 50 -f`
|
- **Deploy:** `cd /opt/broccolini-bot && git pull && docker compose up --build -d` · tail: `docker logs broccolini-bot --tail 50 -f`
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ When the user asks for direct fixes, make them — but still avoid unsolicited r
|
|||||||
Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime.
|
Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 (MongoDB Atlas) · googleapis · express-rate-limit · p-queue · dotenv/dotenv-expand.
|
Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 · googleapis · express-rate-limit · p-queue · dotenv/dotenv-expand.
|
||||||
|
|
||||||
## Hard Rules
|
## Hard Rules
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ Every `interactionCreate` branch runs through `runHandler(name, interaction, fn)
|
|||||||
### Tickets (`services/tickets.js`, `models.js`)
|
### Tickets (`services/tickets.js`, `models.js`)
|
||||||
- `Ticket` schema has indexes on `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`.
|
- `Ticket` schema has indexes on `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`.
|
||||||
- **Discord-originated tickets** use `gmailThreadId` with prefix `discord-` / `discord-msg-` — skip the Gmail reply path entirely.
|
- **Discord-originated tickets** use `gmailThreadId` with prefix `discord-` / `discord-msg-` — skip the Gmail reply path entirely.
|
||||||
- Renames route through `utils/renamer.js` (RENAMER_BOT secondary token). On 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. `canRename()` is retained as an always-ok shim for back-compat. `Ticket.renameCount` / `Ticket.renameWindowStart` remain in the schema but are now unread/unwritten orphan fields.
|
- Renames route through `utils/renamer.js` (RENAMER_BOT secondary token). On 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. `canRename()` in `services/tickets.js` is retained as an always-ok shim for back-compat. `Ticket.renameCount` / `Ticket.renameWindowStart` remain in the schema but are now unread/unwritten orphan fields.
|
||||||
- `getOrCreateTicketCategory()` handles Discord's 50-channels-per-category ceiling by creating `"<name> (Overflow N)"` categories; `cleanupEmptyOverflowCategory()` removes empties. The primary category is never deleted.
|
- `getOrCreateTicketCategory()` handles Discord's 50-channels-per-category ceiling by creating `"<name> (Overflow N)"` categories; `cleanupEmptyOverflowCategory()` removes empties. The primary category is never deleted.
|
||||||
- Scheduled jobs in `ready`: `checkAutoClose`, `checkAutoUnclaim`, `reconcileDeletedTicketChannels`, plus `services/staffNotifications.js#notifyAllStaffUnclaimed` and the pattern/surge/chat checkers.
|
- Scheduled jobs in `ready`: `checkAutoClose`, `checkAutoUnclaim`, `reconcileDeletedTicketChannels`, plus `services/staffNotifications.js#notifyAllStaffUnclaimed` and the pattern/surge/chat checkers.
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ See [Staff notification channels](#staff-notification-channels--reply-alerts) an
|
|||||||
|-------------|--------|
|
|-------------|--------|
|
||||||
| **Node.js** | **18+**; Docker image uses **20** (`Dockerfile`). |
|
| **Node.js** | **18+**; Docker image uses **20** (`Dockerfile`). |
|
||||||
| **npm** | `npm install` locally; `npm ci --omit=dev` in Docker. |
|
| **npm** | `npm install` locally; `npm ci --omit=dev` in Docker. |
|
||||||
| **MongoDB** | Atlas or self-hosted; `MONGODB_URI` required at startup. |
|
| **MongoDB** | Self-hosted; `MONGODB_URI` required at startup. |
|
||||||
| **Discord application** | Bot token, application ID; intents: **Message Content**, **Server Members**; also Guilds + Guild Messages. |
|
| **Discord application** | Bot token, application ID; intents: **Message Content**, **Server Members**; also Guilds + Guild Messages. |
|
||||||
| **Google Cloud** | Gmail API enabled; OAuth2 client ID/secret + refresh token for the support mailbox. |
|
| **Google Cloud** | Gmail API enabled; OAuth2 client ID/secret + refresh token for the support mailbox. |
|
||||||
|
|
||||||
@@ -498,7 +498,7 @@ This repo includes [`.gitlab-ci.yml`](.gitlab-ci.yml) with GitLab **SAST** and *
|
|||||||
|---------|--------|
|
|---------|--------|
|
||||||
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. |
|
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. |
|
||||||
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. |
|
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. |
|
||||||
| **MongoDB errors** | `MONGODB_URI`, Atlas IP allowlist, `npm run test-mongodb`. |
|
| **MongoDB errors** | `MONGODB_URI`, `npm run test-mongodb`. |
|
||||||
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. |
|
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. |
|
||||||
| **Modal / button no response** | Intents + permissions; bot online; check `DEBUGGING_CHANNEL_ID` / console. |
|
| **Modal / button no response** | Intents + permissions; bot online; check `DEBUGGING_CHANNEL_ID` / console. |
|
||||||
| **Renames “too quickly”** | Discord rename cooldown; wait for channel queue / timestamp in bot message. |
|
| **Renames “too quickly”** | Discord rename cooldown; wait for channel queue / timestamp in bot message. |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Node.js (CommonJS) Discord ticketing bot for Indifferent Broccoli. Single proces
|
|||||||
|
|
||||||
- A discord.js v14 client (ticket lifecycle, slash/button/modal handlers, context menus)
|
- A discord.js v14 client (ticket lifecycle, slash/button/modal handlers, context menus)
|
||||||
- A Gmail bridge (~30s polling → Discord channels; staff replies → Gmail)
|
- A Gmail bridge (~30s polling → Discord channels; staff replies → Gmail)
|
||||||
- A Mongoose/MongoDB Atlas layer (`broccoli_db`) for tickets + settings
|
- A Mongoose/self-hosted MongoDB layer (`broccoli_db`) for tickets + settings
|
||||||
- Two Express servers: healthcheck + bOSScord API (`PORT`, default 5000 → host 8892), and an internal settings API (`INTERNAL_API_PORT`)
|
- Two Express servers: healthcheck + bOSScord API (`PORT`, default 5000 → host 8892), and an internal settings API (`INTERNAL_API_PORT`)
|
||||||
- Background jobs: auto-close, unclaimed reminders, auto-unclaim, pattern detection, surge detection, chat monitoring, orphan reconciliation
|
- Background jobs: auto-close, unclaimed reminders, auto-unclaim, pattern detection, surge detection, chat monitoring, orphan reconciliation
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ ticketSchema.index({ ticketNumber: 1 });
|
|||||||
`discordThreadId` should be `unique, sparse` because Discord-only tickets set it immediately, email tickets may briefly lack it during creation, and no two tickets should share a channel. Confirm the sparse-unique behavior doesn't conflict with existing data before enabling (see Verify).
|
`discordThreadId` should be `unique, sparse` because Discord-only tickets set it immediately, email tickets may briefly lack it during creation, and no two tickets should share a channel. Confirm the sparse-unique behavior doesn't conflict with existing data before enabling (see Verify).
|
||||||
**Verify:**
|
**Verify:**
|
||||||
- Before deploy, run `db.tickets.aggregate([{$group: {_id: "$discordThreadId", c: {$sum: 1}}}, {$match: {c: {$gt: 1}}}])` against `broccoli_db` to confirm no duplicate `discordThreadId` values exist. If any do, investigate (they indicate prior orphaning bugs) before adding the unique index.
|
- Before deploy, run `db.tickets.aggregate([{$group: {_id: "$discordThreadId", c: {$sum: 1}}}, {$match: {c: {$gt: 1}}}])` against `broccoli_db` to confirm no duplicate `discordThreadId` values exist. If any do, investigate (they indicate prior orphaning bugs) before adding the unique index.
|
||||||
- After redeploy, run `db.tickets.getIndexes()` in Atlas and confirm all five new indexes exist.
|
- After redeploy, run `db.tickets.getIndexes()` via mongosh and confirm all five new indexes exist.
|
||||||
- Spot-check with `db.tickets.find({discordThreadId: "<some id>"}).explain("executionStats")` — should show `IXSCAN`, not `COLLSCAN`.
|
- Spot-check with `db.tickets.find({discordThreadId: "<some id>"}).explain("executionStats")` — should show `IXSCAN`, not `COLLSCAN`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
services:
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: broccoli-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- broccoli-mongo-data:/data/db
|
||||||
|
networks:
|
||||||
|
- broccoli-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand({ping:1}).ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
broccolini:
|
broccolini:
|
||||||
build: .
|
build: .
|
||||||
image: broccolini-bot
|
image: broccolini-bot
|
||||||
@@ -11,6 +28,9 @@ services:
|
|||||||
- ./.env:/app/.env:rw
|
- ./.env:/app/.env:rw
|
||||||
ports:
|
ports:
|
||||||
- "100.114.205.53:8892:5000"
|
- "100.114.205.53:8892:5000"
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:5000/"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:5000/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -23,3 +43,6 @@ networks:
|
|||||||
broccoli-net:
|
broccoli-net:
|
||||||
name: broccoli-net
|
name: broccoli-net
|
||||||
external: true
|
external: true
|
||||||
|
volumes:
|
||||||
|
broccoli-mongo-data:
|
||||||
|
name: broccoli-mongo-data
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
- **Secrets:** All secrets live in `.env` (or `.env.test` for test). Never commit them. `.gitignore` excludes `.env` and `.env.*` except `.env.example` and `.env.test.example`.
|
- **Secrets:** All secrets live in `.env` (or `.env.test` for test). Never commit them. `.gitignore` excludes `.env` and `.env.*` except `.env.example` and `.env.test.example`.
|
||||||
- **Code:** No `eval()` or `new Function()` of user input. No hardcoded tokens, passwords, or API keys in source.
|
- **Code:** No `eval()` or `new Function()` of user input. No hardcoded tokens, passwords, or API keys in source.
|
||||||
- **Config:** Credentials are read from `process.env` via `config.js`; config is loaded once at startup from the file specified by `ENV_FILE` or default `.env`.
|
- **Config:** Credentials are read from `process.env` via `config.js`; config is loaded once at startup from the file specified by `ENV_FILE` or default `.env`.
|
||||||
- **MongoDB:** Use a dedicated user and database; restrict network access (Atlas IP allowlist or VPC). For test, use a separate DB or cluster.
|
- **MongoDB:** Use a dedicated user and database; bind Mongo to loopback or docker network only; firewall 27017 from public interfaces. For test, use a separate DB or cluster.
|
||||||
- **Discord / Google:** Use tokens with minimal required scopes; rotate if compromised.
|
- **Discord / Google:** Use tokens with minimal required scopes; rotate if compromised.
|
||||||
- **HTML in emails:** `LOGO_URL`, `EMAIL_SIGNATURE`, and closure messages are escaped in outbound HTML to prevent injection.
|
- **HTML in emails:** `LOGO_URL`, `EMAIL_SIGNATURE`, and closure messages are escaped in outbound HTML to prevent injection.
|
||||||
- **Healthcheck:** Optional `HEALTHCHECK_HOST=127.0.0.1` in `.env` binds the healthcheck server to localhost only; omit to listen on all interfaces.
|
- **Healthcheck:** Optional `HEALTHCHECK_HOST=127.0.0.1` in `.env` binds the healthcheck server to localhost only; omit to listen on all interfaces.
|
||||||
|
|||||||
@@ -20,10 +20,23 @@ Broccolini Bot uses **MongoDB only** for persistent storage (tickets, transcript
|
|||||||
Add to your `.env` file:
|
Add to your `.env` file:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
MONGODB_URI=mongodb://localhost:27018/broccolini_bot
|
MONGODB_URI=mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Uses port `27018` to match your existing setup (as defined in docker-compose.yml).
|
**Note:** Mongo runs self-hosted on the same host as the bot. A **dedicated user per database** is required — create `broccoli_bot` with `readWrite` on `broccoli_db` only (no admin/root, no cross-DB access). For test, create a separate user with `readWrite` on `broccoli_db_test` only.
|
||||||
|
|
||||||
|
Example mongosh setup:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
use broccoli_db
|
||||||
|
db.createUser({
|
||||||
|
user: "broccoli_bot",
|
||||||
|
pwd: "CHANGE_ME",
|
||||||
|
roles: [ { role: "readWrite", db: "broccoli_db" } ]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind Mongo to loopback (`bindIp: 127.0.0.1`) or the internal docker network only; firewall `27017` from public interfaces.
|
||||||
|
|
||||||
### 2. Install Dependencies
|
### 2. Install Dependencies
|
||||||
|
|
||||||
@@ -141,11 +154,12 @@ process.on('SIGINT', async () => {
|
|||||||
|
|
||||||
### Connection refused
|
### Connection refused
|
||||||
- Check MongoDB is running: `docker ps` or `systemctl status mongodb`
|
- Check MongoDB is running: `docker ps` or `systemctl status mongodb`
|
||||||
- Verify port 27018 is correct in `.env`
|
- Verify port 27017 is correct in `.env` (or whatever port your mongod is bound to)
|
||||||
- Check MongoDB logs for errors
|
- Check MongoDB logs for errors
|
||||||
|
|
||||||
### Authentication failed
|
### Authentication failed
|
||||||
- If MongoDB requires auth, update URI: `mongodb://username:password@localhost:27018/broccolini_bot`
|
- Verify the user exists in the correct DB's `authSource` (URI must include `?authSource=broccoli_db`)
|
||||||
|
- Confirm the user has `readWrite` on `broccoli_db`: `db.getUser("broccoli_bot")` in mongosh
|
||||||
|
|
||||||
### Schema validation errors
|
### Schema validation errors
|
||||||
- Check required fields are provided when creating documents
|
- Check required fields are provided when creating documents
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ let authErrorNotified = false;
|
|||||||
let pollSuspended = false;
|
let pollSuspended = false;
|
||||||
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
|
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
|
||||||
|
|
||||||
function setPollSuspended(val) { pollSuspended = !!val; }
|
function setPollSuspended(val) {
|
||||||
|
pollSuspended = !!val;
|
||||||
|
if (!pollSuspended) authErrorNotified = false;
|
||||||
|
}
|
||||||
function isPollSuspended() { return pollSuspended; }
|
function isPollSuspended() { return pollSuspended; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -362,18 +365,16 @@ async function poll(client) {
|
|||||||
}
|
}
|
||||||
authErrorNotified = false;
|
authErrorNotified = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const isAuthError =
|
// Only treat Google-reported permanent-grant failures as reasons to suspend
|
||||||
(e.message && (
|
// the loop. Transient 401/403/429/5xx/network errors fall through to the
|
||||||
e.message.includes('invalid_grant') ||
|
// next interval tick naturally. The OAuth error codes come back on the
|
||||||
e.message.includes('unauthorized') ||
|
// response body, not the message string.
|
||||||
e.message.includes('Invalid Credentials')
|
const oauthError = e && e.response && e.response.data && e.response.data.error;
|
||||||
)) ||
|
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
||||||
e.status === 401 ||
|
|
||||||
e.code === 401;
|
|
||||||
|
|
||||||
if (isAuthError) {
|
if (isPermanentAuth) {
|
||||||
pollSuspended = true;
|
pollSuspended = true;
|
||||||
const suspendMsg = 'Gmail OAuth token invalid or expired. Polling SUSPENDED — will not retry automatically. Re-authenticate to resume.';
|
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
|
||||||
console.error('[gmail-poll]', suspendMsg);
|
console.error('[gmail-poll]', suspendMsg);
|
||||||
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
|
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
|
||||||
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
||||||
|
|||||||
@@ -228,6 +228,35 @@ router.post('/notifications/toggle', express.json(), async (req, res) => {
|
|||||||
res.json({ state: getNotificationState() });
|
res.json({ state: getNotificationState() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /gmail/reload — hot-swap Gmail OAuth creds after weekly reauth without
|
||||||
|
// restarting the process. Reads REFRESH_TOKEN from .env via configPersistence,
|
||||||
|
// probes Google with users.getProfile, and on success clears pollSuspended and
|
||||||
|
// re-installs the poll interval. On failure returns 400 with Google's error so
|
||||||
|
// the operator can see why (e.g. still invalid_grant).
|
||||||
|
router.post('/gmail/reload', express.json(), async (req, res) => {
|
||||||
|
const { reloadGmailClient } = require('../services/gmail');
|
||||||
|
const { setPollSuspended } = require('../gmail-poll');
|
||||||
|
try {
|
||||||
|
const { emailAddress } = await reloadGmailClient();
|
||||||
|
setPollSuspended(false);
|
||||||
|
// Lazy require — same reason as /restart above (module scope cycle).
|
||||||
|
const parent = require('../broccolini-discord');
|
||||||
|
if (parent.setGmailPollInterval) {
|
||||||
|
parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
await logSystem('Gmail OAuth reloaded', [
|
||||||
|
{ name: 'Account', value: emailAddress, inline: false }
|
||||||
|
]).catch(() => {});
|
||||||
|
res.json({ ok: true, email: emailAddress });
|
||||||
|
} catch (err) {
|
||||||
|
const oauthError = err && err.response && err.response.data && err.response.data.error;
|
||||||
|
res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
error: oauthError || err.code || err.message || 'reload failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Expose the allowlist for the Phase 8 schema smoke test. Attached to the
|
// Expose the allowlist for the Phase 8 schema smoke test. Attached to the
|
||||||
// router function object; doesn't show up as a route.
|
// router function object; doesn't show up as a route.
|
||||||
router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS);
|
router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { CONFIG } = require('../config');
|
|||||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||||
const { getStaffSignatureBlocks } = require('./staffSignature');
|
const { getStaffSignatureBlocks } = require('./staffSignature');
|
||||||
const { logError } = require('./debugLog');
|
const { logError } = require('./debugLog');
|
||||||
|
const { readEnvFile } = require('./configPersistence');
|
||||||
|
|
||||||
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
|
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
|
||||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
||||||
@@ -19,6 +20,31 @@ function getGmailClient() {
|
|||||||
return google.gmail({ version: 'v1', auth });
|
return google.gmail({ version: 'v1', auth });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google.
|
||||||
|
* Used by the internal /gmail/reload endpoint so the weekly reauth chore does
|
||||||
|
* not require a full container restart.
|
||||||
|
*
|
||||||
|
* Throws if the env file is missing the token, or if the probe call (getProfile)
|
||||||
|
* fails — the caller surfaces the error so the UI can see why.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{emailAddress: string}>}
|
||||||
|
*/
|
||||||
|
async function reloadGmailClient() {
|
||||||
|
const envMap = readEnvFile();
|
||||||
|
const newToken = envMap.get('REFRESH_TOKEN');
|
||||||
|
if (!newToken) {
|
||||||
|
const err = new Error('REFRESH_TOKEN not set in .env');
|
||||||
|
err.code = 'ENOTOKEN';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
process.env.REFRESH_TOKEN = newToken;
|
||||||
|
CONFIG.REFRESH_TOKEN = newToken;
|
||||||
|
const gmail = getGmailClient();
|
||||||
|
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||||
|
return { emailAddress: profile.data.emailAddress };
|
||||||
|
}
|
||||||
|
|
||||||
async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||||
try {
|
try {
|
||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient();
|
||||||
@@ -318,6 +344,7 @@ async function sendGmailReply(
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getGmailClient,
|
getGmailClient,
|
||||||
|
reloadGmailClient,
|
||||||
sendGmailReply,
|
sendGmailReply,
|
||||||
sendTicketClosedEmail,
|
sendTicketClosedEmail,
|
||||||
sendTicketNotificationEmail
|
sendTicketNotificationEmail
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ browser ──► settings server.js (:SETTINGS_PORT, default 12752)
|
|||||||
| `GET /api/discord/guild` | `GET /internal/discord/guild` |
|
| `GET /api/discord/guild` | `GET /internal/discord/guild` |
|
||||||
| `POST /api/restart` | `POST /internal/restart` |
|
| `POST /api/restart` | `POST /internal/restart` |
|
||||||
| `GET /api/restart/status` | `GET /internal/restart/status` |
|
| `GET /api/restart/status` | `GET /internal/restart/status` |
|
||||||
|
| `GET /api/notifications/alerts` | `GET /internal/notifications/alerts` |
|
||||||
|
| `GET /api/notifications/state` | `GET /internal/notifications/state` |
|
||||||
|
| `POST /api/notifications/toggle` | `POST /internal/notifications/toggle` |
|
||||||
|
|
||||||
Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key.
|
Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key.
|
||||||
|
|
||||||
@@ -49,11 +52,11 @@ Every response-shape change in the bot's `/internal/*` handlers (`routes/interna
|
|||||||
`server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters.
|
`server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters.
|
||||||
|
|
||||||
### Client-side routing
|
### Client-side routing
|
||||||
`public/index.html` is a single page with all sections rendered; `public/js/app.js` toggles `.hidden` on sections based on `location.pathname`. Routes live in the `ROUTES` map (`app.js:425`). The server has `app.get('*', requireAuth, …)` as a catch-all back to `index.html` (`server.js:97`), so any new client route works without server changes as long as it's added to `ROUTES`.
|
`public/js/` is split into focused modules (phase 4 refactor): `app.js` (bootstrap), `router.js`, `fields.js`, `notifications.js`, `discord.js`, `login.js`, `util.js` — no bundler, loaded via `<script>` tags. Routes live in the `ROUTES` map (`router.js:4`); the server has a catch-all back to `index.html` (`server.js:202`, Express 5 `'/*splat'` syntax), so adding a client route only requires editing `ROUTES`.
|
||||||
|
|
||||||
### Config field binding (frontend)
|
### Config field binding (frontend)
|
||||||
Any form element with `data-key="SOME_CONFIG_KEY"` participates in the editor:
|
Any form element with `data-key="SOME_CONFIG_KEY"` participates in the editor:
|
||||||
- `populateFields()` (`app.js:102`) fills it from `GET /api/config` and wires change listeners.
|
- `populateFields()` (`fields.js:11`) fills it from `GET /api/config` and wires change listeners.
|
||||||
- Checkboxes serialize to the strings `'true'` / `'false'`, and `<input type="color">` serializes to `0xRRGGBB` — this matches how the bot stores these values.
|
- Checkboxes serialize to the strings `'true'` / `'false'`, and `<input type="color">` serializes to `0xRRGGBB` — this matches how the bot stores these values.
|
||||||
- `pendingChanges` accumulates diffs; `saveConfig()` POSTs the whole diff at once.
|
- `pendingChanges` accumulates diffs; `saveConfig()` POSTs the whole diff at once.
|
||||||
- `data-smart="channel|category|role|member|multi-member"` swaps the bare `<input>` for a searchable Discord picker backed by `GET /api/discord/guild` (see `public/js/discord.js`).
|
- `data-smart="channel|category|role|member|multi-member"` swaps the bare `<input>` for a searchable Discord picker backed by `GET /api/discord/guild` (see `public/js/discord.js`).
|
||||||
@@ -61,7 +64,7 @@ Any form element with `data-key="SOME_CONFIG_KEY"` participates in the editor:
|
|||||||
**To add a new editable config field:** (1) add the key to the bot's `ALLOWED_CONFIG_KEYS`, (2) add a `<input data-key="NEW_KEY">` (optionally `data-smart=…`) inside the appropriate `.section` in `public/index.html`. No JS changes needed.
|
**To add a new editable config field:** (1) add the key to the bot's `ALLOWED_CONFIG_KEYS`, (2) add a `<input data-key="NEW_KEY">` (optionally `data-smart=…`) inside the appropriate `.section` in `public/index.html`. No JS changes needed.
|
||||||
|
|
||||||
### Notification thresholds editor
|
### Notification thresholds editor
|
||||||
The Notifications section is **not** a simple `data-key` field — it's a custom editor (`app.js:239-423`) that serializes into a single hidden `NOTIFICATION_THRESHOLDS_JSON` field. Alert keys are hard-coded in `NOTIFICATION_TAB_KEYS` (surge / patterns / unclaimed / chat) and described in `NOTIFICATION_ALERT_DESCRIPTIONS`. **Adding a new alert key requires editing both of those objects** — otherwise it won't show up in any tab. Threshold values accept whole numbers or duration strings matching `^(\d+[mhd])+$` (e.g. `15m`, `1h`, `1d6h`).
|
The Notifications section is **not** a simple `data-key` field — it's a custom editor in `notifications.js` that serializes into a single hidden `NOTIFICATION_THRESHOLDS_JSON` field. Alert metadata is now a **dynamic registry** (phase 5): the bot is canonical and serves it via `GET /api/notifications/alerts`; `notifications.js` uses `FALLBACK_TAB_KEYS` only if the fetch fails. **To add a new alert key, register it in the bot** (not in this codebase) — the UI picks it up automatically on next load. Threshold values accept whole numbers or duration strings matching `^(\d+[mhd])+$` (e.g. `15m`, `1h`, `1d6h`).
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user