From fe03c1281a24633816bdefa06cfb0a94deb6de31 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 24 Dec 2025 10:48:02 -0500 Subject: [PATCH 1/3] Adding changes to add FAQ in --- src/commands/faq/faq.ts | 87 +++++------ src/commands/faq/services.ts | 229 ++++++++++++++++++++++++++++ src/commands/faq/store.ts | 104 +++++++++++++ src/commands/faq/subcommands/add.ts | 53 +++++++ src/commands/faq/types.ts | 37 +++++ 5 files changed, 463 insertions(+), 47 deletions(-) create mode 100644 src/commands/faq/services.ts create mode 100644 src/commands/faq/store.ts create mode 100644 src/commands/faq/types.ts diff --git a/src/commands/faq/faq.ts b/src/commands/faq/faq.ts index 2599fd72..8299e89b 100644 --- a/src/commands/faq/faq.ts +++ b/src/commands/faq/faq.ts @@ -1,20 +1,17 @@ // src/commands/faq/faq.ts - -/** - * Base /faq slash command. - * - * Responsibilities of this file: - * - Define the /faq command and its subcommands - * - Handle the Discord interaction lifecycle (defer + reply) - * - Route execution to the correct subcommand handler - * - * Non-goals (by design): - * - No FAQ business logic - * - No file I/O - * - No validation or permissions - * - * All real work is delegated to FAQ services in src/services/faq/. - */ +// +// Base /faq slash command. +// +// Responsibilities: +// - Define the /faq command + subcommands (Discord API shape) +// - Own the interaction lifecycle (deferReply + editReply) +// - Route to subcommand handlers +// +// Non-goals: +// - No business logic (validation, storage, permissions) +// - No file I/O +// +// Subcommands live in: src/commands/faq/subcommands/ import { MessageFlags, @@ -23,27 +20,29 @@ import { } from "discord.js"; import { logger } from "../../utils/logger.js"; -/** - * Slash command definition. - * - * This defines the public Discord API shape only. - * Each subcommand intentionally mirrors a future service operation: - * - add → create FAQ - * - get → retrieve FAQ - * - list → list FAQs - * - remove → delete FAQ - * - * Each subcommand supports an optional ephemeral flag so users - * can avoid spamming public channels. - */ +import { run as runAdd } from "./subcommands/add.js"; + export const data = new SlashCommandBuilder() .setName("faq") .setDescription("FAQ commands") + // /faq add .addSubcommand((s) => s .setName("add") .setDescription("Add a FAQ entry") + .addStringOption((o) => + o.setName("key").setDescription("Unique key").setRequired(true), + ) + .addStringOption((o) => + o.setName("title").setDescription("Short title").setRequired(true), + ) + .addStringOption((o) => + o.setName("body").setDescription("Answer text").setRequired(true), + ) + .addStringOption((o) => + o.setName("tags").setDescription("Comma-separated tags").setRequired(false), + ) .addBooleanOption((o) => o .setName("ephemeral") @@ -52,10 +51,14 @@ export const data = new SlashCommandBuilder() ), ) + // /faq get .addSubcommand((s) => s .setName("get") .setDescription("Get a FAQ entry by key") + .addStringOption((o) => + o.setName("key").setDescription("FAQ key").setRequired(true), + ) .addBooleanOption((o) => o .setName("ephemeral") @@ -64,6 +67,7 @@ export const data = new SlashCommandBuilder() ), ) + // /faq list .addSubcommand((s) => s .setName("list") @@ -76,10 +80,14 @@ export const data = new SlashCommandBuilder() ), ) + // /faq remove .addSubcommand((s) => s .setName("remove") .setDescription("Remove a FAQ entry by key") + .addStringOption((o) => + o.setName("key").setDescription("FAQ key").setRequired(true), + ) .addBooleanOption((o) => o .setName("ephemeral") @@ -88,33 +96,20 @@ export const data = new SlashCommandBuilder() ), ); -/** - * Command execution entry point. - * - * Pattern used here: - * - Determine subcommand - * - Defer reply immediately (avoid 3s timeout) - * - Route to the correct handler - * - * IMPORTANT: - * This function intentionally contains no business logic. - * Subcommand implementations will live in separate files/services. - */ export async function execute(interaction: ChatInputCommandInteraction): Promise { const sub = interaction.options.getSubcommand(true); const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; - // Parent command owns the interaction lifecycle + // Parent command owns the interaction lifecycle. await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); try { - // Placeholder routing targets. - // Each branch will later call into faqService helpers. if (sub === "add") { - await interaction.editReply("TODO: /faq add"); + await runAdd(interaction); return; } + // Placeholders for upcoming tickets if (sub === "get") { await interaction.editReply("TODO: /faq get"); return; @@ -130,10 +125,8 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise return; } - // Safety net in case Discord sends something unexpected await interaction.editReply("Unknown subcommand."); } catch (err) { - // Centralized error logging, user sees generic failure logger.error({ err, sub }, "[faq] subcommand failed"); await interaction.editReply("Something went wrong. Try again in a bit."); } diff --git a/src/commands/faq/services.ts b/src/commands/faq/services.ts new file mode 100644 index 00000000..7d9a74ed --- /dev/null +++ b/src/commands/faq/services.ts @@ -0,0 +1,229 @@ +// src/services/faq/services.ts +// +// FAQ business logic layer. +// +// Responsibilities: +// - Validate + normalize inputs (keys, title/body lengths, tags) +// - Read/write the persistent store via store.ts +// - Return domain objects (FaqEntry) to command handlers +// +// Non-goals: +// - Discord interaction handling (that stays in commands) +// - Low-level file I/O details (that stays in store.ts) + +import { loadStore, saveStore } from "./store.js"; +import { logger } from "../../utils/logger.js"; +import { + MAX_KEY_LEN, + MAX_TITLE_LEN, + MAX_BODY_LEN, + MAX_TAGS, + MAX_TAG_LEN, +} from "./types.js"; +import type { FaqEntry, CreateFaqInput, UpdateFaqPatch } from "./types.js"; + +/** + * Return all FAQ entries (unordered). + */ +export function getAll(): FaqEntry[] { + const store = loadStore(); + return Object.values(store.entries); +} + +/** + * Find an entry by key. Returns null when missing. + */ +export function getByKey(rawKey: string): FaqEntry | null { + const key = assertValidKey(rawKey); + const store = loadStore(); + return store.entries[key] ?? null; +} + +/** + * Create a new FAQ entry. + * Throws if duplicate key or invalid fields. + */ +export function create(input: CreateFaqInput): FaqEntry { + const key = assertValidKey(input.key); + + const title = input.title.trim(); + if (title.length === 0) throw new Error("FAQ title cannot be empty"); + if (title.length > MAX_TITLE_LEN) { + throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); + } + + const body = input.body.trim(); + if (body.length === 0) throw new Error("FAQ body cannot be empty"); + if (body.length > MAX_BODY_LEN) { + throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); + } + + const tags = normalizeTags(input.tags); + + const store = loadStore(); + if (store.entries[key]) { + throw new Error("FAQ with this key already exists"); + } + + const now = new Date().toISOString(); + + const entry: FaqEntry = { + key, + title, + body, + tags, + createdAt: now, + updatedAt: now, + createdBy: input.actor, + updatedBy: input.actor, + usageCount: 0, + }; + + store.entries[key] = entry; + saveStore(store); + + logger.info({ key, actor: input.actor }, "[faq] created"); + return entry; +} + +/** + * Update an existing FAQ entry (title/body/tags). + * Throws if key not found or patch fields invalid. + */ +export function update(rawKey: string, patch: UpdateFaqPatch): FaqEntry { + const key = assertValidKey(rawKey); + + const store = loadStore(); + const entry = store.entries[key]; + if (!entry) throw new Error(`FAQ not found: ${key}`); + + const now = new Date().toISOString(); + + // Title (optional) + if (patch.title !== undefined) { + const title = patch.title.trim(); + if (title.length === 0) throw new Error("FAQ title cannot be empty"); + if (title.length > MAX_TITLE_LEN) { + throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); + } + entry.title = title; + } + + // Body (optional) + if (patch.body !== undefined) { + const body = patch.body.trim(); + if (body.length === 0) throw new Error("FAQ body cannot be empty"); + if (body.length > MAX_BODY_LEN) { + throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); + } + entry.body = body; + } + + // Tags (optional) + if (patch.tags !== undefined) { + entry.tags = normalizeTags(patch.tags); + } + + // Always update audit fields on a successful update call. + entry.updatedAt = now; + entry.updatedBy = patch.actor; + + store.entries[key] = entry; + saveStore(store); + + logger.info({ key, actor: patch.actor }, "[faq] updated"); + return entry; +} + +/** + * Remove an entry by key. + * Returns false if missing. + */ +export function remove(rawKey: string): boolean { + const key = assertValidKey(rawKey); + + const store = loadStore(); + if (!store.entries[key]) return false; + + delete store.entries[key]; + saveStore(store); + + logger.info({ key }, "[faq] removed"); + return true; +} + +/** + * Increment usage count for an entry (for analytics/ranking). + * Returns false if missing. + */ +export function incrementUsage(rawKey: string): boolean { + const key = assertValidKey(rawKey); + + const store = loadStore(); + const entry = store.entries[key]; + if (!entry) return false; + + entry.usageCount += 1; + entry.updatedAt = new Date().toISOString(); + + store.entries[key] = entry; + saveStore(store); + + return true; +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * Normalize keys so users can type variations but store is consistent. + * Example: " How To Deploy?! " -> "how-to-deploy" + */ +function normalizeKey(rawKey: string): string { + return rawKey + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-_]/g, ""); +} + +/** + * Validate and return a normalized key (single source of truth). + */ +function assertValidKey(rawKey: string): string { + const key = normalizeKey(rawKey); + if (key.length === 0) throw new Error("FAQ key cannot be empty"); + if (key.length > MAX_KEY_LEN) { + throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); + } + return key; +} + +/** + * Normalize tags: + * - optional input -> [] + * - trim + * - drop empties + * - enforce per-tag max length + * - de-dupe + * - enforce max number of tags + * + * Note: casing is preserved. If you want lowercased tags, add `.toLowerCase()`. + */ +function normalizeTags(tags?: string[]): string[] { + if (!tags) return []; + + const cleaned = tags + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.length > MAX_TAG_LEN ? t.slice(0, MAX_TAG_LEN) : t)); + + const deduped = Array.from(new Set(cleaned)); + + if (deduped.length > MAX_TAGS) { + return deduped.slice(0, MAX_TAGS); + } + + return deduped; +} diff --git a/src/commands/faq/store.ts b/src/commands/faq/store.ts new file mode 100644 index 00000000..6f1216a8 --- /dev/null +++ b/src/commands/faq/store.ts @@ -0,0 +1,104 @@ +// src/services/faq/store.ts +// +// Low-level persistence layer for FAQ data. +// +// Responsibilities: +// - Own the on-disk storage format and location +// - Ensure the data file exists +// - Load and save the FAQ store safely +// +// Non-goals: +// - No business rules (validation, permissions, etc.) +// - No Discord logic +// - No mutation decisions (that belongs in services.ts) +// +// This file is intentionally boring and predictable. + +import fs from "fs"; +import path from "path"; +import { logger } from "../../utils/logger.js"; +import type { FaqStoreV1 } from "./types.js"; + +/** + * Absolute path to the FAQ JSON store. + * + * Using process.cwd() keeps the data location stable regardless + * of where the code is imported from. + */ +const STORE_PATH = path.join(process.cwd(), "data", "faqs.json"); + +/** + * Canonical empty store for version 1. + * + * This acts as: + * - The initial file contents + * - A fallback if loading/parsing fails + */ +const EMPTY_STORE: FaqStoreV1 = { + version: 1, + entries: {}, +}; + +/** + * Ensure the FAQ store file exists on disk. + * + * Behavior: + * - If the file already exists, do nothing + * - If missing, create the parent directory and write EMPTY_STORE + * + * This function is idempotent and safe to call repeatedly. + */ +export function ensureStoreFile(): void { + if (fs.existsSync(STORE_PATH)) return; + + fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); + fs.writeFileSync(STORE_PATH, JSON.stringify(EMPTY_STORE, null, 2), "utf8"); + + logger.info("[faq] created empty faq store"); +} + +/** + * Load the FAQ store from disk. + * + * Guarantees: + * - Always returns a valid FaqStoreV1 object + * + * Failure handling: + * - If the file is missing, it will be created + * - If JSON parsing fails or the shape is invalid, + * logs the error and falls back to EMPTY_STORE + * + * This prevents runtime crashes from malformed data. + */ +export function loadStore(): FaqStoreV1 { + ensureStoreFile(); + + try { + const raw = fs.readFileSync(STORE_PATH, "utf8"); + const parsed = JSON.parse(raw) as FaqStoreV1; + + // Minimal schema guard so downstream code can trust the shape + if (parsed.version !== 1 || typeof parsed.entries !== "object" || !parsed.entries) { + throw new Error("Invalid FAQ store shape"); + } + + return parsed; + } catch (err) { + logger.error({ err }, "[faq] failed to load store, using empty store"); + return { ...EMPTY_STORE }; + } +} + +/** + * Persist the FAQ store back to disk. + * + * Notes: + * - Overwrites the entire file atomically + * - Pretty-prints JSON for human inspection and diffs + * + * Any caller of this function is responsible for ensuring + * the store object is already valid. + */ +export function saveStore(store: FaqStoreV1): void { + fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} diff --git a/src/commands/faq/subcommands/add.ts b/src/commands/faq/subcommands/add.ts index 9384a8e1..a3649ed8 100644 --- a/src/commands/faq/subcommands/add.ts +++ b/src/commands/faq/subcommands/add.ts @@ -1 +1,54 @@ // src/commands/faq/subcommand/add.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; +import { create } from "../services.js"; +import { MAX_KEY_LEN } from "../../../services/faq/types.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + // 1) Read inputs (these must exist in faq.ts builder options) + const rawKey = interaction.options.getString("key", true); + const rawTitle = interaction.options.getString("title", true); + const rawBody = interaction.options.getString("body", true); + const rawTags = interaction.options.getString("tags", false); // optional + + // 2) Clean / normalize user input + const key = rawKey.trim(); + const title = rawTitle.trim(); + const body = rawBody.trim(); + + // 3) Validate minimal stuff here (empty, obvious length checks) + // - disallow empty key after trim + // - maybe guard title/body too + // - keep it small here, deeper rules can live in services.ts + if (key.length === 0) { + await interaction.editReply("❌ Key cannot be empty."); + return; + } + + // 4) Parse tags (comma-separated) + const tags = + rawTags + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean) ?? []; + + // 5) Call service (service does normalization + persistence) + const entry = create({ + key, + title, + body, + tags, + actor: interaction.user.id, + }); + + // 6) Reply + await interaction.editReply(`✅ Added FAQ **${entry.key}**`); + + logger.info({ userId: interaction.user.id, key: entry.key }, "[faq/add] created"); + } catch (err) { + logger.error({ err }, "[faq/add] failed"); + await interaction.editReply("❌ Could not add FAQ right now. Try again in a bit."); + } +} diff --git a/src/commands/faq/types.ts b/src/commands/faq/types.ts new file mode 100644 index 00000000..4b13a059 --- /dev/null +++ b/src/commands/faq/types.ts @@ -0,0 +1,37 @@ +// src/services/faq/types.ts + +// Constants (values live here so they can be imported as real values) +export const MAX_KEY_LEN = 48; +export const MAX_TITLE_LEN = 80; // pick something reasonable +export const MAX_BODY_LEN = 4000; // pick something reasonable +export const MAX_TAGS = 10; +export const MAX_TAG_LEN = 24; + +export type FaqEntry = { + key: string; + title: string; + body: string; + tags: string[]; + createdAt: string; // ISO + updatedAt: string; // ISO + createdBy: string; // user id or "system" + updatedBy: string; // user id or "system" + usageCount: number; +}; + +export type FaqStoreV1 = { + version: 1; + entries: Record; // key -> entry +}; + +export type CreateFaqInput = { + key: string; + title: string; + body: string; + tags?: string[]; + actor: string; +}; + +export type UpdateFaqPatch = Partial> & { + actor: string; +}; From d9551d2701177ae7687bf22df9cf2ae3b2eb121c Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 24 Dec 2025 10:48:50 -0500 Subject: [PATCH 2/3] Adding changes to add FAQ in --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f2fe63ec..b0b8ca72 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,10 @@ OmegaBot is online │ │ │ │ ├── get.ts │ │ │ │ ├── list.ts │ │ │ │ └── remove.ts -│ │ │ └── faq.ts +│ │ │ ├── faq.ts +│ │ │ ├── services.ts +│ │ │ ├── store.ts +│ │ │ └── types.ts │ │ ├── fun │ │ │ ├── subcommands │ │ │ │ ├── chucknorris.ts From bf123628e9e03f2f99943ee7c0d1a87ea918dcf0 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 24 Dec 2025 11:06:09 -0500 Subject: [PATCH 3/3] Adding add subcommand function for FAQ slash command --- src/commands/faq/subcommands/add.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/faq/subcommands/add.ts b/src/commands/faq/subcommands/add.ts index a3649ed8..3f44defc 100644 --- a/src/commands/faq/subcommands/add.ts +++ b/src/commands/faq/subcommands/add.ts @@ -22,8 +22,8 @@ export async function run(interaction: ChatInputCommandInteraction): Promise MAX_KEY_LEN) { + await interaction.editReply(`❌ Key is too long (max ${MAX_KEY_LEN} characters).`); return; }