From 9a1502eca0299731a64929d723ecd5c369a3922f Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sun, 18 Jan 2026 12:13:31 -0500 Subject: [PATCH 1/5] Adding in the jokes --- .env.example | 20 +- CHANGELOG.md | 41 +- README.md | 68 ++- data/omegabot.db | Bin 53248 -> 77824 bytes docs/commands.md | 467 +++++++++++++++----- install-sqlite-simple.sh | 215 --------- src/commands/fun/fun.ts | 27 -- src/commands/fun/subcommands/dadjoke.ts | 218 --------- src/commands/fun/subcommands/joke/add.ts | 30 ++ src/commands/fun/subcommands/joke/index.ts | 97 ++++ src/commands/fun/subcommands/joke/list.ts | 36 ++ src/commands/fun/subcommands/joke/random.ts | 38 ++ src/commands/fun/subcommands/joke/remove.ts | 71 +++ src/commands/help/helpText.ts | 36 +- src/services/database/db-joke-schema.sql | 12 + src/services/joke/jokeStore.ts | 120 +++++ 16 files changed, 891 insertions(+), 605 deletions(-) delete mode 100755 install-sqlite-simple.sh delete mode 100644 src/commands/fun/subcommands/dadjoke.ts create mode 100644 src/commands/fun/subcommands/joke/add.ts create mode 100644 src/commands/fun/subcommands/joke/index.ts create mode 100644 src/commands/fun/subcommands/joke/list.ts create mode 100644 src/commands/fun/subcommands/joke/random.ts create mode 100644 src/commands/fun/subcommands/joke/remove.ts create mode 100644 src/services/database/db-joke-schema.sql create mode 100644 src/services/joke/jokeStore.ts 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..ead751bb 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 +- Better type safety across permission checks \ No newline at end of file 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 fbff965ac3b583281916a73d019cfffea5a0a629..610132a538e52c646099638b7f965a592158ec4c 100644 GIT binary patch literal 77824 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 delta 63 zcmZp8z|ydQd4jYc69WSSFA&23`$QdM9wr98-e0`@KNxs8KTOP)+$<=N!@2nbr%2gm L7J)zVixdn1q;L<8 diff --git a/docs/commands.md b/docs/commands.md index 5c790563..404569c2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,193 +1,444 @@ # 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. --- -## GitHub +### `/gh issues` +List open GitHub issues. -Commands for interacting with GitHub repositories configured for the server. +**Options:** +- `state` (optional) — Filter by state (open, closed, all) +- `limit` (optional) — Number of issues to show (default: 10, max: 50) -- `/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 +**Use case:** See what's currently open at a glance. --- -## Fun +### `/gh prs` +List open pull requests. -All fun commands are grouped under /fun. +**Options:** +- `state` (optional) — Filter by state (open, closed, all) +- `limit` (optional) — Number of PRs to show (default: 10, max: 50) -`/fun chucknorris` +**Use case:** Check PR status without leaving Discord. -Chuck Norris facts. +--- + +### `/gh status` +Show GitHub integration status and configuration. + +**Displays:** +- Repository configuration +- Polling status +- Announcement channel bindings +- API connection health + +**Use case:** Verify bot configuration and troubleshoot issues. -Options: +--- + +### `/pr ` +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:** +- `number` (required) — PR number to look up -Modes: +**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` +Get dad jokes. -Dad jokes. +**Options:** +- `query` (optional) — Search jokes by keyword +- `ephemeral` (optional) — Show result only to you -Options: +**Modes:** +- Random joke (no query) +- Search results (with query) -- query (optional) — Search jokes by keyword -- ephemeral (optional) — Show result only to you +**Use case:** Classic groan-worthy humor. + +--- -Modes: +### `/fun joke` +Community-submitted jokes organized by generation. -- Random joke -- Search results +#### `/fun joke random [category]` +Get a random joke from the database. + +**Options:** +- `category` (optional) — Filter by generation: boomer, genx, millennial, genz, genalpha, random + +**Use case:** Brighten your day with community humor. --- -`/fun coinflip` +#### `/fun joke add ` +Add a new joke to the database. -Flip a coin with a short animation. +**Options:** +- `text` (required) — The joke text (max 1000 characters) +- `category` (required) — Generation category: boomer, genx, millennial, genz, genalpha, random -Options: +**Use case:** Share your favorite jokes with the server. + +--- -- ephemeral (optional) — Show result only to you +#### `/fun joke remove ` +Remove a joke from the database (moderators only). -Output: +**Options:** +- `id` (required) — Joke ID to remove -- Heads or tails +**Permissions:** Requires Joke Moderator role (configured in `.env`) + +**Use case:** Remove inappropriate or low-quality jokes. --- -`/fun dice` +#### `/fun joke list [category]` +Browse recent jokes. -Roll dice with optional animation. +**Options:** +- `category` (optional) — Filter by generation + +**Displays:** Last 10 jokes with ID, category, and usage count. -Options: +**Use case:** See what jokes are available. -- 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: +### `/fun coinflip` +Flip a coin with animation. -- d6 rolls display dice face emojis -- Multiple dice rolls show individual results and a total +**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 poll` +### `/fun coinstats [user] [leaderboard]` +View coin flip statistics. -Create a quick poll. +**Options:** +- `user` (optional) — Check another user's stats +- `leaderboard` (optional) — Show top flippers (true/false) -Options: +**Displays:** +- Personal stats: Heads vs. tails count and percentages +- Leaderboard: Top 10 users by total flips +- User avatar and formatted results -- question — Poll question -- option1 — First option -- option2 — Second option -- option3 (optional) -- option4 (optional) +**Use case:** Track your luck and see who flips the most. -Behavior: +--- -- 2–4 options -- One vote per user -- Poll automatically closes after a timeout +### `/fun dice [sides] [count]` +Roll dice with optional animation. + +**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 + +**Behavior:** +- d6 rolls display dice face emojis +- Multiple dice show individual results and total + +**Use case:** Tabletop gaming, random number generation. --- -`/fun weather` +### `/fun poll [option3] [option4]` +Create a quick poll. -Show today’s weather for a location. +**Options:** +- `question` (required) — Poll question +- `option1` (required) — First option +- `option2` (required) — Second option +- `option3` (optional) — Third option +- `option4` (optional) — Fourth option -Options: +**Behavior:** +- 2–4 options supported +- One vote per user +- Poll automatically closes after timeout -- location — City, ZIP, or region -- unit (optional) — Temperature unit (F or C) -- ephemeral (optional) — Show result only to you +**Use case:** Quick community decisions or feedback. --- -`/fun weather7` +### `/fun weather [unit]` +Show current weather for a location. -Show a 7- day weather forecast. +**Options:** +- `location` (required) — City, ZIP code, or region +- `unit` (optional) — Temperature unit (F or C) +- `ephemeral` (optional) — Show result only to you -Options: +**Displays:** +- Current temperature and feels-like +- Weather conditions +- Sunrise/sunset times -- location — City, ZIP, or region -- unit (optional) — Temperature unit (F or C) -- ephemeral (optional) — Show result only to you +**Use case:** Check weather without leaving Discord. --- -`/fun leaderboard` +### `/fun weather7 [unit]` +Show 7-day weather forecast. + +**Options:** +- `location` (required) — City, ZIP code, or region +- `unit` (optional) — Temperature unit (F or C) +- `ephemeral` (optional) — Show result only to you + +**Displays:** Week-long forecast with high/low temperatures. +**Use case:** Plan ahead for the week. + +--- + +### `/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 -- 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) +Commands for reviewing and summarizing recent messages. -Views: +### `/summary [count]` +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. --- -Summary & History +### `/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. + +--- + +### `/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..faac501e 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -10,8 +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"; @@ -54,22 +52,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 +270,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..a11dbe9b --- /dev/null +++ b/src/commands/fun/subcommands/joke/add.ts @@ -0,0 +1,30 @@ +// 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..e752034c --- /dev/null +++ b/src/commands/fun/subcommands/joke/index.ts @@ -0,0 +1,97 @@ +// src/commands/fun/subcommands/joke/index.ts +import { SlashCommandSubcommandBuilder } 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: any) { + 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..f783e403 --- /dev/null +++ b/src/commands/fun/subcommands/joke/list.ts @@ -0,0 +1,36 @@ +// 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..6f09c715 --- /dev/null +++ b/src/commands/fun/subcommands/joke/random.ts @@ -0,0 +1,38 @@ +// 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..ef9362fd --- /dev/null +++ b/src/commands/fun/subcommands/joke/remove.ts @@ -0,0 +1,71 @@ +// 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..c113c41f 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,41 @@ 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..495fed72 --- /dev/null +++ b/src/services/joke/jokeStore.ts @@ -0,0 +1,120 @@ +// 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" | "oberservational" | "dad"; + +export const JOKE_CATEGORIES: JokeCategory[] = [ + "boomer", + "genx", + "millennial", + "genz", + "genalpha", + "random", + "tech", + "dark", + "wholesome", + "anti", + "puns", + "oberservational", + "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: any[] = []; + + 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: any[] = []; + + 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, + }; +} From 743ef400f5aea6479112d8f0704b612c2e648266 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sun, 18 Jan 2026 12:13:36 -0500 Subject: [PATCH 2/5] style: auto-format with Prettier [skip-precheck] --- CHANGELOG.md | 2 +- docs/commands.md | 71 +++++++++++++++++++ src/commands/fun/fun.ts | 1 - src/commands/fun/subcommands/joke/add.ts | 10 +-- src/commands/fun/subcommands/joke/index.ts | 46 +++++-------- src/commands/fun/subcommands/joke/list.ts | 33 +++++---- src/commands/fun/subcommands/joke/random.ts | 14 ++-- src/commands/fun/subcommands/joke/remove.ts | 29 ++++---- src/commands/help/helpText.ts | 4 +- src/services/joke/jokeStore.ts | 75 ++++++++++++++------- 10 files changed, 194 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ead751bb..4d46f82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,4 +35,4 @@ - Updated to Discord.js v14.16.3 - Improved TypeScript strict mode compliance - Enhanced error handling in FAQ subcommands -- Better type safety across permission checks \ No newline at end of file +- Better type safety across permission checks diff --git a/docs/commands.md b/docs/commands.md index 404569c2..30f4bbf9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,6 +7,7 @@ Complete documentation for all OmegaBot slash commands, including subcommands an ## General ### `/ping` + Verify the bot is online and measure latency. **Use case:** Quick health check to see if the bot is responsive. @@ -18,9 +19,11 @@ Verify the bot is online and measure latency. 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. @@ -28,9 +31,11 @@ Fetch a GitHub issue by number. --- ### `/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) @@ -39,9 +44,11 @@ List open GitHub issues. --- ### `/gh prs` + List open pull requests. **Options:** + - `state` (optional) — Filter by state (open, closed, all) - `limit` (optional) — Number of PRs to show (default: 10, max: 50) @@ -50,9 +57,11 @@ List open pull requests. --- ### `/gh status` + Show GitHub integration status and configuration. **Displays:** + - Repository configuration - Polling status - Announcement channel bindings @@ -63,9 +72,11 @@ Show GitHub integration status and configuration. --- ### `/pr ` + Fetch a single pull request by number. **Options:** + - `number` (required) — PR number to look up **Note:** Legacy shortcut for `/gh pr`. Consider using `/gh` commands instead. @@ -77,14 +88,17 @@ Fetch a single pull request by number. 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 - Keyword search @@ -94,13 +108,16 @@ Get Chuck Norris facts. --- ### `/fun dadjoke` + Get dad jokes. **Options:** + - `query` (optional) — Search jokes by keyword - `ephemeral` (optional) — Show result only to you **Modes:** + - Random joke (no query) - Search results (with query) @@ -109,12 +126,15 @@ Get dad jokes. --- ### `/fun joke` + Community-submitted jokes organized by generation. #### `/fun joke random [category]` + Get a random joke from the database. **Options:** + - `category` (optional) — Filter by generation: boomer, genx, millennial, genz, genalpha, random **Use case:** Brighten your day with community humor. @@ -122,9 +142,11 @@ Get a random joke from the database. --- #### `/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 @@ -133,9 +155,11 @@ Add a new joke to the database. --- #### `/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`) @@ -145,9 +169,11 @@ Remove a joke from the database (moderators only). --- #### `/fun joke list [category]` + Browse recent jokes. **Options:** + - `category` (optional) — Filter by generation **Displays:** Last 10 jokes with ID, category, and usage count. @@ -157,9 +183,11 @@ Browse recent jokes. --- ### `/fun coinflip` + Flip a coin with animation. **Options:** + - `ephemeral` (optional) — Show result only to you **Output:** Heads or tails (result is tracked for stats) @@ -169,13 +197,16 @@ Flip a coin with animation. --- ### `/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 @@ -185,14 +216,17 @@ View coin flip statistics. --- ### `/fun dice [sides] [count]` + Roll dice with optional animation. **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 **Behavior:** + - d6 rolls display dice face emojis - Multiple dice show individual results and total @@ -201,9 +235,11 @@ Roll dice with optional animation. --- ### `/fun poll [option3] [option4]` + Create a quick poll. **Options:** + - `question` (required) — Poll question - `option1` (required) — First option - `option2` (required) — Second option @@ -211,6 +247,7 @@ Create a quick poll. - `option4` (optional) — Fourth option **Behavior:** + - 2–4 options supported - One vote per user - Poll automatically closes after timeout @@ -220,14 +257,17 @@ Create a quick poll. --- ### `/fun weather [unit]` + Show current weather for a location. **Options:** + - `location` (required) — City, ZIP code, or region - `unit` (optional) — Temperature unit (F or C) - `ephemeral` (optional) — Show result only to you **Displays:** + - Current temperature and feels-like - Weather conditions - Sunrise/sunset times @@ -237,9 +277,11 @@ Show current weather for a location. --- ### `/fun weather7 [unit]` + Show 7-day weather forecast. **Options:** + - `location` (required) — City, ZIP code, or region - `unit` (optional) — Temperature unit (F or C) - `ephemeral` (optional) — Show result only to you @@ -251,9 +293,11 @@ Show 7-day weather forecast. --- ### `/fun leaderboard [view] [user] [limit]` + View fun command usage statistics. **Options:** + - `view` (optional) — Display mode: - `users` — Top users (default) - `commands` — Top fun commands @@ -262,6 +306,7 @@ View fun command usage statistics. - `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 @@ -275,12 +320,15 @@ View fun command usage statistics. Commands for reviewing and summarizing recent messages. ### `/summary [count]` + Summarize recent messages in the channel. **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) @@ -291,12 +339,15 @@ Summarize recent messages in the channel. --- ### `/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 @@ -305,9 +356,11 @@ Get recent channel history via DM. --- ### `/playback [count]` + Page through recent messages using interactive buttons. **Options:** + - `count` (optional) — Number of messages to page through **Controls:** Next/Previous buttons for navigation @@ -317,6 +370,7 @@ Page through recent messages using interactive buttons. --- ### `/pagination` + Demo the reusable pagination helper. **Purpose:** Testing/demonstration command for the pagination system. @@ -328,9 +382,11 @@ Demo the reusable pagination helper. 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. @@ -338,9 +394,11 @@ Save your IANA timezone. --- ### `/timezone show [user]` + Display saved timezone. **Options:** + - `user` (optional) — Check another user's timezone **Displays:** Timezone and current local time. @@ -350,6 +408,7 @@ Display saved timezone. --- ### `/timezone clear` + Remove your saved timezone from the database. **Use case:** Stop sharing your timezone information. @@ -357,9 +416,11 @@ Remove your saved timezone from the database. --- ### `/timezone compare ` + Compare your timezone with another user's. **Options:** + - `user` (required) — User to compare with **Displays:** Time difference between timezones. @@ -373,9 +434,11 @@ Compare your timezone with another user's. 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. @@ -383,9 +446,11 @@ Retrieve a FAQ entry. --- ### `/faq add` + Create a new FAQ entry. **Interactive form:** + - Key (unique identifier) - Title - Body content @@ -398,9 +463,11 @@ Create a new FAQ entry. --- ### `/faq list [tag]` + Browse available FAQs. **Options:** + - `tag` (optional) — Filter by tag **Displays:** All FAQs with keys, titles, and usage counts. @@ -410,9 +477,11 @@ Browse available FAQs. --- ### `/faq remove ` + Delete a FAQ entry. **Options:** + - `key` (required) — FAQ key to remove **Permissions:** Moderators only @@ -422,9 +491,11 @@ Delete a FAQ entry. --- ### `/faq search ` + Search FAQ entries by keyword. **Options:** + - `query` (required) — Search term **Searches:** Titles, bodies, and tags diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index faac501e..ea450e8f 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -10,7 +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 runDice } from "./subcommands/dice.js"; import { run as runWeather } from "./subcommands/weather.js"; diff --git a/src/commands/fun/subcommands/joke/add.ts b/src/commands/fun/subcommands/joke/add.ts index a11dbe9b..234bf0bd 100644 --- a/src/commands/fun/subcommands/joke/add.ts +++ b/src/commands/fun/subcommands/joke/add.ts @@ -2,10 +2,12 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { addJoke, type JokeCategory } from "../../../../services/joke/jokeStore.js"; -export async function handleJokeAdd(interaction: ChatInputCommandInteraction): Promise { +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.", @@ -13,9 +15,9 @@ export async function handleJokeAdd(interaction: ChatInputCommandInteraction): P }); return; } - + const joke = addJoke(jokeText, category, interaction.user.id); - + await interaction.reply({ content: [ "✅ **Joke added!**", diff --git a/src/commands/fun/subcommands/joke/index.ts b/src/commands/fun/subcommands/joke/index.ts index e752034c..8686855e 100644 --- a/src/commands/fun/subcommands/joke/index.ts +++ b/src/commands/fun/subcommands/joke/index.ts @@ -15,66 +15,56 @@ export function buildJokeSubcommands(subcommandGroup: any) { sub .setName("random") .setDescription("Get a random joke") - .addStringOption(opt => + .addStringOption((opt) => opt .setName("category") .setDescription("Joke category (generation)") .setRequired(false) - .addChoices( - ...JOKE_CATEGORIES.map(cat => ({ name: cat, value: cat })) - ) - ) + .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("text").setDescription("The joke text").setRequired(true), ) - .addStringOption(opt => + .addStringOption((opt) => opt .setName("category") .setDescription("Joke category (generation)") .setRequired(true) - .addChoices( - ...JOKE_CATEGORIES.map(cat => ({ name: cat, value: cat })) - ) - ) + .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) - ) + .addIntegerOption((opt) => + opt.setName("id").setDescription("Joke ID to remove").setRequired(true), + ), ) .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub .setName("list") .setDescription("List recent jokes") - .addStringOption(opt => + .addStringOption((opt) => opt .setName("category") .setDescription("Filter by category") .setRequired(false) - .addChoices( - ...JOKE_CATEGORIES.map(cat => ({ name: cat, value: cat })) - ) - ) + .addChoices(...JOKE_CATEGORIES.map((cat) => ({ name: cat, value: cat }))), + ), ); } -export async function handleJoke(interaction: ChatInputCommandInteraction): Promise { +export async function handleJoke( + interaction: ChatInputCommandInteraction, +): Promise { const subcommand = interaction.options.getSubcommand(); - + switch (subcommand) { case "random": await handleJokeRandom(interaction); diff --git a/src/commands/fun/subcommands/joke/list.ts b/src/commands/fun/subcommands/joke/list.ts index f783e403..46ed2dcd 100644 --- a/src/commands/fun/subcommands/joke/list.ts +++ b/src/commands/fun/subcommands/joke/list.ts @@ -1,13 +1,19 @@ // src/commands/fun/subcommands/joke/list.ts import { EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { listJokes, getJokeStats, type JokeCategory } from "../../../../services/joke/jokeStore.js"; +import { + listJokes, + getJokeStats, + type JokeCategory, +} from "../../../../services/joke/jokeStore.js"; -export async function handleJokeList(interaction: ChatInputCommandInteraction): Promise { +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 @@ -17,20 +23,23 @@ export async function handleJokeList(interaction: ChatInputCommandInteraction): }); 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") + 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({ + .setFooter({ text: category ? `Showing ${jokes.length} of ${stats.byCategory[category] || 0} ${category} jokes` - : `Showing ${jokes.length} of ${stats.total} total jokes` + : `Showing ${jokes.length} of ${stats.total} total jokes`, }) - .setColor(0x00AE86); - + .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 index 6f09c715..165b4cf3 100644 --- a/src/commands/fun/subcommands/joke/random.ts +++ b/src/commands/fun/subcommands/joke/random.ts @@ -2,21 +2,23 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { getRandomJoke, type JokeCategory } from "../../../../services/joke/jokeStore.js"; -export async function handleJokeRandom(interaction: ChatInputCommandInteraction): Promise { +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 + 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: "🎸", @@ -25,7 +27,7 @@ export async function handleJokeRandom(interaction: ChatInputCommandInteraction) genalpha: "🧒", random: "🎲", }; - + await interaction.reply({ content: [ `${categoryEmoji[joke.category]} **${joke.category.toUpperCase()} JOKE** ${categoryEmoji[joke.category]}`, diff --git a/src/commands/fun/subcommands/joke/remove.ts b/src/commands/fun/subcommands/joke/remove.ts index ef9362fd..6203e132 100644 --- a/src/commands/fun/subcommands/joke/remove.ts +++ b/src/commands/fun/subcommands/joke/remove.ts @@ -2,9 +2,11 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { removeJoke, getJoke } from "../../../../services/joke/jokeStore.js"; -export async function handleJokeRemove(interaction: ChatInputCommandInteraction): Promise { +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({ @@ -13,25 +15,26 @@ export async function handleJokeRemove(interaction: ChatInputCommandInteraction) }); 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') { + 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); - + const hasModRole = + modRoleId && "cache" in member.roles && member.roles.cache.has(modRoleId); + if (!hasModRole) { await interaction.reply({ content: "❌ Only joke moderators can remove jokes.", @@ -39,9 +42,9 @@ export async function handleJokeRemove(interaction: ChatInputCommandInteraction) }); return; } - + const joke = getJoke(jokeId); - + if (!joke) { await interaction.reply({ content: `Joke #${jokeId} not found.`, @@ -49,9 +52,9 @@ export async function handleJokeRemove(interaction: ChatInputCommandInteraction) }); return; } - + const removed = removeJoke(jokeId); - + if (removed) { await interaction.reply({ content: [ diff --git a/src/commands/help/helpText.ts b/src/commands/help/helpText.ts index c113c41f..b3a52cd4 100644 --- a/src/commands/help/helpText.ts +++ b/src/commands/help/helpText.ts @@ -109,7 +109,9 @@ function buildFunHelp(): string { 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( + "boomer, genx, millennial, genz, genalpha, random, tech, dark, wholesome, anti, puns, observational, dad", + ); lines.push("Examples"); lines.push("`/fun joke random`"); lines.push("`/fun joke random category:genz`"); diff --git a/src/services/joke/jokeStore.ts b/src/services/joke/jokeStore.ts index 495fed72..56925c95 100644 --- a/src/services/joke/jokeStore.ts +++ b/src/services/joke/jokeStore.ts @@ -1,11 +1,24 @@ // 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" | "oberservational" | "dad"; +export type JokeCategory = + | "boomer" + | "genx" + | "millennial" + | "genz" + | "genalpha" + | "random" + | "tech" + | "dark" + | "wholesome" + | "anti" + | "puns" + | "oberservational" + | "dad"; export const JOKE_CATEGORIES: JokeCategory[] = [ "boomer", - "genx", + "genx", "millennial", "genz", "genalpha", @@ -16,7 +29,7 @@ export const JOKE_CATEGORIES: JokeCategory[] = [ "anti", "puns", "oberservational", - "dad" + "dad", ]; export interface Joke { @@ -30,11 +43,15 @@ export interface Joke { export function addJoke(jokeText: string, category: JokeCategory, userId: string): Joke { const db = getDb(); - - const result = db.prepare(` + + const result = db + .prepare( + ` INSERT INTO jokes (joke_text, category, added_by, added_at, usage_count) VALUES (?, ?, ?, ?, 0) - `).run(jokeText, category, userId, Date.now()); + `, + ) + .run(jokeText, category, userId, Date.now()); return { id: result.lastInsertRowid as number, @@ -48,24 +65,26 @@ export function addJoke(jokeText: string, category: JokeCategory, userId: string export function getRandomJoke(category?: JokeCategory): Joke | null { const db = getDb(); - + let query = "SELECT * FROM jokes"; const params: any[] = []; - + 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); + db.prepare("UPDATE jokes SET usage_count = usage_count + 1 WHERE id = ?").run( + joke.id, + ); } - + return joke || null; } @@ -82,37 +101,43 @@ export function getJoke(jokeId: number): Joke | null { export function listJokes(category?: JokeCategory, limit: number = 50): Joke[] { const db = getDb(); - + let query = "SELECT * FROM jokes"; const params: any[] = []; - + 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(` + + 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 }[]; - + `, + ) + .all() as { category: string; count: number }[]; + const categoryMap: Record = {}; - byCategory.forEach(row => { + byCategory.forEach((row) => { categoryMap[row.category] = row.count; }); - + return { total: total.count, byCategory: categoryMap, From f048b220d0e82500017f1860d38192effaf827b0 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sun, 18 Jan 2026 12:17:02 -0500 Subject: [PATCH 3/5] trying this again --- src/services/joke/jokeStore.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/joke/jokeStore.ts b/src/services/joke/jokeStore.ts index 56925c95..6cd548fc 100644 --- a/src/services/joke/jokeStore.ts +++ b/src/services/joke/jokeStore.ts @@ -13,7 +13,7 @@ export type JokeCategory = | "wholesome" | "anti" | "puns" - | "oberservational" + | "observational" | "dad"; export const JOKE_CATEGORIES: JokeCategory[] = [ @@ -28,7 +28,7 @@ export const JOKE_CATEGORIES: JokeCategory[] = [ "wholesome", "anti", "puns", - "oberservational", + "observational", "dad", ]; @@ -67,7 +67,7 @@ export function getRandomJoke(category?: JokeCategory): Joke | null { const db = getDb(); let query = "SELECT * FROM jokes"; - const params: any[] = []; + const params: (string | number)[] = []; if (category && category !== "random") { query += " WHERE category = ?"; @@ -103,7 +103,7 @@ export function listJokes(category?: JokeCategory, limit: number = 50): Joke[] { const db = getDb(); let query = "SELECT * FROM jokes"; - const params: any[] = []; + const params: (string | number)[] = []; if (category && category !== "random") { query += " WHERE category = ?"; From 22b9f7d7cf0723539ff10f9fc70056986278b758 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sun, 18 Jan 2026 12:22:59 -0500 Subject: [PATCH 4/5] Fixing htis up --- src/commands/fun/subcommands/joke/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/fun/subcommands/joke/index.ts b/src/commands/fun/subcommands/joke/index.ts index 8686855e..15c879ca 100644 --- a/src/commands/fun/subcommands/joke/index.ts +++ b/src/commands/fun/subcommands/joke/index.ts @@ -1,5 +1,5 @@ // src/commands/fun/subcommands/joke/index.ts -import { SlashCommandSubcommandBuilder } from "discord.js"; +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"; @@ -7,7 +7,7 @@ import { handleJokeAdd } from "./add.js"; import { handleJokeRemove } from "./remove.js"; import { handleJokeList } from "./list.js"; -export function buildJokeSubcommands(subcommandGroup: any) { +export function buildJokeSubcommands(subcommandGroup: SlashCommandSubcommandGroupBuilder) { return subcommandGroup .setName("joke") .setDescription("User-submitted jokes by generation") From feb9c73a7ed76bdf99643bc0f8da75de9c080131 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sun, 18 Jan 2026 12:23:05 -0500 Subject: [PATCH 5/5] style: auto-format with Prettier [skip-precheck] --- src/commands/fun/subcommands/joke/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/fun/subcommands/joke/index.ts b/src/commands/fun/subcommands/joke/index.ts index 15c879ca..cf9c8497 100644 --- a/src/commands/fun/subcommands/joke/index.ts +++ b/src/commands/fun/subcommands/joke/index.ts @@ -1,5 +1,8 @@ // src/commands/fun/subcommands/joke/index.ts -import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from "discord.js"; +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"; @@ -7,7 +10,9 @@ import { handleJokeAdd } from "./add.js"; import { handleJokeRemove } from "./remove.js"; import { handleJokeList } from "./list.js"; -export function buildJokeSubcommands(subcommandGroup: SlashCommandSubcommandGroupBuilder) { +export function buildJokeSubcommands( + subcommandGroup: SlashCommandSubcommandGroupBuilder, +) { return subcommandGroup .setName("joke") .setDescription("User-submitted jokes by generation")