From daf78f586e9ec1341af5da5e72eec5147cf0ea4e Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 19 Jan 2026 17:08:03 -0500 Subject: [PATCH 1/2] Adding in files --- .env.example | 39 ++- src/commands/history/history.ts | 101 ++++-- src/commands/summary/summary.ts | 189 ++++------ src/registerCommands.ts | 33 +- src/services/github/githubApi.ts.backup | 248 ------------- src/services/github/issueAssigneePoller.ts | 101 +----- .../github/issueAssigneePoller.ts.backup | 330 ------------------ .../github/issueAssigneePollerState.ts | 114 ++++++ src/services/summary/llmSummary.ts | 123 +++++-- src/services/summary/summarizer.ts | 5 +- 10 files changed, 408 insertions(+), 875 deletions(-) delete mode 100644 src/services/github/githubApi.ts.backup delete mode 100644 src/services/github/issueAssigneePoller.ts.backup create mode 100644 src/services/github/issueAssigneePollerState.ts diff --git a/.env.example b/.env.example index ad70bbd4..1176547f 100644 --- a/.env.example +++ b/.env.example @@ -232,4 +232,41 @@ LOG_PRETTY=true # Only relevant if using database features (currently not implemented) # # Default: data/omegabot.db -DATABASE_PATH=data/omegabot.dbß \ No newline at end of file +DATABASE_PATH=data/omegabot.db + +# ============================================================ +# AI Services (OPTIONAL) +# ============================================================ +# +# OmegaBot can summarize recent channel messages using either: +# - Local mode (no API calls, free, lower quality) +# - LLM mode (uses OpenAI, higher quality) +# +# Commands that use summarization: +# - /summary → DM you a summary of recent messages +# +# History command does NOT use an LLM: +# - /history → DMs raw recent messages (no AI) +# + +# Summary mode: +# - local → Use lightweight local summarizer (default, no API key needed) +# - llm → Use OpenAI for higher quality summaries (requires OPENAI_API_KEY) +SUMMARY_MODE=local + +# OpenAI API Key (required only if SUMMARY_MODE=llm) +# Get it from: https://platform.openai.com/api-keys +# +# Used for: +# - /summary (when SUMMARY_MODE=llm) +# +# IMPORTANT: Keep this secret. Never commit it to git. +OPENAI_API_KEY= + +# Optional: Override which model is used for LLM summaries. +# If unset, the bot will use a safe default (ex: gpt-4o-mini). +# +# Examples: +# OPENAI_MODEL=gpt-4o-mini +# OPENAI_MODEL=gpt-4o +OPENAI_MODEL= \ No newline at end of file diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 7e5f99b5..bba3e40d 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -1,4 +1,3 @@ -// src/commands/history/history.ts import { SlashCommandBuilder, type ChatInputCommandInteraction, @@ -18,70 +17,100 @@ export const data = new SlashCommandBuilder() .setMaxValue(100), ); +function formatMessageLine(args: { + createdAt: Date; + authorTag: string; + content: string; + attachmentCount: number; + hasEmbeds: boolean; +}): string { + const ts = args.createdAt.toLocaleString(); + const extra: string[] = []; + + if (args.attachmentCount > 0) extra.push(`${args.attachmentCount} attachment(s)`); + if (args.hasEmbeds) extra.push("embed(s)"); + + const suffix = extra.length ? ` [${extra.join(", ")}]` : ""; + return `[${ts}] ${args.authorTag}: ${args.content}${suffix}`; +} + export async function execute(interaction: ChatInputCommandInteraction): Promise { await interaction.deferReply({ ephemeral: true }); const count = interaction.options.getInteger("count") ?? 50; try { - // Fetch messages from the channel - const messages = await interaction.channel?.messages.fetch({ limit: count }); + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply( + "This channel does not support reading message history.", + ); + return; + } - if (!messages || messages.size === 0) { + const messages = await interaction.channel.messages.fetch({ limit: count }); + + if (messages.size === 0) { await interaction.editReply("No messages found in this channel."); return; } - // Format messages in chronological order (oldest first) const formatted = Array.from(messages.values()) .reverse() .map((msg) => { - const timestamp = msg.createdAt.toLocaleString(); - const author = msg.author.tag; - const content = msg.content || "[No text content]"; - return `[${timestamp}] ${author}: ${content}`; + const content = msg.content?.trim() ? msg.content : "[No text content]"; + return formatMessageLine({ + createdAt: msg.createdAt, + authorTag: msg.author.tag, + content, + attachmentCount: msg.attachments.size, + hasEmbeds: msg.embeds.length > 0, + }); }) - .join("\n\n"); + .join("\n"); + + const header = `Message History (${messages.size} messages from #${ + "name" in interaction.channel && interaction.channel.name + ? interaction.channel.name + : "channel" + })`; - // Try to send via DM try { const user = interaction.user; - // If content is small enough, send directly - if (formatted.length < 1900) { - await user.send({ - content: `**Message History** (${messages.size} messages from #${interaction.channel?.name || "channel"})\n\n${formatted}`, - }); - await interaction.editReply(`✅ Sent ${messages.size} messages to your DMs!`); - } else { - // Content too long - send as file - const buffer = Buffer.from(formatted, "utf-8"); - const attachment = new AttachmentBuilder(buffer, { - name: `history-${Date.now()}.txt`, - }); + // If it's short enough, send directly + const asText = `**${header}**\n\n${formatted}`; + if (asText.length <= 1900) { + await user.send({ content: asText }); + await interaction.editReply(`✅ Sent ${messages.size} messages to your DMs.`); + return; + } - await user.send({ - content: `**Message History** (${messages.size} messages from #${interaction.channel?.name || "channel"})`, - files: [attachment], - }); + // Otherwise send as a file + const buffer = Buffer.from(`${header}\n\n${formatted}`, "utf-8"); + const attachment = new AttachmentBuilder(buffer, { + name: `history-${Date.now()}.txt`, + }); - await interaction.editReply( - `✅ Sent ${messages.size} messages to your DMs as a file!`, - ); - } + await user.send({ + content: `**${header}** (sent as a file)`, + files: [attachment], + }); + + await interaction.editReply( + `✅ Sent ${messages.size} messages to your DMs as a file.`, + ); } catch (dmError) { - // User has DMs disabled logger.warn( - { userId: interaction.user.id, error: dmError }, - "[history] Could not send DM", + { userId: interaction.user.id, err: dmError }, + "[history] could not send DM", ); await interaction.editReply( - "❌ I couldn't send you a DM. Please enable DMs from server members and try again.", + "❌ I couldn't DM you. Enable DMs from server members and try again.", ); } } catch (error) { - logger.error({ error, count }, "[history] Command failed"); + logger.error({ error, count }, "[history] command failed"); await interaction.editReply("❌ Failed to fetch message history. Please try again."); } } diff --git a/src/commands/summary/summary.ts b/src/commands/summary/summary.ts index 55b5cf6e..bba3e40d 100644 --- a/src/commands/summary/summary.ts +++ b/src/commands/summary/summary.ts @@ -1,153 +1,116 @@ -// src/commands/summary/summary.ts - import { SlashCommandBuilder, - AttachmentBuilder, - MessageFlags, type ChatInputCommandInteraction, + AttachmentBuilder, } from "discord.js"; -import { summarize } from "../../services/summary/summarizer.js"; import { logger } from "../../utils/logger.js"; -/** - * Defines the /summary command. - * - * Summarizes recent messages from the current channel - * and delivers the result privately via DM. - */ export const data = new SlashCommandBuilder() - .setName("summary") - .setDescription("Summarize recent messages and DM it to you") + .setName("history") + .setDescription("Get recent message history via DM") .addIntegerOption((opt) => opt .setName("count") - .setDescription("How many messages to fetch") - .setMinValue(10) + .setDescription("Number of messages to retrieve (default: 50, max: 100)") + .setRequired(false) + .setMinValue(1) .setMaxValue(100), ); -/** - * Handler for the /summary command. - * - * Flow: - * 1. Defer an ephemeral reply so the user sees feedback immediately. - * 2. Validate the channel supports messages. - * 3. Fetch and filter recent user messages. - * 4. Build a transcript. - * 5. Generate a summary (local or LLM). - * 6. Deliver the result via DM (text or file). - * 7. Handle failures gracefully. - */ +function formatMessageLine(args: { + createdAt: Date; + authorTag: string; + content: string; + attachmentCount: number; + hasEmbeds: boolean; +}): string { + const ts = args.createdAt.toLocaleString(); + const extra: string[] = []; + + if (args.attachmentCount > 0) extra.push(`${args.attachmentCount} attachment(s)`); + if (args.hasEmbeds) extra.push("embed(s)"); + + const suffix = extra.length ? ` [${extra.join(", ")}]` : ""; + return `[${ts}] ${args.authorTag}: ${args.content}${suffix}`; +} + export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + const count = interaction.options.getInteger("count") ?? 50; try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - /** - * Guard: only text-capable channels can be summarized. - */ if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply("This channel does not support summarizing messages."); + await interaction.editReply( + "This channel does not support reading message history.", + ); return; } const messages = await interaction.channel.messages.fetch({ limit: count }); if (messages.size === 0) { - await interaction.editReply("No messages found to summarize."); + await interaction.editReply("No messages found in this channel."); return; } - /** - * Filter out bot messages and empty content, - * then sort oldest → newest for readability. - */ - const userMessages = messages - .filter((m) => !m.author.bot && m.content) - .sort((a, b) => a.createdTimestamp - b.createdTimestamp); - - if (userMessages.size === 0) { - await interaction.editReply("No usable messages found to summarize."); - return; - } - - /** - * Build a simple transcript the summarizer can consume. - */ - const text = userMessages.map((m) => `${m.author.username}: ${m.content}`).join("\n"); - - const output = await summarize(text); - - if (!output || output.trim().length === 0) { - await interaction.editReply("Summary came back empty."); - return; - } - - /** - * If the summary exceeds Discord message limits, - * send it as a file attachment instead. - */ - if (output.length > 2000) { - const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { - name: "summary.txt", - }); - - try { - await interaction.user.send({ - content: "Here is your summary (too long to send as a message):", - files: [file], + const formatted = Array.from(messages.values()) + .reverse() + .map((msg) => { + const content = msg.content?.trim() ? msg.content : "[No text content]"; + return formatMessageLine({ + createdAt: msg.createdAt, + authorTag: msg.author.tag, + content, + attachmentCount: msg.attachments.size, + hasEmbeds: msg.embeds.length > 0, }); + }) + .join("\n"); - await interaction.editReply("Summary sent to your DMs."); - } catch (err) { - logger.warn( - { err, userId: interaction.user.id }, - "[summary] DM file send failed", - ); + const header = `Message History (${messages.size} messages from #${ + "name" in interaction.channel && interaction.channel.name + ? interaction.channel.name + : "channel" + })`; - await interaction.editReply( - "I generated the summary, but your DMs appear to be closed.", - ); + try { + const user = interaction.user; + + // If it's short enough, send directly + const asText = `**${header}**\n\n${formatted}`; + if (asText.length <= 1900) { + await user.send({ content: asText }); + await interaction.editReply(`✅ Sent ${messages.size} messages to your DMs.`); + return; } - return; - } + // Otherwise send as a file + const buffer = Buffer.from(`${header}\n\n${formatted}`, "utf-8"); + const attachment = new AttachmentBuilder(buffer, { + name: `history-${Date.now()}.txt`, + }); - /** - * Normal-sized summary: send as plain DM text. - */ - try { - await interaction.user.send(output); - await interaction.editReply("Summary sent to your DMs."); - } catch (err) { - logger.warn({ err, userId: interaction.user.id }, "[summary] DM text send failed"); + await user.send({ + content: `**${header}** (sent as a file)`, + files: [attachment], + }); await interaction.editReply( - "I generated the summary, but could not DM you. Your DMs may be closed.", + `✅ Sent ${messages.size} messages to your DMs as a file.`, + ); + } catch (dmError) { + logger.warn( + { userId: interaction.user.id, err: dmError }, + "[history] could not send DM", ); - } - } catch (err) { - /** - * Top-level failure handler. - * We log the error and attempt to notify the user once. - */ - logger.error( - { err, command: "summary", userId: interaction.user.id }, - "[summary] Summary generation failed", - ); - try { - if (interaction.replied || interaction.deferred) { - await interaction.editReply("Something went wrong while generating the summary."); - } else { - await interaction.reply({ - content: "Something went wrong while generating the summary.", - flags: MessageFlags.Ephemeral, - }); - } - } catch (replyErr) { - logger.error({ err: replyErr }, "[summary] Failed to send fallback error message"); + await interaction.editReply( + "❌ I couldn't DM you. Enable DMs from server members and try again.", + ); } + } catch (error) { + logger.error({ error, count }, "[history] command failed"); + await interaction.editReply("❌ Failed to fetch message history. Please try again."); } } diff --git a/src/registerCommands.ts b/src/registerCommands.ts index 427131ab..671ca4f1 100644 --- a/src/registerCommands.ts +++ b/src/registerCommands.ts @@ -40,9 +40,7 @@ async function registerCommands(): Promise { const commandsPath = path.join(process.cwd(), "dist", "commands"); if (!fs.existsSync(commandsPath)) { - throw new Error( - `dist/commands not found. Did you forget to run "npm run build"?`, - ); + throw new Error(`dist/commands not found. Did you forget to run "npm run build"?`); } const commandFiles = walkFiles(commandsPath).filter( @@ -59,20 +57,14 @@ async function registerCommands(): Promise { const imported = (await import(moduleUrl)) as Partial; if (!imported.data) { - logger.debug( - { file: relFile }, - "Skipping non-command module (missing data)", - ); + logger.debug({ file: relFile }, "Skipping non-command module (missing data)"); continue; } commands.push(imported.data.toJSON()); logger.info({ command: imported.data.name }, "Prepared command for registration"); } catch (err) { - logger.warn( - { err, file: relFile }, - "Failed to load command for registration", - ); + logger.warn({ err, file: relFile }, "Failed to load command for registration"); } } @@ -81,17 +73,13 @@ async function registerCommands(): Promise { return; } - logger.info( - { count: commands.length }, - "Registering application (/) commands", - ); + logger.info({ count: commands.length }, "Registering application (/) commands"); if (env.guildId) { // Guild-scoped (fast refresh, dev-friendly) - await rest.put( - Routes.applicationGuildCommands(env.appId, env.guildId), - { body: commands }, - ); + await rest.put(Routes.applicationGuildCommands(env.appId, env.guildId), { + body: commands, + }); logger.info( { guildId: env.guildId, count: commands.length }, @@ -103,14 +91,11 @@ async function registerCommands(): Promise { body: commands, }); - logger.info( - { count: commands.length }, - "Global commands registered", - ); + logger.info({ count: commands.length }, "Global commands registered"); } } registerCommands().catch((err) => { logger.error({ err }, "Command registration failed"); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/services/github/githubApi.ts.backup b/src/services/github/githubApi.ts.backup deleted file mode 100644 index d38a50d0..00000000 --- a/src/services/github/githubApi.ts.backup +++ /dev/null @@ -1,248 +0,0 @@ -// src/services/github/githubApi.ts - -import { githubRequest, GitHubApiError } from "./githubClient.js"; -import { logger } from "../../utils/logger.js"; -import type { - GitHubIssue, - GitHubPullRequest, - GitHubIssueSummary, - GitHubPrSummary, -} from "./types.js"; - -/* ------------------------------------------------------------------ */ -/* Path helpers */ -/* ------------------------------------------------------------------ */ - -/** - * Build the base repo API path. - */ -function repoBase(owner: string, repo: string): string { - return `/repos/${owner}/${repo}`; -} - -function issuePath(owner: string, repo: string, number: number): string { - return `${repoBase(owner, repo)}/issues/${number}`; -} - -function prPath(owner: string, repo: string, number: number): string { - return `${repoBase(owner, repo)}/pulls/${number}`; -} - -export type ListIssuesOptions = { - state?: "open" | "closed" | "all"; - limit?: number; - labels?: string[]; -}; - -function listIssuesPath( - owner: string, - repo: string, - options?: ListIssuesOptions, -): string { - const params = new URLSearchParams(); - - // GitHub default is "open", but keep explicit if the caller sets it. - if (options?.state) params.set("state", options.state); - - // GitHub uses per_page for page size. - if (options?.limit) params.set("per_page", String(options.limit)); - - // Comma-separated labels. - if (options?.labels?.length) params.set("labels", options.labels.join(",")); - - // Default sorting: most recently updated first. - params.set("sort", "updated"); - params.set("direction", "desc"); - - const query = params.toString(); - return `${repoBase(owner, repo)}/issues${query ? `?${query}` : ""}`; -} - -export type ListPrOptions = { - state?: "open" | "closed" | "all"; - limit?: number; -}; - -function listPullRequestsPath( - owner: string, - repo: string, - options?: ListPrOptions, -): string { - const params = new URLSearchParams(); - - params.set("state", options?.state ?? "open"); - params.set("per_page", String(options?.limit ?? 20)); - - // For pulls: GitHub supports sort=updated - params.set("sort", "updated"); - params.set("direction", "desc"); - - return `${repoBase(owner, repo)}/pulls?${params.toString()}`; -} - -/* ------------------------------------------------------------------ */ -/* Error helpers */ -/* ------------------------------------------------------------------ */ - -/** - * Narrow unknown errors into a GitHubApiError with status info. - * This lets callers do 404 fallback safely. - */ -function isGitHubApiError(err: unknown): err is GitHubApiError { - return err instanceof GitHubApiError; -} - -function is404(err: unknown): err is GitHubApiError { - return isGitHubApiError(err) && err.status === 404; -} - -/* ------------------------------------------------------------------ */ -/* Single item fetchers */ -/* ------------------------------------------------------------------ */ - -/** - * Fetch a single issue by number. - */ -export async function getIssue( - owner: string, - repo: string, - number: number, -): Promise { - try { - return await githubRequest(issuePath(owner, repo, number)); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, number, status: err.status, url: err.url }, - "getIssue failed", - ); - } else { - logger.error({ err, owner, repo, number }, "getIssue threw"); - } - throw err; - } -} - -/** - * Fetch a single pull request by number. - */ -export async function getPullRequest( - owner: string, - repo: string, - number: number, -): Promise { - try { - return await githubRequest(prPath(owner, repo, number)); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, number, status: err.status, url: err.url }, - "getPullRequest failed", - ); - } else { - logger.error({ err, owner, repo, number }, "getPullRequest threw"); - } - throw err; - } -} - -/** - * Try PR first, fallback to issue if PR endpoint returns 404. - */ -export async function getIssueOrPr( - owner: string, - repo: string, - number: number, -): Promise { - try { - return await getPullRequest(owner, repo, number); - } catch (err) { - if (is404(err)) { - return await getIssue(owner, repo, number); - } - throw err; - } -} - -/* ------------------------------------------------------------------ */ -/* List helpers (command-facing) */ -/* ------------------------------------------------------------------ */ - -/** - * List issues (issues-only; PRs filtered out) - * - * Note: - * GitHub's /issues endpoint can include PRs. - * PRs contain `pull_request` marker. We filter those out here. - */ -export async function listIssues( - owner: string, - repo: string, - options?: ListIssuesOptions, -): Promise { - try { - const data = await githubRequest(listIssuesPath(owner, repo, options)); - - return data - .filter((i) => !i.pull_request) - .map((i) => ({ - number: i.number, - title: i.title, - html_url: i.html_url, - state: i.state, - user: { login: i.user.login }, - comments: i.comments, - })); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, status: err.status, url: err.url, options }, - "listIssues failed", - ); - } else { - logger.error({ err, owner, repo, options }, "listIssues threw"); - } - throw err; - } -} - -/** - * List pull requests (summary view) - * - * This is what the poller should consume because it includes: - * - number, title, url, author - * - updated_at for last-seen comparisons - */ -export async function listPullRequests( - owner: string, - repo: string, - options?: ListPrOptions, -): Promise { - try { - const data = await githubRequest( - listPullRequestsPath(owner, repo, options), - ); - - return data.map((pr) => ({ - number: pr.number, - title: pr.title, - html_url: pr.html_url, - state: pr.state, - merged_at: pr.merged_at, - updated_at: pr.updated_at, - user: { login: pr.user.login }, - comments: pr.comments, - review_comments: pr.review_comments, - })); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, status: err.status, url: err.url, options }, - "listPullRequests failed", - ); - } else { - logger.error({ err, owner, repo, options }, "listPullRequests threw"); - } - throw err; - } -} diff --git a/src/services/github/issueAssigneePoller.ts b/src/services/github/issueAssigneePoller.ts index d3261878..124507ef 100644 --- a/src/services/github/issueAssigneePoller.ts +++ b/src/services/github/issueAssigneePoller.ts @@ -1,10 +1,13 @@ // src/services/github/issueAssigneePoller.ts import type { Client } from "discord.js"; -import { promises as fs } from "node:fs"; -import path from "node:path"; import { env } from "../../config/env.js"; import { logger } from "../../utils/logger.js"; +import { + loadGithubAssigneeState, + saveGithubAssigneeState, + type TrackedItem, +} from "./issueAssigneePollerState.js"; type PollArgs = { client: Client; @@ -24,27 +27,6 @@ type GitHubIssueItem = { pull_request?: unknown; }; -type TrackedItem = { - kind: "PR" | "Issue"; - title: string; - url: string; - assignees: string[]; -}; - -type StateFile = { - /** - * Back-compat: - * Older state files might only have assigneesByNumber. - * We’ll load them and upgrade in-memory automatically. - */ - assigneesByNumber?: Record; - itemsByNumber?: Record; - initializedAt: string; -}; - -const DATA_DIR = path.join(process.cwd(), "data"); -const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); - function uniqSorted(list: string[]): string[] { return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b), @@ -61,35 +43,6 @@ function diff(prev: string[], next: string[]) { return { added, removed, changed: added.length > 0 || removed.length > 0 }; } -async function ensureDataDir(): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }); -} - -async function loadState(): Promise { - try { - const raw = await fs.readFile(STATE_PATH, "utf8"); - const parsed = JSON.parse(raw) as StateFile; - if (!parsed || typeof parsed !== "object") return null; - - // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) - const hasOld = - !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; - const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; - - if (!hasOld && !hasNew) return null; - if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; - - return parsed; - } catch { - return null; - } -} - -async function saveState(state: StateFile): Promise { - await ensureDataDir(); - await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); -} - async function getAnnounceChannel( client: Client, channelId: string, @@ -127,26 +80,6 @@ async function githubFetchJson(url: string, token: string): Promise { return (await res.json()) as T; } -function stateToItems(existing: StateFile): Record { - // New format available - if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { - return existing.itemsByNumber; - } - - // Upgrade old format in-memory (no title/url/kind available) - const old = existing.assigneesByNumber ?? {}; - const upgraded: Record = {}; - for (const [num, assignees] of Object.entries(old)) { - upgraded[num] = { - kind: "Issue", - title: "(unknown title)", - url: "(unknown url)", - assignees: uniqSorted(assignees ?? []), - }; - } - return upgraded; -} - /** * Poll open issues (includes PRs) and notify on: * - assignee changes (add/remove/replace/multi/unassign) @@ -154,7 +87,7 @@ function stateToItems(existing: StateFile): Record { * * Notes: * - Baseline-first: first successful run saves state and does NOT notify. - * - State is persisted to ./data/github-assignees.json + * - State is persisted in SQLite (github_assignees_state) */ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { const { client, owner, repo, announceChannelId } = args; @@ -176,12 +109,8 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Load state (or init) - const existing = (await loadState()) ?? { - assigneesByNumber: {}, - initializedAt: new Date().toISOString(), - }; - - const existingItemsByNumber = stateToItems(existing); + const existing = loadGithubAssigneeState(owner, repo); + const existingItemsByNumber = existing.itemsByNumber; const hadExistingState = Object.keys(existingItemsByNumber).length > 0; // Fetch open issues (includes PRs) @@ -261,13 +190,13 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Save next state (drops closed issues automatically) - const nextState: StateFile = { + const nextState = { + initializedAt: existing.initializedAt, itemsByNumber: nextItemsByNumber, - initializedAt: existing.initializedAt ?? new Date().toISOString(), }; try { - await saveState(nextState); + saveGithubAssigneeState(owner, repo, nextState); } catch (err) { logger.error({ err }, "[github/assignees] failed to save state"); } @@ -281,12 +210,12 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { return; } - // Announce assignee changes + // Announce assignee changes (issues only) const issueNotifications = notifications.filter((n) => n.kind === "Issue"); for (const n of issueNotifications) { const parts: string[] = []; - parts.push(`Issue # assignees updated`); + parts.push(`Issue #${n.number} assignees updated`); parts.push(n.title); parts.push(n.url); @@ -308,12 +237,12 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } } - // Announce closures (issues + PRs) + // Announce closures (issues only) const closedIssues = closedNotifications.filter((c) => c.kind === "Issue"); for (const c of closedIssues) { const parts: string[] = []; - parts.push(`Issue # closed`); + parts.push(`Issue #${c.number} closed`); parts.push(c.title); parts.push(c.url); parts.push("(Closed/merged/etc — detected via polling)"); diff --git a/src/services/github/issueAssigneePoller.ts.backup b/src/services/github/issueAssigneePoller.ts.backup deleted file mode 100644 index 7a928e1c..00000000 --- a/src/services/github/issueAssigneePoller.ts.backup +++ /dev/null @@ -1,330 +0,0 @@ -// src/services/github/issueAssigneePoller.ts - -import type { Client } from "discord.js"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -type PollArgs = { - client: Client; - owner: string; - repo: string; - announceChannelId: string; -}; - -type GitHubAssignee = { login: string }; - -type GitHubIssueItem = { - number: number; - title: string; - html_url: string; - assignees?: GitHubAssignee[]; - // Present on PRs returned in the issues list - pull_request?: unknown; -}; - -type TrackedItem = { - kind: "PR" | "Issue"; - title: string; - url: string; - assignees: string[]; -}; - -type StateFile = { - /** - * Back-compat: - * Older state files might only have assigneesByNumber. - * We’ll load them and upgrade in-memory automatically. - */ - assigneesByNumber?: Record; - itemsByNumber?: Record; - initializedAt: string; -}; - -const DATA_DIR = path.join(process.cwd(), "data"); -const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); - -function uniqSorted(list: string[]): string[] { - return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => - a.localeCompare(b), - ); -} - -function diff(prev: string[], next: string[]) { - const prevSet = new Set(prev); - const nextSet = new Set(next); - - const added = next.filter((x) => !prevSet.has(x)); - const removed = prev.filter((x) => !nextSet.has(x)); - - return { added, removed, changed: added.length > 0 || removed.length > 0 }; -} - -async function ensureDataDir(): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }); -} - -async function loadState(): Promise { - try { - const raw = await fs.readFile(STATE_PATH, "utf8"); - const parsed = JSON.parse(raw) as StateFile; - if (!parsed || typeof parsed !== "object") return null; - - // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) - const hasOld = - !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; - const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; - - if (!hasOld && !hasNew) return null; - if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; - - return parsed; - } catch { - return null; - } -} - -async function saveState(state: StateFile): Promise { - await ensureDataDir(); - await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); -} - -async function getAnnounceChannel( - client: Client, - channelId: string, -): Promise<{ send: (content: string) => Promise }> { - const ch = await client.channels.fetch(channelId); - - if (!ch || !ch.isTextBased()) { - throw new Error(`Announce channel is not a text channel: ${channelId}`); - } - - // TypeScript guard: not all TextBasedChannel unions guarantee send() - if (!("send" in ch) || typeof ch.send !== "function") { - throw new Error(`Announce channel does not support send(): ${channelId}`); - } - - return ch; -} - -async function githubFetchJson(url: string, token: string): Promise { - const res = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "OmegaBot", - Authorization: `token ${token}`, - }, - }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error( - `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, - ); - } - - return (await res.json()) as T; -} - -function stateToItems(existing: StateFile): Record { - // New format available - if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { - return existing.itemsByNumber; - } - - // Upgrade old format in-memory (no title/url/kind available) - const old = existing.assigneesByNumber ?? {}; - const upgraded: Record = {}; - for (const [num, assignees] of Object.entries(old)) { - upgraded[num] = { - kind: "Issue", - title: "(unknown title)", - url: "(unknown url)", - assignees: uniqSorted(assignees ?? []), - }; - } - return upgraded; -} - -/** - * Poll open issues (includes PRs) and notify on: - * - assignee changes (add/remove/replace/multi/unassign) - * - issues/PRs that are no longer open (closed/merged/etc) - * - * Notes: - * - Baseline-first: first successful run saves state and does NOT notify. - * - State is persisted to ./data/github-assignees.json - */ -export async function pollIssueAssigneesOnce(args: PollArgs): Promise { - const { client, owner, repo, announceChannelId } = args; - - let token: string; - try { - token = env.requireGithubToken(); - } catch (err) { - logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); - return; - } - - let channel: { send: (content: string) => Promise }; - try { - channel = await getAnnounceChannel(client, announceChannelId); - } catch (err) { - logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); - return; - } - - // Load state (or init) - const existing = (await loadState()) ?? { - assigneesByNumber: {}, - initializedAt: new Date().toISOString(), - }; - - const existingItemsByNumber = stateToItems(existing); - const hadExistingState = Object.keys(existingItemsByNumber).length > 0; - - // Fetch open issues (includes PRs) - const url = `https://api.github.com/repos/${encodeURIComponent( - owner, - )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; - - let items: GitHubIssueItem[]; - try { - items = await githubFetchJson(url, token); - } catch (err) { - logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); - return; - } - - const nextItemsByNumber: Record = {}; - const notifications: Array<{ - kind: "PR" | "Issue"; - number: number; - title: string; - url: string; - added: string[]; - removed: string[]; - }> = []; - - for (const item of items) { - const numberKey = String(item.number); - - const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; - const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); - - nextItemsByNumber[numberKey] = { - kind, - title: item.title, - url: item.html_url, - assignees: nextAssignees, - }; - - const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); - const { added, removed, changed } = diff(prevAssignees, nextAssignees); - - if (hadExistingState && changed) { - notifications.push({ - kind, - number: item.number, - title: item.title, - url: item.html_url, - added, - removed, - }); - } - } - - // Detect items that were previously open but are no longer open - const closedNotifications: Array<{ - kind: "PR" | "Issue"; - number: number; - title: string; - url: string; - }> = []; - - if (hadExistingState) { - const prevNumbers = new Set(Object.keys(existingItemsByNumber)); - const nextNumbers = new Set(Object.keys(nextItemsByNumber)); - - for (const num of prevNumbers) { - if (!nextNumbers.has(num)) { - const prev = existingItemsByNumber[num]; - closedNotifications.push({ - kind: prev?.kind ?? "Issue", - number: Number(num), - title: prev?.title ?? "(unknown title)", - url: prev?.url ?? "(unknown url)", - }); - } - } - } - - // Save next state (drops closed issues automatically) - const nextState: StateFile = { - itemsByNumber: nextItemsByNumber, - initializedAt: existing.initializedAt ?? new Date().toISOString(), - }; - - try { - await saveState(nextState); - } catch (err) { - logger.error({ err }, "[github/assignees] failed to save state"); - } - - // Baseline-first: no notifications on first successful run - if (!hadExistingState) { - logger.info( - { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, - "[github/assignees] baseline saved (no notifications on first run)", - ); - return; - } - - // Announce assignee changes - for (const n of notifications) { - const parts: string[] = []; - parts.push(`**${n.kind} #${n.number}** assignees updated`); - parts.push(n.title); - parts.push(n.url); - - if (n.added.length) { - parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); - } - if (n.removed.length) { - parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); - } - - try { - await channel.send(parts.join("\n")); - logger.info( - { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, - "[github/assignees] announced", - ); - } catch (err) { - logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); - } - } - - // Announce closures (issues + PRs) - for (const c of closedNotifications) { - const parts: string[] = []; - parts.push(`**${c.kind} #${c.number}** is no longer open`); - parts.push(c.title); - parts.push(c.url); - parts.push("(Closed/merged/etc — detected via polling)"); - - try { - await channel.send(parts.join("\n")); - logger.info( - { number: c.number, kind: c.kind }, - "[github/assignees] closure announced", - ); - } catch (err) { - logger.error( - { err, number: c.number }, - "[github/assignees] failed to announce closure", - ); - } - } -} diff --git a/src/services/github/issueAssigneePollerState.ts b/src/services/github/issueAssigneePollerState.ts new file mode 100644 index 00000000..b0f3020c --- /dev/null +++ b/src/services/github/issueAssigneePollerState.ts @@ -0,0 +1,114 @@ +// src/services/github/issueAssigneePollerState.ts +// Persists GitHub issue/PR assignee tracking state in SQLite. + +import { getDb } from "../database/db.js"; + +export type TrackedItem = { + kind: "PR" | "Issue"; + title: string; + url: string; + assignees: string[]; +}; + +export type RepoState = { + initializedAt: string; + itemsByNumber: Record; +}; + +function safeJsonParse(raw: string | null | undefined, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export function loadGithubAssigneeState(owner: string, repo: string): RepoState { + const db = getDb(); + + const meta = db + .prepare( + `SELECT initialized_at + FROM github_assignees_meta + WHERE owner = ? AND repo = ?`, + ) + .get(owner, repo) as { initialized_at?: string } | undefined; + + const initializedAt = meta?.initialized_at ?? nowIso(); + + const rows = db + .prepare( + `SELECT number, kind, title, url, assignees_json + FROM github_assignees_state + WHERE owner = ? AND repo = ?`, + ) + .all(owner, repo) as Array<{ + number: number; + kind: "PR" | "Issue"; + title: string; + url: string; + assignees_json: string | null; + }>; + + const itemsByNumber: Record = {}; + for (const r of rows) { + itemsByNumber[String(r.number)] = { + kind: r.kind, + title: r.title, + url: r.url, + assignees: safeJsonParse(r.assignees_json, []), + }; + } + + return { initializedAt, itemsByNumber }; +} + +export function saveGithubAssigneeState( + owner: string, + repo: string, + state: RepoState, +): void { + const db = getDb(); + + const tx = db.transaction(() => { + // Ensure meta row exists + db.prepare( + `INSERT INTO github_assignees_meta (owner, repo, initialized_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(owner, repo) DO UPDATE SET updated_at = excluded.updated_at`, + ).run(owner, repo, state.initializedAt, nowIso()); + + // Replace all rows for this repo (simple + safe baseline overwrite) + db.prepare(`DELETE FROM github_assignees_state WHERE owner = ? AND repo = ?`).run( + owner, + repo, + ); + + const insert = db.prepare( + `INSERT INTO github_assignees_state + (owner, repo, number, kind, title, url, assignees_json, updated_at) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + for (const [num, item] of Object.entries(state.itemsByNumber)) { + insert.run( + owner, + repo, + Number(num), + item.kind, + item.title, + item.url, + JSON.stringify(item.assignees ?? []), + nowIso(), + ); + } + }); + + tx(); +} diff --git a/src/services/summary/llmSummary.ts b/src/services/summary/llmSummary.ts index c054a3e8..1487769a 100644 --- a/src/services/summary/llmSummary.ts +++ b/src/services/summary/llmSummary.ts @@ -4,61 +4,118 @@ import { logger } from "../../utils/logger.js"; let client: OpenAI | null = null; -/** - * Initialize OpenAI client only if an API key is present. - * This allows the bot to run in "local" summary mode without crashing. - */ if (env.openAIKey) { client = new OpenAI({ apiKey: env.openAIKey }); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryable(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return ( + msg.includes("timeout") || + msg.includes("temporarily") || + msg.includes("rate") || + msg.includes("429") || + msg.includes("502") || + msg.includes("503") || + msg.includes("504") || + msg.includes("network") || + msg.includes("fetch") + ); +} + +async function withRetries(fn: () => Promise, label: string): Promise { + const delays = [250, 700, 1400]; // quick backoff + let lastErr: unknown = null; + + for (let i = 0; i < delays.length + 1; i += 1) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const retry = isRetryable(err) && i < delays.length; + logger.warn({ err, attempt: i + 1, label, retry }, "[summary/llm] call failed"); + + if (!retry) break; + await sleep(delays[i]!); + } + } + + throw lastErr instanceof Error ? lastErr : new Error("LLM request failed"); +} + /** - * Generate a structured summary (Markdown) from a transcript using an LLM. - * - * Behavior: - * - If LLM mode is requested but no API key is configured, return a safe message - * - If the OpenAI request fails, log the error and return a user-friendly fallback - * - * We intentionally: - * - Do NOT log the transcript or prompt (privacy + noise) - * - Log only the failure signal for observability + * Generate a structured summary (Markdown) from a transcript using OpenAI. */ export async function llmSummary(text: string): Promise { if (!client) { return "LLM mode requested but no API key is configured."; } - const prompt = [ + // Guard: do not send absurd payloads + const trimmed = text.trim(); + if (!trimmed) return "No transcript content provided."; + + const system = [ "You are a Discord channel summarizer.", - "Given the transcript below, produce a structured Markdown output with these sections:", - "## Summary (2 to 5 bullets)", - "## Key points (3 to 8 bullets)", - "## Action items (if any, otherwise say 'None')", - "## Open questions (if any, otherwise say 'None')", + "You produce crisp, actionable, structured Markdown.", + "You do not invent facts that are not in the transcript.", + ].join("\n"); + + const user = [ + "Summarize the transcript below.", + "", + "Return exactly these sections in Markdown:", + "## Summary", + "- 3 to 6 bullets capturing the most important outcomes", + "", + "## Key points", + "- 5 to 10 bullets, concrete and specific", + "", + "## Decisions", + "- If none, write: None", + "", + "## Action items", + "- If none, write: None", + "", + "## Open questions", + "- If none, write: None", "", "Rules:", - "- Do not include timestamps.", - "- Avoid quoting usernames; paraphrase instead unless absolutely necessary.", - "- Keep it concise and readable.", + "- No timestamps.", + "- Avoid quoting usernames; paraphrase as 'someone'/'a teammate' when possible.", + "- Prefer concrete details (commands, files, errors, numbers) over vague statements.", + "- If the transcript is mostly chatter, say that clearly.", "", "Transcript:", - text, + trimmed, ].join("\n"); try { - const response = await client.chat.completions.create({ - model: "gpt-4o-mini", - messages: [{ role: "user", content: prompt }], - }); + const response = await withRetries( + async () => + client!.chat.completions.create({ + // Pick your model. If you want a stronger summary, bump to gpt-4o. + model: "gpt-4o-mini", + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + temperature: 0.2, + }), + "openai.chat.completions.create", + ); - /** - * Extract model output, falling back safely if no content is returned. - */ - const content = response.choices?.[0]?.message?.content ?? "LLM returned no content."; + const content = response.choices?.[0]?.message?.content ?? ""; + const out = content.trim(); - return content.trim(); + return out || "LLM returned no content."; } catch (err) { - logger.error({ err }, "LLM summary generation failed"); + logger.error({ err }, "[summary/llm] summary generation failed"); return "Failed to generate summary via LLM."; } } diff --git a/src/services/summary/summarizer.ts b/src/services/summary/summarizer.ts index 2c885bd9..3bf52396 100644 --- a/src/services/summary/summarizer.ts +++ b/src/services/summary/summarizer.ts @@ -2,15 +2,12 @@ import { env } from "../../config/env.js"; import { localSummary } from "./localSummary.js"; import { llmSummary } from "./llmSummary.js"; -/* +/** * Decide which summarization method to use (LLM or local) based on configuration. */ export async function summarize(text: string): Promise { if (env.summaryMode === "llm") { return llmSummary(text); } - /* - * Use the lightweight local summarizer when LLM mode is disabled. - */ return localSummary(text); } From f04d4e116cec50f46861ed27c345a2a61ecddbac Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 19 Jan 2026 17:13:52 -0500 Subject: [PATCH 2/2] Adding in more changes --- README.md | 373 ++++++++++++++++++++++-- src/services/faq/store.test.ts.disabled | 115 -------- 2 files changed, 343 insertions(+), 145 deletions(-) delete mode 100644 src/services/faq/store.test.ts.disabled diff --git a/README.md b/README.md index 94f420b8..66cd141a 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,378 @@ +

