diff --git a/.env.example b/.env.example index 0566847f..490a0f0d 100644 --- a/.env.example +++ b/.env.example @@ -85,26 +85,14 @@ DISCORD_AUTO_ROLE_ID= JOKE_MODERATOR_ROLE_ID= # ============================================================ -# Conversation Summaries (OPTIONAL) +# AI Services (OPTIONAL) # ============================================================ -# Summary execution mode determines how /summary command works: -# -# Options: -# - local → Fast heuristic summaries (no API needed, always available) -# - llm → High-quality AI summaries using OpenAI (requires API key) -# -# Default: local -SUMMARY_MODE=local - -# OpenAI API key for LLM-powered summaries -# Get it from: https://platform.openai.com/api-keys -# -# Only required if SUMMARY_MODE=llm -# Cost: ~$0.01-0.05 per summary (depends on message count) -# -# If unset and SUMMARY_MODE=llm, bot falls back to local summaries -OPENAI_API_KEY= +# Anthropic API key for Claude AI +# Get it from: https://console.anthropic.com/ +# Used for: /gh history, /gh summary +# Cost: ~$0.003 per summary (much cheaper than ChatGPT!) +ANTHROPIC_API_KEY= # ============================================================ # Weather (OPTIONAL) @@ -245,3 +233,10 @@ LOG_PRETTY=true # # Default: data/omegabot.db DATABASE_PATH=data/omegabot.db + +# ============================================================ +# Claude AI (Optional) +# ============================================================ +# Get your API key from: https://console.anthropic.com/ +# Used for: /gh history, /gh summary (AI-powered summaries) +ANTHROPIC_API_KEY= diff --git a/.gitignore b/.gitignore index 8f0890c9..d2950b17 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ build/ # OS junk Thumbs.db +Claude.dmg # TypeScript (if added later) *.tsbuildinfo diff --git a/README.md b/README.md index 86fa581d..1d75b1e0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ OmegaBot is a modular Discord bot designed to support development projects with ## Table of Contents -- [Features](#features) +- [Core Features](#core-features) - [Documentation](#documentation) - [Getting Started](#getting-started) - [Project Structure](#project-structure) @@ -28,24 +28,32 @@ OmegaBot is a modular Discord bot designed to support development projects with --- -## 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 5-minute API caching (80-90% reduction in API calls), including: - - Health/status checks - - Issue and PR lookups - - New PR announcements - - Issue assignee change announcements (Issues only, PRs filtered for reduced noise) - - Issue closed announcements - - Smart notification filtering (only Issues trigger activity updates) -- 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 -- **SQLite-backed storage** – Persistent data for FAQs, jokes, fun stats, timezones, and GitHub state +## Core Features + +**Discord Integration:** + +- Modular slash-command system with auto-loading +- Welcome messages and auto-role assignment for new members +- Structured logging (pino) and safe error handling + +**GitHub Integration:** + +- Issue and PR lookups with smart caching (80-90% fewer API calls) +- Automated announcements for PRs, issue activity, and closures +- Health checks and status monitoring + +**Community Features:** + +- User-submitted jokes with 13 categories and moderation +- Coin flips, dice rolls, weather, and polls +- FAQ system for server knowledge base +- Timezone management for coordination across time zones + +**Data & Configuration:** + +- SQLite database for persistent storage +- Environment-based configuration with feature gating +- Per-guild settings via admin commands ### Core commands @@ -214,74 +222,69 @@ OmegaBot uses discord.js v14 which includes: ``` . +├── .github +│ ├── ISSUE_TEMPLATE +│ │ ├── bug.yml +│ │ ├── config.yml +│ │ ├── documentation.yml +│ │ ├── enhancement_refactor.yml +│ │ ├── feature_request.yml +│ │ └── question_discussion.yml +│ ├── workflows +│ │ └── OmegaBot.yml +│ └── pull_request_template.md +├── .husky +│ ├── pre-commit +│ └── pre-push ├── assets │ ├── banner.png │ └── omegabot.png -├── CHANGELOG.md -├── CONTRIBUTORS.md ├── data +│ ├── faqs.json +│ ├── fun-usage.json +│ ├── github-assignees.json +│ ├── guild-config.json +│ ├── last-seen.json │ └── omegabot.db ├── docs +│ ├── Claude.dmg │ ├── commands.md │ ├── dev-notes.md │ ├── faq.md │ ├── setup-discord.md │ ├── setup-env.md │ └── transcripts.md -├── .env -├── .env.example -├── eslint.config.ts -├── .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 -├── package-lock.json -├── .prettierignore -├── .prettierrc.yml -├── README.md ├── scripts │ └── precheck.sh ├── src -│ ├── bot.ts │ ├── commands +│ │ ├── admin +│ │ │ └── admin.ts │ │ ├── changelog │ │ │ └── changelog.ts │ │ ├── config │ │ │ └── config.ts │ │ ├── faq -│ │ │ ├── faq.ts -│ │ │ └── subcommands -│ │ │ ├── add.ts -│ │ │ ├── get.ts -│ │ │ ├── list.ts -│ │ │ └── remove.ts +│ │ │ ├── subcommands +│ │ │ │ ├── add.ts +│ │ │ │ ├── get.ts +│ │ │ │ ├── list.ts +│ │ │ │ └── remove.ts +│ │ │ └── faq.ts │ │ ├── fun -│ │ │ ├── fun.ts -│ │ │ └── subcommands -│ │ │ ├── coinflip.ts -│ │ │ ├── dice.ts -│ │ │ ├── joke -│ │ │ │ ├── add.ts -│ │ │ │ ├── index.ts -│ │ │ │ ├── list.ts -│ │ │ │ ├── random.ts -│ │ │ │ └── remove.ts -│ │ │ ├── leaderboard.ts -│ │ │ ├── poll.ts -│ │ │ └── weather.ts +│ │ │ ├── subcommands +│ │ │ │ ├── joke +│ │ │ │ │ ├── add.ts +│ │ │ │ │ ├── index.ts +│ │ │ │ │ ├── list.ts +│ │ │ │ │ ├── random.ts +│ │ │ │ │ └── remove.ts +│ │ │ │ ├── coinflip.ts +│ │ │ │ ├── dice.ts +│ │ │ │ ├── leaderboard.ts +│ │ │ │ ├── poll.ts +│ │ │ │ └── weather.ts +│ │ │ └── fun.ts │ │ ├── general │ │ │ └── ping.ts │ │ ├── github @@ -289,8 +292,8 @@ OmegaBot uses discord.js v14 which includes: │ │ │ ├── pr.ts │ │ │ └── status.ts │ │ ├── help -│ │ │ ├── helpText.ts -│ │ │ └── help.ts +│ │ │ ├── help.ts +│ │ │ └── helpText.ts │ │ ├── history │ │ │ └── history.ts │ │ ├── pagination @@ -303,8 +306,9 @@ OmegaBot uses discord.js v14 which includes: │ │ └── timezone.ts │ ├── config │ │ └── env.ts -│ ├── registerCommands.ts │ ├── services +│ │ ├── ai +│ │ │ └── claudeService.ts │ │ ├── cache │ │ │ └── simpleCache.ts │ │ ├── config @@ -322,11 +326,11 @@ OmegaBot uses discord.js v14 which includes: │ │ │ ├── interactionHandler.ts │ │ │ └── safeReply.ts │ │ ├── faq +│ │ │ ├── _shared.ts │ │ │ ├── faqService.ts │ │ │ ├── permissions.ts │ │ │ ├── services.test.ts │ │ │ ├── services.ts -│ │ │ ├── _shared.ts │ │ │ ├── store.test.ts │ │ │ ├── store.test.ts.disabled │ │ │ ├── store.ts @@ -349,7 +353,6 @@ OmegaBot uses discord.js v14 which includes: │ │ │ └── types.ts │ │ ├── joke │ │ │ └── jokeStore.ts -│ │ ├── permissions │ │ ├── roles │ │ │ └── autoRoleHandler.ts │ │ ├── summary @@ -370,8 +373,24 @@ OmegaBot uses discord.js v14 which includes: │ │ └── welcome │ │ ├── welcomeHandler.ts │ │ └── welcomeMessage.ts -│ └── utils -│ └── logger.ts +│ ├── utils +│ │ ├── colors.ts +│ │ ├── interactions.ts +│ │ └── logger.ts +│ ├── bot.ts +│ └── registerCommands.ts +├── .env.example +├── .gitignore +├── .prettierignore +├── .prettierrc.yml +├── CHANGELOG.md +├── CONTRIBUTORS.md +├── eslint.config.ts +├── install-claude-admin.sh +├── LICENSE +├── package-lock.json +├── package.json +├── README.md ├── tsconfig.json └── vitest.config.ts diff --git a/docs/commands.md b/docs/commands.md index 6c230c36..e60fd7ee 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -468,7 +468,6 @@ Search FAQ entries by keyword. ## Notes -- **Ephemeral Options:** Many commands support `ephemeral: true` to show results only to you - **Usage Tracking:** Fun commands automatically track usage for leaderboards - **Permissions:** Some commands require specific roles (check server configuration) - **API Keys:** Weather and LLM features require API keys in `.env` diff --git a/src/commands/admin/admin.ts b/src/commands/admin/admin.ts new file mode 100644 index 00000000..1cac921f --- /dev/null +++ b/src/commands/admin/admin.ts @@ -0,0 +1,185 @@ +// src/commands/admin/admin.ts +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionFlagsBits, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; +import { getDb } from "../../services/database/db.js"; +import { getFunUsageSnapshot } from "../../services/fun/funUsageStore.js"; +import { EmbedColors } from "../../utils/colors.js"; + +export const data = new SlashCommandBuilder() + .setName("admin") + .setDescription("Admin commands") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand((sub) => + sub + .setName("stats") + .setDescription("Show bot statistics (uptime, database, commands)"), + ) + .addSubcommand((sub) => + sub.setName("health").setDescription("Check bot and service health"), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const subcommand = interaction.options.getSubcommand(); + + await interaction.deferReply(); + + try { + if (subcommand === "stats") { + await handleStats(interaction); + } else if (subcommand === "health") { + await handleHealth(interaction); + } else { + await interaction.editReply("Unknown subcommand"); + } + } catch (error) { + logger.error({ error, subcommand }, "[admin] Command failed"); + await interaction.editReply("❌ Something went wrong"); + } +} + +async function handleStats(interaction: ChatInputCommandInteraction): Promise { + const db = getDb(); + + // Get database stats + const jokeCount = db.prepare("SELECT COUNT(*) as count FROM jokes").get() as { + count: number; + }; + + const coinFlipCount = db.prepare("SELECT COUNT(*) as count FROM coin_flips").get() as { + count: number; + }; + + // Get fun command usage + const funUsage = await getFunUsageSnapshot(); + const totalCommands = Object.values(funUsage.totalsByCommand).reduce( + (sum, count) => sum + count, + 0, + ); + + // Calculate uptime + const uptimeSeconds = process.uptime(); + const uptimeDays = Math.floor(uptimeSeconds / 86400); + const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600); + const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60); + + // Memory usage + const memUsage = process.memoryUsage(); + const memUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); + const memTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024); + + const embed = new EmbedBuilder() + .setTitle("🤖 Bot Statistics") + .setColor(EmbedColors.Info) + .addFields( + { + name: "⏱️ Uptime", + value: `${uptimeDays}d ${uptimeHours}h ${uptimeMinutes}m`, + inline: true, + }, + { + name: "💾 Memory", + value: `${memUsedMB}MB / ${memTotalMB}MB`, + inline: true, + }, + { + name: "📊 Total Commands", + value: totalCommands.toString(), + inline: true, + }, + { + name: "🎭 Jokes", + value: jokeCount.count.toString(), + inline: true, + }, + { + name: "🪙 Coin Flips", + value: coinFlipCount.count.toString(), + inline: true, + }, + { + name: "👥 Unique Users", + value: Object.keys(funUsage.totalsByUser).length.toString(), + inline: true, + }, + ) + .setFooter({ text: `Node ${process.version}` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + logger.info({ userId: interaction.user.id }, "Admin viewed bot statistics"); +} + +async function handleHealth(interaction: ChatInputCommandInteraction): Promise { + const checks: { name: string; status: string; details?: string }[] = []; + + // Check database + try { + const db = getDb(); + db.prepare("SELECT 1").get(); + checks.push({ name: "Database", status: "✅ Healthy" }); + } catch (error) { + checks.push({ + name: "Database", + status: "❌ Error", + details: error instanceof Error ? error.message : "Unknown error", + }); + } + + // Check environment variables + const requiredEnvVars = [ + "DISCORD_TOKEN", + "DISCORD_APP_ID", + "GITHUB_TOKEN", + "GITHUB_REPO_OWNER", + "GITHUB_REPO_NAME", + ]; + + const missingVars = requiredEnvVars.filter((v) => !process.env[v]); + if (missingVars.length === 0) { + checks.push({ name: "Environment", status: "✅ All vars set" }); + } else { + checks.push({ + name: "Environment", + status: "⚠️ Missing vars", + details: missingVars.join(", "), + }); + } + + // Check API keys + const optionalKeys = [ + { name: "Anthropic API", key: "ANTHROPIC_API_KEY" }, + { name: "Weather API", key: "WEATHER_API_KEY" }, + ]; + + optionalKeys.forEach(({ name, key }) => { + if (process.env[key]) { + checks.push({ name, status: "✅ Configured" }); + } else { + checks.push({ name, status: "⚠️ Not configured" }); + } + }); + + const embed = new EmbedBuilder() + .setTitle("🏥 Health Check") + .setColor(EmbedColors.Info) + .setDescription( + checks + .map((c) => + c.details + ? `**${c.name}:** ${c.status}\n ${c.details}` + : `**${c.name}:** ${c.status}`, + ) + .join("\n\n"), + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + logger.info({ userId: interaction.user.id }, "Admin viewed health check"); +} diff --git a/src/commands/config/config.ts b/src/commands/config/config.ts index f65023e9..75c689e0 100644 --- a/src/commands/config/config.ts +++ b/src/commands/config/config.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/commands/config/config.ts import { @@ -53,7 +54,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { if (!interaction.guildId) { await interaction.reply({ content: "This command can only be used in a server (not in DMs).", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -62,7 +63,10 @@ export async function execute(interaction: ChatInputCommandInteraction) { const sub = interaction.options.getSubcommand(); if (group !== "welcome-channel") { - await interaction.reply({ content: "Unknown config group.", ephemeral: true }); + await interaction.reply({ + content: "Unknown config group.", + flags: MessageFlags.Ephemeral, + }); return; } @@ -73,7 +77,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { if (channel.type !== ChannelType.GuildText) { await interaction.reply({ content: "Please choose a normal text channel (not a thread or DM).", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -90,7 +94,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { await interaction.reply({ content: `✅ Welcome messages will be posted in <#${updated.welcomeChannelId}>.`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -103,7 +107,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { await interaction.reply({ content: "Welcome channel is already not set. I will use the system channel or first text channel as a fallback.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -118,7 +122,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { await interaction.reply({ content: "✅ Cleared the welcome channel. I will use the system channel or first text channel as a fallback.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } } diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index 821a9b44..e214665a 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -226,7 +226,7 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: "Something went wrong. Try again in a bit.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } else { await interaction.editReply("Something went wrong. Try again in a bit."); diff --git a/src/commands/fun/subcommands/joke/add.ts b/src/commands/fun/subcommands/joke/add.ts index 234bf0bd..8f6d2fc3 100644 --- a/src/commands/fun/subcommands/joke/add.ts +++ b/src/commands/fun/subcommands/joke/add.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/commands/fun/subcommands/joke/add.ts import type { ChatInputCommandInteraction } from "discord.js"; import { addJoke, type JokeCategory } from "../../../../services/joke/jokeStore.js"; @@ -11,7 +12,7 @@ export async function handleJokeAdd( if (jokeText.length > 1000) { await interaction.reply({ content: "Joke is too long! Keep it under 1000 characters.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -27,6 +28,6 @@ export async function handleJokeAdd( "", joke.joke_text, ].join("\n"), - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/fun/subcommands/joke/index.ts b/src/commands/fun/subcommands/joke/index.ts index d3a7cb1e..b11433aa 100644 --- a/src/commands/fun/subcommands/joke/index.ts +++ b/src/commands/fun/subcommands/joke/index.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/commands/fun/subcommands/joke/index.ts import { SlashCommandSubcommandBuilder, @@ -87,7 +88,7 @@ export async function handleJoke( default: await interaction.reply({ content: "Unknown subcommand", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } } diff --git a/src/commands/fun/subcommands/joke/list.ts b/src/commands/fun/subcommands/joke/list.ts index ff9569e9..6ae4338c 100644 --- a/src/commands/fun/subcommands/joke/list.ts +++ b/src/commands/fun/subcommands/joke/list.ts @@ -1,5 +1,5 @@ // src/commands/fun/subcommands/joke/list.ts -import { EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { MessageFlags, EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; import { listJokes, getJokeStats, @@ -35,7 +35,7 @@ export async function handleJokeList( content: category ? `No jokes in the ${category} category yet.` : "No jokes added yet. Be the first with `/fun joke add`!", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -63,5 +63,5 @@ export async function handleJokeList( }) .setColor(0xffd700); // Gold color for fun commands - await interaction.reply({ embeds: [embed], ephemeral: true }); + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); } diff --git a/src/commands/fun/subcommands/joke/random.ts b/src/commands/fun/subcommands/joke/random.ts index d7ff1b29..47a48e3f 100644 --- a/src/commands/fun/subcommands/joke/random.ts +++ b/src/commands/fun/subcommands/joke/random.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/commands/fun/subcommands/joke/random.ts import type { ChatInputCommandInteraction } from "discord.js"; import { getRandomJoke, type JokeCategory } from "../../../../services/joke/jokeStore.js"; @@ -30,7 +31,7 @@ export async function handleJokeRandom( content: category ? `No jokes found in the ${category} category. Add some with \`/fun joke add\`!` : "No jokes available yet. Add some with `/fun joke add`!", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } diff --git a/src/commands/fun/subcommands/joke/remove.ts b/src/commands/fun/subcommands/joke/remove.ts index 6203e132..c80c5231 100644 --- a/src/commands/fun/subcommands/joke/remove.ts +++ b/src/commands/fun/subcommands/joke/remove.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/commands/fun/subcommands/joke/remove.ts import type { ChatInputCommandInteraction } from "discord.js"; import { removeJoke, getJoke } from "../../../../services/joke/jokeStore.js"; @@ -11,7 +12,7 @@ export async function handleJokeRemove( if (!interaction.inGuild() || !interaction.member) { await interaction.reply({ content: "This command can only be used in a server.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -23,7 +24,7 @@ export async function handleJokeRemove( if (typeof member === "string") { await interaction.reply({ content: "Could not verify your permissions.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -38,7 +39,7 @@ export async function handleJokeRemove( if (!hasModRole) { await interaction.reply({ content: "❌ Only joke moderators can remove jokes.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -48,7 +49,7 @@ export async function handleJokeRemove( if (!joke) { await interaction.reply({ content: `Joke #${jokeId} not found.`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -63,12 +64,12 @@ export async function handleJokeRemove( `Category: ${joke.category}`, `Text: ${joke.joke_text.substring(0, 100)}${joke.joke_text.length > 100 ? "..." : ""}`, ].join("\n"), - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } else { await interaction.reply({ content: `Failed to remove joke #${jokeId}.`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } } diff --git a/src/commands/fun/subcommands/poll.ts b/src/commands/fun/subcommands/poll.ts index d48eedc9..4dc07962 100644 --- a/src/commands/fun/subcommands/poll.ts +++ b/src/commands/fun/subcommands/poll.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/commands/fun/subcommands/poll.ts import { @@ -146,7 +147,10 @@ export async function run(interaction: ChatInputCommandInteraction): Promise= latestPoll.options.length ) { - await btn.reply({ content: "Invalid poll option.", ephemeral: true }); + await btn.reply({ + content: "Invalid poll option.", + flags: MessageFlags.Ephemeral, + }); return; } @@ -161,7 +165,7 @@ export async function run(interaction: ChatInputCommandInteraction): Promise { const sub = interaction.options.getSubcommand(true); - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { if (sub === "set") return await handleSet(interaction); diff --git a/src/services/ai/claudeService.ts b/src/services/ai/claudeService.ts new file mode 100644 index 00000000..7734904b --- /dev/null +++ b/src/services/ai/claudeService.ts @@ -0,0 +1,119 @@ +// src/services/ai/claudeService.ts +import Anthropic from "@anthropic-ai/sdk"; +import { logger } from "../../utils/logger.js"; + +const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, +}); + +export interface ClaudeOptions { + systemPrompt?: string; + maxTokens?: number; + temperature?: number; +} + +/** + * Call Claude API for text generation + */ +export async function callClaude( + prompt: string, + options: ClaudeOptions = {}, +): Promise { + const { + systemPrompt = "You are a helpful assistant.", + maxTokens = 4096, + temperature = 1.0, + } = options; + + try { + logger.info({ promptLength: prompt.length }, "Calling Claude API"); + + const message = await anthropic.messages.create({ + model: "claude-sonnet-4-20250514", + max_tokens: maxTokens, + temperature, + system: systemPrompt, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + // Extract text from response + const textContent = message.content.find((block) => block.type === "text"); + + if (!textContent || textContent.type !== "text") { + throw new Error("No text content in Claude response"); + } + + logger.info( + { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + }, + "Claude API call successful", + ); + + return textContent.text; + } catch (error) { + logger.error({ error }, "Claude API call failed"); + throw new Error("Failed to get response from Claude"); + } +} + +/** + * Summarize GitHub commit history + */ +export async function summarizeCommitHistory(commits: string): Promise { + const systemPrompt = `You are a helpful assistant that summarizes git commit history. +Be concise and focus on the most important changes. +Group related commits together and highlight breaking changes or major features.`; + + const prompt = `Summarize the following commit history. Focus on major changes, features, and fixes: + +${commits} + +Provide a clear, organized summary in bullet points.`; + + return callClaude(prompt, { + systemPrompt, + maxTokens: 2000, + temperature: 0.7, + }); +} + +/** + * Summarize GitHub PR/issue discussion + */ +export async function summarizeDiscussion( + title: string, + body: string, + comments: string, +): Promise { + const systemPrompt = `You are a helpful assistant that summarizes GitHub discussions. +Be objective and highlight key decisions, concerns, and action items.`; + + const prompt = `Summarize this GitHub discussion: + +**Title:** ${title} + +**Description:** +${body} + +**Comments:** +${comments} + +Provide a concise summary covering: +1. Main topic/purpose +2. Key points discussed +3. Decisions made (if any) +4. Action items or next steps`; + + return callClaude(prompt, { + systemPrompt, + maxTokens: 2000, + temperature: 0.7, + }); +} diff --git a/src/services/discord/interactionHandler.ts b/src/services/discord/interactionHandler.ts index c3fb2f1e..e28e779f 100644 --- a/src/services/discord/interactionHandler.ts +++ b/src/services/discord/interactionHandler.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; // src/services/discord/interactionHandler.ts // // Central interaction router for Discord. @@ -69,7 +70,7 @@ export async function handleInteraction( await safeReply(interaction, { content: "Command not found.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -98,7 +99,7 @@ export async function handleInteraction( await safeReply(interaction, { content: "Something went wrong while running this command.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } } diff --git a/src/services/discord/safeReply.ts b/src/services/discord/safeReply.ts index c525b85e..0f58b308 100644 --- a/src/services/discord/safeReply.ts +++ b/src/services/discord/safeReply.ts @@ -1,48 +1,58 @@ // src/services/discord/safeReply.ts -// -// Safe reply helper for Discord interactions. -// -// Goal: -// - Avoid "Interaction already replied" and "Unknown interaction" pitfalls -// - Let callers write one consistent "respond" call -// -// Behavior: -// - If interaction already has a reply or was deferred, use editReply(content) -// - Otherwise use reply({ content, flags? }) -// -// Notes: -// - Discord does not allow setting Ephemeral on editReply. -// So we only apply ephemeral flags on the initial reply. - -import { +import type { + ChatInputCommandInteraction, + InteractionEditReplyOptions, + InteractionReplyOptions, MessageFlags, - type InteractionReplyOptions, - type RepliableInteraction, } from "discord.js"; +import { logger } from "../../utils/logger.js"; -export type SafeReplyOptions = { +export interface SafeReplyOptions { content: string; + flags?: MessageFlags; ephemeral?: boolean; -}; - -function toReplyOptions(opts: SafeReplyOptions): InteractionReplyOptions { - if (opts.ephemeral) { - return { content: opts.content, flags: MessageFlags.Ephemeral }; - } - return { content: opts.content }; } /** - * Safely respond to an interaction exactly once. + * Safely reply to an interaction, handling already-replied cases. */ export async function safeReply( - interaction: RepliableInteraction, - opts: SafeReplyOptions, + interaction: ChatInputCommandInteraction, + options: SafeReplyOptions, ): Promise { - if (interaction.replied || interaction.deferred) { - await interaction.editReply(opts.content); - return; + const replyOptions: InteractionReplyOptions = { + content: options.content, + }; + + // Add flags or ephemeral - prefer flags + if (options.flags !== undefined) { + replyOptions.flags = options.flags as number; // Cast to number for bitfield + } else if (options.ephemeral) { + replyOptions.ephemeral = options.ephemeral; } - await interaction.reply(toReplyOptions(opts)); + try { + if (interaction.replied || interaction.deferred) { + const editOptions: InteractionEditReplyOptions = { + content: options.content, + }; + await interaction.editReply(editOptions); + } else { + await interaction.reply(replyOptions); + } + } catch (error) { + logger.warn( + { error, commandName: interaction.commandName }, + "Failed to reply to interaction, trying followUp", + ); + // If reply fails, try followUp as last resort + try { + await interaction.followUp(replyOptions); + } catch (followUpError) { + logger.error( + { error: followUpError, commandName: interaction.commandName }, + "Failed to followUp on interaction - interaction may be expired", + ); + } + } } diff --git a/src/services/joke/jokeStore.ts b/src/services/joke/jokeStore.ts index 6cd548fc..048a2019 100644 --- a/src/services/joke/jokeStore.ts +++ b/src/services/joke/jokeStore.ts @@ -1,5 +1,6 @@ // src/services/joke/jokeStore.ts import { getDb } from "../database/db.js"; +import { logger } from "../../utils/logger.js"; export type JokeCategory = | "boomer" @@ -44,102 +45,145 @@ export interface Joke { export function addJoke(jokeText: string, category: JokeCategory, userId: string): Joke { const db = getDb(); - const result = db - .prepare( - ` - INSERT INTO jokes (joke_text, category, added_by, added_at, usage_count) - VALUES (?, ?, ?, ?, 0) - `, - ) - .run(jokeText, category, userId, Date.now()); - - return { - id: result.lastInsertRowid as number, - joke_text: jokeText, - category, - added_by: userId, - added_at: Date.now(), - usage_count: 0, - }; + try { + const result = db + .prepare( + ` + INSERT INTO jokes (joke_text, category, added_by, added_at, usage_count) + VALUES (?, ?, ?, ?, 0) + `, + ) + .run(jokeText, category, userId, Date.now()); + + const joke: Joke = { + id: result.lastInsertRowid as number, + joke_text: jokeText, + category, + added_by: userId, + added_at: Date.now(), + usage_count: 0, + }; + + logger.info({ jokeId: joke.id, category, userId }, "Joke added"); + return joke; + } catch (error) { + logger.error({ error, category, userId }, "Failed to add joke"); + throw error; + } } export function getRandomJoke(category?: JokeCategory): Joke | null { const db = getDb(); - let query = "SELECT * FROM jokes"; - const params: (string | number)[] = []; + try { + let query = "SELECT * FROM jokes"; + const params: (string | number)[] = []; - if (category && category !== "random") { - query += " WHERE category = ?"; - params.push(category); - } + if (category && category !== "random") { + query += " WHERE category = ?"; + params.push(category); + } - query += " ORDER BY RANDOM() LIMIT 1"; + query += " ORDER BY RANDOM() LIMIT 1"; - const joke = db.prepare(query).get(...params) as Joke | undefined; + const joke = db.prepare(query).get(...params) as Joke | undefined; - if (joke) { - // Increment usage count - db.prepare("UPDATE jokes SET usage_count = usage_count + 1 WHERE id = ?").run( - joke.id, - ); - } + if (joke) { + // Increment usage count + db.prepare("UPDATE jokes SET usage_count = usage_count + 1 WHERE id = ?").run( + joke.id, + ); + } - return joke || null; + return joke || null; + } catch (error) { + logger.error({ error, category }, "Failed to get random joke"); + throw error; + } } export function removeJoke(jokeId: number): boolean { const db = getDb(); - const result = db.prepare("DELETE FROM jokes WHERE id = ?").run(jokeId); - return result.changes > 0; + + try { + const result = db.prepare("DELETE FROM jokes WHERE id = ?").run(jokeId); + const success = result.changes > 0; + + if (success) { + logger.info({ jokeId }, "Joke removed"); + } else { + logger.warn({ jokeId }, "Attempted to remove non-existent joke"); + } + + return success; + } catch (error) { + logger.error({ error, jokeId }, "Failed to remove joke"); + throw error; + } } export function getJoke(jokeId: number): Joke | null { const db = getDb(); - return db.prepare("SELECT * FROM jokes WHERE id = ?").get(jokeId) as Joke | null; + + try { + return db.prepare("SELECT * FROM jokes WHERE id = ?").get(jokeId) as Joke | null; + } catch (error) { + logger.error({ error, jokeId }, "Failed to get joke"); + throw error; + } } export function listJokes(category?: JokeCategory, limit: number = 50): Joke[] { const db = getDb(); - let query = "SELECT * FROM jokes"; - const params: (string | number)[] = []; + try { + let query = "SELECT * FROM jokes"; + const params: (string | number)[] = []; - if (category && category !== "random") { - query += " WHERE category = ?"; - params.push(category); - } + if (category && category !== "random") { + query += " WHERE category = ?"; + params.push(category); + } - query += " ORDER BY added_at DESC LIMIT ?"; - params.push(limit); + query += " ORDER BY added_at DESC LIMIT ?"; + params.push(limit); - return db.prepare(query).all(...params) as Joke[]; + return db.prepare(query).all(...params) as Joke[]; + } catch (error) { + logger.error({ error, category, limit }, "Failed to list jokes"); + throw error; + } } export function getJokeStats(): { total: number; byCategory: Record } { const db = getDb(); - const total = db.prepare("SELECT COUNT(*) as count FROM jokes").get() as { - count: number; - }; - - const byCategory = db - .prepare( - ` - SELECT category, COUNT(*) as count - FROM jokes - GROUP BY category - `, - ) - .all() as { category: string; count: number }[]; - - const categoryMap: Record = {}; - byCategory.forEach((row) => { - categoryMap[row.category] = row.count; - }); - - return { - total: total.count, - byCategory: categoryMap, - }; + try { + const total = db.prepare("SELECT COUNT(*) as count FROM jokes").get() as { + count: number; + }; + + const byCategory = db + .prepare( + ` + SELECT category, COUNT(*) as count + FROM jokes + GROUP BY category + `, + ) + .all() as { category: string; count: number }[]; + + const categoryMap: Record = {}; + byCategory.forEach((row) => { + categoryMap[row.category] = row.count; + }); + + return { + total: total.count, + byCategory: categoryMap, + }; + } catch (error) { + logger.error({ error }, "Failed to get joke stats"); + throw error; + } } diff --git a/src/utils/colors.ts b/src/utils/colors.ts new file mode 100644 index 00000000..4bb8dfb3 --- /dev/null +++ b/src/utils/colors.ts @@ -0,0 +1,20 @@ +// src/utils/colors.ts +// Consistent embed colors across the bot + +export const EmbedColors = { + // Primary colors + Success: 0x00ae86, // Green - successful operations + Error: 0xff0000, // Red - errors and failures + Warning: 0xffa500, // Orange - warnings + Info: 0x3498db, // Blue - informational + + // Feature-specific colors + GitHub: 0x6e5494, // GitHub purple + Fun: 0xffd700, // Gold for fun commands + Moderation: 0xe74c3c, // Red for mod actions + + // Utility + Default: 0x00ae86, // Default if unsure +} as const; + +export type EmbedColor = (typeof EmbedColors)[keyof typeof EmbedColors]; diff --git a/src/utils/interactions.ts b/src/utils/interactions.ts new file mode 100644 index 00000000..259b59d4 --- /dev/null +++ b/src/utils/interactions.ts @@ -0,0 +1,57 @@ +// src/utils/interactions.ts +// Helper utilities for interaction handling + +import type { ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; + +/** + * Helper for commands that might take a while. + * Shows "thinking..." state, then edits with result. + */ +export async function deferredReply( + interaction: ChatInputCommandInteraction, + handler: () => Promise, +): Promise { + await interaction.deferReply(); + + try { + const result = await handler(); + + if (typeof result === "string") { + await interaction.editReply({ content: result }); + } else { + await interaction.editReply(result); + } + } catch (error) { + await interaction.editReply({ + content: "❌ An error occurred while processing your request.", + }); + throw error; + } +} + +/** + * Standard error reply format + */ +export async function errorReply( + interaction: ChatInputCommandInteraction, + message: string, + suggestions?: string[], +): Promise { + const parts = [`❌ ${message}`]; + + if (suggestions && suggestions.length > 0) { + parts.push(""); + parts.push("💡 **Suggestions:**"); + suggestions.forEach((s) => parts.push(`• ${s}`)); + } + + const replied = interaction.replied || interaction.deferred; + + if (replied) { + await interaction.editReply({ content: parts.join("\n") }); + } else { + await interaction.reply({ + content: parts.join("\n"), + }); + } +}