- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem) and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too - Dedup three near-identical Gmail send paths into sendThreadedEmail helper - Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId, renameCount, renameWindowStart, reminderSent, staffChannelId, unclaimedRemindersSent, lastMessageAuthorIsStaff - Drop dead config fields and their .env.example entries - Inline api/botClient.js (3-line wrapper, 2 callers) - Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js - Fix handlers/messages.js to use isStaff() — old partial check ignored ADDITIONAL_STAFF_ROLES, so those members were treated as customers - Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
7.5 KiB
JavaScript
206 lines
7.5 KiB
JavaScript
/**
|
||
* Per-key config value validator registry.
|
||
*
|
||
* Pattern-driven type inference for every key in ALLOWED_CONFIG_KEYS.
|
||
* getValidator(key) returns { type, validate(value) }, where validate returns
|
||
* { ok: true, coerced } — typed value to assign into CONFIG[key]
|
||
* { ok: false, error } — human-readable reason surfaced in the save UI
|
||
*
|
||
* .env always stores String(coerced); CONFIG gets the typed coerced value so
|
||
* downstream consumers that compare === true / === 5 still work.
|
||
*
|
||
* This file is the canonical source for ALLOWED_CONFIG_KEYS — routes/internalApi
|
||
* imports the Set from here. That keeps the require graph acyclic:
|
||
* internalApi -> configPersistence -> configSchema
|
||
* internalApi -> configSchema
|
||
* No side effects beyond a one-line startup log of the fallback-string keys.
|
||
*/
|
||
'use strict';
|
||
|
||
const ALLOWED_CONFIG_KEYS = new Set([
|
||
// Ticket settings
|
||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
|
||
// Escalation categories
|
||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||
// Roles and staff
|
||
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
||
'ADMIN_ID',
|
||
// Channel IDs
|
||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||
'RENAME_LOG_CHANNEL_ID',
|
||
// Messages and labels
|
||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
||
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
||
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
||
// Branding
|
||
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
||
// Toggles
|
||
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
||
'ALLOW_CLAIM_OVERWRITE',
|
||
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||
// Limits and thresholds
|
||
'GLOBAL_TICKET_LIMIT',
|
||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||
// Embed colors
|
||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
||
]);
|
||
|
||
// ---------- Regex primitives ----------
|
||
|
||
const SNOWFLAKE_RE = /^[0-9]{17,20}$/;
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const HEX_COLOR_RE = /^(?:0x|#)?([0-9A-Fa-f]{6})$/;
|
||
const INT_RE = /^-?\d+$/;
|
||
const NUMERIC_COERCE_RE = /^-?\d+(?:\.\d+)?$/;
|
||
|
||
function isEmptyInput(v) {
|
||
return v === '' || v === null || v === undefined;
|
||
}
|
||
|
||
// ---------- Validators ----------
|
||
|
||
const VALIDATORS = {
|
||
boolean: {
|
||
type: 'boolean',
|
||
validate(value) {
|
||
if (value === true || value === 'true') return { ok: true, coerced: true };
|
||
if (value === false || value === 'false') return { ok: true, coerced: false };
|
||
return { ok: false, error: 'must be true or false' };
|
||
}
|
||
},
|
||
integer: {
|
||
type: 'integer',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value).trim();
|
||
if (!INT_RE.test(str)) return { ok: false, error: 'must be a whole number' };
|
||
const n = parseInt(str, 10);
|
||
if (!Number.isFinite(n) || n < 0) return { ok: false, error: 'must be zero or a positive integer' };
|
||
return { ok: true, coerced: n };
|
||
}
|
||
},
|
||
hex_color: {
|
||
type: 'hex_color',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value).trim();
|
||
const m = str.match(HEX_COLOR_RE);
|
||
if (!m) return { ok: false, error: 'must be a 6-digit hex color like 0xRRGGBB or #RRGGBB' };
|
||
return { ok: true, coerced: '0x' + m[1].toUpperCase() };
|
||
}
|
||
},
|
||
url: {
|
||
type: 'url',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value).trim();
|
||
try {
|
||
new URL(str);
|
||
return { ok: true, coerced: str };
|
||
} catch (_) {
|
||
return { ok: false, error: 'must be a valid URL (include the protocol)' };
|
||
}
|
||
}
|
||
},
|
||
email: {
|
||
type: 'email',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value).trim();
|
||
if (!EMAIL_RE.test(str)) return { ok: false, error: 'must look like a valid email address' };
|
||
return { ok: true, coerced: str };
|
||
}
|
||
},
|
||
discord_id: {
|
||
type: 'discord_id',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value).trim();
|
||
if (!SNOWFLAKE_RE.test(str)) return { ok: false, error: 'must be a Discord ID (17–20 digits) or empty' };
|
||
return { ok: true, coerced: str };
|
||
}
|
||
},
|
||
discord_id_list: {
|
||
type: 'discord_id_list',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value).trim();
|
||
if (str === '') return { ok: true, coerced: '' };
|
||
const parts = str.split(',').map(p => p.trim()).filter(Boolean);
|
||
for (const p of parts) {
|
||
if (!SNOWFLAKE_RE.test(p)) return { ok: false, error: `"${p}" is not a Discord ID` };
|
||
}
|
||
return { ok: true, coerced: parts.join(',') };
|
||
}
|
||
},
|
||
json: {
|
||
type: 'json',
|
||
validate(value) {
|
||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||
const str = String(value);
|
||
try {
|
||
JSON.parse(str);
|
||
return { ok: true, coerced: str };
|
||
} catch (_) {
|
||
return { ok: false, error: 'must be valid JSON' };
|
||
}
|
||
}
|
||
},
|
||
string_or_json: {
|
||
type: 'string_or_json',
|
||
validate(value) {
|
||
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
|
||
return { ok: true, coerced: String(value) };
|
||
}
|
||
},
|
||
// Fallback. Preserves legacy coercion so CONFIG.* values keep their types
|
||
// for consumers that compare with === true / === 5 (see old applyConfigUpdates).
|
||
string: {
|
||
type: 'string',
|
||
validate(value) {
|
||
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
|
||
if (value === 'true' || value === true) return { ok: true, coerced: true };
|
||
if (value === 'false' || value === false) return { ok: true, coerced: false };
|
||
const str = String(value);
|
||
if (str !== '' && NUMERIC_COERCE_RE.test(str)) return { ok: true, coerced: Number(str) };
|
||
return { ok: true, coerced: str };
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---------- Type inference ----------
|
||
|
||
function inferType(key) {
|
||
// 1. Explicit overrides
|
||
if (key === 'LOGO_URL') return 'url';
|
||
if (/_EMAIL$/.test(key)) return 'email';
|
||
if (key.includes('COLOR')) return 'hex_color';
|
||
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
||
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
||
|
||
// 2. Name patterns
|
||
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';
|
||
if (/_IDS$/.test(key)) return 'discord_id_list';
|
||
if (/_ID$/.test(key)) return 'discord_id';
|
||
if (/_HOURS$|_MINUTES$|_SECONDS$|_COUNT$|_LIMIT$|_THRESHOLD$/.test(key)) return 'integer';
|
||
|
||
// 3. Fallback
|
||
return 'string';
|
||
}
|
||
|
||
function getValidator(key) {
|
||
return VALIDATORS[inferType(key)];
|
||
}
|
||
|
||
module.exports = {
|
||
ALLOWED_CONFIG_KEYS,
|
||
getValidator
|
||
};
|