Files
broccolini-bot/services/configSchema.js
indifferentketchup 840b6bfcf8 simplify: prune dead code, dedup gmail send, drop neutered log stubs
- 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>
2026-05-07 18:37:14 +00:00

206 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (1720 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
};