diff --git a/README.md b/README.md
index 1d75b1e0..94f420b8 100644
--- a/README.md
+++ b/README.md
@@ -1,431 +1,65 @@
-
-
-
-
-
-
-
-
-
-
-
-
# OmegaBot
-OmegaBot is a modular Discord bot designed to support development projects with quick summaries, FAQs, GitHub lookups, and automated notifications. The structure is clean and fully service based which makes it easy to extend.
-
----
-
-## Table of Contents
-
-- [Core Features](#core-features)
-- [Documentation](#documentation)
-- [Getting Started](#getting-started)
-- [Project Structure](#project-structure)
-- [Extending OmegaBot](#extending-omegabot)
-- [Contributors](#contributors)
-- [License](#license)
+OmegaBot is a modular, community-driven Discord bot focused on fun commands, utilities, and learning by building together.
---
-## Core Features
-
-**Discord Integration:**
-
-- Modular slash-command system with auto-loading
-- Welcome messages and auto-role assignment for new members
-- Structured logging (pino) and safe error handling
-
-**GitHub Integration:**
-
-- Issue and PR lookups with smart caching (80-90% fewer API calls)
-- Automated announcements for PRs, issue activity, and closures
-- Health checks and status monitoring
-
-**Community Features:**
-
-- User-submitted jokes with 13 categories and moderation
-- Coin flips, dice rolls, weather, and polls
-- FAQ system for server knowledge base
-- Timezone management for coordination across time zones
-
-**Data & Configuration:**
-
-- SQLite database for persistent storage
-- Environment-based configuration with feature gating
-- Per-guild settings via admin commands
-
-### Core commands
-
-- /help – command discovery and getting started guide
-- /ping – health check
-- /summary – conversation summaries (local + LLM mode)
-- /history – conversation history (DM + file fallback)
-- /playback – transcript playback with button pagination
-- /pagination – reusable inline paging helper
-- /timezone – per-user IANA timezone support
-- /changelog – ephemeral release preview
-
-### FAQ system
-
-- /faq add – create persistent FAQ entries
-- /faq get – retrieve FAQs by key
-- /faq list – list FAQs with sorting and filtering
-- /faq remove – remove FAQs with confirmation flow
-- SQLite-backed persistent storage
-- Usage tracking for FAQs
-- Permission guardrails for destructive actions
-
-### Fun / utility commands
-
-All fun commands are available under `/fun`:
-
-- `/fun chucknorris` – Chuck Norris facts (random, category, or search)
-- `/fun dadjoke` – Random or searched dad jokes
-- `/fun 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
-- `/fun leaderboard` – Track fun command usage and top users
-
-## Planned features
-
-- `/docs` command for documentation lookups
-- Expanded GitHub automation (labels, reviews, merge events)
-- Enhanced AI-powered summaries with Claude API
-- Admin dashboard for bot statistics and monitoring
-
----
-
-## Documentation
-
-- 🤖 [Discord Bot Setup Guide](docs/setup-discord.md)
-- 📘 [Command Reference](docs/commands.md)
-- ❓ [FAQ Storage Design](docs/faq.md)
-- 🧠 [Transcript & Summary Design](docs/transcripts.md)
-- 🛠️ [Development Notes](docs/dev-notes.md)
-
----
-
-## Getting Started
-
-### Requirements
-
-- Node 18 or newer
-- A Discord bot token
-- A development server where you have Manage Server permissions
-
-### Setup
-
-> Need help creating a Discord bot and token?
-> See the [Discord Bot Setup Guide](docs/setup-discord.md).
-
-1. Clone the repository:
-
-```bash
-git clone https://github.com/NickTheDevOpsGuy/OmegaBot.git
-cd OmegaBot
-```
+## 🚀 Quick Start
-2. Install dependencies:
+1. Clone the repo
+2. Copy `.env.example` to `.env`
+3. Fill in required environment variables
+4. Install deps and run
```bash
npm install
-```
-
-3. Create a .env file based on the example configuration:
-
-- [.env.example](.env.example)
-
-4. Register slash commands with your development guild:
-
-```bash
-npm run register
-```
-
-**Command registration mode**
-
-OmegaBot supports two registration modes:
-
-- Guild registration (recommended for development)
- If DISCORD_GUILD_ID is set, commands are registered to that guild and appear immediately.
-
-- Global registration
- If DISCORD_GUILD_ID is not set, commands are registered globally and may take up to 1 hour to appear.
-
-5. Build and run the bot:
-
-```bash
npm run build
+npm run register
npm start
```
-For development with auto-reload:
-
-```bash
-npm run dev
-```
-
-You should see:
-
-```
-OmegaBot is online
-```
-
---
-## Technical Stack
-
-- **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
-
-### Discord.js v14 Features
-
-OmegaBot uses discord.js v14 which includes:
+## 📚 Documentation
-- Improved TypeScript support
-- Better slash command handling
-- 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
+- [Discord Setup Guide](./setup-discord.md)
+- [Commands Reference](./commands.md)
+- [FAQ](./faq.md)
+- [Transcripts & Examples](./transcripts.md)
+- [Developer Notes](./dev-notes.md)
---
-## Project Structure
+## 🔐 Admin & Moderation Commands
-
-🗂 Click to expand file structure
+Some admin commands (timeout, kick, ban, health, stats) require:
-```
-.
-├── .github
-│ ├── ISSUE_TEMPLATE
-│ │ ├── bug.yml
-│ │ ├── config.yml
-│ │ ├── documentation.yml
-│ │ ├── enhancement_refactor.yml
-│ │ ├── feature_request.yml
-│ │ └── question_discussion.yml
-│ ├── workflows
-│ │ └── OmegaBot.yml
-│ └── pull_request_template.md
-├── .husky
-│ ├── pre-commit
-│ └── pre-push
-├── assets
-│ ├── banner.png
-│ └── omegabot.png
-├── data
-│ ├── faqs.json
-│ ├── fun-usage.json
-│ ├── github-assignees.json
-│ ├── guild-config.json
-│ ├── last-seen.json
-│ └── omegabot.db
-├── docs
-│ ├── Claude.dmg
-│ ├── commands.md
-│ ├── dev-notes.md
-│ ├── faq.md
-│ ├── setup-discord.md
-│ ├── setup-env.md
-│ └── transcripts.md
-├── scripts
-│ └── precheck.sh
-├── src
-│ ├── commands
-│ │ ├── admin
-│ │ │ └── admin.ts
-│ │ ├── changelog
-│ │ │ └── changelog.ts
-│ │ ├── config
-│ │ │ └── config.ts
-│ │ ├── faq
-│ │ │ ├── subcommands
-│ │ │ │ ├── add.ts
-│ │ │ │ ├── get.ts
-│ │ │ │ ├── list.ts
-│ │ │ │ └── remove.ts
-│ │ │ └── faq.ts
-│ │ ├── fun
-│ │ │ ├── subcommands
-│ │ │ │ ├── joke
-│ │ │ │ │ ├── add.ts
-│ │ │ │ │ ├── index.ts
-│ │ │ │ │ ├── list.ts
-│ │ │ │ │ ├── random.ts
-│ │ │ │ │ └── remove.ts
-│ │ │ │ ├── coinflip.ts
-│ │ │ │ ├── dice.ts
-│ │ │ │ ├── leaderboard.ts
-│ │ │ │ ├── poll.ts
-│ │ │ │ └── weather.ts
-│ │ │ └── fun.ts
-│ │ ├── general
-│ │ │ └── ping.ts
-│ │ ├── github
-│ │ │ ├── gh.ts
-│ │ │ ├── pr.ts
-│ │ │ └── status.ts
-│ │ ├── help
-│ │ │ ├── help.ts
-│ │ │ └── helpText.ts
-│ │ ├── history
-│ │ │ └── history.ts
-│ │ ├── pagination
-│ │ │ └── pagination.ts
-│ │ ├── playback
-│ │ │ └── playback.ts
-│ │ ├── summary
-│ │ │ └── summary.ts
-│ │ └── timezone
-│ │ └── timezone.ts
-│ ├── config
-│ │ └── env.ts
-│ ├── services
-│ │ ├── ai
-│ │ │ └── claudeService.ts
-│ │ ├── cache
-│ │ │ └── simpleCache.ts
-│ │ ├── config
-│ │ │ ├── guildConfigStore.ts
-│ │ │ ├── index.ts
-│ │ │ └── types.ts
-│ │ ├── database
-│ │ │ ├── db-joke-schema.sql
-│ │ │ └── db.ts
-│ │ ├── discord
-│ │ │ ├── commandLoader.ts
-│ │ │ ├── commandMeta.ts
-│ │ │ ├── cooldowns.ts
-│ │ │ ├── fetchChannelMessages.ts
-│ │ │ ├── interactionHandler.ts
-│ │ │ └── safeReply.ts
-│ │ ├── faq
-│ │ │ ├── _shared.ts
-│ │ │ ├── faqService.ts
-│ │ │ ├── permissions.ts
-│ │ │ ├── services.test.ts
-│ │ │ ├── services.ts
-│ │ │ ├── store.test.ts
-│ │ │ ├── store.test.ts.disabled
-│ │ │ ├── store.ts
-│ │ │ └── types.ts
-│ │ ├── fun
-│ │ │ ├── coinStore.ts
-│ │ │ ├── funUsageStore.ts
-│ │ │ └── pollStore.ts
-│ │ ├── github
-│ │ │ ├── githubApi.ts
-│ │ │ ├── githubApi.ts.backup
-│ │ │ ├── githubCache.ts
-│ │ │ ├── githubClient.ts
-│ │ │ ├── githubErrorMessage.ts
-│ │ │ ├── issueAssigneePoller.ts
-│ │ │ ├── issueAssigneePoller.ts.backup
-│ │ │ ├── lastSeenStore.ts
-│ │ │ ├── prFormatter.ts
-│ │ │ ├── prPoller.ts
-│ │ │ └── types.ts
-│ │ ├── joke
-│ │ │ └── jokeStore.ts
-│ │ ├── roles
-│ │ │ └── autoRoleHandler.ts
-│ │ ├── summary
-│ │ │ ├── llmSummary.ts
-│ │ │ ├── localSummary.ts
-│ │ │ └── summarizer.ts
-│ │ ├── time
-│ │ │ ├── formatTimestamp.ts
-│ │ │ └── validateTimezone.ts
-│ │ ├── timezone
-│ │ │ └── timezoneStore.ts
-│ │ ├── transcript
-│ │ │ ├── buildTranscript.ts
-│ │ │ └── defaults.ts
-│ │ ├── weather
-│ │ │ ├── forecast.ts
-│ │ │ └── types.ts
-│ │ └── welcome
-│ │ ├── welcomeHandler.ts
-│ │ └── welcomeMessage.ts
-│ ├── utils
-│ │ ├── colors.ts
-│ │ ├── interactions.ts
-│ │ └── logger.ts
-│ ├── bot.ts
-│ └── registerCommands.ts
-├── .env.example
-├── .gitignore
-├── .prettierignore
-├── .prettierrc.yml
-├── CHANGELOG.md
-├── CONTRIBUTORS.md
-├── eslint.config.ts
-├── install-claude-admin.sh
-├── LICENSE
-├── package-lock.json
-├── package.json
-├── README.md
-├── tsconfig.json
-└── vitest.config.ts
-
-```
+- Proper bot permissions in the server
+- The bot role to be **above** the target user’s role
+- Required gateway intents enabled
+- Correct OAuth2 scopes on invite
-
+See the [Discord Setup Guide](./setup-discord.md) for full details.
---
-## Extending OmegaBot
+## 🤝 Contributing
-OmegaBot is designed for small, focused modules. To add new features:
+Pull requests are welcome.
-1. Create a new command file under `src/commands//`
-2. Add any logic needed inside `src/services//`
-3. Run `npm run register` to publish new slash commands
+If you want to contribute, help shape the bot, or just hang out and build together,
+see [CONTRIBUTORS.md](./CONTRIBUTORS.md) or join the Discord.
---
-## Contributors
-
-Thanks to everyone who has helped build or improve OmegaBot.
-
-
-
-
-
-Generated using https://contrib.rocks
-
-To learn how to contribute, read the [CONTRIBUTOR.md](CONTRIBUTOR.md) file.
+## 📄 License
-If you would like to contribute, please open an issue or submit a pull request.
+This project is licensed under the MIT License.
+See the [LICENSE](./LICENSE) file for full details.
---
-## License
+## Contributing
-MIT License. Use and modify freely.
+Want to help build OmegaBot?
+See [CONTRIBUTORS.md](./CONTRIBUTORS.md) or open a PR.
diff --git a/docs/setup-discord.md b/docs/setup-discord.md
index 6c69c4b4..e5c0d9f9 100644
--- a/docs/setup-discord.md
+++ b/docs/setup-discord.md
@@ -1,223 +1,96 @@
# Discord Bot Setup Guide (OmegaBot)
-This guide walks you through creating and configuring a Discord bot for **OmegaBot**, including required gateway intents, installation settings, and common pitfalls.
+This guide walks you through creating and configuring a Discord bot for **OmegaBot**, including
+required scopes, permissions, gateway intents, and common moderation pitfalls.
---
-## 1. Create a Discord Application
+## Required OAuth Scopes
-1. Go to [Applications](https://discord.com/developers/applications)
-2. Click **New Application**
-3. Name it (e.g. `OmegaBot`)
-4. Open the application
+When inviting the bot, you **must** include:
----
-
-## 2. Create a Bot User (Required)
-
-1. In the left sidebar, click **Bot**
-2. Click **Add Bot**
-3. Confirm
-
-> Without a bot user, gateway intents and bot tokens will not behave correctly.
-
----
-
-## 3. Copy Required Credentials
-
-### Bot Token
-
-- Location: **Bot → Token**
-- Click **Reset Token** or **Copy**
-- Store securely in `.env`:
-
-```env
-DISCORD_TOKEN=your_bot_token_here
-```
-
-### Application ID
-
-- Location: **General Information → Application ID**
-- Store in `.env`:
-
-```env
-DISCORD_APP_ID=your_application_id_here
-```
-
-> The Application ID is **not secret**.
-> The Bot Token **must be kept private**.
-
----
-
-## 4. Select Installation Type (Important)
-
-Go to **Installation** (sometimes labeled Integration Type).
-
-### Enable:
-
-- ✅ **Guild Install**
-
-### Do NOT rely on:
-
-- ❌ User Install (OAuth-only apps, no gateway events)
-
-Guild Install is required for:
-
-- Gateway bots
-- Slash commands
-- Member join events
-- Welcome messages
-
-Save changes.
-
----
-
-## 5. Enable Privileged Gateway Intents
-
-Go to **Bot → Privileged Gateway Intents**.
+- bot
+- applications.commands
-Enable:
-
-- ✅ **Server Members Intent**
-
-This is required for:
-
-- `guildMemberAdd`
-- welcome / onboarding messages
-
-Optional (enable only if needed later):
-
-- Message Content Intent
-- Presence Intent
-
-Click **Save Changes**.
-
-> Both the **portal toggle** and the **code intent** must be enabled.
+If you change scopes later, you must re-invite the bot.
---
-## 6. Invite the Bot to Your Server
-
-Go to **OAuth2 → URL Generator**.
+## Required Bot Permissions
-### Scopes
+For full functionality, especially **admin/moderation commands**, the bot role needs:
-- ✅ `bot`
-- ✅ `applications.commands`
-
-### Bot Permissions (minimum)
+### Core
- View Channels
- Send Messages
- Read Message History
+- Embed Links
-Copy the generated URL and open it in your browser to invite the bot.
-
-> If you change permissions later, you must **re-invite** the bot.
-
----
+### Moderation (Admin commands)
-## 7. Enable Developer Mode (Local Setup)
+- Moderate Members (timeouts)
+- Kick Members
+- Ban Members
-In Discord:
-
-1. User Settings → Advanced
-2. Enable **Developer Mode**
-
-This allows copying IDs.
+⚠️ The bot’s role **must be higher** than the roles it is moderating.
---
-## 8. Get IDs
+## Gateway Intents
-- **Guild ID**: Right-click server → Copy ID
-- **Channel ID**: Right-click channel → Copy ID
+Enable in **Developer Portal → Bot → Privileged Gateway Intents**:
-Add to `.env` as needed:
+- Server Members Intent (required)
-```env
-DISCORD_GUILD_ID=your_guild_id_here
-WELCOME_CHANNEL_ID=your_channel_id_here
-```
+Your code must also request the same intent.
---
-## 9. Verify Gateway Intents in Code
-
-Your bot client **must request the same intents** you enabled in the portal.
+## Why Admin Commands Might Fail
-Example:
+If `/admin timeout`, `/admin kick`, or `/admin ban` do nothing:
-```ts
-const client = new Client({
- intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
-});
-```
-
-Missing either side causes:
-
-- Gateway disconnects
-- “Used disallowed intents” errors
+- User is not an Administrator or approved moderator
+- Bot role is below target user
+- Bot lacks permission (Kick/Ban/Moderate)
+- Bot was not re-invited after permission changes
---
-## Testing Welcome Messages
+## Moderator Roles (SQLite-backed)
-The `guildMemberAdd` event **only fires when a real join happens**.
+OmegaBot supports moderator roles stored in SQLite.
-Valid test methods:
+Admins must configure these roles using the config command.
+Only users with:
-- Join with an alt account
-- Ask an admin to kick you once and rejoin
-- Create a private test server and join there
+- Administrator permission, OR
+- A configured moderator role
-There is no “fake join” or manual trigger in Discord.
+can run moderation commands.
---
-## Common Issues
-
-### Bot logs in but welcome message never fires
-
-- Server Members Intent not enabled in portal
-- `GatewayIntentBits.GuildMembers` missing in code
-- Bot was not restarted after enabling intent
+## Re-inviting the Bot
-### Slash commands not showing
+You MUST re-invite the bot if you change:
-- Run the command registration script
-- Ensure `applications.commands` scope was used on invite
+- Permissions
+- Scopes
+- Installation type
-### Bot cannot send welcome message
-
-- Missing **View Channel** or **Send Messages** permission
-- Wrong `WELCOME_CHANNEL_ID`
-- Channel overrides blocking the bot role
+Old invites do not update permissions.
---
-## Official Discord Documentation
-
-- Developer Portal
-
- [Applications](https://discord.com/developers/applications)
+## Helpful Links
-- Getting Started
-
- [Getting-Started](https://discord.com/developers/docs/getting-started)
-
-- OAuth2 & Inviting Bots
-
- [Oauth2](https://discord.com/developers/docs/topics/oauth2)
+- Discord Developer Portal
+ https://discord.com/developers/applications
- Bot Permissions Reference
+ https://discord.com/developers/docs/topics/permissions
- [Permissions](https://discord.com/developers/docs/topics/permissions)
-
-- Gateway Intents
-
- [Gateway](https://discord.com/developers/docs/topics/gateway#gateway-intents)
-
-- discord.js Slash Commands
-
- [Slash Commands](https://discordjs.guide/interactions/slash-commands.html)
+- OAuth2 Scopes
+ https://discord.com/developers/docs/topics/oauth2
diff --git a/src/commands/admin/admin.ts b/src/commands/admin/admin.ts
index e1829758..1736ed9a 100644
--- a/src/commands/admin/admin.ts
+++ b/src/commands/admin/admin.ts
@@ -1,30 +1,21 @@
// src/commands/admin/admin.ts
-
import {
SlashCommandBuilder,
EmbedBuilder,
PermissionFlagsBits,
type ChatInputCommandInteraction,
type GuildMember,
- MessageFlags,
} from "discord.js";
import { logger } from "../../utils/logger.js";
import { getDb } from "../../services/database/db.js";
import { getFunUsageSnapshot } from "../../services/fun/funUsageStore.js";
import { EmbedColors } from "../../utils/colors.js";
-/**
- * NOTE
- * This file intentionally centralizes reply behavior:
- * - We deferReply once per execution path
- * - We prefer editReply after deferring
- * - We fall back to reply if not deferred/replied
- */
-
export const data = new SlashCommandBuilder()
.setName("admin")
.setDescription("Admin and moderation commands")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
+
// Moderation subcommands
.addSubcommand((sub) =>
sub
@@ -75,6 +66,7 @@ export const data = new SlashCommandBuilder()
.setMaxValue(7),
),
)
+
// Bot stats subcommands
.addSubcommand((sub) =>
sub
@@ -86,112 +78,100 @@ export const data = new SlashCommandBuilder()
)
.setDMPermission(true);
-async function safeEphemeralMessage(
+/* -------------------------------------------------------------------------- */
+/* Reply helpers */
+/* -------------------------------------------------------------------------- */
+
+type ReplyPayload = {
+ content?: string;
+ embeds?: EmbedBuilder[];
+ ephemeral?: boolean;
+};
+
+async function safeReply(
interaction: ChatInputCommandInteraction,
- content: string,
+ payload: ReplyPayload,
): Promise {
try {
if (interaction.deferred || interaction.replied) {
- await interaction.editReply({ content });
+ // editReply cannot set ephemeral or flags
+ const { content, embeds } = payload;
+ await interaction.editReply({ content, embeds });
return;
}
- await interaction.reply({ content, flags: MessageFlags.Ephemeral });
+
+ await interaction.reply({
+ content: payload.content,
+ embeds: payload.embeds,
+ ephemeral: payload.ephemeral ?? true,
+ });
} catch (err) {
- logger.error({ err }, "[admin] failed to send safe reply");
+ logger.error({ err }, "[admin] failed to reply/editReply");
}
}
-async function ensureDeferred(
- interaction: ChatInputCommandInteraction,
- ephemeral: boolean,
-): Promise {
- if (interaction.deferred || interaction.replied) return;
-
- // discord.js supports either { ephemeral: true } or flags
- // Using flags keeps compatibility with the rest of this repo style.
- await interaction.deferReply({
- flags: ephemeral ? MessageFlags.Ephemeral : undefined,
- });
-}
-
-function isModeratorOrManager(interaction: ChatInputCommandInteraction): boolean {
- return Boolean(interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild));
-}
-
-async function checkModeratorRole(
- interaction: ChatInputCommandInteraction,
-): Promise {
- // Hard guard
- if (!interaction.inGuild() || !interaction.guildId || !interaction.member) return false;
-
- // Manage Guild always allowed
- if (isModeratorOrManager(interaction)) return true;
+function userFacingError(err: unknown): string {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ const low = msg.toLowerCase();
- try {
- const db = getDb();
- const roles = db
- .prepare(`SELECT role_id FROM moderator_roles WHERE guild_id = ?`)
- .all(interaction.guildId) as Array<{ role_id: string }>;
-
- if (!roles.length) return false;
-
- const member = interaction.member as GuildMember;
- const memberRoles = member.roles.cache;
- return roles.some((r) => memberRoles.has(r.role_id));
- } catch (err) {
- logger.error(
- { err, guildId: interaction.guildId },
- "[admin] failed to check moderator role",
+ if (low.includes("missing permissions")) {
+ return (
+ "❌ I am missing required permissions.\n" +
+ "Check my role permissions, and make sure my role is above the target user's role."
);
- return false;
}
+
+ if (low.includes("unknown interaction")) {
+ return "❌ That took too long and Discord expired the command. Try again.";
+ }
+
+ return "❌ Command failed. Check my permissions and role position.";
}
-export async function execute(interaction: ChatInputCommandInteraction): Promise {
- const subcommand = interaction.options.getSubcommand();
+/* -------------------------------------------------------------------------- */
+/* Execute */
+/* -------------------------------------------------------------------------- */
- // Route 1: stats and health can run anywhere
- if (subcommand === "stats" || subcommand === "health") {
- await ensureDeferred(interaction, false);
+export async function execute(interaction: ChatInputCommandInteraction): Promise {
+ const subcommand = interaction.options.getSubcommand(true);
- try {
- if (subcommand === "stats") {
- await handleStats(interaction);
- } else {
- await handleHealth(interaction);
- }
+ try {
+ // Always defer so we never hit the 3s interaction timeout.
+ // Moderation defaults to ephemeral to keep channels clean.
+ const ephemeral = subcommand !== "stats" && subcommand !== "health";
+ await interaction.deferReply({ ephemeral });
+
+ // Stats + health do not need mod role checks
+ if (subcommand === "stats") {
+ await handleStats(interaction);
return;
- } catch (err) {
- logger.error(
- { err, command: "admin", subcommand, userId: interaction.user.id },
- "[admin] stats/health failed",
- );
- await safeEphemeralMessage(
- interaction,
- "Something went wrong. Please try again later.",
- );
+ }
+ if (subcommand === "health") {
+ await handleHealth(interaction);
return;
}
- }
-
- // Route 2: moderation commands must be in guild
- if (!interaction.inGuild() || !interaction.guildId) {
- await safeEphemeralMessage(interaction, "This command can only be used in a server.");
- return;
- }
- // Moderation commands should be ephemeral to reduce channel noise
- await ensureDeferred(interaction, true);
+ // Moderation must be in a guild
+ if (!interaction.inGuild()) {
+ await safeReply(interaction, {
+ content: "This command can only be used in a server.",
+ ephemeral: true,
+ });
+ return;
+ }
- try {
const hasPermission = await checkModeratorRole(interaction);
if (!hasPermission) {
- await safeEphemeralMessage(
- interaction,
- [
- "You do not have permission to use moderation commands.",
+ await safeReply(interaction, {
+ content:
+ "❌ You don't have permission to use moderation commands.\n" +
"Ask an admin to set up moderator roles with `/config moderator-role`.",
- ].join("\n"),
+ ephemeral: true,
+ });
+
+ logger.info(
+ { userId: interaction.user.id, guildId: interaction.guildId, subcommand },
+ "[admin] moderation blocked, missing permission",
);
return;
}
@@ -207,34 +187,71 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
await handleBan(interaction);
return;
default:
- await safeEphemeralMessage(interaction, "Unknown subcommand.");
+ await safeReply(interaction, { content: "Unknown subcommand.", ephemeral: true });
return;
}
} catch (err) {
logger.error(
- {
- err,
- command: "admin",
- subcommand,
- userId: interaction.user.id,
- guildId: interaction.guildId,
- },
- "[admin] moderation command failed",
- );
- await safeEphemeralMessage(
- interaction,
- "Something went wrong. Please try again later.",
+ { err, command: "admin", userId: interaction.user.id, subcommand },
+ "[admin] command failed",
);
+
+ await safeReply(interaction, {
+ content: "Something went wrong. Please try again later.",
+ ephemeral: true,
+ });
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* Permission check */
+/* -------------------------------------------------------------------------- */
+
+async function checkModeratorRole(
+ interaction: ChatInputCommandInteraction,
+): Promise {
+ if (!interaction.inGuild() || !interaction.member) return false;
+
+ // Allow built-in Discord perms first
+ if (interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) return true;
+ if (interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) return true;
+ if (interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers))
+ return true;
+
+ try {
+ const db = getDb();
+ const guildId = interaction.guildId!;
+
+ const roles = db
+ .prepare(`SELECT role_id FROM moderator_roles WHERE guild_id = ?`)
+ .all(guildId) as Array<{ role_id: string }>;
+
+ if (!roles.length) return false;
+
+ const member = interaction.member as GuildMember;
+ const memberRoles = member.roles.cache;
+
+ return roles.some((r) => memberRoles.has(r.role_id));
+ } catch (err) {
+ logger.error({ err }, "[admin] failed to check moderator role");
+ return false;
}
}
+/* -------------------------------------------------------------------------- */
+/* Moderation handlers */
+/* -------------------------------------------------------------------------- */
+
async function handleTimeout(interaction: ChatInputCommandInteraction): Promise {
const targetUser = interaction.options.getUser("user", true);
const duration = interaction.options.getInteger("duration", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
if (!interaction.guild) {
- await safeEphemeralMessage(interaction, "This command can only be used in a server.");
+ await safeReply(interaction, {
+ content: "This command can only be used in a server.",
+ ephemeral: true,
+ });
return;
}
@@ -242,50 +259,44 @@ async function handleTimeout(interaction: ChatInputCommandInteraction): Promise<
const member = await interaction.guild.members.fetch(targetUser.id);
if (member.user.bot) {
- await safeEphemeralMessage(interaction, "Cannot timeout bots.");
+ await safeReply(interaction, {
+ content: "❌ Cannot timeout bots.",
+ ephemeral: true,
+ });
return;
}
-
if (member.id === interaction.user.id) {
- await safeEphemeralMessage(interaction, "You cannot timeout yourself.");
+ await safeReply(interaction, {
+ content: "❌ You cannot timeout yourself.",
+ ephemeral: true,
+ });
return;
}
const executor = interaction.member as GuildMember;
if (member.roles.highest.position >= executor.roles.highest.position) {
- await safeEphemeralMessage(
- interaction,
- "You cannot timeout someone with an equal or higher role.",
- );
+ await safeReply(interaction, {
+ content: "❌ You cannot timeout someone with an equal or higher role.",
+ ephemeral: true,
+ });
return;
}
const durationMs = duration * 60 * 1000;
await member.timeout(durationMs, reason);
- await interaction.editReply(
- `Timed out **${targetUser.tag}** for **${duration}** minute(s).\nReason: ${reason}`,
- );
+ await safeReply(interaction, {
+ content: `✅ ${targetUser.tag} has been timed out for ${duration} minute(s).\nReason: ${reason}`,
+ ephemeral: false,
+ });
logger.info(
- {
- moderator: interaction.user.tag,
- target: targetUser.tag,
- duration,
- reason,
- guildId: interaction.guildId,
- },
+ { moderator: interaction.user.tag, target: targetUser.tag, duration, reason },
"[admin] user timed out",
);
} catch (err) {
- logger.error(
- { err, targetUser: targetUser.id, guildId: interaction.guildId },
- "[admin] timeout failed",
- );
- await safeEphemeralMessage(
- interaction,
- "Failed to timeout user. Check my permissions and role position.",
- );
+ logger.error({ err, targetUser: targetUser.id }, "[admin] timeout failed");
+ await safeReply(interaction, { content: userFacingError(err), ephemeral: true });
}
}
@@ -294,7 +305,10 @@ async function handleKick(interaction: ChatInputCommandInteraction): Promise= executor.roles.highest.position) {
- await safeEphemeralMessage(
- interaction,
- "You cannot kick someone with an equal or higher role.",
- );
+ await safeReply(interaction, {
+ content: "❌ You cannot kick someone with an equal or higher role.",
+ ephemeral: true,
+ });
return;
}
if (!member.kickable) {
- await safeEphemeralMessage(
- interaction,
- "I do not have permission to kick this user.",
- );
+ await safeReply(interaction, {
+ content:
+ "❌ I don't have permission to kick this user.\nCheck my role position and Kick Members permission.",
+ ephemeral: true,
+ });
return;
}
await member.kick(reason);
- await interaction.editReply(`Kicked **${targetUser.tag}**.\nReason: ${reason}`);
+ await safeReply(interaction, {
+ content: `✅ ${targetUser.tag} has been kicked.\nReason: ${reason}`,
+ ephemeral: false,
+ });
logger.info(
- {
- moderator: interaction.user.tag,
- target: targetUser.tag,
- reason,
- guildId: interaction.guildId,
- },
+ { moderator: interaction.user.tag, target: targetUser.tag, reason },
"[admin] user kicked",
);
} catch (err) {
- logger.error(
- { err, targetUser: targetUser.id, guildId: interaction.guildId },
- "[admin] kick failed",
- );
- await safeEphemeralMessage(
- interaction,
- "Failed to kick user. Check my permissions and role position.",
- );
+ logger.error({ err, targetUser: targetUser.id }, "[admin] kick failed");
+ await safeReply(interaction, { content: userFacingError(err), ephemeral: true });
}
}
@@ -359,38 +368,45 @@ async function handleBan(interaction: ChatInputCommandInteraction): Promise null);
+
if (targetUser.id === interaction.user.id) {
- await safeEphemeralMessage(interaction, "You cannot ban yourself.");
+ await safeReply(interaction, {
+ content: "❌ You cannot ban yourself.",
+ ephemeral: true,
+ });
return;
}
- const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null);
-
if (member) {
if (member.user.bot) {
- await safeEphemeralMessage(interaction, "Cannot ban bots.");
+ await safeReply(interaction, { content: "❌ Cannot ban bots.", ephemeral: true });
return;
}
const executor = interaction.member as GuildMember;
if (member.roles.highest.position >= executor.roles.highest.position) {
- await safeEphemeralMessage(
- interaction,
- "You cannot ban someone with an equal or higher role.",
- );
+ await safeReply(interaction, {
+ content: "❌ You cannot ban someone with an equal or higher role.",
+ ephemeral: true,
+ });
return;
}
if (!member.bannable) {
- await safeEphemeralMessage(
- interaction,
- "I do not have permission to ban this user.",
- );
+ await safeReply(interaction, {
+ content:
+ "❌ I don't have permission to ban this user.\nCheck my role position and Ban Members permission.",
+ ephemeral: true,
+ });
return;
}
}
@@ -400,34 +416,27 @@ async function handleBan(interaction: ChatInputCommandInteraction): Promise 0 ? `\nMessages deleted: last ${deleteDays} day(s)` : "";
- await interaction.editReply(
- `Banned **${targetUser.tag}**.\nReason: ${reason}${deletedText}`,
- );
+ await safeReply(interaction, {
+ content:
+ `✅ ${targetUser.tag} has been banned.\nReason: ${reason}` +
+ (deleteDays > 0 ? `\nMessages deleted: last ${deleteDays} day(s)` : ""),
+ ephemeral: false,
+ });
logger.info(
- {
- moderator: interaction.user.tag,
- target: targetUser.tag,
- reason,
- deleteDays,
- guildId: interaction.guildId,
- },
+ { moderator: interaction.user.tag, target: targetUser.tag, reason, deleteDays },
"[admin] user banned",
);
} catch (err) {
- logger.error(
- { err, targetUser: targetUser.id, guildId: interaction.guildId },
- "[admin] ban failed",
- );
- await safeEphemeralMessage(
- interaction,
- "Failed to ban user. Check my permissions and role position.",
- );
+ logger.error({ err, targetUser: targetUser.id }, "[admin] ban failed");
+ await safeReply(interaction, { content: userFacingError(err), ephemeral: true });
}
}
+/* -------------------------------------------------------------------------- */
+/* Stats + Health */
+/* -------------------------------------------------------------------------- */
+
async function handleStats(interaction: ChatInputCommandInteraction): Promise {
try {
const db = getDb();
@@ -435,15 +444,15 @@ async function handleStats(interaction: ChatInputCommandInteraction): Promise;
- const totalCommands = Object.values(totalsByCommand).reduce(
- (sum, count) => sum + (count ?? 0),
+ const totals = (funUsage?.totalsByCommand ?? {}) as Record;
+ const totalCommands = Object.values(totals).reduce(
+ (sum, count) => sum + (Number.isFinite(count) ? count : 0),
0,
);
@@ -478,12 +487,14 @@ async function handleStats(interaction: ChatInputCommandInteraction): Promise !process.env[v]?.trim());
+ const requiredEnvVars = ["DISCORD_TOKEN", "DISCORD_APP_ID"];
+ const missingVars = requiredEnvVars.filter((v) => !process.env[v]);
- if (missingRequired.length === 0) {
- checks.push({ name: "Required env", status: "All set" });
+ if (missingVars.length === 0) {
+ checks.push({ name: "Environment", status: "✅ Required vars set" });
} else {
checks.push({
- name: "Required env",
- status: "Missing",
- details: missingRequired.join(", "),
+ name: "Environment",
+ status: "⚠️ Missing vars",
+ details: missingVars.join(", "),
});
}
- const optionalEnv = [
+ const optionalKeys = [
{ name: "OpenAI", key: "OPENAI_API_KEY" },
{ name: "GitHub", key: "GITHUB_TOKEN" },
- { name: "WeatherAPI", key: "WEATHERAPI_KEY" },
+ { name: "Weather API", key: "WEATHERAPI_KEY" },
];
- for (const item of optionalEnv) {
+ for (const { name, key } of optionalKeys) {
checks.push({
- name: item.name,
- status: process.env[item.key]?.trim() ? "Configured" : "Not configured",
+ name,
+ status: process.env[key] ? "✅ Configured" : "⚠️ Not configured",
});
}
@@ -543,11 +554,13 @@ async function handleHealth(interaction: ChatInputCommandInteraction): Promise