Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,31 @@ OmegaBot is online
│ │ └── OmegaBot.yml
│ └── pull_request_template.md
├── .husky
│ ├── _
│ │ ├── .gitignore
│ │ ├── applypatch-msg
│ │ ├── commit-msg
│ │ ├── h
│ │ ├── husky.sh
│ │ ├── post-applypatch
│ │ ├── post-checkout
│ │ ├── post-commit
│ │ ├── post-merge
│ │ ├── post-rewrite
│ │ ├── pre-applypatch
│ │ ├── pre-auto-gc
│ │ ├── pre-commit
│ │ ├── pre-merge-commit
│ │ ├── pre-push
│ │ ├── pre-rebase
│ │ └── prepare-commit-msg
│ ├── pre-commit
│ └── pre-push
├── assets
│ ├── banner.png
│ └── omegabot.png
├── data
│ ├── faqs.json
│ ├── last-seen.json
│ └── timezones.json
├── docs
Expand Down Expand Up @@ -183,7 +202,9 @@ OmegaBot is online
│ │ │ ├── fetchChannelMessages.ts
│ │ │ └── interactionHandler.ts
│ │ ├── faq
│ │ │ └── type.ts
│ │ │ ├── faqService.ts
│ │ │ ├── store.ts
│ │ │ └── types.ts
│ │ ├── github
│ │ │ ├── githubApi.ts
│ │ │ ├── githubClient.ts
Expand Down
176 changes: 176 additions & 0 deletions src/services/faq/faqService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// src/services/faq/services.ts

import { loadStore, saveStore } from "./store.js";
import { MAX_KEY_LEN, type CreateFaqInput, type FaqEntry } from "./types.js";

/* -------------------------------------------------------------------------- */
/* Public API */
/* -------------------------------------------------------------------------- */

/**
* Return all FAQ entries.
*
* Note:
* - Returns values only (does not guarantee order).
* - Caller can sort if needed (ex: by usageCount or updatedAt).
*/
export function getAll(): FaqEntry[] {
const store = loadStore();
return Object.values(store.entries);
}

/**
* Lookup an FAQ by key.
*
* The key is normalized the same way as create/update/remove.
*/
export function getByKey(key: string): FaqEntry | null {
const store = loadStore();
const k = assertValidKey(key);
return store.entries[k] ?? null;
}

/**
* Create a new FAQ entry.
*
* Throws if:
* - key normalizes to empty
* - key is too long
* - key already exists
*/
export function create(input: CreateFaqInput): FaqEntry {
const store = loadStore();

const key = assertValidKey(input.key);
if (store.entries[key]) {
throw new Error(`FAQ with this key already exists: ${key}`);
}

const now = new Date().toISOString();

const entry: FaqEntry = {
key,
title: input.title.trim(),
body: input.body.trim(),
tags: normalizeTags(input.tags ?? []),
createdAt: now,
updatedAt: now,
createdBy: input.actor,
updatedBy: input.actor,
usageCount: 0,
};

store.entries[key] = entry;
saveStore(store);

return entry;
}

/**
* Allowed update fields for an FAQ.
* actor is required so we can attribute updatedBy.
*/
export type UpdateFaqPatch = Partial<Pick<FaqEntry, "title" | "body" | "tags">> & {
actor: string;
};

/**
* Update an existing FAQ entry by key.
*
* Throws if:
* - key invalid
* - entry not found
*/
export function update(key: string, patch: UpdateFaqPatch): FaqEntry {
const k = assertValidKey(key);

const store = loadStore();
const existing = store.entries[k];

if (!existing) {
throw new Error(`FAQ not found: ${k}`);
}

// Apply patch (only touch provided fields).
if (patch.title !== undefined) existing.title = patch.title.trim();
if (patch.body !== undefined) existing.body = patch.body.trim();
if (patch.tags !== undefined) existing.tags = normalizeTags(patch.tags);

existing.updatedAt = new Date().toISOString();
existing.updatedBy = patch.actor;

store.entries[k] = existing;
saveStore(store);

return existing;
}

/**
* Remove an FAQ by key.
*
* Returns:
* - true if removed
* - false if it did not exist
*/
export function remove(key: string): boolean {
const store = loadStore();
const k = assertValidKey(key);

if (!store.entries[k]) return false;

delete store.entries[k];
saveStore(store);
return true;
}

