diff --git a/.env.example b/.env.example index 6f1d3675..0566847f 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,24 @@ DISCORD_WELCOME_CHANNEL_ID= # Get it by: Server Settings → Roles → Right-click role → Copy ID DISCORD_AUTO_ROLE_ID= +# ============================================================ +# Fun Commands - Joke Moderation (OPTIONAL) +# ============================================================ + +# Role ID for users who can moderate jokes (remove inappropriate ones) +# Get it by: Server Settings → Roles → Right-click role → Copy ID +# +# Users with this role can use: +# - /fun joke remove → Delete jokes from the database +# +# If unset, only the bot owner can remove jokes. +# +# Recommended setup: +# 1. Create a "Joke Moderator" role in your Discord server +# 2. Assign it to trusted members +# 3. Add the role ID here +JOKE_MODERATOR_ROLE_ID= + # ============================================================ # Conversation Summaries (OPTIONAL) # ============================================================ @@ -226,4 +244,4 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b9f185..4d46f82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,28 @@ -[2.0.0] - 2026-01-17 +## [Unreleased] -### Changed +### Added + +- Joke command system with generational categories (boomer, genx, millennial, genz, genalpha) +- Coin flip result tracking and statistics +- Coin flip leaderboard showing top flippers +- Playback and pagination commands +- Timezone commands (save, show, clear, compare) +- Centralized structured logging +- Initial changelog tracking -## BREAKING: Upgraded to Discord.js v14 +--- -- Updated all interaction handlers to use v14 API -- Fixed type narrowing issues with guild checks -- Updated permission system for v14 compatibility -- Enhanced TypeScript type safety +## [2.0.0] - 2026-01-17 + +### Changed -## Fixed +- **BREAKING**: Upgraded to Discord.js v14 + - Updated all interaction handlers to use v14 API + - Fixed type narrowing issues with guild checks + - Updated permission system for v14 compatibility + - Enhanced TypeScript type safety + +### Fixed - Fixed TypeScript compilation errors with type narrowing - Fixed FAQ remove command interaction handling @@ -17,17 +30,9 @@ - Resolved avatar resolution to use maximum 4096px - Fixed safeReply type compatibility issues -## Technical +### Technical - Updated to Discord.js v14.16.3 - Improved TypeScript strict mode compliance - Enhanced error handling in FAQ subcommands - Better type safety across permission checks - -## Unreleased - -- Added - - Playback and pagination commands - - Timezone commands (save, show, clear, compare) - - Centralized structured logging - - Initial changelog tracking diff --git a/README.md b/README.md index 82d1c5c0..ae0032d0 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,17 @@ OmegaBot is a modular Discord bot designed to support development projects with - 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 now caching calls instead of polling, including: +- 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 and PR assignee change announcements - - Issue and PR closed 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 -- Now all storage uses SQLite for slash commands! +- **SQLite-backed storage** – Persistent data for FAQs, jokes, fun stats, timezones, and GitHub state ### Core commands @@ -63,7 +64,7 @@ OmegaBot is a modular Discord bot designed to support development projects with - /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) +- SQLite-backed persistent storage - Usage tracking for FAQs - Permission guardrails for destructive actions @@ -73,7 +74,15 @@ 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 joke` – Community-submitted jokes organized by generation + - Categories: boomer, genx, millennial, genz, genalpha, random + - Add jokes, browse by category, track usage + - Moderator tools for content management +- `/fun coinflip` – Heads or tails (results tracked for stats) +- `/fun coinstats` – Coin flip statistics and leaderboards + - Track your heads vs. tails record + - View personal stats with avatar display + - See top flippers leaderboard - `/fun dice` – Custom dice rolls - `/fun weather` – Daily weather - `/fun weather7` – 7-day forecast @@ -83,8 +92,8 @@ All fun commands are available under `/fun`: - `/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) +- Enhanced AI-powered summaries with Claude API +- Admin dashboard for bot statistics and monitoring --- @@ -170,6 +179,7 @@ OmegaBot is online - **Runtime**: Node.js 18+ - **Language**: TypeScript 5.x - **Discord Library**: discord.js v14 +- **Database**: SQLite (better-sqlite3) - **Logging**: pino - **Code Quality**: ESLint, Prettier - **Git Hooks**: Husky @@ -183,6 +193,18 @@ OmegaBot uses discord.js v14 which includes: - Enhanced permission system - Modern Discord API features +### Performance Optimizations + +- **GitHub API Caching**: In-memory cache with 5-minute TTL + - 80-90% reduction in API calls + - Sub-millisecond response times on cache hits + - Automatic expiration and cleanup + - Rate limit protection +- **SQLite Storage**: Fast, reliable persistent data storage + - Zero-configuration database + - ACID transactions + - Efficient indexing for quick lookups + --- ## Project Structure @@ -222,8 +244,26 @@ OmegaBot uses discord.js v14 which includes: │ └── OmegaBot.yml ├── .gitignore ├── .husky +│ ├── _ +│ │ ├── applypatch-msg +│ │ ├── commit-msg +│ │ ├── .gitignore +│ │ ├── h +│ │ ├── husky.sh +│ │ ├── post-applypatch +│ │ ├── post-checkout +│ │ ├── post-commit +│ │ ├── post-merge +│ │ ├── post-rewrite +│ │ ├── pre-applypatch +│ │ ├── pre-auto-gc +│ │ ├── pre-commit +│ │ ├── pre-merge-commit +│ │ ├── prepare-commit-msg +│ │ ├── pre-push +│ │ └── pre-rebase +│ ├── pre-commit │ └── pre-push -├── install-sqlite-simple.sh ├── LICENSE ├── package.json ├── package-lock.json @@ -251,9 +291,14 @@ OmegaBot uses discord.js v14 which includes: │ │ │ └── subcommands │ │ │ ├── chucknorris.ts │ │ │ ├── coinflip.ts -│ │ │ ├── dadjoke.ts │ │ │ ├── dice.ts │ │ │ ├── java.ts +│ │ │ ├── joke +│ │ │ │ ├── add.ts +│ │ │ │ ├── index.ts +│ │ │ │ ├── list.ts +│ │ │ │ ├── random.ts +│ │ │ │ └── remove.ts │ │ │ ├── leaderboard.ts │ │ │ ├── poll.ts │ │ │ └── weather.ts @@ -287,6 +332,7 @@ OmegaBot uses discord.js v14 which includes: │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── database +│ │ │ ├── db-joke-schema.sql │ │ │ └── db.ts │ │ ├── discord │ │ │ ├── commandLoader.ts @@ -321,6 +367,8 @@ OmegaBot uses discord.js v14 which includes: │ │ │ ├── prFormatter.ts │ │ │ ├── prPoller.ts │ │ │ └── types.ts +│ │ ├── joke +│ │ │ └── jokeStore.ts │ │ ├── permissions │ │ ├── roles │ │ │ └── autoRoleHandler.ts diff --git a/data/omegabot.db b/data/omegabot.db index fbff965a..610132a5 100644 Binary files a/data/omegabot.db and b/data/omegabot.db differ diff --git a/docs/commands.md b/docs/commands.md index 5c790563..30f4bbf9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,193 +1,515 @@ # Command Reference -This page documents all slash commands supported by OmegaBot, including subcommands and options where relevant. +Complete documentation for all OmegaBot slash commands, including subcommands and options. --- ## General -- `/ping` - - Verify the bot is online and measure latency +### `/ping` + +Verify the bot is online and measure latency. + +**Use case:** Quick health check to see if the bot is responsive. + +--- + +## GitHub Integration + +Commands for interacting with GitHub repositories. Requires GitHub integration to be configured. + +### `/gh issue ` + +Fetch a GitHub issue by number. + +**Options:** + +- `number` (required) — Issue number to look up + +**Use case:** Get issue details instantly without opening a browser. + +--- + +### `/gh issues` + +List open GitHub issues. + +**Options:** + +- `state` (optional) — Filter by state (open, closed, all) +- `limit` (optional) — Number of issues to show (default: 10, max: 50) + +**Use case:** See what's currently open at a glance. --- -## GitHub +### `/gh prs` -Commands for interacting with GitHub repositories configured for the server. +List open pull requests. -- `/gh issue` - - Fetch a GitHub issue by number -- `/gh issues` - - List open GitHub issues -- `/gh prs` - - List open pull requests -- `/gh status` - - Show GitHub integration status - - Includes configuration, polling, and channel bindings -- `/pr` - - Fetch a single pull request by number - - Legacy shortcut for /gh pr +**Options:** + +- `state` (optional) — Filter by state (open, closed, all) +- `limit` (optional) — Number of PRs to show (default: 10, max: 50) + +**Use case:** Check PR status without leaving Discord. --- -## Fun +### `/gh status` + +Show GitHub integration status and configuration. -All fun commands are grouped under /fun. +**Displays:** -`/fun chucknorris` +- Repository configuration +- Polling status +- Announcement channel bindings +- API connection health + +**Use case:** Verify bot configuration and troubleshoot issues. + +--- -Chuck Norris facts. +### `/pr ` -Options: +Fetch a single pull request by number. -- category (optional) — Fetch a fact from a specific category -- query (optional) — Search facts by keyword -- ephemeral (optional) — Show result only to you +**Options:** -Modes: +- `number` (required) — PR number to look up + +**Note:** Legacy shortcut for `/gh pr`. Consider using `/gh` commands instead. + +--- + +## Fun Commands + +Entertainment and engagement commands. All fun commands track usage for leaderboards. + +### `/fun chucknorris` + +Get Chuck Norris facts. + +**Options:** + +- `category` (optional) — Fetch from a specific category +- `query` (optional) — Search facts by keyword +- `ephemeral` (optional) — Show result only to you + +**Modes:** - Random fact (no options) -- Category- based fact -- Search- based fact +- Category-based fact +- Keyword search + +**Use case:** Lighten the mood with absurd humor. -⸻ +--- -`/fun dadjoke` +### `/fun dadjoke` -Dad jokes. +Get dad jokes. -Options: +**Options:** -- query (optional) — Search jokes by keyword -- ephemeral (optional) — Show result only to you +- `query` (optional) — Search jokes by keyword +- `ephemeral` (optional) — Show result only to you -Modes: +**Modes:** -- Random joke -- Search results +- Random joke (no query) +- Search results (with query) + +**Use case:** Classic groan-worthy humor. --- -`/fun coinflip` +### `/fun joke` + +Community-submitted jokes organized by generation. -Flip a coin with a short animation. +#### `/fun joke random [category]` -Options: +Get a random joke from the database. -- ephemeral (optional) — Show result only to you +**Options:** -Output: +- `category` (optional) — Filter by generation: boomer, genx, millennial, genz, genalpha, random -- Heads or tails +**Use case:** Brighten your day with community humor. --- -`/fun dice` +#### `/fun joke add ` + +Add a new joke to the database. + +**Options:** + +- `text` (required) — The joke text (max 1000 characters) +- `category` (required) — Generation category: boomer, genx, millennial, genz, genalpha, random + +**Use case:** Share your favorite jokes with the server. + +--- + +#### `/fun joke remove ` + +Remove a joke from the database (moderators only). + +**Options:** + +- `id` (required) — Joke ID to remove + +**Permissions:** Requires Joke Moderator role (configured in `.env`) + +**Use case:** Remove inappropriate or low-quality jokes. + +--- + +#### `/fun joke list [category]` + +Browse recent jokes. + +**Options:** + +- `category` (optional) — Filter by generation + +**Displays:** Last 10 jokes with ID, category, and usage count. + +**Use case:** See what jokes are available. + +--- + +### `/fun coinflip` + +Flip a coin with animation. + +**Options:** + +- `ephemeral` (optional) — Show result only to you + +**Output:** Heads or tails (result is tracked for stats) + +**Use case:** Make quick decisions or settle debates. + +--- + +### `/fun coinstats [user] [leaderboard]` + +View coin flip statistics. + +**Options:** + +- `user` (optional) — Check another user's stats +- `leaderboard` (optional) — Show top flippers (true/false) + +**Displays:** + +- Personal stats: Heads vs. tails count and percentages +- Leaderboard: Top 10 users by total flips +- User avatar and formatted results + +**Use case:** Track your luck and see who flips the most. + +--- + +### `/fun dice [sides] [count]` Roll dice with optional animation. -Options: +**Options:** -- sides (optional, default: 6) — Number of sides per die (2–100) -- count (optional, default: 1) — Number of dice to roll (1–10) -- ephemeral (optional) — Show result only to you +- `sides` (optional, default: 6) — Number of sides per die (2–100) +- `count` (optional, default: 1) — Number of dice to roll (1–10) +- `ephemeral` (optional) — Show result only to you -Behavior: +**Behavior:** - d6 rolls display dice face emojis -- Multiple dice rolls show individual results and a total +- Multiple dice show individual results and total + +**Use case:** Tabletop gaming, random number generation. --- -`/fun poll` +### `/fun poll [option3] [option4]` Create a quick poll. -Options: +**Options:** -- question — Poll question -- option1 — First option -- option2 — Second option -- option3 (optional) -- option4 (optional) +- `question` (required) — Poll question +- `option1` (required) — First option +- `option2` (required) — Second option +- `option3` (optional) — Third option +- `option4` (optional) — Fourth option -Behavior: +**Behavior:** -- 2–4 options +- 2–4 options supported - One vote per user -- Poll automatically closes after a timeout +- Poll automatically closes after timeout + +**Use case:** Quick community decisions or feedback. --- -`/fun weather` +### `/fun weather [unit]` + +Show current weather for a location. + +**Options:** -Show today’s weather for a location. +- `location` (required) — City, ZIP code, or region +- `unit` (optional) — Temperature unit (F or C) +- `ephemeral` (optional) — Show result only to you -Options: +**Displays:** -- location — City, ZIP, or region -- unit (optional) — Temperature unit (F or C) -- ephemeral (optional) — Show result only to you +- Current temperature and feels-like +- Weather conditions +- Sunrise/sunset times + +**Use case:** Check weather without leaving Discord. --- -`/fun weather7` +### `/fun weather7 [unit]` + +Show 7-day weather forecast. + +**Options:** -Show a 7- day weather forecast. +- `location` (required) — City, ZIP code, or region +- `unit` (optional) — Temperature unit (F or C) +- `ephemeral` (optional) — Show result only to you -Options: +**Displays:** Week-long forecast with high/low temperatures. -- location — City, ZIP, or region -- unit (optional) — Temperature unit (F or C) -- ephemeral (optional) — Show result only to you +**Use case:** Plan ahead for the week. --- -`/fun leaderboard` +### `/fun leaderboard [view] [user] [limit]` View fun command usage statistics. -Options: +**Options:** + +- `view` (optional) — Display mode: + - `users` — Top users (default) + - `commands` — Top fun commands + - `user` — Single-user breakdown +- `user` (optional) — Target user (for single-user view) +- `limit` (optional) — Number of results (default: 10, max: 25) + +**Views:** + +- **Top users:** Shows most active users with avatar and per-command highlights +- **Top commands:** Commands ranked by usage count +- **Single-user:** Detailed breakdown of one user's activity + +**Use case:** See who's most engaged with fun commands. + +--- + +## Summary & History + +Commands for reviewing and summarizing recent messages. -- view (optional) - - users — Top users (default) - - commands — Top fun commands - - user — Single- user breakdown -- user (optional) — Target user (used with view:user) -- limit (optional) — Number of results to show (default: 10, max: 25) +### `/summary [count]` -Views: +Summarize recent messages in the channel. -- Top users with per- command highlights -- Top commands by usage count -- Single- user view with avatar and command breakdown +**Options:** + +- `count` (optional) — Number of messages to summarize (default: 50, max: 100) + +**Modes:** + +- `local` — Fast heuristic summaries (always available) +- `llm` — High-quality AI summaries (requires OpenAI API key) + +**Configuration:** Set `SUMMARY_MODE` in `.env` + +**Use case:** Catch up on conversations you missed. + +--- + +### `/history [count]` + +Get recent channel history via DM. + +**Options:** + +- `count` (optional) — Number of messages to retrieve (default: 50, max: 100) + +**Behavior:** + +- Sends messages via DM +- Falls back to file upload if content is too long + +**Use case:** Review detailed message history privately. + +--- + +### `/playback [count]` + +Page through recent messages using interactive buttons. + +**Options:** + +- `count` (optional) — Number of messages to page through + +**Controls:** Next/Previous buttons for navigation + +**Use case:** Review messages interactively. --- -Summary & History +### `/pagination` + +Demo the reusable pagination helper. + +**Purpose:** Testing/demonstration command for the pagination system. + +--- + +## Timezone Management + +Commands for managing user timezones. Useful for coordinating across time zones. + +### `/timezone save ` + +Save your IANA timezone. + +**Options:** + +- `timezone` (required) — IANA timezone identifier (e.g., `America/New_York`) + +**Use case:** Let others know your local time for better coordination. + +--- + +### `/timezone show [user]` + +Display saved timezone. + +**Options:** + +- `user` (optional) — Check another user's timezone + +**Displays:** Timezone and current local time. + +**Use case:** Check someone's local time before messaging. + +--- + +### `/timezone clear` + +Remove your saved timezone from the database. + +**Use case:** Stop sharing your timezone information. + +--- + +### `/timezone compare ` + +Compare your timezone with another user's. + +**Options:** + +- `user` (required) — User to compare with + +**Displays:** Time difference between timezones. + +**Use case:** Calculate time differences for scheduling. + +--- + +## FAQ System + +Build and maintain a server knowledge base. All FAQ entries track usage count. + +### `/faq get ` + +Retrieve a FAQ entry. + +**Options:** + +- `key` (required) — FAQ key/identifier + +**Use case:** Quick answers to common questions. + +--- + +### `/faq add` + +Create a new FAQ entry. + +**Interactive form:** + +- Key (unique identifier) +- Title +- Body content +- Tags (comma-separated) + +**Permissions:** May require specific role (check server settings) + +**Use case:** Document frequently asked questions. + +--- + +### `/faq list [tag]` + +Browse available FAQs. + +**Options:** + +- `tag` (optional) — Filter by tag + +**Displays:** All FAQs with keys, titles, and usage counts. + +**Use case:** Discover what FAQs are available. + +--- + +### `/faq remove ` + +Delete a FAQ entry. + +**Options:** + +- `key` (required) — FAQ key to remove + +**Permissions:** Moderators only + +**Use case:** Remove outdated or incorrect information. + +--- + +### `/faq search ` + +Search FAQ entries by keyword. + +**Options:** + +- `query` (required) — Search term -Commands for summarizing and reviewing recent messages. +**Searches:** Titles, bodies, and tags -- /summary - - Summarize recent messages - - Uses local or LLM- based summarization -- `/history` - - DM recent channel history - - Falls back to file upload if content is too long -- `/playback` - - Page through recent messages using buttons - - `/pagination` -- Demo the reusable pagination helper +**Use case:** Find relevant FAQs when you don't know the exact key. --- -Timezone +## Notes -Commands for managing user timezones. +- **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` +- **GitHub Integration:** GitHub commands require repository configuration -- `/timezone set` - - Save your IANA timezone (example: America/New_York) -- `/timezone show` - - Display your currently saved timezone -- `/timezone clear` - - Remove your saved timezone +For configuration details, see `.env.example` in the repository. diff --git a/install-sqlite-simple.sh b/install-sqlite-simple.sh deleted file mode 100755 index 06421800..00000000 --- a/install-sqlite-simple.sh +++ /dev/null @@ -1,215 +0,0 @@ -#!/bin/bash - -set -e - -echo "🗄️ SQLite Installation (No Backup, No Migration)" -echo "==================================================" -echo "" - -GREEN='\033[0;32m' -RED='\033[0;31m' -NC='\033[0m' - -print_success() { echo -e "${GREEN}✓${NC} $1"; } -print_error() { echo -e "${RED}✗${NC} $1"; } - -if [ ! -f "package.json" ]; then - print_error "Run from OmegaBot root" - exit 1 -fi - -# ============================================================ -# Step 1: Install dependencies -# ============================================================ -echo "1. Installing better-sqlite3..." -npm install better-sqlite3 -npm install --save-dev @types/better-sqlite3 - -print_success "Dependencies installed" - -# ============================================================ -# Step 2: Delete old JSON files -# ============================================================ -echo "" -echo "2. Removing old JSON files..." - -rm -f data/faqs.json -rm -f data/fun-usage.json -rm -f data/timezones.json -rm -f data/guild-config.json -rm -f data/github-assignees.json -rm -f data/*.json.backup -rm -f data/*.json.migrated - -print_success "Removed old data files" - -# ============================================================ -# Step 3: Create database service -# ============================================================ -echo "" -echo "3. Creating database service..." - -mkdir -p src/services/database - -cat > src/services/database/db.ts << 'DBEOF' -// src/services/database/db.ts -import Database from "better-sqlite3"; -import { logger } from "../../utils/logger.js"; -import fs from "fs"; -import path from "path"; - -let db: Database.Database | null = null; - -export function initDatabase(): Database.Database { - if (db) return db; - - const databasePath = process.env.DATABASE_PATH || "data/omegabot.db"; - const dbDir = path.dirname(databasePath); - - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); - } - - logger.info({ path: databasePath }, "Initializing database"); - db = new Database(databasePath); - db.pragma("journal_mode = WAL"); - - db.exec(` - CREATE TABLE IF NOT EXISTS faqs ( - key TEXT PRIMARY KEY, - title TEXT NOT NULL, - body TEXT NOT NULL, - tags TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - usage_count INTEGER DEFAULT 0, - created_by TEXT, - updated_by TEXT - ); - - CREATE TABLE IF NOT EXISTS user_timezones ( - user_id TEXT PRIMARY KEY, - timezone TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS guild_config ( - guild_id TEXT PRIMARY KEY, - config TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS fun_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - command TEXT NOT NULL, - timestamp INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_fun_usage_user ON fun_usage(user_id); - CREATE INDEX IF NOT EXISTS idx_fun_usage_command ON fun_usage(command); - - CREATE TABLE IF NOT EXISTS github_last_seen ( - repo_key TEXT PRIMARY KEY, - last_seen_timestamp INTEGER NOT NULL, - entity_type TEXT NOT NULL - ); - `); - - logger.info("Database tables created"); - return db; -} - -export function getDb(): Database.Database { - if (!db) { - throw new Error("Database not initialized. Call initDatabase() first."); - } - return db; -} - -export function closeDatabase(): void { - if (db) { - logger.info("Closing database"); - db.close(); - db = null; - } -} - -export function transaction(fn: (db: Database.Database) => T): T { - const database = getDb(); - const txn = database.transaction(fn); - return txn(database); -} -DBEOF - -print_success "Created database service" - -# ============================================================ -# Step 4: Update bot.ts -# ============================================================ -echo "" -echo "4. Updating bot.ts..." - -if grep -q "initDatabase" src/bot.ts; then - print_success "Database initialization already in bot.ts" -else - sed -i '1a import { initDatabase, closeDatabase } from "./services/database/db.js";' src/bot.ts - - sed -i '/client\.login/i \ -initDatabase();\ -logger.info("Database initialized");\ -\ -process.on("SIGINT", () => {\ - logger.info("Shutting down...");\ - closeDatabase();\ - process.exit(0);\ -});\ -' src/bot.ts - - print_success "Updated bot.ts" -fi - -# ============================================================ -# Step 5: Update .env -# ============================================================ -echo "" -echo "5. Updating .env files..." - -if ! grep -q "DATABASE_PATH" .env.example; then - echo "" >> .env.example - echo "DATABASE_PATH=data/omegabot.db" >> .env.example -fi - -if [ -f ".env" ] && ! grep -q "DATABASE_PATH" .env; then - echo "" >> .env - echo "DATABASE_PATH=data/omegabot.db" >> .env -fi - -print_success "Updated .env files" - -# ============================================================ -# Step 6: Build -# ============================================================ -echo "" -echo "6. Building..." -npm run build - -if [ $? -eq 0 ]; then - echo "" - echo "╔════════════════════════════════════════════════════════╗" - echo "║ ✅ SQLite Installed! ║" - echo "╚════════════════════════════════════════════════════════╝" - echo "" - print_success "Database service ready" - print_success "All tables created" - print_success "Old JSON files deleted" - echo "" - echo "📋 Next Step:" - echo " ./fix-store-compatibility.sh" - echo "" - echo " Then: npm start" - echo "" -else - print_error "Build failed" - exit 1 -fi diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index eb1f5982..ea450e8f 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -10,9 +10,6 @@ import { logger } from "../../utils/logger.js"; import { run as runChuckNorris } from "./subcommands/chucknorris.js"; import type { ChuckNorrisMode } from "./subcommands/chucknorris.js"; -import { run as runDadJoke } from "./subcommands/dadjoke.js"; -import type { DadJokeMode } from "./subcommands/dadjoke.js"; - import { run as runDice } from "./subcommands/dice.js"; import { run as runWeather } from "./subcommands/weather.js"; @@ -54,22 +51,6 @@ export const data = new SlashCommandBuilder() ), ) - // /fun dadjoke - .addSubcommand((s) => - s - .setName("dadjoke") - .setDescription("Random dad joke (or search)") - .addStringOption((o) => - o.setName("query").setDescription("Search term (optional)").setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ) - // /fun dice .addSubcommand((s) => s @@ -288,15 +269,6 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise return; } - if (sub === "dadjoke") { - const query = interaction.options.getString("query")?.trim() ?? ""; - const mode: DadJokeMode = query ? { kind: "search", query } : { kind: "random" }; - - await runDadJoke(interaction, mode); - await maybeRecordUsage(interaction, sub); - return; - } - if (sub === "dice") { await runDice(interaction); await maybeRecordUsage(interaction, sub); diff --git a/src/commands/fun/subcommands/dadjoke.ts b/src/commands/fun/subcommands/dadjoke.ts deleted file mode 100644 index ce1a73a6..00000000 --- a/src/commands/fun/subcommands/dadjoke.ts +++ /dev/null @@ -1,218 +0,0 @@ -// src/commands/fun/subcommands/dadjoke.ts - -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; - -type DadJokeRandomResponse = { - id: string; - joke: string; - status: number; -}; - -type DadJokeSearchResponse = { - current_page: number; - limit: number; - next_page: number; - previous_page: number; - results: Array<{ - id: string; - joke: string; - }>; - search_term: string; - status: number; - total_jokes: number; - total_pages: number; -}; - -export type DadJokeMode = { kind: "random" } | { kind: "search"; query: string }; - -type DadJokeErrorCode = - | "NO_RESULTS" - | "RATE_LIMIT" - | "UPSTREAM" - | "TIMEOUT" - | "BAD_SHAPE"; - -class DadJokeError extends Error { - public code: DadJokeErrorCode; - - constructor(code: DadJokeErrorCode, message: string) { - super(message); - this.code = code; - } -} - -const USER_AGENT = "OmegaBot"; -const API_ROOT = "https://icanhazdadjoke.com"; -const FETCH_TIMEOUT_MS = 7_000; - -/** - * Run handler for /fun dadjoke - * - * IMPORTANT: - * - This file is NOT a slash command by itself - * - It must NOT call reply() or deferReply() - * - The parent command (fun.ts) owns the interaction lifecycle - */ -export async function run( - interaction: ChatInputCommandInteraction, - mode: DadJokeMode, -): Promise { - try { - const joke = await fetchDadJoke(mode); - - await interaction.editReply(joke); - - logger.debug({ userId: interaction.user.id, mode: mode.kind }, "[fun/dadjoke] sent"); - } catch (err) { - logger.error({ err, mode }, "[fun/dadjoke] failed"); - - // Friendly, specific messages for expected cases - if (err instanceof DadJokeError) { - if (err.code === "NO_RESULTS") { - await interaction.editReply( - "No jokes found for that search. Try a different word, or run `/fun dadjoke` with no query.", - ); - return; - } - - if (err.code === "RATE_LIMIT") { - await interaction.editReply( - "Too many requests right now. Try again in a minute.", - ); - return; - } - - if (err.code === "TIMEOUT") { - await interaction.editReply("Dad Joke API took too long to respond. Try again."); - return; - } - - // UPSTREAM / BAD_SHAPE - await interaction.editReply("Dad Joke API is having issues. Try again later."); - return; - } - - // Unknown error type - await interaction.editReply("Dad Joke API is being dramatic. Try again later."); - } -} - -async function fetchDadJoke(mode: DadJokeMode): Promise { - if (mode.kind === "random") { - return fetchRandom(); - } - - const q = mode.query.trim(); - if (!q) { - return fetchRandom(); - } - - // Optional guard: avoids silly calls and often improves UX - if (q.length < 2) { - return fetchRandom(); - } - - return fetchSearch(q); -} - -function baseHeaders(): Record { - return { - Accept: "application/json", - "User-Agent": USER_AGENT, - }; -} - -async function fetchWithTimeout(url: string): Promise { - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - try { - return await fetch(url, { headers: baseHeaders(), signal: controller.signal }); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - throw new DadJokeError( - "TIMEOUT", - `Dad Joke API timed out after ${FETCH_TIMEOUT_MS}ms`, - ); - } - throw err; - } finally { - clearTimeout(t); - } -} - -function mapHttpError(res: Response): never { - if (res.status === 429) { - throw new DadJokeError("RATE_LIMIT", "Dad Joke API rate limited (429)"); - } - - // Treat any non-OK as upstream trouble for the user, but keep details in logs via error message. - throw new DadJokeError( - "UPSTREAM", - `Dad Joke API error: ${res.status} ${res.statusText}`, - ); -} - -/** - * Random joke - * Docs: https://icanhazdadjoke.com/api - */ -async function fetchRandom(): Promise { - const res = await fetchWithTimeout(`${API_ROOT}/`); - - if (!res.ok) { - mapHttpError(res); - } - - let data: DadJokeRandomResponse; - try { - data = (await res.json()) as DadJokeRandomResponse; - } catch { - throw new DadJokeError("BAD_SHAPE", "Dad Joke API returned invalid JSON"); - } - - if (!data?.joke || typeof data.joke !== "string") { - throw new DadJokeError( - "BAD_SHAPE", - "Dad Joke API returned an unexpected response shape", - ); - } - - return data.joke.trim(); -} - -/** - * Search jokes (we pick a random result from the first page) - */ -async function fetchSearch(query: string): Promise { - const url = `${API_ROOT}/search?term=${encodeURIComponent(query)}`; - - const res = await fetchWithTimeout(url); - - if (!res.ok) { - mapHttpError(res); - } - - let data: DadJokeSearchResponse; - try { - data = (await res.json()) as DadJokeSearchResponse; - } catch { - throw new DadJokeError("BAD_SHAPE", "Dad Joke API returned invalid JSON"); - } - - const results = data?.results; - if (!Array.isArray(results) || results.length === 0) { - throw new DadJokeError("NO_RESULTS", `No results for "${query}"`); - } - - const pick = results[Math.floor(Math.random() * results.length)]; - if (!pick?.joke || typeof pick.joke !== "string") { - throw new DadJokeError( - "BAD_SHAPE", - "Dad Joke API returned an unexpected response shape", - ); - } - - return pick.joke.trim(); -} diff --git a/src/commands/fun/subcommands/joke/add.ts b/src/commands/fun/subcommands/joke/add.ts new file mode 100644 index 00000000..234bf0bd --- /dev/null +++ b/src/commands/fun/subcommands/joke/add.ts @@ -0,0 +1,32 @@ +// src/commands/fun/subcommands/joke/add.ts +import type { ChatInputCommandInteraction } from "discord.js"; +import { addJoke, type JokeCategory } from "../../../../services/joke/jokeStore.js"; + +export async function handleJokeAdd( + interaction: ChatInputCommandInteraction, +): Promise { + const jokeText = interaction.options.getString("text", true); + const category = interaction.options.getString("category", true) as JokeCategory; + + if (jokeText.length > 1000) { + await interaction.reply({ + content: "Joke is too long! Keep it under 1000 characters.", + ephemeral: true, + }); + return; + } + + const joke = addJoke(jokeText, category, interaction.user.id); + + await interaction.reply({ + content: [ + "✅ **Joke added!**", + "", + `Category: ${category}`, + `Joke #${joke.id}`, + "", + joke.joke_text, + ].join("\n"), + ephemeral: true, + }); +} diff --git a/src/commands/fun/subcommands/joke/index.ts b/src/commands/fun/subcommands/joke/index.ts new file mode 100644 index 00000000..cf9c8497 --- /dev/null +++ b/src/commands/fun/subcommands/joke/index.ts @@ -0,0 +1,92 @@ +// src/commands/fun/subcommands/joke/index.ts +import { + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from "discord.js"; +import type { ChatInputCommandInteraction } from "discord.js"; +import { JOKE_CATEGORIES } from "../../../../services/joke/jokeStore.js"; +import { handleJokeRandom } from "./random.js"; +import { handleJokeAdd } from "./add.js"; +import { handleJokeRemove } from "./remove.js"; +import { handleJokeList } from "./list.js"; + +export function buildJokeSubcommands( + subcommandGroup: SlashCommandSubcommandGroupBuilder, +) { + return subcommandGroup + .setName("joke") + .setDescription("User-submitted jokes by generation") + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName("random") + .setDescription("Get a random joke") + .addStringOption((opt) => + opt + .setName("category") + .setDescription("Joke category (generation)") + .setRequired(false) + .addChoices(...JOKE_CATEGORIES.map((cat) => ({ name: cat, value: cat }))), + ), + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName("add") + .setDescription("Add a new joke") + .addStringOption((opt) => + opt.setName("text").setDescription("The joke text").setRequired(true), + ) + .addStringOption((opt) => + opt + .setName("category") + .setDescription("Joke category (generation)") + .setRequired(true) + .addChoices(...JOKE_CATEGORIES.map((cat) => ({ name: cat, value: cat }))), + ), + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName("remove") + .setDescription("Remove a joke (moderators only)") + .addIntegerOption((opt) => + opt.setName("id").setDescription("Joke ID to remove").setRequired(true), + ), + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName("list") + .setDescription("List recent jokes") + .addStringOption((opt) => + opt + .setName("category") + .setDescription("Filter by category") + .setRequired(false) + .addChoices(...JOKE_CATEGORIES.map((cat) => ({ name: cat, value: cat }))), + ), + ); +} + +export async function handleJoke( + interaction: ChatInputCommandInteraction, +): Promise { + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case "random": + await handleJokeRandom(interaction); + break; + case "add": + await handleJokeAdd(interaction); + break; + case "remove": + await handleJokeRemove(interaction); + break; + case "list": + await handleJokeList(interaction); + break; + default: + await interaction.reply({ + content: "Unknown subcommand", + ephemeral: true, + }); + } +} diff --git a/src/commands/fun/subcommands/joke/list.ts b/src/commands/fun/subcommands/joke/list.ts new file mode 100644 index 00000000..46ed2dcd --- /dev/null +++ b/src/commands/fun/subcommands/joke/list.ts @@ -0,0 +1,45 @@ +// src/commands/fun/subcommands/joke/list.ts +import { EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { + listJokes, + getJokeStats, + type JokeCategory, +} from "../../../../services/joke/jokeStore.js"; + +export async function handleJokeList( + interaction: ChatInputCommandInteraction, +): Promise { + const category = interaction.options.getString("category") as JokeCategory | null; + + const jokes = listJokes(category || undefined, 10); + const stats = getJokeStats(); + + if (jokes.length === 0) { + await interaction.reply({ + content: category + ? `No jokes in the ${category} category yet.` + : "No jokes added yet. Be the first with `/fun joke add`!", + ephemeral: true, + }); + return; + } + + const embed = new EmbedBuilder() + .setTitle(category ? `${category.toUpperCase()} Jokes` : "All Jokes") + .setDescription( + jokes + .map( + (j) => + `**#${j.id}** [${j.category}] - ${j.joke_text.substring(0, 60)}${j.joke_text.length > 60 ? "..." : ""} (${j.usage_count} uses)`, + ) + .join("\n"), + ) + .setFooter({ + text: category + ? `Showing ${jokes.length} of ${stats.byCategory[category] || 0} ${category} jokes` + : `Showing ${jokes.length} of ${stats.total} total jokes`, + }) + .setColor(0x00ae86); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} diff --git a/src/commands/fun/subcommands/joke/random.ts b/src/commands/fun/subcommands/joke/random.ts new file mode 100644 index 00000000..165b4cf3 --- /dev/null +++ b/src/commands/fun/subcommands/joke/random.ts @@ -0,0 +1,40 @@ +// src/commands/fun/subcommands/joke/random.ts +import type { ChatInputCommandInteraction } from "discord.js"; +import { getRandomJoke, type JokeCategory } from "../../../../services/joke/jokeStore.js"; + +export async function handleJokeRandom( + interaction: ChatInputCommandInteraction, +): Promise { + const category = interaction.options.getString("category") as JokeCategory | null; + + const joke = getRandomJoke(category || undefined); + + if (!joke) { + await interaction.reply({ + 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, + }); + return; + } + + const categoryEmoji = { + boomer: "👴", + genx: "🎸", + millennial: "📱", + genz: "🔥", + genalpha: "🧒", + random: "🎲", + }; + + await interaction.reply({ + content: [ + `${categoryEmoji[joke.category]} **${joke.category.toUpperCase()} JOKE** ${categoryEmoji[joke.category]}`, + "", + joke.joke_text, + "", + `_Joke #${joke.id} • Used ${joke.usage_count} times_`, + ].join("\n"), + }); +} diff --git a/src/commands/fun/subcommands/joke/remove.ts b/src/commands/fun/subcommands/joke/remove.ts new file mode 100644 index 00000000..6203e132 --- /dev/null +++ b/src/commands/fun/subcommands/joke/remove.ts @@ -0,0 +1,74 @@ +// src/commands/fun/subcommands/joke/remove.ts +import type { ChatInputCommandInteraction } from "discord.js"; +import { removeJoke, getJoke } from "../../../../services/joke/jokeStore.js"; + +export async function handleJokeRemove( + interaction: ChatInputCommandInteraction, +): Promise { + const jokeId = interaction.options.getInteger("id", true); + + // Check if in guild + if (!interaction.inGuild() || !interaction.member) { + await interaction.reply({ + content: "This command can only be used in a server.", + ephemeral: true, + }); + return; + } + + // At this point TypeScript knows we're in a guild + const member = interaction.member; + + // Check if member is a GuildMember (not just a string ID) + if (typeof member === "string") { + await interaction.reply({ + content: "Could not verify your permissions.", + ephemeral: true, + }); + return; + } + + // Check moderator role + const modRoleId = process.env.JOKE_MODERATOR_ROLE_ID; + + // Type guard: ensure roles is GuildMemberRoleManager, not string[] + const hasModRole = + modRoleId && "cache" in member.roles && member.roles.cache.has(modRoleId); + + if (!hasModRole) { + await interaction.reply({ + content: "❌ Only joke moderators can remove jokes.", + ephemeral: true, + }); + return; + } + + const joke = getJoke(jokeId); + + if (!joke) { + await interaction.reply({ + content: `Joke #${jokeId} not found.`, + ephemeral: true, + }); + return; + } + + const removed = removeJoke(jokeId); + + if (removed) { + await interaction.reply({ + content: [ + `✅ **Removed joke #${jokeId}**`, + "", + `Category: ${joke.category}`, + `Text: ${joke.joke_text.substring(0, 100)}${joke.joke_text.length > 100 ? "..." : ""}`, + ].join("\n"), + ephemeral: true, + }); + } else { + await interaction.reply({ + content: `Failed to remove joke #${jokeId}.`, + ephemeral: true, + }); + } +} diff --git a/src/commands/help/helpText.ts b/src/commands/help/helpText.ts index 971a2419..b3a52cd4 100644 --- a/src/commands/help/helpText.ts +++ b/src/commands/help/helpText.ts @@ -67,7 +67,7 @@ function buildOverviewHelp(args: { isAdmin: boolean }): string { ); lines.push(""); lines.push("**Quick picks**"); - lines.push("`/fun dadjoke` Random dad joke"); + lines.push("`/fun joke random` Community jokes across 13 categories"); lines.push("`/fun poll` Create a quick poll"); lines.push("`/fun weather` Weather for a location"); lines.push("`/gh status` Check GitHub integration status"); @@ -75,7 +75,7 @@ function buildOverviewHelp(args: { isAdmin: boolean }): string { lines.push("`/timezone show` Show your saved timezone"); lines.push(""); lines.push( - "If new commands don’t show up, an admin may need to run the register script.", + "If new commands don't show up, an admin may need to run the register script.", ); return lines.join("\n"); @@ -101,21 +101,43 @@ function buildFunHelp(): string { lines.push("`/fun chucknorris query:docker ephemeral:true`"); lines.push(""); - lines.push("**Dad Joke**"); - lines.push("`/fun dadjoke`"); + lines.push("**Joke (Community)**"); + lines.push("`/fun joke` User-submitted jokes with 13 categories"); + lines.push("Subcommands"); + lines.push("`/fun joke random` Get a random joke"); + lines.push("`/fun joke add` Add a new joke to the database"); + lines.push("`/fun joke list` Browse recent jokes"); + lines.push("`/fun joke remove` Remove a joke (moderators only)"); + lines.push("Categories"); + lines.push( + "boomer, genx, millennial, genz, genalpha, random, tech, dark, wholesome, anti, puns, observational, dad", + ); lines.push("Examples"); - lines.push("`/fun dadjoke`"); - lines.push("`/fun dadjoke query:coffee`"); - lines.push("`/fun dadjoke query:kubernetes ephemeral:true`"); + lines.push("`/fun joke random`"); + lines.push("`/fun joke random category:genz`"); + lines.push("`/fun joke random category:tech`"); + lines.push("`/fun joke add text:Why did... category:millennial`"); + lines.push("`/fun joke list category:dad`"); lines.push(""); lines.push("**Coin Flip**"); - lines.push("`/fun coinflip`"); + lines.push("`/fun coinflip` Flip a coin (results tracked for stats)"); lines.push("Examples"); lines.push("`/fun coinflip`"); lines.push("`/fun coinflip ephemeral:true`"); lines.push(""); + lines.push("**Coin Stats**"); + lines.push("`/fun coinstats` View coin flip statistics and leaderboards"); + lines.push("Options"); + lines.push("`user` Check another user's stats"); + lines.push("`leaderboard` Show top flippers (true/false)"); + lines.push("Examples"); + lines.push("`/fun coinstats`"); + lines.push("`/fun coinstats user:@Someone`"); + lines.push("`/fun coinstats leaderboard:true`"); + lines.push(""); + lines.push("**Dice**"); lines.push("`/fun dice`"); lines.push("Options"); diff --git a/src/services/database/db-joke-schema.sql b/src/services/database/db-joke-schema.sql new file mode 100644 index 00000000..b6425ccd --- /dev/null +++ b/src/services/database/db-joke-schema.sql @@ -0,0 +1,12 @@ +-- Add this to your db.ts file in the db.exec() section: + +CREATE TABLE IF NOT EXISTS jokes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + joke_text TEXT NOT NULL, + category TEXT NOT NULL, + added_by TEXT NOT NULL, + added_at INTEGER NOT NULL, + usage_count INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_jokes_category ON jokes(category); diff --git a/src/services/joke/jokeStore.ts b/src/services/joke/jokeStore.ts new file mode 100644 index 00000000..6cd548fc --- /dev/null +++ b/src/services/joke/jokeStore.ts @@ -0,0 +1,145 @@ +// src/services/joke/jokeStore.ts +import { getDb } from "../database/db.js"; + +export type JokeCategory = + | "boomer" + | "genx" + | "millennial" + | "genz" + | "genalpha" + | "random" + | "tech" + | "dark" + | "wholesome" + | "anti" + | "puns" + | "observational" + | "dad"; + +export const JOKE_CATEGORIES: JokeCategory[] = [ + "boomer", + "genx", + "millennial", + "genz", + "genalpha", + "random", + "tech", + "dark", + "wholesome", + "anti", + "puns", + "observational", + "dad", +]; + +export interface Joke { + id: number; + joke_text: string; + category: JokeCategory; + added_by: string; + added_at: number; + usage_count: number; +} + +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, + }; +} + +export function getRandomJoke(category?: JokeCategory): Joke | null { + const db = getDb(); + + let query = "SELECT * FROM jokes"; + const params: (string | number)[] = []; + + if (category && category !== "random") { + query += " WHERE category = ?"; + params.push(category); + } + + query += " ORDER BY RANDOM() LIMIT 1"; + + 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, + ); + } + + return joke || null; +} + +export function removeJoke(jokeId: number): boolean { + const db = getDb(); + const result = db.prepare("DELETE FROM jokes WHERE id = ?").run(jokeId); + return result.changes > 0; +} + +export function getJoke(jokeId: number): Joke | null { + const db = getDb(); + return db.prepare("SELECT * FROM jokes WHERE id = ?").get(jokeId) as Joke | null; +} + +export function listJokes(category?: JokeCategory, limit: number = 50): Joke[] { + const db = getDb(); + + let query = "SELECT * FROM jokes"; + const params: (string | number)[] = []; + + if (category && category !== "random") { + query += " WHERE category = ?"; + params.push(category); + } + + query += " ORDER BY added_at DESC LIMIT ?"; + params.push(limit); + + return db.prepare(query).all(...params) as Joke[]; +} + +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, + }; +}