+ OmegaBot Banner +

+ +

+ + + + + +

+ # OmegaBot -OmegaBot is a modular, community-driven Discord bot focused on fun commands, utilities, and learning by building together. +OmegaBot is a modular Discord bot designed to support development projects with quick summaries, FAQs, GitHub lookups, and automated notifications. The structure is clean and fully service based which makes it easy to extend. --- -## 🚀 Quick Start +## Table of Contents + +- [Features](#features) +- [Documentation](#documentation) +- [Getting Started](#getting-started) +- [Project Structure](#project-structure) +- [Extending OmegaBot](#extending-omegabot) +- [Contributors](#contributors) +- [License](#license) + +--- + +## Current features + +- Modular slash-command system with auto-loading from `dist/commands` +- Centralized interaction routing with consistent, safe error handling +- Structured logging (pino) +- Welcome and onboarding flows triggered on member join (`guildMemberAdd`) +- Optional auto-role assignment for new members (`DISCORD_AUTO_ROLE_ID`) +- GitHub integration with polling-based automation, including: + - Health/status checks + - Issue and PR lookups + - New PR announcements + - Issue and PR assignee change announcements + - Issue and PR closed announcements +- Configuration and feature gating via environment variables (optional features run only when enabled) +- Per-guild configuration backed by persistent storage and admin slash commands +- **Timezone support** – Save your timezone, view it later, and compare times across locations or users + +### Core commands + +- /help – command discovery and getting started guide +- /ping – health check +- /summary – conversation summaries (local + LLM mode) +- /history – conversation history (DM + file fallback) +- /playback – transcript playback with button pagination +- /pagination – reusable inline paging helper +- /timezone – per-user IANA timezone support +- /changelog – ephemeral release preview -1. Clone the repo -2. Copy `.env.example` to `.env` -3. Fill in required environment variables -4. Install deps and run +### FAQ system + +- /faq add – create persistent FAQ entries +- /faq get – retrieve FAQs by key +- /faq list – list FAQs with sorting and filtering +- /faq remove – remove FAQs with confirmation flow +- Persistent on-disk storage (versioned JSON) +- Usage tracking for FAQs +- Permission guardrails for destructive actions + +### Fun / utility commands + +All fun commands are available under `/fun`: + +- `/fun chucknorris` – Chuck Norris facts (random, category, or search) +- `/fun dadjoke` – Random or searched dad jokes +- `/fun coinflip` – Heads or tails +- `/fun dice` – Custom dice rolls +- `/fun weather` – Daily weather +- `/fun weather7` – 7-day forecast +- `/fun leaderboard` – Track fun command usage and top users + +## Planned features + +- `/docs` command for documentation lookups +- Expanded GitHub automation (labels, reviews, merge events) +- Enhanced fun leaderboard views and stats +- Improved summary output (highlights, action items, structured sections) + +--- + +## Documentation + +- 🤖 [Discord Bot Setup Guide](docs/setup-discord.md) +- 📘 [Command Reference](docs/commands.md) +- ❓ [FAQ Storage Design](docs/faq.md) +- 🧠 [Transcript & Summary Design](docs/transcripts.md) +- 🛠️ [Development Notes](docs/dev-notes.md) + +--- + +## Getting Started + +### Requirements + +- Node 18 or newer +- A Discord bot token +- A development server where you have Manage Server permissions + +### Setup + +> Need help creating a Discord bot and token? +> See the [Discord Bot Setup Guide](docs/setup-discord.md). + +1. Clone the repository: + +```bash +git clone https://github.com/NickTheDevOpsGuy/OmegaBot.git +cd OmegaBot +``` + +2. Install dependencies: ```bash npm install -npm run build +``` + +3. Create a .env file based on the example configuration: + +- [.env.example](.env.example) + +4. Register slash commands with your development guild: + +```bash npm run register +``` + +**Command registration mode** + +OmegaBot supports two registration modes: + +- Guild registration (recommended for development) + If DISCORD_GUILD_ID is set, commands are registered to that guild and appear immediately. + +- Global registration + If DISCORD_GUILD_ID is not set, commands are registered globally and may take up to 1 hour to appear. + +5. Build and run the bot: + +```bash +npm run build npm start ``` +For development with auto-reload: + +```bash +npm run dev +``` + +You should see: + +``` +OmegaBot is online +``` + --- -## 📚 Documentation +## Technical Stack + +- **Runtime**: Node.js 18+ +- **Language**: TypeScript 5.x +- **Discord Library**: discord.js v14 +- **Logging**: pino +- **Code Quality**: ESLint, Prettier +- **Git Hooks**: Husky + +### Discord.js v14 Features -- [Discord Setup Guide](./setup-discord.md) -- [Commands Reference](./commands.md) -- [FAQ](./faq.md) -- [Transcripts & Examples](./transcripts.md) -- [Developer Notes](./dev-notes.md) +OmegaBot uses discord.js v14 which includes: + +- Improved TypeScript support +- Better slash command handling +- Enhanced permission system +- Modern Discord API features --- -## 🔐 Admin & Moderation Commands +## Project Structure + +
+🗂 Click to expand file structure -Some admin commands (timeout, kick, ban, health, stats) require: +``` +. +├── assets +│ ├── banner.png +│ └── omegabot.png +├── CHANGELOG.md +├── CONTRIBUTORS.md +├── data +│ ├── fun-usage.json +│ └── timezones.json +├── docs +│ ├── commands.md +│ ├── dev-notes.md +│ ├── faq.md +│ ├── setup-discord.md +│ ├── setup-env.md +│ └── transcripts.md +├── .env +├── .env.example +├── eslint.config.ts +├── fix-pr-noise-simple.sh +├── .github +│ ├── ISSUE_TEMPLATE +│ │ ├── bug.yml +│ │ ├── config.yml +│ │ ├── documentation.yml +│ │ ├── enhancement_refactor.yml +│ │ ├── feature_request.yml +│ │ └── question_discussion.yml +│ ├── pull_request_template.md +│ └── workflows +│ └── OmegaBot.yml +├── .gitignore +├── .husky +│ ├── pre-commit +│ └── pre-push +├── LICENSE +├── package.json +├── .prettierignore +├── .prettierrc.yml +├── README.md +├── scripts +│ └── precheck.sh +├── src +│ ├── bot.ts +│ ├── commands +│ │ ├── changelog +│ │ │ └── changelog.ts +│ │ ├── config +│ │ │ └── config.ts +│ │ ├── faq +│ │ │ ├── faq.ts +│ │ │ └── subcommands +│ │ │ ├── add.ts +│ │ │ ├── get.ts +│ │ │ ├── list.ts +│ │ │ └── remove.ts +│ │ ├── fun +│ │ │ ├── fun.ts +│ │ │ └── subcommands +│ │ │ ├── chucknorris.ts +│ │ │ ├── coinflip.ts +│ │ │ ├── dadjoke.ts +│ │ │ ├── dice.ts +│ │ │ ├── java.ts +│ │ │ ├── leaderboard.ts +│ │ │ ├── poll.ts +│ │ │ └── weather.ts +│ │ ├── general +│ │ │ └── ping.ts +│ │ ├── github +│ │ │ ├── gh.ts +│ │ │ ├── pr.ts +│ │ │ └── status.ts +│ │ ├── help +│ │ │ ├── helpText.ts +│ │ │ └── help.ts +│ │ ├── history +│ │ │ └── history.ts +│ │ ├── pagination +│ │ │ └── pagination.ts +│ │ ├── playback +│ │ │ └── playback.ts +│ │ ├── summary +│ │ │ └── summary.ts +│ │ └── timezone +│ │ └── timezone.ts +│ ├── config +│ │ └── env.ts +│ ├── registerCommands.ts +│ ├── services +│ │ ├── config +│ │ │ ├── guildConfigStore.ts +│ │ │ ├── index.ts +│ │ │ └── types.ts +│ │ ├── discord +│ │ │ ├── commandLoader.ts +│ │ │ ├── commandMeta.ts +│ │ │ ├── cooldowns.ts +│ │ │ ├── fetchChannelMessages.ts +│ │ │ ├── interactionHandler.ts +│ │ │ └── safeReply.ts +│ │ ├── faq +│ │ │ ├── faqService.ts +│ │ │ ├── permissions.ts +│ │ │ ├── services.test.ts +│ │ │ ├── services.ts +│ │ │ ├── _shared.ts +│ │ │ ├── store.test.ts +│ │ │ ├── store.test.ts.disabled +│ │ │ ├── store.ts +│ │ │ └── types.ts +│ │ ├── fun +│ │ │ ├── coinStore.ts +│ │ │ ├── funUsageStore.ts +│ │ │ └── pollStore.ts +│ │ ├── github +│ │ │ ├── githubApi.ts +│ │ │ ├── githubClient.ts +│ │ │ ├── githubErrorMessage.ts +│ │ │ ├── issueAssigneePoller.ts +│ │ │ ├── issueAssigneePoller.ts.backup +│ │ │ ├── lastSeenStore.ts +│ │ │ ├── prFormatter.ts +│ │ │ ├── prPoller.ts +│ │ │ └── types.ts +│ │ ├── permissions +│ │ ├── roles +│ │ │ └── autoRoleHandler.ts +│ │ ├── summary +│ │ │ ├── llmSummary.ts +│ │ │ ├── localSummary.ts +│ │ │ └── summarizer.ts +│ │ ├── time +│ │ │ ├── formatTimestamp.ts +│ │ │ └── validateTimezone.ts +│ │ ├── timezone +│ │ │ └── timezoneStore.ts +│ │ ├── transcript +│ │ │ ├── buildTranscript.ts +│ │ │ └── defaults.ts +│ │ ├── weather +│ │ │ ├── forecast.ts +│ │ │ └── types.ts +│ │ └── welcome +│ │ ├── welcomeHandler.ts +│ │ └── welcomeMessage.ts +│ └── utils +│ └── logger.ts +├── tsconfig.json +└── vitest.config.ts -- Proper bot permissions in the server -- The bot role to be **above** the target user’s role -- Required gateway intents enabled -- Correct OAuth2 scopes on invite +``` -See the [Discord Setup Guide](./setup-discord.md) for full details. +
--- -## 🤝 Contributing +## Extending OmegaBot -Pull requests are welcome. +OmegaBot is designed for small, focused modules. To add new features: -If you want to contribute, help shape the bot, or just hang out and build together, -see [CONTRIBUTORS.md](./CONTRIBUTORS.md) or join the Discord. +1. Create a new command file under `src/commands//` +2. Add any logic needed inside `src/services//` +3. Run `npm run register` to publish new slash commands --- -## 📄 License +## Contributors + +Thanks to everyone who has helped build or improve OmegaBot. + + + Contributors + + +Generated using https://contrib.rocks + +To learn how to contribute, read the [CONTRIBUTOR.md](CONTRIBUTOR.md) file. -This project is licensed under the MIT License. -See the [LICENSE](./LICENSE) file for full details. +If you would like to contribute, please open an issue or submit a pull request. --- -## Contributing +## License -Want to help build OmegaBot? -See [CONTRIBUTORS.md](./CONTRIBUTORS.md) or open a PR. +MIT License. Use and modify freely. diff --git a/src/services/faq/store.test.ts.disabled b/src/services/faq/store.test.ts.disabled deleted file mode 100644 index d2be28ed..00000000 --- a/src/services/faq/store.test.ts.disabled +++ /dev/null @@ -1,115 +0,0 @@ -// src/services/faq/store.test.ts -// -// Tests for the FAQ persistence layer (store.ts). -// -// Goals: -// - Never touch your real repo ./data folder -// - Exercise real filesystem behavior in an isolated temp directory -// - Verify safe fallbacks when the store file is missing or malformed - -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; -import fs from "fs"; -import path from "path"; -import os from "os"; - -type StoreModule = typeof import("./store.js"); - -let originalCwd = ""; -let tempRoot = ""; -let storePath = ""; - -/** - * Import store.ts AFTER we switch cwd. - * store.ts computes STORE_PATH at import time using process.cwd(). - */ -async function importStoreFresh(): Promise { - vi.resetModules(); - return await import("./store.js"); -} - -function rmIfExists(p: string): void { - if (!fs.existsSync(p)) return; - fs.rmSync(p, { recursive: true, force: true }); -} - -beforeAll(() => { - originalCwd = process.cwd(); - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omegabot-faq-store-")); - process.chdir(tempRoot); - - storePath = path.join(process.cwd(), "data", "faqs.json"); -}); - -afterAll(() => { - process.chdir(originalCwd); - rmIfExists(tempRoot); -}); - -beforeEach(() => { - // Each test starts with a clean tempRoot/data folder. - rmIfExists(path.join(process.cwd(), "data")); -}); - -describe("faq store", () => { - it("creates the store file if missing", async () => { - const { loadStore } = await importStoreFresh(); - - expect(fs.existsSync(storePath)).toBe(false); - - const store = loadStore(); - - expect(fs.existsSync(storePath)).toBe(true); - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("falls back to empty store when JSON is malformed", async () => { - const { loadStore, ensureStoreFile } = await importStoreFresh(); - - ensureStoreFile(); - fs.writeFileSync(storePath, "not json", "utf8"); - - const store = loadStore(); - - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("falls back to empty store when shape is invalid", async () => { - const { loadStore, ensureStoreFile } = await importStoreFresh(); - - ensureStoreFile(); - - // Wrong version + missing entries - fs.writeFileSync(storePath, JSON.stringify({ version: 999 }, null, 2), "utf8"); - - const store = loadStore(); - - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("saveStore writes valid JSON that loadStore can read back", async () => { - const { loadStore, saveStore } = await importStoreFresh(); - - const store = loadStore(); - - store.entries["hello"] = { - key: "hello", - title: "Hello", - body: "World", - tags: ["test"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - createdBy: "system", - updatedBy: "system", - usageCount: 0, - }; - - saveStore(store); - - const reread = loadStore(); - expect(reread.entries["hello"]?.title).toBe("Hello"); - expect(reread.entries["hello"]?.tags).toEqual(["test"]); - }); -});