/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */

/**
* Normalize an FAQ key into a stable URL-ish slug.
*
* Example:
* "How do I reset my token?" -> "how-do-i-reset-my-token"
*/
function normalizeKey(key: string): string {
return key
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-_]/g, "");
}

/**
* Validate and return the normalized key.
*
* Keep this throwing:
* - Callers can catch at the command layer and reply nicely.
* - Services stay strict and predictable.
*/
function assertValidKey(key: string): string {
const k = normalizeKey(key);

if (k.length === 0) {
throw new Error("FAQ key is empty after normalization");
}

if (k.length > MAX_KEY_LEN) {
throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`);
}

return k;
}

/**
* Normalize tags for consistency:
* - trim
* - lowercase
* - remove empties
* - de-dupe
*/
function normalizeTags(tags: string[]): string[] {
const cleaned = tags.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);

return Array.from(new Set(cleaned));
}
83 changes: 83 additions & 0 deletions src/services/faq/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// src/services/faq/store.ts

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 data file.
* Stored outside src/ so it persists across builds.
*/
const STORE_PATH = path.join(process.cwd(), "data", "faqs.json");

/**
* Empty FAQ store template (schema v1).
* Used on first run or when recovery is required.
*/
const EMPTY_STORE: FaqStoreV1 = {
version: 1,
entries: {},
};

/**
* Ensure the FAQ store file exists on disk.
*
* - Creates parent directories if missing
* - Writes an empty store if the file does not exist
* - Safe to call multiple times
*/
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.
*
* Behavior:
* - Ensures the store file exists
* - Parses JSON from disk
* - Validates basic schema shape
* - Falls back to an empty store on error
*/
export function loadStore(): FaqStoreV1 {
ensureStoreFile();

try {
const raw = fs.readFileSync(STORE_PATH, "utf8");
const parsed = JSON.parse(raw) as FaqStoreV1;

if (
parsed.version !== 1 ||
typeof parsed.entries !== "object" ||
parsed.entries === null
) {
throw new Error("Invalid FAQ store shape");
}

return parsed;
} catch (err) {
logger.error({ err }, "[faq] failed to load store, falling back to empty store");

return {
version: 1,
entries: {},
};
}
}

/**
* Persist the FAQ store back to disk.
*
* Overwrites the entire store atomically.
*/
export function saveStore(store: FaqStoreV1): void {
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8");
}
16 changes: 0 additions & 16 deletions src/services/faq/type.ts

This file was deleted.

74 changes: 74 additions & 0 deletions src/services/faq/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// src/services/faq/types.ts

/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */

/**
* Maximum length of an FAQ key after normalization.
*
* Keys are used as:
* - slash command arguments
* - map keys in the JSON store
* - human-readable identifiers
*
* Keeping this short prevents abuse and awkward UX.
*/
export const MAX_KEY_LEN = 48;

/* -------------------------------------------------------------------------- */
/* Core Types */
/* -------------------------------------------------------------------------- */

/**
* A single FAQ entry as stored and returned by the service layer.
*
* Notes:
* - `key` is normalized (lowercase, kebab-case).
* - timestamps are ISO strings for easy JSON storage.
* - usageCount is incremented when an FAQ is retrieved.
*/
export type FaqEntry = {
key: string;
title: string;
body: string;
tags: string[];

createdAt: string;
updatedAt: string;

createdBy: string; // user id or "system"
updatedBy: string; // user id or "system"

usageCount: number;
};

/**
* On-disk FAQ store format (v1).
*
* Versioning lets us migrate later without breaking users.
*/
export type FaqStoreV1 = {
version: 1;
entries: Record<string, FaqEntry>;
};

/* -------------------------------------------------------------------------- */
/* Input Types */
/* -------------------------------------------------------------------------- */

/**
* Input required to create a new FAQ.
*
* Validation rules:
* - key is normalized and validated by the service layer
* - title/body are trimmed
* - tags are optional and normalized if provided
*/
export type CreateFaqInput = {
key: string;
title: string;
body: string;
tags?: string[];
actor: string; // user id or "system"
};