diff --git a/README.md b/README.md index 07183036..122d1ae1 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ 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: issue and PR lookups plus polling-based announcements - - Check if Github is working +- GitHub integration with polling-based automation + - Health/status checks + - Issue and PR lookups - New PR announcements - Issue and PR assignee change announcements - Issue and PR closed announcements @@ -65,17 +66,21 @@ OmegaBot is a modular Discord bot designed to support development projects with ### Fun / utility commands -- chucknorris -- dadjoke -- coinflip -- dice -- weather +All fun commands are available under `/fun`: + +- `/fun chucknorris` — Chuck Norris facts (random, category, or search) +- `/fun dadjoke` — Random or searched dad jokes +- `/fun coinflip` — Heads or tails +- `/fun dice` — Custom dice rolls +- `/fun weather` — Daily weather +- `/fun weather7` — 7-day forecast +- `/fun leaderboard` — Track fun command usage and top users ## Planned features -- /docs command for documentation lookups -- GitHub issues and pull request lookups -- Pull request announcements +- `/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) --- diff --git a/docs/commands.md b/docs/commands.md index 4e4654a0..2582e2dd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,13 +4,6 @@ - `/ping` — Verify the bot is online and measure latency -## Summary & History - -- `/summary` — Summarize recent messages (local or LLM mode) -- `/history` — DM recent channel history (file fallback if too long) -- `/playback` — Page through recent messages using buttons -- `/pagination` — Inline paginated view of recent messages - ## GitHub - `/gh issue` — Fetch a GitHub issue by number @@ -19,6 +12,25 @@ - `/gh status` — Show GitHub integration status (configuration, polling, and channels) - `/pr` — Fetch a single pull request by number (legacy shortcut) +## Fun + +All fun commands are grouped under `/fun`. + +- `/fun chucknorris` — Chuck Norris facts +- `/fun dadjoke` — Dad jokes (random or search) +- `/fun coinflip` — Flip a coin +- `/fun dice` — Roll dice with custom sides/count +- `/fun weather` — Today’s weather +- `/fun weather7` — 7-day forecast +- `/fun leaderboard` — Fun command usage leaderboard + +## Summary & History + +- `/summary` — Summarize recent messages (local or LLM mode) +- `/history` — DM recent channel history (file fallback if too long) +- `/playback` — Page through recent messages using buttons +- `/pagination` — Inline paginated view of recent messages + ## Timezone - `/timezone set` — Save your IANA timezone diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index 9cfa976d..f26ce7f7 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -18,6 +18,15 @@ import { run as runDice } from "./subcommands/dice.js"; import { run as runWeather } from "./subcommands/weather.js"; import type { TempUnit, WeatherMode } from "../../services/weather/types.js"; +import { run as runCoinflip } from "./subcommands/coinflip.js"; +import { run as runPoll } from "./subcommands/poll.js"; +import { run as runJava } from "./subcommands/java.js"; + +import { run as runLeaderboard } from "./subcommands/leaderboard.js"; +import type { LeaderboardMode } from "./subcommands/leaderboard.js"; + +import { recordFunUsage, type FunCommandKey } from "./funUsageStore.js"; + function parseTempUnit(raw: string | null): TempUnit { return raw?.toLowerCase() === "c" ? "c" : "f"; } @@ -90,6 +99,54 @@ export const data = new SlashCommandBuilder() ), ) + // /fun coinflip + .addSubcommand((s) => + s + .setName("coinflip") + .setDescription("Flip a coin") + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun java + .addSubcommand((s) => + s + .setName("java") + .setDescription("Random Java jokes ☕") + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun poll (2–4 options) + .addSubcommand((s) => + s + .setName("poll") + .setDescription("Create a quick poll (2–4 options)") + .addStringOption((o) => + o.setName("question").setDescription("Poll question").setRequired(true), + ) + .addStringOption((o) => + o.setName("option1").setDescription("Option 1").setRequired(true), + ) + .addStringOption((o) => + o.setName("option2").setDescription("Option 2").setRequired(true), + ) + .addStringOption((o) => + o.setName("option3").setDescription("Option 3 (optional)").setRequired(false), + ) + .addStringOption((o) => + o.setName("option4").setDescription("Option 4 (optional)").setRequired(false), + ), + ) + // /fun weather (daily) .addSubcommand((s) => s @@ -140,10 +197,75 @@ export const data = new SlashCommandBuilder() .setDescription("Only show the result to you") .setRequired(false), ), + ) + + // /fun leaderboard + .addSubcommand((s) => + s + .setName("leaderboard") + .setDescription("Show fun command leaderboard") + .addStringOption((o) => + o + .setName("view") + .setDescription("What leaderboard view to show") + .setRequired(false) + .addChoices( + { name: "Top users", value: "users" }, + { name: "Top commands", value: "commands" }, + { name: "Single user", value: "user" }, + ), + ) + .addUserOption((o) => + o + .setName("user") + .setDescription("User to inspect (used with view: Single user)") + .setRequired(false), + ) + .addIntegerOption((o) => + o + .setName("limit") + .setDescription("How many results to show (default 10, max 25)") + .setMinValue(1) + .setMaxValue(25) + .setRequired(false), + ), ); +function funKeyFromSub(sub: string): FunCommandKey | null { + const allowed: Record = { + chucknorris: "chucknorris", + dadjoke: "dadjoke", + dice: "dice", + coinflip: "coinflip", + java: "java", + poll: "poll", + weather: "weather", + weather7: "weather7", + leaderboard: "leaderboard", + }; + + return allowed[sub] ?? null; +} + +async function maybeRecordUsage( + interaction: ChatInputCommandInteraction, + sub: string, +): Promise { + const key = funKeyFromSub(sub); + if (!key) return; + + try { + await recordFunUsage({ userId: interaction.user.id, command: key }); + } catch (err) { + // Do not fail the command if usage tracking fails + logger.warn({ err, sub }, "[fun] failed to record usage"); + } +} + export async function execute(interaction: ChatInputCommandInteraction): Promise { const sub = interaction.options.getSubcommand(true); + + // NOTE: most subcommands accept "ephemeral". Poll does not. const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; // Parent command owns the interaction lifecycle @@ -160,19 +282,65 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise ? { kind: "category", category } : { kind: "random" }; - return await runChuckNorris(interaction, mode); + await runChuckNorris(interaction, mode); + await maybeRecordUsage(interaction, sub); + return; } if (sub === "dadjoke") { - const query = interaction.options.getString("search")?.trim() ?? ""; + // NOTE: option name is "query" (not "search") + const query = interaction.options.getString("query")?.trim() ?? ""; const mode: DadJokeMode = query ? { kind: "search", query } : { kind: "random" }; - return await runDadJoke(interaction, mode); + await runDadJoke(interaction, mode); + await maybeRecordUsage(interaction, sub); + return; } if (sub === "dice") { - return await runDice(interaction); + await runDice(interaction); + await maybeRecordUsage(interaction, sub); + return; + } + + if (sub === "coinflip") { + await runCoinflip(interaction); + await maybeRecordUsage(interaction, sub); + return; + } + + if (sub === "java") { + await runJava(interaction); + await maybeRecordUsage(interaction, sub); + return; + } + + if (sub === "poll") { + await runPoll(interaction); + await maybeRecordUsage(interaction, sub); + return; + } + + if (sub === "leaderboard") { + const view = interaction.options.getString("view") ?? "users"; + const limit = interaction.options.getInteger("limit") ?? 10; + + if (view === "commands") { + const mode: LeaderboardMode = { kind: "commands", limit }; + await runLeaderboard(interaction, mode); + } else if (view === "user") { + const u = interaction.options.getUser("user"); + const targetId = u?.id ?? interaction.user.id; + const mode: LeaderboardMode = { kind: "user", userId: targetId }; + await runLeaderboard(interaction, mode); + } else { + const mode: LeaderboardMode = { kind: "users", limit }; + await runLeaderboard(interaction, mode); + } + + await maybeRecordUsage(interaction, sub); + return; } if (sub === "weather" || sub === "weather7") { @@ -184,7 +352,9 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise ? { kind: "daily", location, unit } : { kind: "7day", location, unit }; - return await runWeather(interaction, mode); + await runWeather(interaction, mode); + await maybeRecordUsage(interaction, sub); + return; } await interaction.editReply("Unknown subcommand."); diff --git a/src/commands/fun/funUsageStore.ts b/src/commands/fun/funUsageStore.ts new file mode 100644 index 00000000..a93f7630 --- /dev/null +++ b/src/commands/fun/funUsageStore.ts @@ -0,0 +1,128 @@ +// src/services/fun/funUsageStore.ts + +import { promises as fs } from "node:fs"; +import path from "node:path"; + +export type FunCommandKey = + | "chucknorris" + | "dadjoke" + | "dice" + | "weather" + | "weather7" + | "coinflip" + | "poll" + | "java" + | "leaderboard"; + +export type FunUsageSnapshot = { + updatedAt: string; + totalsByCommand: Record; + totalsByUser: Record; + byUserByCommand: Record>; +}; + +type FunUsageFile = { + version: 1; + updatedAt: string; + totalsByCommand: Record; + totalsByUser: Record; + byUserByCommand: Record>; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STATE_PATH = path.join(DATA_DIR, "fun-usage.json"); + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +function emptyState(): FunUsageFile { + return { + version: 1, + updatedAt: new Date().toISOString(), + totalsByCommand: {}, + totalsByUser: {}, + byUserByCommand: {}, + }; +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as FunUsageFile; + + if (!parsed || typeof parsed !== "object") return emptyState(); + if (parsed.version !== 1) return emptyState(); + + return { + version: 1, + updatedAt: + typeof parsed.updatedAt === "string" + ? parsed.updatedAt + : new Date().toISOString(), + totalsByCommand: + parsed.totalsByCommand && typeof parsed.totalsByCommand === "object" + ? parsed.totalsByCommand + : {}, + totalsByUser: + parsed.totalsByUser && typeof parsed.totalsByUser === "object" + ? parsed.totalsByUser + : {}, + byUserByCommand: + parsed.byUserByCommand && typeof parsed.byUserByCommand === "object" + ? parsed.byUserByCommand + : {}, + }; + } catch { + return emptyState(); + } +} + +async function saveState(state: FunUsageFile): Promise { + await ensureDataDir(); + await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); +} + +function inc(map: Record, key: string, by = 1): void { + const cur = map[key] ?? 0; + map[key] = cur + by; +} + +function incNested( + map: Record>, + outer: string, + inner: string, + by = 1, +): void { + if (!map[outer]) map[outer] = {}; + inc(map[outer], inner, by); +} + +export async function recordFunUsage(args: { + userId: string; + command: FunCommandKey; +}): Promise { + const { userId, command } = args; + + const state = await loadState(); + inc(state.totalsByCommand, command, 1); + inc(state.totalsByUser, userId, 1); + incNested(state.byUserByCommand, userId, command, 1); + + state.updatedAt = new Date().toISOString(); + await saveState(state); +} + +export async function getFunUsageSnapshot(): Promise { + const state = await loadState(); + return { + updatedAt: state.updatedAt, + totalsByCommand: state.totalsByCommand, + totalsByUser: state.totalsByUser, + byUserByCommand: state.byUserByCommand, + }; +} + +export async function resetFunUsage(): Promise { + await saveState(emptyState()); +} diff --git a/src/commands/fun/subcommands/java.ts b/src/commands/fun/subcommands/java.ts new file mode 100644 index 00000000..0dc4b976 --- /dev/null +++ b/src/commands/fun/subcommands/java.ts @@ -0,0 +1,21 @@ +// src/commands/fun/subcommands/java.ts + +import type { ChatInputCommandInteraction } from "discord.js"; + +const JOKES: string[] = [ + "Why do Java developers wear glasses? Because they don’t C#.", + "A SQL query walks into a bar, walks up to two tables and asks: 'Can I join you?'", + "I told my Java program a joke… it didn’t laugh. It just threw an exception.", + "Java: Write once, debug everywhere.", + "Why was the Java developer broke? Because they used up all their cache.", + "My Java app is really secure. It has *private* everything.", + "How many Java devs does it take to change a light bulb? None, it’s a hardware problem.", +]; + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] as T; +} + +export async function run(interaction: ChatInputCommandInteraction): Promise { + await interaction.editReply(`☕ ${pick(JOKES)}`); +} diff --git a/src/commands/fun/subcommands/leaderboard.ts b/src/commands/fun/subcommands/leaderboard.ts new file mode 100644 index 00000000..e3ec1133 --- /dev/null +++ b/src/commands/fun/subcommands/leaderboard.ts @@ -0,0 +1,128 @@ +// src/commands/fun/subcommands/leaderboard.ts + +import type { ChatInputCommandInteraction, User } from "discord.js"; +import { EmbedBuilder } from "discord.js"; +import { getFunUsageSnapshot } from "../funUsageStore.js"; + +export type LeaderboardMode = + | { kind: "users"; limit: number } + | { kind: "commands"; limit: number } + | { kind: "user"; userId: string }; + +type RankedItem = { key: string; count: number }; + +function rank(map: Record, limit: number): RankedItem[] { + return Object.entries(map) + .map(([key, count]) => ({ key, count })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); +} + +function medals(idx: number): string { + if (idx === 0) return "🥇"; + if (idx === 1) return "🥈"; + if (idx === 2) return "🥉"; + return `${idx + 1}.`; +} + +async function safeFetchUser( + interaction: ChatInputCommandInteraction, + userId: string, +): Promise { + try { + return await interaction.client.users.fetch(userId); + } catch { + return null; + } +} + +function prettyCommandName(cmd: string): string { + // Keep it simple and readable in output + return cmd.startsWith("fun ") ? cmd : `fun ${cmd}`; +} + +export async function run( + interaction: ChatInputCommandInteraction, + mode: LeaderboardMode, +): Promise { + const snapshot = await getFunUsageSnapshot(); + + const totalEvents = Object.values(snapshot.totalsByCommand).reduce( + (sum: number, n: number) => sum + n, + 0, + ); + + if (totalEvents === 0) { + await interaction.editReply( + [`Updated: ${snapshot.updatedAt}`, "", "No fun command usage recorded yet."].join( + "\n", + ), + ); + return; + } + + if (mode.kind === "commands") { + const top = rank(snapshot.totalsByCommand, mode.limit); + + const lines: string[] = []; + for (let i = 0; i < top.length; i += 1) { + const item = top[i]!; + lines.push(`${medals(i)} \`/${prettyCommandName(item.key)}\` — **${item.count}**`); + } + + const embed = new EmbedBuilder() + .setTitle("🎉 Fun Leaderboard: Top Commands") + .setDescription(lines.join("\n")) + .setFooter({ text: `Updated: ${snapshot.updatedAt}` }); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + if (mode.kind === "users") { + const top = rank(snapshot.totalsByUser, mode.limit); + + const lines: string[] = []; + for (let i = 0; i < top.length; i += 1) { + const item = top[i]!; + lines.push(`${medals(i)} <@${item.key}> — **${item.count}**`); + } + + const embed = new EmbedBuilder() + .setTitle("🏆 Fun Leaderboard: Top Users") + .setDescription(lines.join("\n")) + .setFooter({ text: `Updated: ${snapshot.updatedAt}` }); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + // mode.kind === "user" + const userId = mode.userId; + const user = await safeFetchUser(interaction, userId); + + const perCmd = snapshot.byUserByCommand[userId] ?? {}; + const sorted = rank(perCmd, 25); + + const lines: string[] = []; + if (sorted.length === 0) { + lines.push("No recorded fun commands for this user yet."); + } else { + for (let i = 0; i < sorted.length; i += 1) { + const item = sorted[i]!; + lines.push(`${medals(i)} \`/${prettyCommandName(item.key)}\` — **${item.count}**`); + } + } + + const titleName = user?.username ? `${user.username}` : `User ${userId}`; + const embed = new EmbedBuilder() + .setTitle(`👤 Fun Usage: ${titleName}`) + .setDescription(lines.join("\n")) + .setFooter({ text: `Updated: ${snapshot.updatedAt}` }); + + if (user) { + embed.setThumbnail(user.displayAvatarURL({ size: 128 })); + } + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/src/commands/fun/subcommands/poll.ts b/src/commands/fun/subcommands/poll.ts new file mode 100644 index 00000000..7cb1237d --- /dev/null +++ b/src/commands/fun/subcommands/poll.ts @@ -0,0 +1,62 @@ +// src/commands/fun/subcommands/poll.ts + +import type { ChatInputCommandInteraction, Message } from "discord.js"; + +const REACTIONS: string[] = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"]; + +type PollArgs = { + question: string; + options: string[]; +}; + +function buildPollText(args: PollArgs): string { + const { question, options } = args; + + const lines: string[] = []; + lines.push(`📊 **${question}**`); + lines.push(""); + + for (let i = 0; i < options.length; i += 1) { + const emoji = REACTIONS[i] ?? "•"; + lines.push(`${emoji} ${options[i]}`); + } + + lines.push(""); + lines.push("_React below to vote._"); + + return lines.join("\n"); +} + +export async function run(interaction: ChatInputCommandInteraction): Promise { + const question = interaction.options.getString("question", true).trim(); + + // options are option1..option4 + const opt1 = interaction.options.getString("option1", true).trim(); + const opt2 = interaction.options.getString("option2", true).trim(); + const opt3 = interaction.options.getString("option3")?.trim() ?? ""; + const opt4 = interaction.options.getString("option4")?.trim() ?? ""; + + const options = [opt1, opt2, opt3, opt4].filter((s: string) => s.length > 0); + + if (options.length < 2 || options.length > 4) { + await interaction.editReply("Poll requires 2–4 options."); + return; + } + + await interaction.editReply(buildPollText({ question, options })); + + // Add reactions to the sent message + const reply = (await interaction.fetchReply()) as Message; + + for (let i = 0; i < options.length; i += 1) { + const emoji = REACTIONS[i]; + if (!emoji) continue; + + try { + await reply.react(emoji); + } catch { + // If bot can't add reactions, don't fail the command. + // The poll message is still usable. + } + } +} diff --git a/src/commands/help/helpText.ts b/src/commands/help/helpText.ts index 5708d407..d4714402 100644 --- a/src/commands/help/helpText.ts +++ b/src/commands/help/helpText.ts @@ -15,23 +15,32 @@ export function buildHelpText(args: { const lines: string[] = []; - lines.push("**OmegaBot Help**"); + lines.push("**🤖 OmegaBot Help**"); lines.push(""); - lines.push("**Start here**"); + + lines.push("**🚀 Start here**"); lines.push("- Try `/help` any time you forget what I can do"); lines.push(""); - lines.push("**Onboarding**"); + lines.push("**👋 Onboarding**"); lines.push("- Welcome messages are posted when someone joins"); lines.push(""); + lines.push("**🎉 Fun**"); + lines.push("- Use `/fun` to access lightweight, fun commands:"); + lines.push(" - `/fun chucknorris`, `/fun dadjoke`, `/fun dice` 😄"); + lines.push(" - `/fun weather`, `/fun weather7` 🌦️"); + lines.push(" - `/fun leaderboard` 🏆 see who’s having the most fun"); + lines.push("- Tip: type `/fun` and choose a subcommand from the menu"); + lines.push(""); + if (isAdmin) { - lines.push("**Admin config** (Manage Server)"); + lines.push("**🛠️ Admin config** (Manage Server)"); lines.push("- `/config welcome-channel set channel:#your-channel`"); lines.push("- `/config welcome-channel clear`"); lines.push(""); } else { - lines.push("**Admin config**"); + lines.push("**🛠️ Admin config**"); lines.push("- Ask a server admin to run `/config welcome-channel set` if needed"); lines.push(""); } @@ -40,7 +49,7 @@ export function buildHelpText(args: { const pretty = formatCommandList(commands, { isAdmin }); if (pretty.length) { - lines.push("**Commands**"); + lines.push("**📚 Commands**"); lines.push(...pretty); lines.push(""); }