diff --git a/README.md b/README.md index 122d1ae1..e2650c1c 100644 --- a/README.md +++ b/README.md @@ -174,24 +174,6 @@ OmegaBot is online │ │ └── OmegaBot.yml │ └── pull_request_template.md ├── .husky -│ ├── _ -│ │ ├── .gitignore -│ │ ├── applypatch-msg -│ │ ├── commit-msg -│ │ ├── h -│ │ ├── husky.sh -│ │ ├── post-applypatch -│ │ ├── post-checkout -│ │ ├── post-commit -│ │ ├── post-merge -│ │ ├── post-rewrite -│ │ ├── pre-applypatch -│ │ ├── pre-auto-gc -│ │ ├── pre-commit -│ │ ├── pre-merge-commit -│ │ ├── pre-push -│ │ ├── pre-rebase -│ │ └── prepare-commit-msg │ ├── pre-commit │ └── pre-push ├── assets @@ -199,6 +181,7 @@ OmegaBot is online │ └── omegabot.png ├── data │ ├── faqs.json +│ ├── fun-usage.json │ ├── github-assignees.json │ ├── guild-config.json │ ├── last-seen.json @@ -231,6 +214,9 @@ OmegaBot is online │ │ │ │ ├── coinflip.ts │ │ │ │ ├── dadjoke.ts │ │ │ │ ├── dice.ts +│ │ │ │ ├── java.ts +│ │ │ │ ├── leaderboard.ts +│ │ │ │ ├── poll.ts │ │ │ │ └── weather.ts │ │ │ └── fun.ts │ │ ├── general @@ -273,6 +259,9 @@ OmegaBot is online │ │ │ ├── store.test.ts │ │ │ ├── store.ts │ │ │ └── types.ts +│ │ ├── fun +│ │ │ ├── funUsageStore.ts +│ │ │ └── pollStore.ts │ │ ├── github │ │ │ ├── githubApi.ts │ │ │ ├── githubClient.ts @@ -320,7 +309,6 @@ OmegaBot is online ├── package.json ├── README.md ├── tsconfig.json -└── vitest.config.ts ``` diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index f26ce7f7..04850744 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -25,7 +25,7 @@ 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"; +import { recordFunUsage, type FunCommandKey } from "../../services/fun/funUsageStore.js"; function parseTempUnit(raw: string | null): TempUnit { return raw?.toLowerCase() === "c" ? "c" : "f"; diff --git a/src/commands/fun/funUsageStore.ts b/src/commands/fun/funUsageStore.ts deleted file mode 100644 index a93f7630..00000000 --- a/src/commands/fun/funUsageStore.ts +++ /dev/null @@ -1,128 +0,0 @@ -// 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/leaderboard.ts b/src/commands/fun/subcommands/leaderboard.ts index e3ec1133..d9b394c7 100644 --- a/src/commands/fun/subcommands/leaderboard.ts +++ b/src/commands/fun/subcommands/leaderboard.ts @@ -1,28 +1,43 @@ // src/commands/fun/subcommands/leaderboard.ts -import type { ChatInputCommandInteraction, User } from "discord.js"; -import { EmbedBuilder } from "discord.js"; -import { getFunUsageSnapshot } from "../funUsageStore.js"; +import { EmbedBuilder, type ChatInputCommandInteraction, type User } from "discord.js"; +import { getFunUsageSnapshot } from "../../../services/fun/funUsageStore.js"; +import { logger } from "../../../utils/logger.js"; export type LeaderboardMode = | { kind: "users"; limit: number } | { kind: "commands"; limit: number } | { kind: "user"; userId: string }; -type RankedItem = { key: string; count: number }; +type PerCommandCounts = Record; +type PerUserByCommand = Record; -function rank(map: Record, limit: number): RankedItem[] { - return Object.entries(map) - .map(([key, count]) => ({ key, count })) +function toCount(n: unknown): number { + return typeof n === "number" && Number.isFinite(n) ? n : 0; +} + +function topCommandsFromMap( + perCmd: PerCommandCounts | undefined, + limit: number, +): Array<{ command: string; count: number }> { + if (!perCmd) return []; + + return Object.entries(perCmd) + .map(([command, raw]) => ({ command, count: toCount(raw) })) + .filter((x) => x.count > 0) .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}.`; +function formatInlineBreakdown( + perCmd: PerCommandCounts | undefined, + limit: number, +): string { + const top = topCommandsFromMap(perCmd, limit); + if (!top.length) return ""; + + // Example: "leaderboard 10x, coinflip 4x, chucknorris 2x" + return top.map((x) => `${x.command} ${x.count}x`).join(", "); } async function safeFetchUser( @@ -31,98 +46,113 @@ async function safeFetchUser( ): Promise { try { return await interaction.client.users.fetch(userId); - } catch { + } catch (err) { + logger.debug({ err, userId }, "[fun/leaderboard] failed to fetch user"); 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, + // Snapshot shapes can drift if a file is edited manually, so we defensive-cast. + const totalsByUser = (snapshot.totalsByUser ?? {}) as Record; + const totalsByCommand = (snapshot.totalsByCommand ?? {}) as Record; + const byUserByCommand = (snapshot.byUserByCommand ?? {}) as PerUserByCommand; + + const anyUserUsage = Object.keys(totalsByUser).length > 0; + const anyCommandUsage = (Object.values(totalsByCommand) as unknown[]).some( + (n) => toCount(n) > 0, ); + const anyUsage = anyUserUsage || anyCommandUsage; + + const embed = new EmbedBuilder().setFooter({ + text: `Updated: ${snapshot.updatedAt}`, + }); - if (totalEvents === 0) { - await interaction.editReply( - [`Updated: ${snapshot.updatedAt}`, "", "No fun command usage recorded yet."].join( - "\n", - ), + if (!anyUsage) { + embed.setTitle("Fun Leaderboard"); + embed.setDescription( + "No fun command usage recorded yet. Try `/fun dadjoke` to get started.", ); + await interaction.editReply({ embeds: [embed] }); 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 items = (Object.entries(totalsByCommand) as Array<[string, unknown]>) + .map(([cmd, raw]) => ({ cmd, count: toCount(raw) })) + .filter((x) => x.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, mode.limit); + + embed.setTitle("Fun Leaderboard: Top Commands"); + + if (!items.length) { + embed.setDescription("No command usage yet."); + await interaction.editReply({ embeds: [embed] }); + return; } - const embed = new EmbedBuilder() - .setTitle("🎉 Fun Leaderboard: Top Commands") - .setDescription(lines.join("\n")) - .setFooter({ text: `Updated: ${snapshot.updatedAt}` }); - + // No numbering, no dashes + const lines = items.map((x) => `• \`/fun ${x.cmd}\` ${x.count}x`); + embed.setDescription(lines.join("\n")); await interaction.editReply({ embeds: [embed] }); return; } - if (mode.kind === "users") { - const top = rank(snapshot.totalsByUser, mode.limit); + if (mode.kind === "user") { + const total = toCount(totalsByUser[mode.userId]); + const perCmd = byUserByCommand[mode.userId] ?? {}; - 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 u = await safeFetchUser(interaction, mode.userId); + const displayName = u?.username ?? `User ${mode.userId}`; + + embed.setTitle(`Fun Usage: ${displayName}`); - const embed = new EmbedBuilder() - .setTitle("🏆 Fun Leaderboard: Top Users") - .setDescription(lines.join("\n")) - .setFooter({ text: `Updated: ${snapshot.updatedAt}` }); + const top = topCommandsFromMap(perCmd, 25); + const commandLines = top.length + ? top.map((x) => `• \`/fun ${x.command}\` ${x.count}x`) + : ["• No per-command data yet."]; + embed.setDescription([`Total uses: ${total}x`, "", ...commandLines].join("\n")); await interaction.editReply({ embeds: [embed] }); return; } - // mode.kind === "user" - const userId = mode.userId; - const user = await safeFetchUser(interaction, userId); + // Top users (default) + const userItems = (Object.entries(totalsByUser) as Array<[string, unknown]>) + .map(([userId, raw]) => ({ userId, total: toCount(raw) })) + .filter((x) => x.total > 0) + .sort((a, b) => b.total - a.total) + .slice(0, mode.limit); - const perCmd = snapshot.byUserByCommand[userId] ?? {}; - const sorted = rank(perCmd, 25); + embed.setTitle("Fun Leaderboard: Top Users"); - 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}**`); - } + if (!userItems.length) { + embed.setDescription("No user usage yet."); + await interaction.editReply({ embeds: [embed] }); + return; } - 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}` }); + // Show “user X ran fun command Y this many times” + // No emojis, no numbering, no dash separators + const lines: string[] = []; - if (user) { - embed.setThumbnail(user.displayAvatarURL({ size: 128 })); + for (const item of userItems) { + const perCmd = byUserByCommand[item.userId] ?? {}; + const breakdown = formatInlineBreakdown(perCmd, 3); + + if (breakdown) { + lines.push(`• <@${item.userId}> ${item.total}x (top: ${breakdown})`); + } else { + lines.push(`• <@${item.userId}> ${item.total}x`); + } } + embed.setDescription(lines.join("\n")); await interaction.editReply({ embeds: [embed] }); } diff --git a/src/commands/fun/subcommands/poll.ts b/src/commands/fun/subcommands/poll.ts index 7cb1237d..d48eedc9 100644 --- a/src/commands/fun/subcommands/poll.ts +++ b/src/commands/fun/subcommands/poll.ts @@ -1,62 +1,211 @@ // 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[]; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type ButtonInteraction, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../../utils/logger.js"; +import { + createPoll, + recordVote, + type StoredPoll, +} from "../../../services/fun/pollStore.js"; + +type PollOption = { + label: string; + index: number; }; -function buildPollText(args: PollArgs): string { - const { question, options } = args; +function buildPollEmbed(poll: StoredPoll): EmbedBuilder { + const totalVotes = poll.counts.reduce((acc: number, n: number) => acc + n, 0); + + const lines = poll.options.map((opt: string, idx: number) => { + const n = poll.counts[idx] ?? 0; + return `• **${opt}**: ${n}`; + }); + + return new EmbedBuilder() + .setTitle("📊 Fun Poll") + .setDescription( + [`**${poll.question}**`, "", ...lines, "", `Total votes: **${totalVotes}**`].join( + "\n", + ), + ) + .setFooter({ text: `Poll ID: ${poll.messageId}` }); +} - const lines: string[] = []; - lines.push(`📊 **${question}**`); - lines.push(""); +function buildPollButtons( + poll: StoredPoll, + opts?: { disabled?: boolean }, +): ActionRowBuilder[] { + const disabled = opts?.disabled ?? false; + + // Discord max 5 buttons per row. We have 2–4 options: one row is fine. + const row = new ActionRowBuilder(); + + for (let i = 0; i < poll.options.length; i += 1) { + const label = poll.options[i] ?? `Option ${i + 1}`; + + row.addComponents( + new ButtonBuilder() + .setCustomId(`funpoll:${poll.messageId}:${i}`) + .setLabel(label.length > 80 ? `${label.slice(0, 77)}...` : label) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled), + ); + } - for (let i = 0; i < options.length; i += 1) { - const emoji = REACTIONS[i] ?? "•"; - lines.push(`${emoji} ${options[i]}`); + return [row]; +} + +function parseOptions(interaction: ChatInputCommandInteraction): PollOption[] { + const o1 = interaction.options.getString("option1", true).trim(); + const o2 = interaction.options.getString("option2", true).trim(); + const o3 = interaction.options.getString("option3")?.trim() ?? ""; + const o4 = interaction.options.getString("option4")?.trim() ?? ""; + + const raw = [o1, o2, o3, o4].filter((s: string) => s.length > 0); + + // Deduplicate exact duplicates (case-insensitive) to avoid confusion + const seen = new Set(); + const unique: string[] = []; + for (const s of raw) { + const key = s.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + unique.push(s); } - lines.push(""); - lines.push("_React below to vote._"); + // Enforce 2–4 options (after dedupe) + if (unique.length < 2) throw new Error("Poll must have at least 2 unique options."); + if (unique.length > 4) throw new Error("Poll can have at most 4 options."); - return lines.join("\n"); + return unique.map((label: string, index: number) => ({ label, index })); } 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."); + let opts: PollOption[]; + try { + opts = parseOptions(interaction); + } catch (err) { + const msg = err instanceof Error ? err.message : "Invalid poll options."; + await interaction.editReply(msg); 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; + // Create a placeholder message first so we can use messageId as pollId. + const placeholder = new EmbedBuilder() + .setTitle("📊 Fun Poll") + .setDescription("Creating poll…"); + await interaction.editReply({ embeds: [placeholder] }); + + const sent = await interaction.fetchReply(); + const messageId = sent.id; + + const poll = await createPoll({ + messageId, + channelId: sent.channelId, + guildId: sent.guildId ?? null, + creatorUserId: interaction.user.id, + question, + options: opts.map((o: PollOption) => o.label), + }); + + let latestPoll: StoredPoll = poll; + + await interaction.editReply({ + embeds: [buildPollEmbed(latestPoll)], + components: buildPollButtons(latestPoll), + }); + + // Collector is in-memory: if the bot restarts, existing polls won’t accept votes anymore. + const collector = sent.createMessageComponentCollector({ + time: 1000 * 60 * 60 * 24, // 24h + }); + + collector.on("collect", async (btn: ButtonInteraction) => { + try { + if (!btn.customId.startsWith("funpoll:")) return; + + const parts = btn.customId.split(":"); + // funpoll:: + const pollId = parts[1] ?? ""; + const idxStr = parts[2] ?? ""; + const optionIndex = Number(idxStr); + + if (pollId !== messageId) return; + + if ( + !Number.isFinite(optionIndex) || + optionIndex < 0 || + optionIndex >= latestPoll.options.length + ) { + await btn.reply({ content: "Invalid poll option.", ephemeral: true }); + return; + } + + const result = await recordVote({ + messageId: pollId, + userId: btn.user.id, + optionIndex, + }); + + if (result.kind === "alreadyVoted") { + await btn.reply({ + content: `You already voted: **${ + latestPoll.options[result.previousOptionIndex] ?? "Unknown" + }**`, + ephemeral: true, + }); + return; + } + + if (result.kind === "notFound") { + await btn.reply({ + content: "Poll not found (maybe it expired).", + ephemeral: true, + }); + return; + } + + // Updated poll snapshot + latestPoll = result.poll; + + await btn.deferUpdate(); // avoid “interaction failed” and extra message spam + await interaction.editReply({ + embeds: [buildPollEmbed(latestPoll)], + components: buildPollButtons(latestPoll), + }); + } catch (err) { + logger.warn({ err }, "[fun/poll] vote handling failed"); + try { + if (!btn.replied && !btn.deferred) { + await btn.reply({ + content: "Something went wrong recording that vote.", + ephemeral: true, + }); + } + } catch { + // ignore + } + } + }); + collector.on("end", async () => { + // Disable buttons when done try { - await reply.react(emoji); - } catch { - // If bot can't add reactions, don't fail the command. - // The poll message is still usable. + await interaction.editReply({ + embeds: [buildPollEmbed(latestPoll)], + components: buildPollButtons(latestPoll, { disabled: true }), + }); + } catch (err) { + logger.debug({ err }, "[fun/poll] failed to disable buttons on end"); } - } + }); } diff --git a/src/commands/help/helpText.ts b/src/commands/help/helpText.ts index d4714402..19bfdaf7 100644 --- a/src/commands/help/helpText.ts +++ b/src/commands/help/helpText.ts @@ -15,41 +15,71 @@ export function buildHelpText(args: { const lines: string[] = []; - lines.push("**🤖 OmegaBot Help**"); + lines.push("**OmegaBot Help**"); lines.push(""); - lines.push("**🚀 Start here**"); - lines.push("- Try `/help` any time you forget what I can do"); + lines.push("**Start here**"); + lines.push("- Run `/help` any time you forget what I can do"); + lines.push("- Tip: type `/fun` or `/gh` and pick a subcommand from the menu"); lines.push(""); - lines.push("**👋 Onboarding**"); - lines.push("- Welcome messages are posted when someone joins"); + lines.push("**Onboarding**"); + lines.push("- Posts a welcome message 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("**Fun**"); + lines.push("- `/fun chucknorris` — Chuck Norris facts (random, category, or search)"); + lines.push("- `/fun dadjoke` — Random dad joke (or search)"); + lines.push("- `/fun coinflip` — Heads or tails"); + lines.push("- `/fun dice` — Roll dice (custom sides/count)"); + lines.push("- `/fun poll` — Create a quick poll (2–4 options)"); + lines.push("- `/fun weather` — Today’s weather for a location"); + lines.push("- `/fun weather7` — 7-day forecast for a location"); + lines.push( + "- `/fun leaderboard` — Usage stats (top users, top commands, or a single user)", + ); + lines.push(""); + + lines.push("**GitHub**"); + lines.push("- `/gh issue` — Fetch a GitHub issue by number"); + lines.push("- `/gh issues` — List open GitHub issues"); + lines.push("- `/gh prs` — List open pull requests"); + lines.push( + "- `/gh status` — Show GitHub integration status (config, polling, channels)", + ); + lines.push("- `/pr` — Fetch a single pull request by number (legacy shortcut)"); + lines.push(""); + + lines.push("**Summary & History**"); + lines.push("- `/summary` — Summarize recent messages (local or LLM mode)"); + lines.push("- `/history` — DM recent channel history (file fallback if too long)"); + lines.push("- `/playback` — Page through recent messages using buttons"); + lines.push("- `/pagination` — Demo the reusable pagination helper"); + lines.push(""); + + lines.push("**Timezone**"); + lines.push("- `/timezone set` — Save your IANA timezone"); + lines.push("- `/timezone show` — Display your current timezone"); + lines.push("- `/timezone clear` — Remove your saved timezone"); 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(""); } // Optional dynamic section: list known commands if we can extract them. + // This is useful as a "sanity list" even if it doesn't include subcommands. const pretty = formatCommandList(commands, { isAdmin }); if (pretty.length) { - lines.push("**📚 Commands**"); + lines.push("**Registered commands**"); lines.push(...pretty); lines.push(""); } diff --git a/src/services/fun/funUsageStore.ts b/src/services/fun/funUsageStore.ts new file mode 100644 index 00000000..8d0e14bb --- /dev/null +++ b/src/services/fun/funUsageStore.ts @@ -0,0 +1,200 @@ +// src/services/fun/funUsageStore.ts + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { logger } from "../../utils/logger.js"; + +export type FunCommandKey = + | "chucknorris" + | "dadjoke" + | "dice" + | "coinflip" + | "java" + | "poll" + | "weather" + | "weather7" + | "leaderboard"; + +type FunUsageStoreV1 = { + version: 1; + initializedAt: string; + updatedAt: string; + + totalsByUser: Record; + totalsByCommand: Record; + + // This is the canonical per-user breakdown (matches your JSON) + byUserByCommand: Record>; + + // Backward-compat: older name some code used + breakdownByUser?: Record>; +}; + +export type FunUsageSnapshot = FunUsageStoreV1; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STORE_PATH = path.join(DATA_DIR, "fun-usage.json"); + +const ALL_COMMANDS: FunCommandKey[] = [ + "chucknorris", + "dadjoke", + "dice", + "coinflip", + "java", + "poll", + "weather", + "weather7", + "leaderboard", +]; + +function nowIso(): string { + return new Date().toISOString(); +} + +function emptyTotalsByCommand(): Record { + return Object.fromEntries(ALL_COMMANDS.map((k) => [k, 0])) as Record< + FunCommandKey, + number + >; +} + +function emptyStore(): FunUsageStoreV1 { + const now = nowIso(); + return { + version: 1, + initializedAt: now, + updatedAt: now, + totalsByUser: {}, + totalsByCommand: emptyTotalsByCommand(), + byUserByCommand: {}, + }; +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +function isRecord(v: unknown): v is Record { + return Boolean(v) && typeof v === "object" && !Array.isArray(v); +} + +function sanitizeTotalsByUser(input: unknown): Record { + if (!isRecord(input)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (typeof v === "number" && Number.isFinite(v) && v >= 0) out[k] = v; + } + return out; +} + +function sanitizeTotalsByCommand(input: unknown): Record { + const base = emptyTotalsByCommand(); + if (!isRecord(input)) return base; + + for (const key of ALL_COMMANDS) { + const v = input[key]; + if (typeof v === "number" && Number.isFinite(v) && v >= 0) base[key] = v; + } + + return base; +} + +function sanitizeByUserByCommand( + input: unknown, +): Record> { + if (!isRecord(input)) return {}; + const out: Record> = {}; + + for (const [userId, rawBreakdown] of Object.entries(input)) { + if (!isRecord(rawBreakdown)) continue; + + const breakdown: Record = emptyTotalsByCommand(); + let any = false; + + for (const cmd of ALL_COMMANDS) { + const v = rawBreakdown[cmd]; + if (typeof v === "number" && Number.isFinite(v) && v > 0) { + breakdown[cmd] = v; + any = true; + } + } + + if (any) out[userId] = breakdown; + } + + return out; +} + +async function loadStore(): Promise { + try { + const raw = await fs.readFile(STORE_PATH, "utf8"); + const parsed = JSON.parse(raw) as unknown; + + if (!isRecord(parsed)) return emptyStore(); + if (parsed.version !== 1) return emptyStore(); + + const base = emptyStore(); + + const initializedAt = + typeof parsed.initializedAt === "string" + ? parsed.initializedAt + : base.initializedAt; + const updatedAt = + typeof parsed.updatedAt === "string" ? parsed.updatedAt : base.updatedAt; + + const totalsByUser = sanitizeTotalsByUser(parsed.totalsByUser); + const totalsByCommand = sanitizeTotalsByCommand(parsed.totalsByCommand); + + // Prefer canonical name + const byUserByCommand = sanitizeByUserByCommand(parsed.byUserByCommand); + + // Backward-compat: if file has breakdownByUser but not byUserByCommand + const legacyBreakdown = sanitizeByUserByCommand(parsed.breakdownByUser); + const finalByUserByCommand = + Object.keys(byUserByCommand).length > 0 ? byUserByCommand : legacyBreakdown; + + return { + ...base, + initializedAt, + updatedAt, + totalsByUser, + totalsByCommand, + byUserByCommand: finalByUserByCommand, + }; + } catch { + return emptyStore(); + } +} + +async function saveStore(store: FunUsageStoreV1): Promise { + await ensureDataDir(); + await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +export async function recordFunUsage(args: { + userId: string; + command: FunCommandKey; +}): Promise { + const { userId, command } = args; + + const store = await loadStore(); + + store.totalsByUser[userId] = (store.totalsByUser[userId] ?? 0) + 1; + store.totalsByCommand[command] = (store.totalsByCommand[command] ?? 0) + 1; + + const existing = store.byUserByCommand[userId] ?? emptyTotalsByCommand(); + existing[command] = (existing[command] ?? 0) + 1; + store.byUserByCommand[userId] = existing; + + store.updatedAt = nowIso(); + + try { + await saveStore(store); + } catch (err) { + logger.error({ err }, "[fun/usage] failed to save fun usage store"); + } +} + +export async function getFunUsageSnapshot(): Promise { + return loadStore(); +} diff --git a/src/services/fun/pollStore.ts b/src/services/fun/pollStore.ts new file mode 100644 index 00000000..1e97f6cb --- /dev/null +++ b/src/services/fun/pollStore.ts @@ -0,0 +1,146 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { logger } from "../../utils/logger.js"; + +export type StoredPoll = { + version: 1; + messageId: string; + channelId: string; + guildId: string | null; + creatorUserId: string; + createdAt: string; + updatedAt: string; + question: string; + options: string[]; + counts: number[]; // same length as options + votesByUser: Record; // userId -> optionIndex +}; + +type PollStoreFileV1 = { + version: 1; + updatedAt: string; + polls: Record; // messageId -> poll +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STORE_PATH = path.join(DATA_DIR, "fun-polls.json"); + +function nowIso(): string { + return new Date().toISOString(); +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +function emptyStore(): PollStoreFileV1 { + return { + version: 1, + updatedAt: nowIso(), + polls: {}, + }; +} + +async function loadStore(): Promise { + try { + const raw = await fs.readFile(STORE_PATH, "utf8"); + const parsed = JSON.parse(raw) as Partial | null; + + if (!parsed || typeof parsed !== "object") return emptyStore(); + if (parsed.version !== 1) return emptyStore(); + + const polls = parsed.polls && typeof parsed.polls === "object" ? parsed.polls : {}; + + return { + version: 1, + updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(), + polls: polls as Record, + }; + } catch { + return emptyStore(); + } +} + +async function saveStore(store: PollStoreFileV1): Promise { + await ensureDataDir(); + await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +export async function createPoll(args: { + messageId: string; + channelId: string; + guildId: string | null; + creatorUserId: string; + question: string; + options: string[]; +}): Promise { + const store = await loadStore(); + + const createdAt = nowIso(); + const poll: StoredPoll = { + version: 1, + messageId: args.messageId, + channelId: args.channelId, + guildId: args.guildId, + creatorUserId: args.creatorUserId, + createdAt, + updatedAt: createdAt, + question: args.question, + options: args.options, + counts: args.options.map(() => 0), + votesByUser: {}, + }; + + store.polls[poll.messageId] = poll; + store.updatedAt = nowIso(); + + try { + await saveStore(store); + } catch (err) { + logger.error({ err }, "[fun/pollStore] failed to save poll store"); + } + + return poll; +} + +export async function getPoll(messageId: string): Promise { + const store = await loadStore(); + return store.polls[messageId] ?? null; +} + +export async function recordVote(args: { + messageId: string; + userId: string; + optionIndex: number; +}): Promise< + | { kind: "ok"; poll: StoredPoll } + | { kind: "alreadyVoted"; previousOptionIndex: number } + | { kind: "notFound" } +> { + const store = await loadStore(); + const poll = store.polls[args.messageId]; + + if (!poll) return { kind: "notFound" }; + + const prev = poll.votesByUser[args.userId]; + if (typeof prev === "number" && Number.isFinite(prev)) { + return { kind: "alreadyVoted", previousOptionIndex: prev }; + } + + poll.votesByUser[args.userId] = args.optionIndex; + + const current = poll.counts[args.optionIndex] ?? 0; + poll.counts[args.optionIndex] = current + 1; + + poll.updatedAt = nowIso(); + store.updatedAt = nowIso(); + store.polls[poll.messageId] = poll; + + try { + await saveStore(store); + } catch (err) { + logger.error({ err }, "[fun/pollStore] failed to save poll store"); + } + + return { kind: "ok", poll }; +}