From e40d188d52ca5b5f299d07b8847591f32c7b169d Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sat, 17 Jan 2026 15:10:37 -0500 Subject: [PATCH 1/5] Adding in fixes --- .env.example | 20 +- data/backup-20260117-145403/src-backup/bot.ts | 150 +++++ .../commands/changelog/changelog.ts | 48 ++ .../src-backup/commands/config/config.ts | 124 ++++ .../src-backup/commands/faq/faq.ts | 173 ++++++ .../commands/faq/subcommands/add.ts | 64 +++ .../commands/faq/subcommands/get.ts | 45 ++ .../commands/faq/subcommands/list.ts | 66 +++ .../commands/faq/subcommands/remove.ts | 110 ++++ .../src-backup/commands/fun/fun.ts | 364 ++++++++++++ .../commands/fun/subcommands/chucknorris.ts | 220 +++++++ .../commands/fun/subcommands/coinflip.ts | 36 ++ .../commands/fun/subcommands/dadjoke.ts | 218 +++++++ .../commands/fun/subcommands/dice.ts | 98 ++++ .../commands/fun/subcommands/java.ts | 21 + .../commands/fun/subcommands/leaderboard.ts | 233 ++++++++ .../commands/fun/subcommands/poll.ts | 211 +++++++ .../commands/fun/subcommands/weather.ts | 142 +++++ .../src-backup/commands/general/ping.ts | 68 +++ .../src-backup/commands/github/gh.ts | 200 +++++++ .../src-backup/commands/github/pr.ts | 56 ++ .../src-backup/commands/github/status.ts | 51 ++ .../src-backup/commands/help/help.ts | 74 +++ .../src-backup/commands/help/helpText.ts | 320 +++++++++++ .../src-backup/commands/history/history.ts | 281 +++++++++ .../commands/pagination/pagination.ts | 199 +++++++ .../src-backup/commands/playback/playback.ts | 179 ++++++ .../src-backup/commands/summary/summary.ts | 153 +++++ .../src-backup/commands/timezone/timezone.ts | 543 ++++++++++++++++++ .../src-backup/config/env.ts | 225 ++++++++ .../src-backup/registerCommands.ts | 130 +++++ .../src-backup/services/cache/simpleCache.ts | 86 +++ .../services/config/guildConfigStore.ts | 83 +++ .../src-backup/services/config/index.ts | 14 + .../src-backup/services/config/types.ts | 43 ++ .../src-backup/services/database/db.ts | 89 +++ .../services/discord/commandLoader.ts | 149 +++++ .../services/discord/commandMeta.ts | 94 +++ .../src-backup/services/discord/cooldowns.ts | 58 ++ .../services/discord/fetchChannelMessages.ts | 69 +++ .../services/discord/interactionHandler.ts | 104 ++++ .../src-backup/services/discord/safeReply.ts | 48 ++ .../src-backup/services/faq/_shared.ts | 238 ++++++++ .../src-backup/services/faq/faqService.ts | 176 ++++++ .../src-backup/services/faq/permissions.ts | 47 ++ .../src-backup/services/faq/services.test.ts | 15 + .../src-backup/services/faq/services.ts | 229 ++++++++ .../src-backup/services/faq/store.test.ts | 115 ++++ .../services/faq/store.test.ts.disabled | 115 ++++ .../src-backup/services/faq/store.ts | 62 ++ .../src-backup/services/faq/store.ts.backup | 57 ++ .../src-backup/services/faq/types.ts | 110 ++++ .../src-backup/services/fun/coinStore.ts | 146 +++++ .../src-backup/services/fun/funUsageStore.ts | 200 +++++++ .../src-backup/services/fun/pollStore.ts | 146 +++++ .../src-backup/services/github/githubApi.ts | 248 ++++++++ .../src-backup/services/github/githubCache.ts | 37 ++ .../services/github/githubClient.ts | 85 +++ .../services/github/githubErrorMessage.ts | 17 + .../services/github/issueAssigneePoller.ts | 334 +++++++++++ .../github/issueAssigneePoller.ts.backup | 334 +++++++++++ .../services/github/lastSeenStore.ts | 136 +++++ .../src-backup/services/github/prFormatter.ts | 53 ++ .../src-backup/services/github/prPoller.ts | 95 +++ .../src-backup/services/github/types.ts | 130 +++++ .../services/roles/autoRoleHandler.ts | 63 ++ .../src-backup/services/summary/llmSummary.ts | 64 +++ .../services/summary/localSummary.ts | 72 +++ .../src-backup/services/summary/summarizer.ts | 16 + .../services/time/formatTimestamp.ts | 21 + .../services/time/validateTimezone.ts | 28 + .../services/timezone/timezoneStore.ts | 144 +++++ .../services/transcript/buildTranscript.ts | 101 ++++ .../services/transcript/defaults.ts | 35 ++ .../src-backup/services/weather/forecast.ts | 274 +++++++++ .../src-backup/services/weather/types.ts | 47 ++ .../services/welcome/welcomeHandler.ts | 86 +++ .../services/welcome/welcomeMessage.ts | 25 + .../src-backup/utils/logger.ts | 49 ++ fix-pr-noise-simple.sh | 79 +++ scripts/migrate-all-data.ts | 159 +++++ src/services/github/issueAssigneePoller.ts | 12 +- .../github/issueAssigneePoller.ts.backup | 330 +++++++++++ 83 files changed, 10377 insertions(+), 12 deletions(-) create mode 100644 data/backup-20260117-145403/src-backup/bot.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/config/config.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/faq/faq.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/fun.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/general/ping.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/github/gh.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/github/pr.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/github/status.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/help/help.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/help/helpText.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/history/history.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/playback/playback.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/summary/summary.ts create mode 100644 data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts create mode 100644 data/backup-20260117-145403/src-backup/config/env.ts create mode 100644 data/backup-20260117-145403/src-backup/registerCommands.ts create mode 100644 data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts create mode 100644 data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts create mode 100644 data/backup-20260117-145403/src-backup/services/config/index.ts create mode 100644 data/backup-20260117-145403/src-backup/services/config/types.ts create mode 100644 data/backup-20260117-145403/src-backup/services/database/db.ts create mode 100644 data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts create mode 100644 data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts create mode 100644 data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts create mode 100644 data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts create mode 100644 data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts create mode 100644 data/backup-20260117-145403/src-backup/services/discord/safeReply.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/_shared.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/faqService.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/permissions.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/services.test.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/services.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.test.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled create mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.ts create mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.ts.backup create mode 100644 data/backup-20260117-145403/src-backup/services/faq/types.ts create mode 100644 data/backup-20260117-145403/src-backup/services/fun/coinStore.ts create mode 100644 data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts create mode 100644 data/backup-20260117-145403/src-backup/services/fun/pollStore.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/githubApi.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/githubCache.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/githubClient.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup create mode 100644 data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/prFormatter.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/prPoller.ts create mode 100644 data/backup-20260117-145403/src-backup/services/github/types.ts create mode 100644 data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts create mode 100644 data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts create mode 100644 data/backup-20260117-145403/src-backup/services/summary/localSummary.ts create mode 100644 data/backup-20260117-145403/src-backup/services/summary/summarizer.ts create mode 100644 data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts create mode 100644 data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts create mode 100644 data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts create mode 100644 data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts create mode 100644 data/backup-20260117-145403/src-backup/services/transcript/defaults.ts create mode 100644 data/backup-20260117-145403/src-backup/services/weather/forecast.ts create mode 100644 data/backup-20260117-145403/src-backup/services/weather/types.ts create mode 100644 data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts create mode 100644 data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts create mode 100644 data/backup-20260117-145403/src-backup/utils/logger.ts create mode 100755 fix-pr-noise-simple.sh create mode 100644 scripts/migrate-all-data.ts create mode 100644 src/services/github/issueAssigneePoller.ts.backup diff --git a/.env.example b/.env.example index 8aeb4d29..6f1d3675 100644 --- a/.env.example +++ b/.env.example @@ -155,19 +155,23 @@ GITHUB_ANNOUNCE_CHANNEL_ID= # Channel for pull request announcements # Receives: # - New PR created -# - PR updated (title, description) -# - PR status changes +# - New PR announcements from prPoller +# +# Note: PR assignee changes and PR status updates are NOT announced +# (filtered out to reduce noise - only Issues trigger these notifications) # # If unset, falls back to GITHUB_ANNOUNCE_CHANNEL_ID GITHUB_PR_ANNOUNCE_CHANNEL_ID= -# Channel for assignee and closure activity +# Channel for Issue activity (assignees and closures) # Receives: -# - Assignee added to issue/PR -# - Assignee removed from issue/PR -# - Self-assignment notifications -# - Issue closed -# - PR closed/merged +# - Assignee added to ISSUE (not PRs) +# - Assignee removed from ISSUE (not PRs) +# - Self-assignment notifications for ISSUES +# - ISSUE closed +# +# Note: PR assignee changes and closures are NOT announced here +# (filtered out to reduce noise) # # If unset, falls back to GITHUB_ANNOUNCE_CHANNEL_ID # If neither is set, assignee polling is disabled diff --git a/data/backup-20260117-145403/src-backup/bot.ts b/data/backup-20260117-145403/src-backup/bot.ts new file mode 100644 index 00000000..b2cf7516 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/bot.ts @@ -0,0 +1,150 @@ +// src/bot.ts +import { initDatabase, closeDatabase } from "./services/database/db.js"; + +import { Client, GatewayIntentBits } from "discord.js"; +import { loadCommands, type CommandClient } from "./services/discord/commandLoader.js"; +import { handleInteraction } from "./services/discord/interactionHandler.js"; +import { pollPullRequestsOnce } from "./services/github/prPoller.js"; +import { pollIssueAssigneesOnce } from "./services/github/issueAssigneePoller.js"; +import { handleAutoRole } from "./services/roles/autoRoleHandler.js"; +import { onGuildMemberAdd } from "./services/welcome/welcomeHandler.js"; +import { env } from "./config/env.js"; +import { logger } from "./utils/logger.js"; + +/** + * Create the Discord client. + * + * Required intents: + * - Guilds: base guild access, slash commands + * - GuildMembers: REQUIRED for guildMemberAdd (welcome messages) + */ +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], +}) as CommandClient; + +/** + * Command registry populated by the command loader. + */ +client.commands = new Map(); + +/** + * Load compiled slash command modules. + */ +await loadCommands(client); + +/** + * Handle slash command interactions. + */ +client.on("interactionCreate", async (interaction) => { + await handleInteraction(interaction, client); +}); + +/** + * Welcome handler for new guild members. + * + * This will ONLY fire if: + * - Server Members Intent is enabled in the portal + * - GatewayIntentBits.GuildMembers is requested here + */ +client.on("guildMemberAdd", async (member) => { + logger.info( + { + guildId: member.guild.id, + userId: member.user.id, + username: member.user.username, + }, + "guildMemberAdd event fired", + ); + + // Auto-assign a default role on join (if configured) + await handleAutoRole(member); + + await onGuildMemberAdd(member); +}); + +/** + * Optional GitHub polling. + * Each stream is enabled only when all required env vars are present. + */ +const githubPrPollingEnabled = env.githubPrPollingEnabled; +const githubAssigneePollingEnabled = env.githubAssigneePollingEnabled; + +/** + * Log once when the bot is ready. + */ +client.once("clientReady", () => { + logger.info("OmegaBot is online"); + + if (githubPrPollingEnabled) { + logger.info( + { + owner: env.githubOwner, + repo: env.githubRepo, + channelId: env.githubPrAnnounceChannelId, + intervalMs: env.githubPollIntervalMs, + }, + "GitHub PR polling enabled (new PRs)", + ); + } else { + logger.info("GitHub PR polling disabled"); + } + + if (githubAssigneePollingEnabled) { + logger.info( + { + owner: env.githubOwner, + repo: env.githubRepo, + channelId: env.githubAssigneeAnnounceChannelId, + intervalMs: env.githubPollIntervalMs, + }, + "GitHub assignee polling enabled (assignee changes)", + ); + } else { + logger.info("GitHub assignee polling disabled"); + } + + if (!githubPrPollingEnabled && !githubAssigneePollingEnabled) { + logger.info("GitHub polling disabled"); + } +}); + +/** + * Start the bot. + */ +initDatabase(); +logger.info("Database initialized"); + +process.on("SIGINT", () => { + logger.info("Shutting down..."); + closeDatabase(); + process.exit(0); +}); + +void client.login(env.token); + +/** + * Schedule GitHub polling (if enabled). + */ +if (githubPrPollingEnabled || githubAssigneePollingEnabled) { + setInterval(() => { + // 1) PR creation polling (new PR detection) + if (githubPrPollingEnabled) { + void pollPullRequestsOnce({ + client, + owner: env.githubOwner!, + repo: env.githubRepo!, + announceChannelId: env.githubPrAnnounceChannelId!, + }); + } + + // 2) Assignee change polling (issues and PRs) + if (githubAssigneePollingEnabled) { + void pollIssueAssigneesOnce({ + client, + owner: env.githubOwner!, + repo: env.githubRepo!, + announceChannelId: env.githubAssigneeAnnounceChannelId!, + }); + } + }, env.githubPollIntervalMs); +} diff --git a/data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts b/data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts new file mode 100644 index 00000000..ac1f7f59 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts @@ -0,0 +1,48 @@ +// src/commands/changelog/changelog.ts + +import fs from "fs"; +import path from "path"; +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; + +/** + * /changelog command + * + * Shows a short preview of CHANGELOG.md from the repo root. + * Intended as a lightweight reference, not a full file dump. + */ +export const data = new SlashCommandBuilder() + .setName("changelog") + .setDescription("Show a preview of CHANGELOG.md"); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const filePath = path.join(process.cwd(), "CHANGELOG.md"); + + if (!fs.existsSync(filePath)) { + logger.warn({ userId: interaction.user.id }, "[changelog] CHANGELOG.md not found"); + + await interaction.editReply("No CHANGELOG.md found at the repo root."); + return; + } + + try { + const preview = fs.readFileSync(filePath, "utf8").split("\n").slice(0, 40).join("\n"); + + await interaction.editReply(`\`\`\`md\n${preview}\n\`\`\``); + + logger.debug({ userId: interaction.user.id }, "[changelog] Preview sent"); + } catch (err) { + logger.error( + { err, userId: interaction.user.id }, + "[changelog] Failed to read CHANGELOG.md", + ); + + await interaction.editReply("Failed to read CHANGELOG.md."); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/config/config.ts b/data/backup-20260117-145403/src-backup/commands/config/config.ts new file mode 100644 index 00000000..f65023e9 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/config/config.ts @@ -0,0 +1,124 @@ +// src/commands/config/config.ts + +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + type ChatInputCommandInteraction, +} from "discord.js"; +import { + setGuildConfig, + getGuildConfig, +} from "../../services/config/guildConfigStore.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Guild configuration command. + * + * This is intentionally small and focused: + * - Set/clear the welcome channel for onboarding messages + * + * Notes: + * - Requires "Manage Server" to prevent random users changing guild config + * - Only allows guild text channels (not DMs, not threads) + */ +export const data = new SlashCommandBuilder() + .setName("config") + .setDescription("Configure OmegaBot settings for this server") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommandGroup((group) => + group + .setName("welcome-channel") + .setDescription("Configure where welcome messages are posted") + .addSubcommand((sub) => + sub + .setName("set") + .setDescription("Set the channel used for welcome messages") + .addChannelOption((opt) => + opt + .setName("channel") + .setDescription("Channel to post welcome messages in") + .setRequired(true) + // Only allow normal guild text channels. + .addChannelTypes(ChannelType.GuildText), + ), + ) + .addSubcommand((sub) => + sub.setName("clear").setDescription("Clear the configured welcome channel"), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction) { + // Only valid inside a guild. + if (!interaction.guildId) { + await interaction.reply({ + content: "This command can only be used in a server (not in DMs).", + ephemeral: true, + }); + return; + } + + const group = interaction.options.getSubcommandGroup(); + const sub = interaction.options.getSubcommand(); + + if (group !== "welcome-channel") { + await interaction.reply({ content: "Unknown config group.", ephemeral: true }); + return; + } + + if (sub === "set") { + const channel = interaction.options.getChannel("channel", true); + + // Extra safety: ensure it’s a guild text channel. + if (channel.type !== ChannelType.GuildText) { + await interaction.reply({ + content: "Please choose a normal text channel (not a thread or DM).", + ephemeral: true, + }); + return; + } + + const updated = setGuildConfig(interaction.guildId, { + welcomeChannelId: channel.id, + welcomeEnabled: true, + }); + + logger.info( + { guildId: interaction.guildId, welcomeChannelId: channel.id }, + "Updated guild welcome channel", + ); + + await interaction.reply({ + content: `✅ Welcome messages will be posted in <#${updated.welcomeChannelId}>.`, + ephemeral: true, + }); + return; + } + + if (sub === "clear") { + const current = getGuildConfig(interaction.guildId); + + // If nothing is set, still respond clearly. + if (!current.welcomeChannelId) { + await interaction.reply({ + content: + "Welcome channel is already not set. I will use the system channel or first text channel as a fallback.", + ephemeral: true, + }); + return; + } + + // Clear configured channel and fall back to system/first text channel. + setGuildConfig(interaction.guildId, { + welcomeChannelId: null, + }); + + logger.info({ guildId: interaction.guildId }, "Cleared guild welcome channel"); + + await interaction.reply({ + content: + "✅ Cleared the welcome channel. I will use the system channel or first text channel as a fallback.", + ephemeral: true, + }); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/faq.ts b/data/backup-20260117-145403/src-backup/commands/faq/faq.ts new file mode 100644 index 00000000..2a0a33f7 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/faq/faq.ts @@ -0,0 +1,173 @@ +// src/commands/faq/faq.ts +// +// Base /faq slash command. +// +// Responsibilities: +// - Define the /faq command and subcommands (Discord API surface) +// - Own the interaction lifecycle (deferReply + final reply) +// - Route execution to subcommand handlers +// +// Non-goals: +// - No FAQ business logic (validation, persistence, permissions, filtering logic) +// - No file I/O +// +// Subcommands live in ./subcommands/*.ts and delegate to src/services/faq/*. + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; + +import { run as runAdd } from "./subcommands/add.js"; +import { run as runGet } from "./subcommands/get.js"; +import { run as runList } from "./subcommands/list.js"; +import { run as runRemove } from "./subcommands/remove.js"; + +/** + * Slash command definition. + * + * This is the public "contract" Discord registers. + * Keep this file focused on: + * - option names/types/descriptions + * - routing to subcommand handlers + */ +export const data = new SlashCommandBuilder() + .setName("faq") + .setDescription("FAQ commands") + + // /faq add + .addSubcommand((s) => + s + .setName("add") + .setDescription("Add a FAQ entry") + .addStringOption((o) => + o.setName("key").setDescription("Unique key").setRequired(true), + ) + .addStringOption((o) => + o.setName("title").setDescription("Short title").setRequired(true), + ) + .addStringOption((o) => + o.setName("body").setDescription("Answer text").setRequired(true), + ) + .addStringOption((o) => + o + .setName("tags") + .setDescription("Comma-separated tags (optional)") + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /faq get + .addSubcommand((s) => + s + .setName("get") + .setDescription("Get a FAQ entry by key") + .addStringOption((o) => + o.setName("key").setDescription("Key to fetch").setRequired(true), + ) + .addBooleanOption((o) => + o.setName("full").setDescription("Show the full answer text").setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /faq list + .addSubcommand((s) => + s + .setName("list") + .setDescription("List FAQ entries") + .addStringOption((o) => + o + .setName("query") + .setDescription("Search in key/title/body (optional)") + .setRequired(false), + ) + .addStringOption((o) => + o.setName("tag").setDescription("Filter by tag (optional)").setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("full") + .setDescription("Show full entries (not just a compact list)") + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /faq remove + .addSubcommand((s) => + s + .setName("remove") + .setDescription("Remove a FAQ entry by key") + .addStringOption((o) => + o.setName("key").setDescription("Key to remove").setRequired(true), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ); + +/** + * Command execution entry point. + * + * Pattern: + * - Determine subcommand + * - Defer reply immediately (avoids the 3s Discord timeout) + * - Route to handler + */ +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); + const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; + + // Parent owns the interaction lifecycle: always defer first. + await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); + + try { + if (sub === "add") { + await runAdd(interaction); + return; + } + + if (sub === "get") { + await runGet(interaction); + return; + } + + if (sub === "list") { + await runList(interaction); + return; + } + + if (sub === "remove") { + await runRemove(interaction); + return; + } + + // Safety net in case Discord sends something unexpected + await interaction.editReply("Unknown subcommand."); + } catch (err) { + logger.error({ err, sub }, "[faq] subcommand failed"); + await interaction.editReply("Something went wrong. Try again in a bit."); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts new file mode 100644 index 00000000..d4e5b1b1 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts @@ -0,0 +1,64 @@ +// src/commands/faq/subcommands/add.ts +// +// /faq add +// +// Responsibilities: +// - Read slash command options +// - Run shared guardrails (permissions, basic input checks) +// - Delegate creation to FAQ service layer +// +// IMPORTANT: +// - This file is NOT a slash command by itself +// - It must NOT call reply() or deferReply() +// - The parent command (faq.ts) owns the interaction lifecycle + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; +import { create } from "../../../services/faq/services.js"; + +import { + guardFaqAction, + parseTags, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + if (!(await guardFaqAction(interaction, "add"))) return; + + const key = interaction.options.getString("key", true).trim(); + const title = interaction.options.getString("title", true).trim(); + const body = interaction.options.getString("body", true).trim(); + const tagsRaw = interaction.options.getString("tags", false); + + // Keep command-layer checks tiny. Service layer is strict. + if (!key) { + await interaction.editReply("❌ Key cannot be empty."); + return; + } + if (!title) { + await interaction.editReply("❌ Title cannot be empty."); + return; + } + if (!body) { + await interaction.editReply("❌ Body cannot be empty."); + return; + } + + const tags = parseTags(tagsRaw); + + const entry = create({ + key, + title, + body, + tags, + actor: interaction.user.id, + }); + + await interaction.editReply(`✅ Added FAQ **${entry.key}**`); + + logger.info({ userId: interaction.user.id, key: entry.key }, "[faq/add] created"); + } catch (err) { + await handleFaqSubcommandError(interaction, err, "[faq/add] failed"); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts new file mode 100644 index 00000000..0095f446 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts @@ -0,0 +1,45 @@ +// src/commands/faq/subcommands/get.ts +// +// /faq get +// +// Responsibilities: +// - Read key +// - Fetch from service layer +// - Increment usage (optional analytics) +// - Format a clean response + +import type { ChatInputCommandInteraction } from "discord.js"; +import { getByKey, incrementUsage } from "../../../services/faq/services.js"; + +import { + guardFaqAction, + readRequiredKey, + formatFaqEntry, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + if (!(await guardFaqAction(interaction, "get"))) return; + + const key = await readRequiredKey(interaction, "key"); + if (!key) return; + + const entry = getByKey(key); + if (!entry) { + await interaction.editReply(`❌ FAQ not found: **${key}**`); + return; + } + + // Optional analytics. If it fails, we still show the FAQ. + try { + incrementUsage(entry.key); + } catch { + // ignore + } + + await interaction.editReply(formatFaqEntry(entry)); + } catch (err) { + await handleFaqSubcommandError(interaction, err, "[faq/get] failed"); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts new file mode 100644 index 00000000..f0e83eff --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts @@ -0,0 +1,66 @@ +// src/commands/faq/subcommands/list.ts +// +// /faq list +// +// Responsibilities: +// - Enforce permissions via guardFaqAction +// - Parse lightweight UI options (query, tag, sort, full, limit) +// - Fetch entries from the FAQ service +// - Delegate filtering + formatting to _shared helpers +// +// Non-goals: +// - No persistence logic (store.ts) +// - No business rules (services.ts) +// +// Notes: +// - Keep this file thin so it stays easy to change the Discord UX later. + +import type { ChatInputCommandInteraction } from "discord.js"; +import { getAll } from "../../../services/faq/services.js"; +import { + guardFaqAction, + formatFaqList, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + // Guardrails first: permissions and consistent error messaging live in _shared.ts + if (!(await guardFaqAction(interaction, "list"))) return; + + // Optional filters. These options must exist in faq.ts builder to work. + const query = interaction.options.getString("query")?.trim() ?? ""; + const tag = interaction.options.getString("tag")?.trim() ?? ""; + + // Small, safe defaults to avoid Discord message spam. + const full = interaction.options.getBoolean("full") ?? false; + const limitRaw = interaction.options.getInteger("limit"); + const limit = clampInt(limitRaw ?? (full ? 5 : 25), 1, full ? 10 : 50); + + // Fetch all entries from the service layer. + // Filtering and sorting happens in formatFaqList (via _shared.ts). + const entries = getAll(); + + const text = formatFaqList(entries, { + query: query.length ? query : undefined, + tag: tag.length ? tag : undefined, + limit, + full, + }); + + await interaction.editReply(text); + } catch (err) { + await handleFaqSubcommandError(interaction, err, "[faq/list] failed"); + } +} + +/** + * Clamp an integer to a safe range. + * Keeps user input from producing huge outputs or weird negatives. + */ +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + if (value < min) return min; + if (value > max) return max; + return value; +} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts new file mode 100644 index 00000000..248a4af9 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts @@ -0,0 +1,110 @@ +// src/commands/faq/subcommands/remove.ts +// +// /faq remove +// +// Responsibilities: +// - Enforce permissions (via shared guard) +// - Confirm intent (button) before deleting +// - Delegate all persistence + rules to the FAQ service layer +// +// IMPORTANT: +// - This file is NOT a slash command by itself +// - It must NOT call reply() or deferReply() +// - The parent command (faq.ts) owns the interaction lifecycle + +import type { ChatInputCommandInteraction, Message, ButtonInteraction } from "discord.js"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js"; + +import { logger } from "../../../utils/logger.js"; +import { getByKey, remove } from "../../../services/faq/services.js"; +import { + guardFaqAction, + readRequiredKey, + handleFaqSubcommandError, +} from "../../../services/faq/_shared.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + // Permissions only make sense in a guild context + if (!interaction.inGuild()) { + // Type assertion needed due to TypeScript narrowing issue + await (interaction as ChatInputCommandInteraction).editReply( + "❌ `/faq remove` can only be used in a server.", + ); + return; + } + + // Centralized permission gate (ManageGuild/Admin or whatever your policy is) + if (!(await guardFaqAction(interaction, "remove"))) return; + + // Read + minimal validate key from options + const rawKey = await readRequiredKey(interaction, "key"); + if (!rawKey) return; + + // Lookup entry (service normalizes internally too, but we keep messaging clean here) + const existing = getByKey(rawKey); + if (!existing) { + await interaction.editReply(`❌ FAQ not found: **${rawKey}**`); + return; + } + + // Build a confirmation UI + const confirmId = `faq:remove:confirm:${existing.key}:${interaction.user.id}`; + const cancelId = `faq:remove:cancel:${existing.key}:${interaction.user.id}`; + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(confirmId) + .setLabel("Delete") + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(cancelId) + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary), + ); + + // Show confirmation prompt (keep it very explicit) + const replied = (await interaction.editReply({ + content: + `🗑️ **Confirm delete**\n` + + `Key: **${existing.key}**\n` + + `Title: **${existing.title}**\n\n` + + `This cannot be undone.`, + components: [row], + })) as unknown as Message; + + // Wait for the requester's button click + const clicked = (await replied.awaitMessageComponent({ + componentType: ComponentType.Button, + time: 20_000, + filter: (i: ButtonInteraction) => i.user.id === interaction.user.id, + })) as ButtonInteraction; + + // Cancel path + if (clicked.customId === cancelId) { + await clicked.update({ + content: "✅ Cancelled. Nothing was deleted.", + components: [], + }); + return; + } + + // Confirm path + const ok = remove(existing.key); + + await clicked.update({ + content: ok + ? `✅ Deleted FAQ **${existing.key}**` + : `❌ Could not delete FAQ **${existing.key}** (it may have already been removed).`, + components: [], + }); + + logger.info( + { userId: interaction.user.id, key: existing.key }, + "[faq/remove] removed", + ); + } catch (err: unknown) { + // No `any` here. Keep the handler strict and log the raw error. + await handleFaqSubcommandError(interaction, err, "[faq/remove] failed"); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/fun.ts b/data/backup-20260117-145403/src-backup/commands/fun/fun.ts new file mode 100644 index 00000000..eb1f5982 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/fun.ts @@ -0,0 +1,364 @@ +// src/commands/fun/fun.ts + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; + +import { run as runChuckNorris } from "./subcommands/chucknorris.js"; +import type { ChuckNorrisMode } from "./subcommands/chucknorris.js"; + +import { run as runDadJoke } from "./subcommands/dadjoke.js"; +import type { DadJokeMode } from "./subcommands/dadjoke.js"; + +import { run as runDice } from "./subcommands/dice.js"; + +import { run as runWeather } from "./subcommands/weather.js"; +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 "../../services/fun/funUsageStore.js"; + +function parseTempUnit(raw: string | null): TempUnit { + return raw?.toLowerCase() === "c" ? "c" : "f"; +} + +export const data = new SlashCommandBuilder() + .setName("fun") + .setDescription("Fun commands") + + // /fun chucknorris + .addSubcommand((s) => + s + .setName("chucknorris") + .setDescription("Chuck Norris facts (random, category, or search)") + .addStringOption((o) => + o.setName("category").setDescription("Category (optional)").setRequired(false), + ) + .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 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 + .setName("dice") + .setDescription("Roll some dice") + .addIntegerOption((o) => + o + .setName("sides") + .setDescription("Number of sides on each die") + .setMinValue(2) + .setMaxValue(100) + .setRequired(false), + ) + .addIntegerOption((o) => + o + .setName("count") + .setDescription("How many dice to roll") + .setMinValue(1) + .setMaxValue(10) + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /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 + .setName("weather") + .setDescription("Weather for a location (includes current conditions)") + .addStringOption((o) => + o + .setName("location") + .setDescription('City, "City, ST", ZIP, etc.') + .setRequired(true), + ) + .addStringOption((o) => + o + .setName("unit") + .setDescription("Temperature unit") + .addChoices({ name: "F", value: "f" }, { name: "C", value: "c" }) + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun weather7 (7-day) + .addSubcommand((s) => + s + .setName("weather7") + .setDescription("7-day forecast (includes current conditions)") + .addStringOption((o) => + o + .setName("location") + .setDescription('City, "City, ST", ZIP, etc.') + .setRequired(true), + ) + .addStringOption((o) => + o + .setName("unit") + .setDescription("Temperature unit") + .addChoices({ name: "F", value: "f" }, { name: "C", value: "c" }) + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .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) { + logger.warn({ err, sub }, "[fun] failed to record usage"); + } +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); + + // Only some subcommands have the "ephemeral" option (poll does not). + const supportsEphemeral = sub !== "poll"; + const ephemeral = supportsEphemeral + ? (interaction.options.getBoolean("ephemeral") ?? false) + : false; + + await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); + + try { + if (sub === "chucknorris") { + const category = interaction.options.getString("category")?.trim(); + const query = interaction.options.getString("query")?.trim(); + + const mode: ChuckNorrisMode = query + ? { kind: "search", query } + : category + ? { kind: "category", category } + : { kind: "random" }; + + await runChuckNorris(interaction, mode); + await maybeRecordUsage(interaction, sub); + 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); + 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") { + const location = interaction.options.getString("location", true).trim(); + const unit = parseTempUnit(interaction.options.getString("unit") ?? "f"); + + const mode: WeatherMode = + sub === "weather" + ? { kind: "daily", location, unit } + : { kind: "7day", location, unit }; + + await runWeather(interaction, mode); + await maybeRecordUsage(interaction, sub); + return; + } + + await interaction.editReply("Unknown subcommand."); + } catch (err) { + logger.error({ err, sub }, "[fun] subcommand failed"); + await interaction.editReply("Something went wrong. Try again in a bit."); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts new file mode 100644 index 00000000..6b3ce038 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts @@ -0,0 +1,220 @@ +// src/commands/fun/subcommands/chucknorris.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +type ChuckNorrisApiResponse = { + value: string; +}; + +type ChuckNorrisSearchResponse = { + total?: number; + result?: ChuckNorrisApiResponse[]; +}; + +export type ChuckNorrisMode = + | { kind: "random" } + | { kind: "category"; category: string } + | { kind: "search"; query: string }; + +/** + * Run handler for /fun chucknorris + * + * 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: ChuckNorrisMode, +): Promise { + try { + const joke = await fetchChuckNorris(mode); + + await interaction.editReply(joke); + + logger.debug( + { userId: interaction.user.id, mode: mode.kind }, + "[fun/chucknorris] sent", + ); + } catch (err) { + const userMessage = toUserMessage(err); + + logger.error({ err, mode, userMessage }, "[fun/chucknorris] failed"); + await interaction.editReply(userMessage); + } +} + +class UserFacingError extends Error { + constructor(message: string) { + super(message); + this.name = "UserFacingError"; + } +} + +function toUserMessage(err: unknown): string { + if (err instanceof UserFacingError) return err.message; + + // Keep the fun fallback, but also provide a tiny bit of “why” for non-user errors. + if (err instanceof Error) { + // Avoid leaking raw internals; give a short category of failure. + if (err.message.toLowerCase().includes("network")) { + return "I couldn’t reach the Chuck Norris API (network issue). Try again in a bit."; + } + if (err.message.toLowerCase().includes("api error")) { + return "The Chuck Norris API returned an error. Try again later."; + } + } + + return "Chuck Norris is currently roundhouse kicking the API. Try again later."; +} + +async function fetchChuckNorris(mode: ChuckNorrisMode): Promise { + if (mode.kind === "random") + return fetchSingle("https://api.chucknorris.io/jokes/random"); + + if (mode.kind === "category") { + const category = mode.category.trim(); + if (!category) { + throw new UserFacingError("Please provide a category."); + } + + const url = `https://api.chucknorris.io/jokes/random?category=${encodeURIComponent( + category, + )}`; + + // fetchSingle throws on non-OK; we intercept 404 here to explain “why”. + try { + return await fetchSingle(url); + } catch (err) { + if (isHttpError(err, 404)) { + const categories = await safeFetchCategories(); + const hint = categories?.length + ? `Valid categories include: ${categories + .slice(0, 12) + .map((c) => `\`${c}\``) + .join(", ")}` + : "Try something like `dev`, `movie`, or `science`."; + + throw new UserFacingError( + `That category was not found: \`${category}\`. ${hint}`, + ); + } + throw err; + } + } + + // mode.kind === "search" + const query = mode.query.trim(); + if (!query) { + throw new UserFacingError("Please provide a search query."); + } + + const url = `https://api.chucknorris.io/jokes/search?query=${encodeURIComponent( + query, + )}`; + + const res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + // For search, a 400 usually means bad query; treat as user-facing + if (res.status === 400) { + throw new UserFacingError( + "That search query didn’t work. Try a simpler word or phrase.", + ); + } + throw new Error(`Chuck Norris API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as ChuckNorrisSearchResponse; + const results = Array.isArray(data.result) ? data.result : []; + + if (!results.length) { + throw new UserFacingError(`No results for \`${query}\`. Try something broader.`); + } + + // Pick a random result so it feels fresh + const pick = results[Math.floor(Math.random() * results.length)]; + const joke = pick?.value; + + if (!joke || typeof joke !== "string") { + throw new Error("Chuck Norris API returned an unexpected response shape"); + } + + return joke.trim(); +} + +async function fetchSingle(url: string): Promise { + let res: Response; + + try { + res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + } catch (err) { + throw new Error( + `Network error calling Chuck Norris API: ${(err as Error)?.message ?? String(err)}`, + ); + } + + if (!res.ok) { + throw new HttpError( + `Chuck Norris API error: ${res.status} ${res.statusText}`, + res.status, + ); + } + + const data = (await res.json()) as ChuckNorrisApiResponse; + + if (!data?.value || typeof data.value !== "string") { + throw new Error("Chuck Norris API returned an unexpected response shape"); + } + + return data.value.trim(); +} + +class HttpError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = "HttpError"; + } +} + +function isHttpError(err: unknown, status: number): boolean { + return err instanceof HttpError && err.status === status; +} + +async function safeFetchCategories(): Promise { + try { + const res = await fetch("https://api.chucknorris.io/jokes/categories", { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) return null; + + const data = (await res.json()) as unknown; + + if (!Array.isArray(data) || !data.every((x) => typeof x === "string")) { + return null; + } + + return data as string[]; + } catch { + return null; + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts new file mode 100644 index 00000000..513c1897 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts @@ -0,0 +1,36 @@ +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function run(interaction: ChatInputCommandInteraction): Promise { + // Parent (fun.ts) owns deferReply(). We only editReply() here. + + // Simple spinning animation + const frames = ["|", "/", "-", "\\", "|", "/", "-", "\\"]; + for (const f of frames) { + await interaction.editReply(`🪙 Flipping ${f}`); + await sleep(140); + } + + // Small pause before reveal + await interaction.editReply("🪙 Tossed…"); + await sleep(260); + + const isHeads = Math.random() < 0.5; + + // Clean final result (no duplicate coin emoji) + const result = isHeads ? "🟡 **HEADS**" : "⚪ **TAILS**"; + + await interaction.editReply(result); + + logger.debug( + { + userId: interaction.user.id, + result: isHeads ? "heads" : "tails", + }, + "[fun/coinflip] result sent", + ); +} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts new file mode 100644 index 00000000..ce1a73a6 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts @@ -0,0 +1,218 @@ +// 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/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts new file mode 100644 index 00000000..6d2f0ed5 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts @@ -0,0 +1,98 @@ +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +/** + * Dice faces for a standard d6. + * Used only when sides === 6. + */ +const D6_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"]; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function clampInt(n: number, min: number, max: number): number { + return Math.min(Math.max(n, min), max); +} + +function rollDie(sides: number): number { + return Math.floor(Math.random() * sides) + 1; +} + +function formatOneRoll(sides: number, roll: number): string { + // If the rolled value is between 1–6, show emoji + number + if (roll >= 1 && roll <= 6) { + const face = D6_FACES[roll - 1] ?? "🎲"; + return `${face} (${roll})`; + } + + // Otherwise just show the number + return `${roll}`; +} + +async function rollAnimation( + interaction: ChatInputCommandInteraction, + sides: number, + count: number, +): Promise { + const frames = 8; + + for (let i = 0; i < frames; i += 1) { + let frameText = ""; + + if (sides === 6) { + // show a few random faces when rolling multiple dice + const shown = Math.min(count, 5); + const faces: string[] = []; + for (let j = 0; j < shown; j += 1) { + faces.push(D6_FACES[Math.floor(Math.random() * D6_FACES.length)] ?? "🎲"); + } + frameText = faces.join(" "); + } else { + // show one changing number for non-d6 + frameText = String(rollDie(sides)); + } + + await interaction.editReply(`🎲 Rolling… ${frameText}`); + await sleep(120); + } +} + +export async function run(interaction: ChatInputCommandInteraction): Promise { + const sidesRaw = interaction.options.getInteger("sides") ?? 6; + const countRaw = interaction.options.getInteger("count") ?? 1; + + const sides = clampInt(sidesRaw, 2, 100); + const count = clampInt(countRaw, 1, 10); + + try { + await rollAnimation(interaction, sides, count); + + const rolls: number[] = []; + for (let i = 0; i < count; i += 1) { + rolls.push(rollDie(sides)); + } + + const total = rolls.reduce((a, b) => a + b, 0); + + // Stacked output like you showed + const display = rolls.map((r) => formatOneRoll(sides, r)).join(", "); + + const lines: string[] = []; + lines.push(`You rolled (d${sides}): ${display}`); + + if (count > 1) { + lines.push(`Total: ${total}`); + } + + await interaction.editReply(lines.join("\n")); + + logger.debug( + { userId: interaction.user.id, sides, count, rolls, total }, + "[fun/dice] roll complete", + ); + } catch (err) { + logger.error({ err }, "[fun/dice] failed"); + await interaction.editReply("🎲 The dice fell off the table. Try again."); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts new file mode 100644 index 00000000..0dc4b976 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/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/data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts new file mode 100644 index 00000000..c0ff550f --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts @@ -0,0 +1,233 @@ +// src/commands/fun/subcommands/leaderboard.ts + +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 PerCommandCounts = Record; +type ByUserByCommand = Record; + +function toCount(v: unknown): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} + +function clampLimit(n: number, min: number, max: number): number { + if (!Number.isFinite(n)) return min; + return Math.max(min, Math.min(max, n)); +} + +function topFromPerCmd( + 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 formatPerCmdInline(perCmd: PerCommandCounts | undefined, limit: number): string { + const top = topFromPerCmd(perCmd, limit); + if (!top.length) return ""; + // Example: "dadjoke 3x, coinflip 2x, chucknorris 1x" + return top.map((x) => `${x.command} ${x.count}x`).join(", "); +} + +function formatPerCmdLines(perCmd: PerCommandCounts | undefined): string[] { + const top = topFromPerCmd(perCmd, 50); + if (!top.length) return ["No per-command data yet."]; + + // Keep per-command breakdown easy to scan + return top.map((x) => `/fun ${x.command} ${x.count}x`); +} + +function rankLabel(idx: number): string { + // 0-based idx + const medals = ["🥇", "🥈", "🥉"]; + if (idx < medals.length) return medals[idx]; + + // 4th+ as emoji digits when possible; fallback to plain number. + const n = idx + 1; + const digitEmoji: Record = { + "0": "0️⃣", + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣", + }; + + const s = String(n); + const allDigits = [...s].every((c) => c >= "0" && c <= "9"); + if (!allDigits) return `${n}.`; + + // Works great up through 9. For 10+ it becomes "1️⃣0️⃣" which is still readable. + return [...s].map((c) => digitEmoji[c] ?? c).join(""); +} + +async function safeFetchUser( + interaction: ChatInputCommandInteraction, + userId: string, +): Promise { + try { + return await interaction.client.users.fetch(userId); + } catch (err) { + logger.debug({ err, userId }, "[fun/leaderboard] failed to fetch user"); + return null; + } +} + +export async function run( + interaction: ChatInputCommandInteraction, + mode: LeaderboardMode, +): Promise { + const snapshot = await getFunUsageSnapshot(); + + // Defensive casts (JSON file can drift) + const totalsByUserRaw = (snapshot as unknown as { totalsByUser?: unknown }) + .totalsByUser; + const totalsByCommandRaw = (snapshot as unknown as { totalsByCommand?: unknown }) + .totalsByCommand; + const byUserByCommandRaw = (snapshot as unknown as { byUserByCommand?: unknown }) + .byUserByCommand; + + const totalsByUser = (totalsByUserRaw ?? {}) as Record; + const totalsByCommand = (totalsByCommandRaw ?? {}) as Record; + const byUserByCommand = (byUserByCommandRaw ?? {}) as ByUserByCommand; + + const anyUserUsage = Object.keys(totalsByUser).length > 0; + const anyCommandUsage = (Object.values(totalsByCommand) as unknown[]).some( + (n) => toCount(n) > 0, + ); + + const updatedAt = + (snapshot as unknown as { updatedAt?: string }).updatedAt ?? "unknown"; + + const embed = new EmbedBuilder().setFooter({ text: `Updated: ${updatedAt}` }); + + if (!anyUserUsage && !anyCommandUsage) { + embed.setTitle("Fun Leaderboard"); + embed.setDescription( + "No fun command usage recorded yet. Try `/fun dadjoke` to get started.", + ); + await interaction.editReply({ embeds: [embed] }); + return; + } + + const limit = clampLimit("limit" in mode ? mode.limit : 10, 1, 25); + + // ---- Top Commands view ---- + if (mode.kind === "commands") { + 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, limit); + + embed.setTitle("Fun Leaderboard: Top Commands"); + + if (!items.length) { + embed.setDescription("No command usage yet."); + await interaction.editReply({ embeds: [embed] }); + return; + } + + // Emoji ranks, no bullets, no dashes + const lines = items.map((x, idx) => `${rankLabel(idx)} /fun ${x.cmd} ${x.count}x`); + embed.setDescription(lines.join("\n")); + await interaction.editReply({ embeds: [embed] }); + return; + } + + // ---- Single User view (avatar + top command) ---- + if (mode.kind === "user") { + const userId = mode.userId; + + const total = toCount(totalsByUser[userId]); + const perCmd = byUserByCommand[userId] ?? {}; + + const user = await safeFetchUser(interaction, userId); + const name = user?.username ?? `User ${userId}`; + + // Better resolution avatar + // - request a larger size (1024 or 2048 is plenty) + // - force png for consistency + const avatar = + user?.displayAvatarURL({ + extension: "png", + size: 2048, + }) ?? null; + + embed.setTitle(`Fun Usage: ${name}`); + if (avatar) embed.setThumbnail(avatar); + + const top1 = topFromPerCmd(perCmd, 1)[0] ?? null; + const topLine = top1 ? `Top command: /fun ${top1.command} ${top1.count}x` : ""; + + const lines = formatPerCmdLines(perCmd); + + // Keep this clean, but still readable + embed.setDescription( + [ + `User: <@${userId}>`, + `Total uses: ${total}x`, + topLine, + "", + "Per-command breakdown:", + ...lines, + ] + .filter(Boolean) + .join("\n"), + ); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + // ---- 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, limit); + + embed.setTitle("Fun Leaderboard: Top Users"); + + if (!userItems.length) { + embed.setDescription("No user usage yet."); + await interaction.editReply({ embeds: [embed] }); + return; + } + + // Emoji ranks + “user X ran command Y this many times” + // No bullets, no dash separators + const out: string[] = []; + + for (let idx = 0; idx < userItems.length; idx += 1) { + const item = userItems[idx]!; + const perCmd = byUserByCommand[item.userId] ?? {}; + const breakdown = formatPerCmdInline(perCmd, 3); + + const prefix = rankLabel(idx); + if (breakdown) { + out.push(`${prefix} <@${item.userId}> ${item.total}x (${breakdown})`); + } else { + out.push(`${prefix} <@${item.userId}> ${item.total}x`); + } + } + + embed.setDescription(out.join("\n")); + await interaction.editReply({ embeds: [embed] }); +} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts new file mode 100644 index 00000000..d48eedc9 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts @@ -0,0 +1,211 @@ +// src/commands/fun/subcommands/poll.ts + +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 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}` }); +} + +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), + ); + } + + 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); + } + + // 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 unique.map((label: string, index: number) => ({ label, index })); +} + +export async function run(interaction: ChatInputCommandInteraction): Promise { + const question = interaction.options.getString("question", true).trim(); + + let opts: PollOption[]; + try { + opts = parseOptions(interaction); + } catch (err) { + const msg = err instanceof Error ? err.message : "Invalid poll options."; + await interaction.editReply(msg); + return; + } + + // 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 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/data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts new file mode 100644 index 00000000..e120bc49 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts @@ -0,0 +1,142 @@ +// src/commands/fun/subcommands/weather.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; +import type { WeatherMode } from "../../../services/weather/types.js"; +import { fetchWeatherBundle } from "../../../services/weather/forecast.js"; + +function weatherEmoji(text: string): string { + const t = text.toLowerCase(); + if (t.includes("thunder")) return "🌩️"; + if (t.includes("snow") || t.includes("sleet") || t.includes("blizzard")) return "❄️"; + if (t.includes("rain") || t.includes("shower") || t.includes("drizzle")) return "🌧️"; + if (t.includes("fog") || t.includes("mist") || t.includes("haze")) return "🌫️"; + if (t.includes("cloud") || t.includes("overcast")) return "☁️"; + if (t.includes("sun") || t.includes("clear")) return "☀️"; + return "🌤️"; +} + +function friendlyWeatherError(err: unknown): string { + const msg = err instanceof Error ? err.message : "Unknown error"; + + if (msg.toLowerCase().includes("missing weatherapi_key")) { + return "Weather is not configured (missing WEATHERAPI_KEY). Ask an admin to set it in `.env`."; + } + + if (msg.toLowerCase().includes("auth error")) { + return "Weather is misconfigured (bad WEATHERAPI_KEY). Ask an admin to fix `.env`."; + } + + if (msg.toLowerCase().includes("rejected the location")) { + return "I could not find that location. Try a ZIP, city, or `City, ST`."; + } + + if (msg.toLowerCase().includes("rate limit")) { + return "WeatherAPI rate limit hit. Try again in a bit."; + } + + return "Weather API is being dramatic. Try again later."; +} + +export async function run( + interaction: ChatInputCommandInteraction, + mode: WeatherMode, +): Promise { + try { + const days = mode.kind === "daily" ? 1 : 7; + + const bundle = await fetchWeatherBundle({ + location: mode.location, + unit: mode.unit, + days, + }); + + const header = `📍 **${bundle.placeLabel}**`; + + const nowBlock: string[] = []; + if (bundle.now) { + const nowEmoji = weatherEmoji(bundle.now.condition); + const wind = bundle.now.wind ? ` • Wind: ${bundle.now.wind}` : ""; + const hum = bundle.now.humidity ? ` • Humidity: ${bundle.now.humidity}` : ""; + const feels = bundle.now.feelsLike ? ` • Feels like: ${bundle.now.feelsLike}` : ""; + + nowBlock.push( + `${nowEmoji} Now: **${bundle.now.temp}** • ${bundle.now.condition}${feels}${wind}${hum}`, + ); + if (bundle.now.asOf) { + nowBlock.push( + `As of: ${bundle.now.asOf}${bundle.tzId ? ` (${bundle.tzId})` : ""}`, + ); + } + } + + if (mode.kind === "daily") { + const today = bundle.days[0]; + if (!today) { + await interaction.editReply(`${header}\nNo forecast data available right now.`); + return; + } + + const emoji = weatherEmoji(today.condition); + const pop = today.pop ? `☔ ${today.pop}` : ""; + const astro = + today.sunrise || today.sunset + ? `Sunrise: ${today.sunrise ?? "N/A"} • Sunset: ${today.sunset ?? "N/A"}` + : ""; + + const lines: string[] = []; + lines.push(header); + lines.push(""); + if (nowBlock.length) { + lines.push(...nowBlock); + lines.push(""); + } + lines.push(`${emoji} **Today**`); + lines.push(`🌡️ ${today.temp}${pop ? ` • ${pop}` : ""}`); + if (astro) lines.push(astro); + + await interaction.editReply(lines.join("\n")); + return; + } + + // 7-day + const lines: string[] = []; + lines.push(header); + lines.push(""); + if (nowBlock.length) { + lines.push(...nowBlock); + lines.push(""); + } + + lines.push("📆 **7-Day Forecast**"); + lines.push(""); + + for (const d of bundle.days.slice(0, 7)) { + const emoji = weatherEmoji(d.condition); + const pop = d.pop ? ` • ☔ ${d.pop}` : ""; + lines.push(`${emoji} **${d.label}**: ${d.temp}${pop}`); + if (d.sunrise || d.sunset) { + lines.push(`Sunrise: ${d.sunrise ?? "N/A"} • Sunset: ${d.sunset ?? "N/A"}`); + } + lines.push(""); + } + + // remove trailing blank line + while (lines.length && lines[lines.length - 1] === "") lines.pop(); + + await interaction.editReply(lines.join("\n")); + + logger.debug( + { + userId: interaction.user.id, + mode: mode.kind, + unit: mode.unit, + location: mode.location, + }, + "[fun/weather] sent", + ); + } catch (err) { + logger.error({ err, mode }, "[fun/weather] failed"); + await interaction.editReply(friendlyWeatherError(err)); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/general/ping.ts b/data/backup-20260117-145403/src-backup/commands/general/ping.ts new file mode 100644 index 00000000..f4d27571 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/general/ping.ts @@ -0,0 +1,68 @@ +import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Defines the /ping command. + * Used to verify that the bot is responding and to measure latency. + */ +export const data = new SlashCommandBuilder() + .setName("ping") + .setDescription("Ping test with latency"); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + try { + /** + * Send the initial reply. + * We avoid deprecated fetchReply option and instead fetch the reply after. + */ + await interaction.reply("Pinging..."); + + /** + * Fetch the bot's reply message so we can compute latency. + */ + const sent = await interaction.fetchReply(); + + /** + * Measure latency: + * - interaction.createdTimestamp is when Discord received the slash command + * - sent.createdTimestamp is when Discord created the bot's response + */ + const latency = sent.createdTimestamp - interaction.createdTimestamp; + + /** + * WebSocket heartbeat is the current ping between the bot and Discord's gateway. + * Lower numbers mean a healthier connection. + */ + const wsPing = interaction.client.ws.ping; + + /** + * Debug-level logging so we can observe bot health without spamming logs. + */ + logger.debug( + { + latency, + wsPing, + userId: interaction.user.id, + }, + "[ping] latency check", + ); + + /** + * Edit the original message to show actual latency numbers. + */ + await interaction.editReply( + `Pong. Round trip latency is ${latency}ms. WebSocket heartbeat is ${wsPing}ms.`, + ); + } catch (err) { + /** + * Extremely unlikely path, but included for consistency with other commands. + */ + logger.error({ err }, "[ping] command failed"); + + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Ping failed unexpectedly."); + } else { + await interaction.reply("Ping failed unexpectedly."); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/github/gh.ts b/data/backup-20260117-145403/src-backup/commands/github/gh.ts new file mode 100644 index 00000000..356d70af --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/github/gh.ts @@ -0,0 +1,200 @@ +// src/commands/github/gh.ts + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { env } from "../../config/env.js"; +import { + getIssue, + listIssues, + listPullRequests, +} from "../../services/github/githubApi.js"; +import { getGitHubUserMessage } from "../../services/github/githubErrorMessage.js"; +import { logger } from "../../utils/logger.js"; + +/** + * /gh command + * GitHub helpers (issues + PRs) + */ +export const data = new SlashCommandBuilder() + .setName("gh") + .setDescription("GitHub helpers") + + .addSubcommand((s) => + s + .setName("status") + .setDescription("Show GitHub integration status for this bot") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user (optional; defaults to env)"), + ) + .addStringOption((o) => + o.setName("repo").setDescription("Repo (optional; defaults to env)"), + ), + ) + + .addSubcommand((s) => + s + .setName("issue") + .setDescription("Fetch a GitHub issue by number") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user").setRequired(true), + ) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) + .addIntegerOption((o) => + o.setName("number").setDescription("Issue #").setRequired(true), + ), + ) + + .addSubcommand((s) => + s + .setName("issues") + .setDescription("List open issues") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user").setRequired(true), + ) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) + .addIntegerOption((o) => + o + .setName("limit") + .setDescription("How many to show (default 5, max 20)") + .setMinValue(1) + .setMaxValue(20), + ), + ) + + .addSubcommand((s) => + s + .setName("prs") + .setDescription("List open pull requests") + .addStringOption((o) => + o.setName("owner").setDescription("Org/user").setRequired(true), + ) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) + .addIntegerOption((o) => + o + .setName("limit") + .setDescription("How many to show (default 5, max 20)") + .setMinValue(1) + .setMaxValue(20), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + if (sub === "status") { + const owner = + interaction.options.getString("owner") ?? env.githubOwner ?? "(unset)"; + const repo = interaction.options.getString("repo") ?? env.githubRepo ?? "(unset)"; + + const lines: string[] = []; + lines.push("**GitHub Status**"); + lines.push(""); + + // Config (never print secrets) + lines.push(`Token: ${env.githubToken ? "configured" : "missing"}`); + lines.push(`Default repo: ${owner}/${repo}`); + lines.push(`Poll interval: ${env.githubPollIntervalMs}ms`); + lines.push(""); + + // Polling streams + lines.push("**Polling streams**"); + lines.push( + `- PR announcements: ${env.githubPrPollingEnabled ? "enabled" : "disabled"}`, + ); + lines.push(` - Channel: ${env.githubPrAnnounceChannelId ?? "(unset)"}`); + lines.push( + `- Assignee activity: ${env.githubAssigneePollingEnabled ? "enabled" : "disabled"}`, + ); + lines.push(` - Channel: ${env.githubAssigneeAnnounceChannelId ?? "(unset)"}`); + + lines.push(""); + lines.push( + "_Tip: If polling is disabled, set GITHUB_TOKEN + GITHUB_OWNER + GITHUB_REPO + the channel env var(s)._", + ); + + await interaction.editReply(lines.join("\n")); + return; + } + + // The remaining subcommands require explicit owner/repo options + const owner = interaction.options.getString("owner", true); + const repo = interaction.options.getString("repo", true); + + if (sub === "issue") { + const number = interaction.options.getInteger("number", true); + const issue = await getIssue(owner, repo, number); + + await interaction.editReply( + [ + `**${owner}/${repo}#${issue.number}**`, + issue.title, + `State: ${issue.state}`, + `Author: ${issue.user?.login ?? "unknown"}`, + issue.html_url, + ].join("\n"), + ); + return; + } + + if (sub === "issues") { + const limit = interaction.options.getInteger("limit") ?? 5; + const issues = await listIssues(owner, repo, { state: "open", limit }); + + if (!issues.length) { + await interaction.editReply("No open issues found."); + return; + } + + await interaction.editReply( + [ + `**Open issues for ${owner}/${repo}**`, + "", + ...issues.map( + (i) => + `#${i.number} ${i.title} (by ${i.user?.login ?? "unknown"})\n${i.html_url}`, + ), + ].join("\n"), + ); + return; + } + + if (sub === "prs") { + const limit = interaction.options.getInteger("limit") ?? 5; + const prs = await listPullRequests(owner, repo, { state: "open", limit }); + + if (!prs.length) { + await interaction.editReply("No open pull requests found."); + return; + } + + await interaction.editReply( + [ + `**Open PRs for ${owner}/${repo}**`, + "", + ...prs.map( + (p) => + `#${p.number} ${p.title} (by ${p.user?.login ?? "unknown"})\n${p.html_url}`, + ), + ].join("\n"), + ); + return; + } + + await interaction.editReply("Unknown subcommand."); + } catch (err) { + const msg = getGitHubUserMessage(err); + if (msg) { + await interaction.editReply(msg); + return; + } + + logger.warn({ err, sub }, "[gh] GitHub request failed"); + await interaction.editReply("GitHub request failed. Please try again in a bit."); + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/github/pr.ts b/data/backup-20260117-145403/src-backup/commands/github/pr.ts new file mode 100644 index 00000000..9d1c60c5 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/github/pr.ts @@ -0,0 +1,56 @@ +// src/commands/github/pr.ts + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { getPullRequest } from "../../services/github/githubApi.js"; +import { getGitHubUserMessage } from "../../services/github/githubErrorMessage.js"; +import { logger } from "../../utils/logger.js"; + +/** + * /pr command + * Fetch a single PR by number. + */ +export const data = new SlashCommandBuilder() + .setName("pr") + .setDescription("Fetch a GitHub pull request by number") + .addIntegerOption((o) => o.setName("number").setDescription("PR #").setRequired(true)) + .addStringOption((o) => o.setName("owner").setDescription("Org/user").setRequired(true)) + .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const owner = interaction.options.getString("owner", true); + const repo = interaction.options.getString("repo", true); + const number = interaction.options.getInteger("number", true); + + try { + const pr = await getPullRequest(owner, repo, number); + + const mergedLabel = pr.merged ? "merged" : "not merged"; + + const body = [ + `**${owner}/${repo} PR #${pr.number}**`, + pr.title, + `State: ${pr.state} (${mergedLabel})`, + `Author: ${pr.user?.login ?? "unknown"}`, + pr.html_url, + ].join("\n"); + + await interaction.editReply(body); + return; + } catch (err) { + const msg = getGitHubUserMessage(err); + if (msg) { + await interaction.editReply(msg); + return; + } + + logger.warn({ err, owner, repo, number }, "[pr] GitHub request failed"); + await interaction.editReply("GitHub request failed. Please try again in a bit."); + return; + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/github/status.ts b/data/backup-20260117-145403/src-backup/commands/github/status.ts new file mode 100644 index 00000000..0f3d8774 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/github/status.ts @@ -0,0 +1,51 @@ +// src/commands/github/status.ts + +import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +/** + * /github status command + * + * Reports current GitHub-related configuration and which GitHub pollers are enabled. + * This command does not call the GitHub API. + */ +export const data = new SlashCommandBuilder() + .setName("status") + .setDescription("Show GitHub integration status for this bot"); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + try { + const lines: string[] = []; + + // High level config (do not print secrets) + lines.push("**GitHub Status**"); + lines.push(`Repo: ${env.githubOwner ?? "(unset)"}/${env.githubRepo ?? "(unset)"}`); + lines.push(`Token: ${env.githubToken ? "present" : "missing"}`); + lines.push(`Poll interval: ${env.githubPollIntervalMs} ms`); + lines.push(""); + + // PR polling + lines.push("**PR announcements**"); + lines.push(`Enabled: ${env.githubPrPollingEnabled ? "yes" : "no"}`); + lines.push(`Channel: ${env.githubPrAnnounceChannelId ?? "(unset)"}`); + lines.push(""); + + // Assignee/issue activity polling + lines.push("**Assignee + issue activity**"); + lines.push(`Enabled: ${env.githubAssigneePollingEnabled ? "yes" : "no"}`); + lines.push(`Channel: ${env.githubAssigneeAnnounceChannelId ?? "(unset)"}`); + + await interaction.reply({ content: lines.join("\n"), ephemeral: true }); + } catch (err) { + logger.error({ err }, "[/status] failed"); + + // Best-effort reply (avoid throwing twice) + const msg = "Something went wrong while building GitHub status."; + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content: msg }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/help/help.ts b/data/backup-20260117-145403/src-backup/commands/help/help.ts new file mode 100644 index 00000000..873a0b2d --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/help/help.ts @@ -0,0 +1,74 @@ +// src/commands/help/help.ts + +import { + SlashCommandBuilder, + PermissionFlagsBits, + type ChatInputCommandInteraction, +} from "discord.js"; +import { buildHelpText, type HelpTopic } from "./helpText.js"; +import { + extractCommandList, + type CommandListItem, +} from "../../services/discord/commandMeta.js"; + +/** + * /help + * + * Uses topics to keep output readable and under Discord limits. + */ +export const data = new SlashCommandBuilder() + .setName("help") + .setDescription("Show what OmegaBot can do and how to get started") + .addStringOption((o) => + o + .setName("topic") + .setDescription("Choose a help topic") + .setRequired(false) + .addChoices( + { name: "Overview", value: "overview" }, + { name: "Fun", value: "fun" }, + { name: "GitHub", value: "github" }, + { name: "Summary", value: "summary" }, + { name: "Timezone", value: "timezone" }, + { name: "Admin", value: "admin" }, + { name: "Commands", value: "commands" }, + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const isAdmin = + interaction.inGuild() && + Boolean(interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)); + + const commands: CommandListItem[] = extractCommandList(interaction.client); + + const rawTopic = interaction.options.getString("topic") ?? "overview"; + + // Keep runtime safe: only allow known topics + const allowedTopics: HelpTopic[] = [ + "overview", + "fun", + "github", + "summary", + "timezone", + "admin", + "commands", + ]; + + const topic: HelpTopic = allowedTopics.includes(rawTopic as HelpTopic) + ? (rawTopic as HelpTopic) + : "overview"; + + // If someone requests admin help but isn't admin, still show the admin topic + // (it will explain they need Manage Server) + const text = buildHelpText({ + isAdmin, + commands, + topic, + }); + + await interaction.reply({ + content: text, + ephemeral: true, + }); +} diff --git a/data/backup-20260117-145403/src-backup/commands/help/helpText.ts b/data/backup-20260117-145403/src-backup/commands/help/helpText.ts new file mode 100644 index 00000000..971a2419 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/help/helpText.ts @@ -0,0 +1,320 @@ +// src/commands/help/helpText.ts + +import type { CommandListItem } from "../../services/discord/commandMeta.js"; + +export type HelpTopic = + | "overview" + | "fun" + | "github" + | "summary" + | "timezone" + | "admin" + | "commands"; + +/** + * Help text builder for /help. + * + * Goal: stay readable and under Discord limits by splitting into topics. + * Style: no emojis on the left, no " - " separators. + */ +export function buildHelpText(args: { + isAdmin: boolean; + commands: CommandListItem[]; + topic: HelpTopic; +}): string { + const { isAdmin, commands, topic } = args; + + switch (topic) { + case "fun": + return buildFunHelp(); + + case "github": + return buildGitHubHelp(); + + case "summary": + return buildSummaryHelp(); + + case "timezone": + return buildTimezoneHelp(); + + case "admin": + return buildAdminHelp({ isAdmin }); + + case "commands": + return buildCommandsHelp({ isAdmin, commands }); + + case "overview": + default: + return buildOverviewHelp({ isAdmin }); + } +} + +function buildOverviewHelp(args: { isAdmin: boolean }): string { + const { isAdmin } = args; + + const lines: string[] = []; + + lines.push("**OmegaBot Help**"); + lines.push(""); + 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("**Topics**"); + lines.push("Use `/help topic:`"); + lines.push( + `overview, fun, github, summary, timezone${isAdmin ? ", admin" : ""}, commands`, + ); + lines.push(""); + lines.push("**Quick picks**"); + lines.push("`/fun dadjoke` Random dad joke"); + lines.push("`/fun poll` Create a quick poll"); + lines.push("`/fun weather` Weather for a location"); + lines.push("`/gh status` Check GitHub integration status"); + lines.push("`/summary` Summarize recent messages"); + 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.", + ); + + return lines.join("\n"); +} + +function buildFunHelp(): string { + const lines: string[] = []; + + lines.push("**Help: Fun**"); + lines.push(""); + lines.push("All fun commands live under `/fun`."); + lines.push( + "Most fun commands support `ephemeral:true` to only show the result to you.", + ); + lines.push(""); + + lines.push("**Chuck Norris**"); + lines.push("`/fun chucknorris`"); + lines.push("Examples"); + lines.push("`/fun chucknorris`"); + lines.push("`/fun chucknorris category:dev`"); + lines.push("`/fun chucknorris query:roundhouse`"); + lines.push("`/fun chucknorris query:docker ephemeral:true`"); + lines.push(""); + + lines.push("**Dad Joke**"); + lines.push("`/fun dadjoke`"); + lines.push("Examples"); + lines.push("`/fun dadjoke`"); + lines.push("`/fun dadjoke query:coffee`"); + lines.push("`/fun dadjoke query:kubernetes ephemeral:true`"); + lines.push(""); + + lines.push("**Coin Flip**"); + lines.push("`/fun coinflip`"); + lines.push("Examples"); + lines.push("`/fun coinflip`"); + lines.push("`/fun coinflip ephemeral:true`"); + lines.push(""); + + lines.push("**Dice**"); + lines.push("`/fun dice`"); + lines.push("Options"); + lines.push("`sides` 2–100 (default 6)"); + lines.push("`count` 1–10 (default 1)"); + lines.push("Examples"); + lines.push("`/fun dice`"); + lines.push("`/fun dice sides:20`"); + lines.push("`/fun dice sides:6 count:10`"); + lines.push(""); + + lines.push("**Poll**"); + lines.push("`/fun poll`"); + lines.push("Notes"); + lines.push("2–4 options"); + lines.push("One vote per user"); + lines.push("Examples"); + lines.push("`/fun poll question:Best pizza? option1:NY option2:Chicago`"); + lines.push("`/fun poll question:Tonight? option1:R6 option2:Netflix option3:Gym`"); + lines.push(""); + + lines.push("**Weather**"); + lines.push("`/fun weather` Current conditions + today"); + lines.push("`/fun weather7` Current conditions + 7-day forecast"); + lines.push("Options"); + lines.push("`location` required"); + lines.push("`unit` f or c (default f)"); + lines.push("Examples"); + lines.push("`/fun weather location:Sharon, MA`"); + lines.push("`/fun weather location:Boston, MA unit:c`"); + lines.push("`/fun weather7 location:02110`"); + lines.push(""); + + lines.push("**Leaderboard**"); + lines.push("`/fun leaderboard`"); + lines.push("Options"); + lines.push("`view` users | commands | user (default users)"); + lines.push("`user` only used when view:user (defaults to you)"); + lines.push("`limit` 1–25 (default 10)"); + lines.push("Examples"); + lines.push("`/fun leaderboard`"); + lines.push("`/fun leaderboard view:commands limit:10`"); + lines.push("`/fun leaderboard view:user`"); + lines.push("`/fun leaderboard view:user user:@Someone`"); + + return lines.join("\n"); +} + +function buildGitHubHelp(): string { + const lines: string[] = []; + + lines.push("**Help: GitHub**"); + lines.push(""); + 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 integration status (config, polling, channels)"); + lines.push("`/pr` Fetch a single pull request by number (legacy shortcut)"); + lines.push(""); + lines.push("Tip: if `/gh status` shows misconfiguration, check `.env` and setup docs."); + + return lines.join("\n"); +} + +function buildSummaryHelp(): string { + const lines: string[] = []; + + lines.push("**Help: Summary & History**"); + lines.push(""); + 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"); + + return lines.join("\n"); +} + +function buildTimezoneHelp(): string { + const lines: string[] = []; + + lines.push("**Help: Timezone**"); + lines.push(""); + lines.push( + "Save your timezone once, then compare times with other users or locations.", + ); + lines.push( + "We show both the IANA timezone and an offset like UTC-05:00 when possible.", + ); + lines.push(""); + + lines.push("**Commands**"); + lines.push("`/timezone set` Save your IANA timezone"); + lines.push("`/timezone show` Display your current saved timezone"); + lines.push("`/timezone clear` Remove your saved timezone"); + lines.push("`/timezone now` Show the current time in a timezone or location"); + lines.push("`/timezone convert` Convert a time from one timezone to another"); + lines.push("`/timezone compare` Compare your time with another user (if both set)"); + lines.push(""); + + lines.push("**Examples**"); + lines.push("`/timezone set tz:America/New_York`"); + lines.push("`/timezone show`"); + lines.push("`/timezone now tz:America/Los_Angeles`"); + lines.push("`/timezone now location:02110`"); + lines.push("`/timezone convert time:14:30 from:America/New_York to:America/Chicago`"); + lines.push("`/timezone compare user:@Someone`"); + + return lines.join("\n"); +} + +function buildAdminHelp(args: { isAdmin: boolean }): string { + const { isAdmin } = args; + + const lines: string[] = []; + + lines.push("**Help: Admin**"); + lines.push(""); + + if (isAdmin) { + lines.push("Welcome config"); + lines.push("`/config welcome-channel set channel:#your-channel`"); + lines.push("`/config welcome-channel clear`"); + lines.push(""); + lines.push("Notes"); + lines.push("You need Manage Server to run admin config commands."); + } else { + lines.push("You do not have Manage Server permissions."); + lines.push("Ask a server admin to configure the welcome channel:"); + lines.push("`/config welcome-channel set channel:#your-channel`"); + } + + return lines.join("\n"); +} + +function buildCommandsHelp(args: { + isAdmin: boolean; + commands: CommandListItem[]; +}): string { + const { isAdmin, commands } = args; + + const lines: string[] = []; + + lines.push("**Help: Commands**"); + lines.push(""); + lines.push("Sanity list of top-level commands currently loaded."); + lines.push(""); + + const pretty = formatCommandList(commands, { isAdmin }); + + if (!pretty.length) { + lines.push("No commands found."); + lines.push("If this is unexpected, check your command loader and build output."); + return lines.join("\n"); + } + + lines.push(...pretty); + + return lines.join("\n"); +} + +function formatCommandList( + commands: CommandListItem[], + opts: { isAdmin: boolean }, +): string[] { + if (!commands.length) return []; + + const visible = commands.filter((c) => (opts.isAdmin ? true : !c.adminOnly)); + + visible.sort((a, b) => { + const g = a.group.localeCompare(b.group); + if (g !== 0) return g; + return a.name.localeCompare(b.name); + }); + + const out: string[] = []; + let currentGroup: string | null = null; + + for (const cmd of visible) { + const groupLabel = titleCase(cmd.group); + + if (currentGroup !== groupLabel) { + currentGroup = groupLabel; + out.push(""); + out.push(`**${currentGroup}**`); + } + + const desc = cmd.description ? ` ${cmd.description}` : ""; + out.push(`/${cmd.name}${desc ? ` ${desc}` : ""}`); + } + + while (out.length && out[0] === "") out.shift(); + return out; +} + +function titleCase(s: string): string { + if (!s) return "Other"; + return s + .split(/[-_\s]+/g) + .filter(Boolean) + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(" "); +} diff --git a/data/backup-20260117-145403/src-backup/commands/history/history.ts b/data/backup-20260117-145403/src-backup/commands/history/history.ts new file mode 100644 index 00000000..5d3a975a --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/history/history.ts @@ -0,0 +1,281 @@ +// src/services/weather/forecast.ts + +import { logger } from "../../utils/logger.js"; +import type { TempUnit } from "../../services/weather/types.js"; + +type WeatherApiError = { + error?: { + code?: number; + message?: string; + }; +}; + +type WeatherApiResponse = { + location?: { + name?: string; + region?: string; + country?: string; + tz_id?: string; + localtime?: string; // "2026-01-16 10:18" + }; + current?: { + last_updated?: string; // "2026-01-16 10:15" + temp_f?: number; + temp_c?: number; + condition?: { text?: string }; + wind_mph?: number; + wind_kph?: number; + wind_dir?: string; + humidity?: number; + feelslike_f?: number; + feelslike_c?: number; + }; + forecast?: { + forecastday?: Array<{ + date?: string; // "2026-01-16" + day?: { + maxtemp_f?: number; + maxtemp_c?: number; + mintemp_f?: number; + mintemp_c?: number; + daily_chance_of_rain?: number; + daily_chance_of_snow?: number; + condition?: { text?: string }; + }; + astro?: { + sunrise?: string; + sunset?: string; + }; + }>; + }; +}; + +// Safe “unwrap optional forecast.forecastday” type +type ForecastDay = NonNullable< + NonNullable["forecastday"] +>[number]; + +export type WeatherNow = { + temp: string; + feelsLike?: string; + condition: string; + asOf?: string; + humidity?: string; + wind?: string; +}; + +export type WeatherDay = { + label: string; + temp: string; + pop?: string; + condition: string; + sunrise?: string; + sunset?: string; +}; + +export type WeatherBundle = { + placeLabel: string; + tzId?: string; + localTime?: string; + now?: WeatherNow; + days: WeatherDay[]; +}; + +function requireWeatherApiKey(): string { + const key = process.env.WEATHERAPI_KEY?.trim(); + if (!key) { + throw new Error("Missing WEATHERAPI_KEY. Set it in .env (WEATHERAPI_KEY=...)."); + } + return key; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +function readApiErrorMessage(data: unknown): string | null { + const root = isRecord(data) ? data : null; + const err = + root && isRecord(root.error) ? (root.error as WeatherApiError["error"]) : null; + const msg = err && typeof err.message === "string" ? err.message : null; + return msg ?? null; +} + +function pickTemp(unit: TempUnit, f?: number, c?: number): string | null { + if (unit === "c") { + return typeof c === "number" && Number.isFinite(c) ? `${Math.round(c)}°C` : null; + } + return typeof f === "number" && Number.isFinite(f) ? `${Math.round(f)}°F` : null; +} + +function formatWind( + unit: TempUnit, + dir?: string, + mph?: number, + kph?: number, +): string | null { + const d = dir?.trim(); + + if (unit === "c") { + if (typeof kph === "number" && Number.isFinite(kph)) { + return d ? `${d} ${Math.round(kph)} kph` : `${Math.round(kph)} kph`; + } + return null; + } + + if (typeof mph === "number" && Number.isFinite(mph)) { + return d ? `${d} ${Math.round(mph)} mph` : `${Math.round(mph)} mph`; + } + return null; +} + +function buildPlaceLabel(loc: WeatherApiResponse["location"] | undefined): string { + const name = loc?.name?.trim(); + const region = loc?.region?.trim(); + const country = loc?.country?.trim(); + + const bits = [name, region].filter(Boolean); + if (bits.length) return bits.join(", "); + + return country ? country : "Unknown location"; +} + +function dailyPopString(item: ForecastDay | undefined): string | null { + if (!item?.day) return null; + + const rain = item.day.daily_chance_of_rain; + const snow = item.day.daily_chance_of_snow; + + const r = typeof rain === "number" && Number.isFinite(rain) ? rain : null; + const s = typeof snow === "number" && Number.isFinite(snow) ? snow : null; + + const best = [r, s] + .filter((x) => x !== null) + .sort((a, b) => (b as number) - (a as number))[0] as number | undefined; + + return typeof best === "number" ? `${Math.round(best)}%` : null; +} + +/** + * Fetch current + forecast (and astro) from WeatherAPI.com + * Uses /forecast.json which includes current + forecast days. + */ +export async function fetchWeatherBundle(args: { + location: string; + unit: TempUnit; + days: number; // 1..10 (WeatherAPI plan dependent) +}): Promise { + const key = requireWeatherApiKey(); + + const q = args.location.trim(); + if (!q) { + throw new Error("Location cannot be empty."); + } + + const safeDays = Math.max(1, Math.min(args.days, 10)); + + const url = + `https://api.weatherapi.com/v1/forecast.json` + + `?key=${encodeURIComponent(key)}` + + `&q=${encodeURIComponent(q)}` + + `&days=${encodeURIComponent(String(safeDays))}` + + `&aqi=no&alerts=no`; + + const res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + const text = await res.text(); + let data: unknown = null; + try { + data = JSON.parse(text) as unknown; + } catch { + data = null; + } + + if (!res.ok) { + const msg = readApiErrorMessage(data) ?? res.statusText ?? "Unknown error"; + + if (res.status === 401 || res.status === 403) { + throw new Error( + `WeatherAPI auth error (${res.status}). Check WEATHERAPI_KEY. ${msg}`, + ); + } + if (res.status === 400) { + throw new Error(`WeatherAPI rejected the location. ${msg}`); + } + if (res.status === 429) { + throw new Error("WeatherAPI rate limit hit. Try again in a bit."); + } + + throw new Error(`WeatherAPI error (${res.status}): ${msg}`); + } + + const parsed = (data ?? {}) as WeatherApiResponse; + + const placeLabel = buildPlaceLabel(parsed.location); + const tzId = parsed.location?.tz_id; + const localTime = parsed.location?.localtime; + + const nowTemp = pickTemp(args.unit, parsed.current?.temp_f, parsed.current?.temp_c); + const feels = pickTemp( + args.unit, + parsed.current?.feelslike_f, + parsed.current?.feelslike_c, + ); + const cond = parsed.current?.condition?.text?.trim() ?? "Unknown"; + + const now: WeatherNow | undefined = nowTemp + ? { + temp: nowTemp, + feelsLike: feels ?? undefined, + condition: cond, + asOf: parsed.current?.last_updated, + humidity: + typeof parsed.current?.humidity === "number" + ? `${parsed.current.humidity}%` + : undefined, + wind: + formatWind( + args.unit, + parsed.current?.wind_dir, + parsed.current?.wind_mph, + parsed.current?.wind_kph, + ) ?? undefined, + } + : undefined; + + const fd = parsed.forecast?.forecastday ?? []; + const days: WeatherDay[] = []; + + for (let i = 0; i < fd.length; i += 1) { + const item = fd[i]; + const label = i === 0 ? "Today" : (item.date ?? `Day ${i + 1}`); + + const max = pickTemp(args.unit, item.day?.maxtemp_f, item.day?.maxtemp_c); + const min = pickTemp(args.unit, item.day?.mintemp_f, item.day?.mintemp_c); + const temp = max && min ? `${min} to ${max}` : (max ?? min ?? "N/A"); + + const condition = item.day?.condition?.text?.trim() ?? "Forecast unavailable"; + const pop = dailyPopString(item) ?? undefined; + + days.push({ + label, + temp, + pop, + condition, + sunrise: item.astro?.sunrise, + sunset: item.astro?.sunset, + }); + } + + logger.debug( + { q, placeLabel, tzId, localTime, days: days.length }, + "[weather] weather bundle fetched", + ); + + return { placeLabel, tzId, localTime, now, days }; +} diff --git a/data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts b/data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts new file mode 100644 index 00000000..830140fe --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts @@ -0,0 +1,199 @@ +// src/commands/pagination/pagination.ts + +import { + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; + +/** + * /pagination command + * MVP: fetch recent user messages, format into a transcript, split into pages, + * then let the requester page through it with buttons. + */ +export const data = new SlashCommandBuilder() + .setName("pagination") + .setDescription("Page through recent messages in this channel") + .addIntegerOption((opt) => + opt + .setName("count") + .setDescription("How many messages to fetch") + .setMinValue(10) + .setMaxValue(100), + ); + +const MAX_PAGE_CHARS = 1800; // leave room for header + safety +const COLLECTOR_MS = 90_000; + +function chunkByChars(text: string, maxChars: number): string[] { + const lines = text.split("\n"); + const pages: string[] = []; + let current = ""; + + for (const line of lines) { + // +1 for newline we may add + const nextLen = current.length + (current ? 1 : 0) + line.length; + + // If a single line is huge, hard-split it + if (!current && line.length > maxChars) { + for (let i = 0; i < line.length; i += maxChars) { + pages.push(line.slice(i, i + maxChars)); + } + continue; + } + + if (nextLen > maxChars) { + if (current) pages.push(current); + current = line; + continue; + } + + current = current ? `${current}\n${line}` : line; + } + + if (current) pages.push(current); + return pages.length ? pages : ["(no content)"]; +} + +function buildRow(pageIndex: number, pageCount: number, disabledAll = false) { + const prev = new ButtonBuilder() + .setCustomId("pagination_prev") + .setLabel("Prev") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabledAll || pageIndex <= 0); + + const next = new ButtonBuilder() + .setCustomId("pagination_next") + .setLabel("Next") + .setStyle(ButtonStyle.Primary) + .setDisabled(disabledAll || pageIndex >= pageCount - 1); + + const stop = new ButtonBuilder() + .setCustomId("pagination_stop") + .setLabel("Stop") + .setStyle(ButtonStyle.Danger) + .setDisabled(disabledAll); + + return new ActionRowBuilder().addComponents(prev, next, stop); +} + +function renderPage(pages: string[], pageIndex: number) { + const header = `**Playback** (page ${pageIndex + 1}/${pages.length})`; + return `${header}\n\n${pages[pageIndex]}`; +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const count = interaction.options.getInteger("count") ?? 50; + + try { + // Not ephemeral: buttons + paging is easier as a normal message. + await interaction.deferReply(); + + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support pagination."); + return; + } + + const messages = await interaction.channel.messages.fetch({ limit: count }); + + const userMessages = messages + .filter((m) => !m.author.bot && m.content) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + if (userMessages.size === 0) { + await interaction.editReply("No usable messages found."); + return; + } + + const transcript = userMessages + .map((m) => `${m.author.username}: ${m.content}`) + .join("\n"); + + const pages = chunkByChars(transcript, MAX_PAGE_CHARS); + let pageIndex = 0; + + const replyMessage = await interaction.editReply({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length)], + }); + + const collector = replyMessage.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: COLLECTOR_MS, + filter: (i) => i.user.id === interaction.user.id, + }); + + collector.on("collect", async (i) => { + try { + if (i.customId === "pagination_stop") { + collector.stop("stopped"); + await i.update({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length, true)], + }); + return; + } + + if (i.customId === "pagination_prev") { + pageIndex = Math.max(0, pageIndex - 1); + } + + if (i.customId === "pagination_next") { + pageIndex = Math.min(pages.length - 1, pageIndex + 1); + } + + await i.update({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length)], + }); + } catch (err) { + logger.warn( + { err, userId: interaction.user.id }, + "[pagination] button update failed", + ); + } + }); + + collector.on("end", async (collected, reason) => { + try { + await replyMessage.edit({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length, true)], + }); + + logger.debug( + { + reason, + clicks: collected.size, + userId: interaction.user.id, + }, + "[pagination] collector ended", + ); + } catch (err) { + logger.warn({ err }, "[pagination] failed to disable buttons"); + } + }); + } catch (err) { + logger.error( + { err, command: "pagination", userId: interaction.user.id }, + "[pagination] command failed", + ); + + try { + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong during pagination."); + } else { + await interaction.reply("Something went wrong during pagination."); + } + } catch (replyErr) { + logger.error( + { err: replyErr }, + "[pagination] failed to send fallback error message", + ); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/playback/playback.ts b/data/backup-20260117-145403/src-backup/commands/playback/playback.ts new file mode 100644 index 00000000..f1aef268 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/playback/playback.ts @@ -0,0 +1,179 @@ +// src/commands/playback/playback.ts + +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; +import { buildTranscript } from "../../services/transcript/buildTranscript.js"; +import { + HISTORY_DEFAULTS, + DISCORD_SAFE_TEXT_LIMIT, +} from "../../services/transcript/defaults.js"; +import { logger } from "../../utils/logger.js"; + +function chunkText(text: string, maxChars: number): string[] { + if (text.length <= maxChars) return [text]; + + const lines = text.split("\n"); + const chunks: string[] = []; + + let buf = ""; + for (const line of lines) { + const next = buf.length === 0 ? line : `${buf}\n${line}`; + if (next.length > maxChars) { + chunks.push(buf); + buf = line; + } else { + buf = next; + } + } + if (buf) chunks.push(buf); + + return chunks; +} + +export const data = new SlashCommandBuilder() + .setName("playback") + .setDescription("Page through recent messages with buttons") + .addIntegerOption((opt) => + opt + .setName("count") + .setDescription("How many messages to fetch") + .setMinValue(10) + .setMaxValue(100), + ) + .addStringOption((opt) => + opt + .setName("before") + .setDescription("Message ID: show messages before this ID") + .setRequired(false), + ) + .addStringOption((opt) => + opt + .setName("after") + .setDescription("Message ID: show messages after this ID") + .setRequired(false), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const count = interaction.options.getInteger("count") ?? 50; + const before = interaction.options.getString("before") ?? undefined; + const after = interaction.options.getString("after") ?? undefined; + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support playback."); + return; + } + + const msgs = await fetchChannelMessages(interaction.channel, { + count, + before, + after, + }); + + if (msgs.length === 0) { + await interaction.editReply("No usable messages found for playback."); + return; + } + + const transcript = buildTranscript(msgs, { + ...HISTORY_DEFAULTS, + // For pagination, do not truncate by maxChars here, we chunk instead. + maxChars: undefined, + maxLines: undefined, + }); + + const pages = chunkText(transcript.text, DISCORD_SAFE_TEXT_LIMIT); + + let index = 0; + + const makeRow = (i: number) => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("pb_prev") + .setStyle(ButtonStyle.Secondary) + .setLabel("Prev") + .setDisabled(i <= 0), + new ButtonBuilder() + .setCustomId("pb_next") + .setStyle(ButtonStyle.Secondary) + .setLabel("Next") + .setDisabled(i >= pages.length - 1), + new ButtonBuilder() + .setCustomId("pb_close") + .setStyle(ButtonStyle.Danger) + .setLabel("Close"), + ); + + await interaction.editReply({ + content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, + components: [makeRow(index)], + }); + + const msg = await interaction.fetchReply(); + + const collector = msg.createMessageComponentCollector({ + time: 5 * 60_000, + filter: (i) => i.user.id === interaction.user.id, + }); + + collector.on("collect", async (btn) => { + try { + if (btn.customId === "pb_close") { + collector.stop("closed"); + await btn.update({ content: "Playback closed.", components: [] }); + return; + } + + if (btn.customId === "pb_prev") index = Math.max(0, index - 1); + if (btn.customId === "pb_next") index = Math.min(pages.length - 1, index + 1); + + await btn.update({ + content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, + components: [makeRow(index)], + }); + } catch (err) { + logger.warn( + { err, userId: interaction.user.id }, + "[playback] button update failed", + ); + } + }); + + collector.on("end", async () => { + try { + // disable buttons after timeout + await interaction.editReply({ components: [] }); + } catch (err) { + // ignore + logger.debug({ err }, "[playback] cleanup after collector end failed"); + } + }); + } catch (err) { + logger.error( + { err, command: "playback", userId: interaction.user.id }, + "[playback] command failed", + ); + + try { + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong during playback."); + } else { + await interaction.reply({ + content: "Something went wrong during playback.", + flags: MessageFlags.Ephemeral, + }); + } + } catch (replyErr) { + logger.error({ err: replyErr }, "[playback] failed to send fallback error message"); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/summary/summary.ts b/data/backup-20260117-145403/src-backup/commands/summary/summary.ts new file mode 100644 index 00000000..55b5cf6e --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/summary/summary.ts @@ -0,0 +1,153 @@ +// src/commands/summary/summary.ts + +import { + SlashCommandBuilder, + AttachmentBuilder, + MessageFlags, + type ChatInputCommandInteraction, +} from "discord.js"; +import { summarize } from "../../services/summary/summarizer.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Defines the /summary command. + * + * Summarizes recent messages from the current channel + * and delivers the result privately via DM. + */ +export const data = new SlashCommandBuilder() + .setName("summary") + .setDescription("Summarize recent messages and DM it to you") + .addIntegerOption((opt) => + opt + .setName("count") + .setDescription("How many messages to fetch") + .setMinValue(10) + .setMaxValue(100), + ); + +/** + * Handler for the /summary command. + * + * Flow: + * 1. Defer an ephemeral reply so the user sees feedback immediately. + * 2. Validate the channel supports messages. + * 3. Fetch and filter recent user messages. + * 4. Build a transcript. + * 5. Generate a summary (local or LLM). + * 6. Deliver the result via DM (text or file). + * 7. Handle failures gracefully. + */ +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const count = interaction.options.getInteger("count") ?? 50; + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + /** + * Guard: only text-capable channels can be summarized. + */ + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support summarizing messages."); + return; + } + + const messages = await interaction.channel.messages.fetch({ limit: count }); + + if (messages.size === 0) { + await interaction.editReply("No messages found to summarize."); + return; + } + + /** + * Filter out bot messages and empty content, + * then sort oldest → newest for readability. + */ + const userMessages = messages + .filter((m) => !m.author.bot && m.content) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + if (userMessages.size === 0) { + await interaction.editReply("No usable messages found to summarize."); + return; + } + + /** + * Build a simple transcript the summarizer can consume. + */ + const text = userMessages.map((m) => `${m.author.username}: ${m.content}`).join("\n"); + + const output = await summarize(text); + + if (!output || output.trim().length === 0) { + await interaction.editReply("Summary came back empty."); + return; + } + + /** + * If the summary exceeds Discord message limits, + * send it as a file attachment instead. + */ + if (output.length > 2000) { + const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { + name: "summary.txt", + }); + + try { + await interaction.user.send({ + content: "Here is your summary (too long to send as a message):", + files: [file], + }); + + await interaction.editReply("Summary sent to your DMs."); + } catch (err) { + logger.warn( + { err, userId: interaction.user.id }, + "[summary] DM file send failed", + ); + + await interaction.editReply( + "I generated the summary, but your DMs appear to be closed.", + ); + } + + return; + } + + /** + * Normal-sized summary: send as plain DM text. + */ + try { + await interaction.user.send(output); + await interaction.editReply("Summary sent to your DMs."); + } catch (err) { + logger.warn({ err, userId: interaction.user.id }, "[summary] DM text send failed"); + + await interaction.editReply( + "I generated the summary, but could not DM you. Your DMs may be closed.", + ); + } + } catch (err) { + /** + * Top-level failure handler. + * We log the error and attempt to notify the user once. + */ + logger.error( + { err, command: "summary", userId: interaction.user.id }, + "[summary] Summary generation failed", + ); + + try { + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong while generating the summary."); + } else { + await interaction.reply({ + content: "Something went wrong while generating the summary.", + flags: MessageFlags.Ephemeral, + }); + } + } catch (replyErr) { + logger.error({ err: replyErr }, "[summary] Failed to send fallback error message"); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts b/data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts new file mode 100644 index 00000000..5ea9cfe4 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts @@ -0,0 +1,543 @@ +// src/commands/timezone/timezone.ts + +import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../utils/logger.js"; +import { + clearUserTimezone, + getUserTimezone, + setUserTimezone, +} from "../../services/timezone/timezoneStore.js"; + +export const data = new SlashCommandBuilder() + .setName("timezone") + .setDescription("Set and compare timezones, and convert times") + + // /timezone set + .addSubcommand((s) => + s + .setName("set") + .setDescription( + "Save your timezone (IANA like America/New_York or short name like ET)", + ) + .addStringOption((o) => + o + .setName("zone") + .setDescription('Examples: "US Eastern", "ET", "America/New_York"') + .setRequired(true), + ) + .addBooleanOption((o) => + o + .setName("guild") + .setDescription("If true, store per-server (otherwise global)") + .setRequired(false), + ), + ) + + // /timezone show + .addSubcommand((s) => + s + .setName("show") + .setDescription("Show your saved timezone") + .addBooleanOption((o) => + o + .setName("guild") + .setDescription("If true, read the per-server timezone") + .setRequired(false), + ), + ) + + // /timezone clear + .addSubcommand((s) => + s + .setName("clear") + .setDescription("Remove your saved timezone") + .addBooleanOption((o) => + o + .setName("guild") + .setDescription("If true, clear the per-server timezone") + .setRequired(false), + ), + ) + + // /timezone compare + .addSubcommand((s) => + s + .setName("compare") + .setDescription("Compare your time with another user") + .addUserOption((o) => + o.setName("user").setDescription("User to compare with").setRequired(true), + ) + .addBooleanOption((o) => + o + .setName("guild") + .setDescription("Use per-server timezones if set") + .setRequired(false), + ), + ) + + // /timezone convert + .addSubcommand((s) => + s + .setName("convert") + .setDescription("Convert a time from your timezone to another zone") + .addStringOption((o) => + o + .setName("time") + .setDescription('Time like "7:30pm" or "19:30"') + .setRequired(true), + ) + .addStringOption((o) => + o + .setName("to") + .setDescription( + 'Target zone (examples: "US Pacific", "PT", "America/Los_Angeles")', + ) + .setRequired(true), + ) + .addStringOption((o) => + o + .setName("from") + .setDescription("Optional from-zone (defaults to your saved timezone)") + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("guild") + .setDescription("Use per-server timezone for default 'from'") + .setRequired(false), + ), + ) + .setDMPermission(true); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); + + await interaction.deferReply({ ephemeral: true }); + + try { + if (sub === "set") return await handleSet(interaction); + if (sub === "show") return await handleShow(interaction); + if (sub === "clear") return await handleClear(interaction); + if (sub === "compare") return await handleCompare(interaction); + if (sub === "convert") return await handleConvert(interaction); + + await interaction.editReply("Unknown subcommand."); + } catch (err) { + logger.error({ err, sub }, "[timezone] failed"); + await interaction.editReply("Timezone command failed. Try again in a bit."); + } +} + +/* -------------------------------------------------------------------------- */ +/* Timezone input normalization */ +/* -------------------------------------------------------------------------- */ + +/** + * Small curated set of friendly names -> IANA zones. + * This avoids exposing the full IANA list while covering common needs. + */ +const COMMON_TIMEZONES: Array<{ name: string; tz: string; label?: string }> = [ + { name: "US Eastern", tz: "America/New_York", label: "ET" }, + { name: "US Central", tz: "America/Chicago", label: "CT" }, + { name: "US Mountain", tz: "America/Denver", label: "MT" }, + { name: "US Pacific", tz: "America/Los_Angeles", label: "PT" }, + + { name: "UTC", tz: "Etc/UTC", label: "UTC" }, + + { name: "UK", tz: "Europe/London" }, + { name: "Central Europe", tz: "Europe/Berlin" }, + + { name: "India", tz: "Asia/Kolkata" }, + { name: "Japan", tz: "Asia/Tokyo" }, + { name: "Australia East", tz: "Australia/Sydney" }, +]; + +/** + * Common abbreviations people actually type. + * Note: abbreviations are ambiguous globally, but this is a pragmatic bot UX choice. + */ +const ALIAS_TO_IANA: Record = { + // US / common + et: { tz: "America/New_York", label: "ET" }, + est: { tz: "America/New_York", label: "ET" }, + edt: { tz: "America/New_York", label: "ET" }, + + ct: { tz: "America/Chicago", label: "CT" }, + cst: { tz: "America/Chicago", label: "CT" }, + cdt: { tz: "America/Chicago", label: "CT" }, + + mt: { tz: "America/Denver", label: "MT" }, + mst: { tz: "America/Denver", label: "MT" }, + mdt: { tz: "America/Denver", label: "MT" }, + + pt: { tz: "America/Los_Angeles", label: "PT" }, + pst: { tz: "America/Los_Angeles", label: "PT" }, + pdt: { tz: "America/Los_Angeles", label: "PT" }, + + // UTC-ish + utc: { tz: "Etc/UTC", label: "UTC" }, + gmt: { tz: "Etc/UTC", label: "UTC" }, +}; + +function isValidIanaZone(tz: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date()); + return true; + } catch { + return false; + } +} + +function normalizeKey(s: string): string { + return s.trim().toLowerCase().replace(/\s+/g, " "); +} + +function normalizeZoneInput(raw: string): { tz: string; label?: string } | null { + const v = raw.trim(); + if (!v) return null; + + const key = normalizeKey(v); + + // Friendly names: "us eastern", "central europe", etc. + for (const z of COMMON_TIMEZONES) { + if (normalizeKey(z.name) === key) return { tz: z.tz, label: z.label }; + } + + // Allow a couple shorthand friendly variants people type + if (key === "eastern" || key === "east") return { tz: "America/New_York", label: "ET" }; + if (key === "central" || key === "midwest") + return { tz: "America/Chicago", label: "CT" }; + if (key === "mountain") return { tz: "America/Denver", label: "MT" }; + if (key === "pacific" || key === "west") + return { tz: "America/Los_Angeles", label: "PT" }; + + // Abbreviations: "ET", "PST", etc. + const alias = ALIAS_TO_IANA[key.replace(/\./g, "")]; + if (alias) return { tz: alias.tz, label: alias.label }; + + // Power user path: accept IANA directly + if (isValidIanaZone(v)) return { tz: v }; + + return null; +} + +function formatLocalTime(date: Date, tz: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +function utcOffsetMinutes(date: Date, tz: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + timeZoneName: "shortOffset" as unknown as "short", + }).formatToParts(date); + + const name = parts.find((p) => p.type === "timeZoneName")?.value ?? "UTC+0"; + const m = name.match(/([+-])\s*(\d{1,2})(?::(\d{2}))?$/); + if (!m) return 0; + + const sign = m[1] === "-" ? -1 : 1; + const hh = Number(m[2]); + const mm = Number(m[3] ?? 0); + + return sign * (hh * 60 + mm); +} + +function formatUtcOffset(date: Date, tz: string): string { + const mins = utcOffsetMinutes(date, tz); + const sign = mins < 0 ? "-" : "+"; + const abs = Math.abs(mins); + const hh = String(Math.floor(abs / 60)).padStart(2, "0"); + const mm = String(abs % 60).padStart(2, "0"); + return `UTC${sign}${hh}:${mm}`; +} + +function formatDeltaRelative(deltaMinutes: number): string { + if (deltaMinutes === 0) return "same as you"; + + const ahead = deltaMinutes > 0; + const abs = Math.abs(deltaMinutes); + const hours = Math.floor(abs / 60); + const mins = abs % 60; + + const hPart = hours > 0 ? `${hours}h` : ""; + const mPart = mins > 0 ? `${mins}m` : ""; + const space = hPart && mPart ? " " : ""; + + return `${hPart}${space}${mPart} ${ahead ? "ahead" : "behind"}`.trim(); +} + +function scopeFromBool(guildFlag: boolean | null | undefined): "guild" | "global" { + return guildFlag ? "guild" : "global"; +} + +async function getTzOrNull( + interaction: ChatInputCommandInteraction, + userId: string, + scope: "guild" | "global", +) { + const guildId = interaction.inGuild() ? interaction.guildId : null; + return getUserTimezone({ userId, guildId, scope }); +} + +function shortHint(): string { + return [ + "Examples:", + '`/timezone set zone:"US Eastern"`', + "`/timezone set zone:ET`", + "`/timezone set zone:America/New_York`", + "", + "Common zones: US Eastern, US Central, US Mountain, US Pacific, UTC, UK, Central Europe, India, Japan, Australia East", + ].join("\n"); +} + +/* -------------------------------------------------------------------------- */ +/* Handlers */ +/* -------------------------------------------------------------------------- */ + +async function handleSet(interaction: ChatInputCommandInteraction): Promise { + const raw = interaction.options.getString("zone", true); + const guildFlag = interaction.options.getBoolean("guild") ?? false; + const scope = scopeFromBool(guildFlag); + + const normalized = normalizeZoneInput(raw); + if (!normalized) { + await interaction.editReply( + ["I could not understand that timezone.", "", shortHint()].join("\n"), + ); + return; + } + + const guildId = interaction.inGuild() ? interaction.guildId : null; + const saved = await setUserTimezone({ + userId: interaction.user.id, + guildId, + scope, + timezone: normalized.tz, + label: normalized.label, + }); + + const now = new Date(); + const local = formatLocalTime(now, saved.timezone); + const offset = formatUtcOffset(now, saved.timezone); + + await interaction.editReply( + [ + "Saved your timezone.", + "", + `Zone: ${saved.timezone}${saved.label ? ` (${saved.label})` : ""}`, + `Now: ${local} | ${offset}`, + `Scope: ${scope === "guild" ? "this server" : "global"}`, + ].join("\n"), + ); +} + +async function handleShow(interaction: ChatInputCommandInteraction): Promise { + const guildFlag = interaction.options.getBoolean("guild") ?? false; + const scope = scopeFromBool(guildFlag); + + const tz = await getTzOrNull(interaction, interaction.user.id, scope); + if (!tz) { + await interaction.editReply(["No timezone saved yet.", "", shortHint()].join("\n")); + return; + } + + const now = new Date(); + const local = formatLocalTime(now, tz.timezone); + const offset = formatUtcOffset(now, tz.timezone); + + await interaction.editReply( + [ + "Your timezone:", + `Zone: ${tz.timezone}${tz.label ? ` (${tz.label})` : ""}`, + `Now: ${local} | ${offset}`, + `Scope: ${scope === "guild" ? "this server" : "global"}`, + ].join("\n"), + ); +} + +async function handleClear(interaction: ChatInputCommandInteraction): Promise { + const guildFlag = interaction.options.getBoolean("guild") ?? false; + const scope = scopeFromBool(guildFlag); + const guildId = interaction.inGuild() ? interaction.guildId : null; + + const ok = await clearUserTimezone({ userId: interaction.user.id, guildId, scope }); + await interaction.editReply( + ok ? "Cleared your saved timezone." : "No saved timezone to clear.", + ); +} + +async function handleCompare(interaction: ChatInputCommandInteraction): Promise { + const target = interaction.options.getUser("user", true); + const guildFlag = interaction.options.getBoolean("guild") ?? false; + const scope = scopeFromBool(guildFlag); + + const [a, b] = await Promise.all([ + getTzOrNull(interaction, interaction.user.id, scope), + getTzOrNull(interaction, target.id, scope), + ]); + + if (!a) { + await interaction.editReply( + ["You have no timezone saved.", "Run `/timezone set` first.", "", shortHint()].join( + "\n", + ), + ); + return; + } + + if (!b) { + await interaction.editReply( + "That user has no timezone saved. Ask them to run `/timezone set` first.", + ); + return; + } + + const now = new Date(); + const aNow = formatLocalTime(now, a.timezone); + const bNow = formatLocalTime(now, b.timezone); + const aOff = formatUtcOffset(now, a.timezone); + const bOff = formatUtcOffset(now, b.timezone); + + const delta = utcOffsetMinutes(now, b.timezone) - utcOffsetMinutes(now, a.timezone); + const rel = formatDeltaRelative(delta); + + await interaction.editReply( + [ + "Timezone compare:", + "", + `${interaction.user.username}: ${aNow} (${a.timezone}) | ${aOff}`, + `${target.username}: ${bNow} (${b.timezone}) | ${bOff}`, + "", + `${target.username} is ${rel}.`, + ].join("\n"), + ); +} + +function parseTimeString(raw: string): { hours: number; minutes: number } | null { + const s = raw.trim().toLowerCase(); + if (!s) return null; + + // Match "19:30" or "7:30pm" or "7pm" + const m = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i); + if (!m) return null; + + let hh = Number(m[1]); + const mm = Number(m[2] ?? 0); + const ap = (m[3] ?? "").toLowerCase(); + + if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null; + if (mm < 0 || mm > 59) return null; + + if (ap) { + if (hh < 1 || hh > 12) return null; + if (ap === "pm" && hh !== 12) hh += 12; + if (ap === "am" && hh === 12) hh = 0; + } else { + if (hh < 0 || hh > 23) return null; + } + + return { hours: hh, minutes: mm }; +} + +function formatTimeForZone(date: Date, tz: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function guessDateInZone(now: Date, tz: string, h: number, m: number): Date { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(now); + + const y = parts.find((p) => p.type === "year")?.value ?? "1970"; + const mo = parts.find((p) => p.type === "month")?.value ?? "01"; + const d = parts.find((p) => p.type === "day")?.value ?? "01"; + + const assumedUtc = new Date( + `${y}-${mo}-${d}T${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00.000Z`, + ); + + const offMin = utcOffsetMinutes(assumedUtc, tz); + return new Date(assumedUtc.getTime() - offMin * 60_000); +} + +async function handleConvert(interaction: ChatInputCommandInteraction): Promise { + const rawTime = interaction.options.getString("time", true); + const rawTo = interaction.options.getString("to", true); + const rawFrom = interaction.options.getString("from") ?? ""; + const guildFlag = interaction.options.getBoolean("guild") ?? false; + const scope = scopeFromBool(guildFlag); + + const toNorm = normalizeZoneInput(rawTo); + if (!toNorm) { + await interaction.editReply( + ["I could not understand the **to** timezone.", "", shortHint()].join("\n"), + ); + return; + } + + let fromTz: string | null = null; + let fromLabel: string | undefined; + + if (rawFrom.trim()) { + const fromNorm = normalizeZoneInput(rawFrom); + if (!fromNorm) { + await interaction.editReply( + ["I could not understand the **from** timezone.", "", shortHint()].join("\n"), + ); + return; + } + fromTz = fromNorm.tz; + fromLabel = fromNorm.label; + } else { + const saved = await getTzOrNull(interaction, interaction.user.id, scope); + if (!saved) { + await interaction.editReply( + "No saved timezone found for you. Either run `/timezone set` first, or pass `from:`.", + ); + return; + } + fromTz = saved.timezone; + fromLabel = saved.label; + } + + const parsed = parseTimeString(rawTime); + if (!parsed) { + await interaction.editReply('Time must look like "7:30pm" or "19:30".'); + return; + } + + const now = new Date(); + const instant = guessDateInZone(now, fromTz, parsed.hours, parsed.minutes); + + const fromTime = formatTimeForZone(instant, fromTz); + const toTime = formatTimeForZone(instant, toNorm.tz); + + const fromOff = formatUtcOffset(instant, fromTz); + const toOff = formatUtcOffset(instant, toNorm.tz); + + const delta = utcOffsetMinutes(instant, toNorm.tz) - utcOffsetMinutes(instant, fromTz); + const rel = formatDeltaRelative(delta); + + await interaction.editReply( + [ + "Time conversion:", + "", + `From: ${fromTime} (${fromTz}${fromLabel ? `, ${fromLabel}` : ""}) | ${fromOff}`, + `To: ${toTime} (${toNorm.tz}${toNorm.label ? `, ${toNorm.label}` : ""}) | ${toOff}`, + "", + `That is ${rel}.`, + ].join("\n"), + ); +} diff --git a/data/backup-20260117-145403/src-backup/config/env.ts b/data/backup-20260117-145403/src-backup/config/env.ts new file mode 100644 index 00000000..9876cc78 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/config/env.ts @@ -0,0 +1,225 @@ +// src/config/env.ts + +import dotenv from "dotenv"; +dotenv.config({ path: ".env" }); + +/** + * Helper to enforce that required environment variables are present. + * + * If a variable is missing, the bot fails fast at startup instead of + * crashing later at runtime in unpredictable ways. + */ +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not set in environment`); + } + return value; +} + +/** + * Parse an integer env var safely. + * Falls back to `defaultValue` if missing/invalid. + */ +function envInt(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (!raw) return defaultValue; + + const n = Number(raw); + if (!Number.isFinite(n) || !Number.isInteger(n)) return defaultValue; + if (n <= 0) return defaultValue; + + return n; +} + +type SummaryMode = "local" | "llm"; + +/** + * Centralized environment configuration for OmegaBot. + * + * Keeping this configuration in one place: + * - Prevents scattered process.env lookups + * - Makes configuration explicit and auditable + * - Allows optional features to be gated cleanly + * + * ------------------------------------------------------------------ + * Required (core bot functionality) + * ------------------------------------------------------------------ + * + * - DISCORD_TOKEN + * Bot authentication token used to connect to Discord. + * + * - DISCORD_APP_ID + * Application ID required for slash command registration. + * + * - DISCORD_GUILD_ID + * OPTIONAL: Guild where development commands are registered. + * If not set, commands should be registered globally instead. + * + * ------------------------------------------------------------------ + * Optional (feature flags / enhancements) + * ------------------------------------------------------------------ + * + * - SUMMARY_MODE + * "local" (default) or "llm" + * Determines which summarizer implementation is used. + * + * - OPENAI_API_KEY + * Required only when SUMMARY_MODE === "llm". + * + * ------------------------------------------------------------------ + * GitHub integration + * ------------------------------------------------------------------ + * + * - GITHUB_TOKEN + * Personal Access Token used for authenticated GitHub REST calls. + * Required only when GitHub-backed features are enabled. + * + * - GITHUB_OWNER + * Default repository owner for polling (optional). + * + * - GITHUB_REPO + * Default repository name for polling (optional). + * + * - GITHUB_ANNOUNCE_CHANNEL_ID + * Legacy: single channel where GitHub announcements are posted (optional). + * + * - GITHUB_PR_ANNOUNCE_CHANNEL_ID + * NEW: channel where PR creation announcements are posted (optional). + * Falls back to GITHUB_ANNOUNCE_CHANNEL_ID if unset. + * + * - GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID + * NEW: channel where assignee change announcements are posted (optional). + * Falls back to GITHUB_ANNOUNCE_CHANNEL_ID if unset. + * + * - GITHUB_POLL_INTERVAL_MS + * Polling interval for GitHub polling. + * Defaults to 60 seconds if not provided. + * + * All GitHub-related fields are OPTIONAL so the bot can run + * without any GitHub configuration or tokens. + */ +const summaryMode = (process.env.SUMMARY_MODE ?? "local") as SummaryMode; + +// Fail fast ONLY when LLM summaries are explicitly enabled +if (summaryMode === "llm" && !process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY is required when SUMMARY_MODE=llm"); +} + +const legacyGithubAnnounceChannelId = process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null; + +// New split channels (fall back to legacy) +const githubPrAnnounceChannelId = + process.env.GITHUB_PR_ANNOUNCE_CHANNEL_ID ?? legacyGithubAnnounceChannelId; + +const githubAssigneeAnnounceChannelId = + process.env.GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID ?? legacyGithubAnnounceChannelId; + +export const env = { + /* ---------------------------------------------------------------- */ + /* Discord (required) */ + /* ---------------------------------------------------------------- */ + + token: requireEnv("DISCORD_TOKEN"), + appId: requireEnv("DISCORD_APP_ID"), + + /** + * Optional: + * Used for faster slash-command iteration during development. + * If unset, you can register commands globally instead. + */ + guildId: process.env.DISCORD_GUILD_ID ?? null, + + /** + * Optional: + * Discord role ID automatically assigned to new members on join. + * + * If unset, auto-role assignment is disabled. + * + * IMPORTANT: + * - This must be a ROLE ID, not a role name + * - The bot must have permission to manage this role + * - The role must be lower than the bot’s highest role + */ + discordAutoRoleId: process.env.DISCORD_AUTO_ROLE_ID ?? null, + + /* ---------------------------------------------------------------- */ + /* Summaries */ + /* ---------------------------------------------------------------- */ + + // Defaults to local so the bot runs without AI keys. + summaryMode, + + // Only required when summaryMode === "llm" + openAIKey: process.env.OPENAI_API_KEY ?? null, + + /* ---------------------------------------------------------------- */ + /* GitHub */ + /* ---------------------------------------------------------------- */ + + // Auth token for GitHub REST API (optional) + githubToken: process.env.GITHUB_TOKEN ?? null, + + // Default repo configuration for polling (optional) + githubOwner: process.env.GITHUB_OWNER ?? null, + githubRepo: process.env.GITHUB_REPO ?? null, + + // Legacy: Where GitHub announcements should be posted (optional) + githubAnnounceChannelId: legacyGithubAnnounceChannelId, + + // NEW: split announcement channels (optional; fall back to legacy) + githubPrAnnounceChannelId, + githubAssigneeAnnounceChannelId, + + // Polling interval (ms). Defaults to 60s. + githubPollIntervalMs: envInt("GITHUB_POLL_INTERVAL_MS", 60_000), + + /** + * Legacy feature gate: + * + * GitHub announcement polling is enabled ONLY when all required + * configuration is present. This prevents the bot from attempting + * GitHub API calls (or requiring tokens) at startup. + */ + githubAnnouncementsEnabled: + Boolean(process.env.GITHUB_TOKEN) && + Boolean(process.env.GITHUB_OWNER) && + Boolean(process.env.GITHUB_REPO) && + Boolean(process.env.GITHUB_ANNOUNCE_CHANNEL_ID), + + /** + * NEW feature gates: + * + * Separate enablement for each GitHub polling stream. + * These fall back to the legacy channel var automatically. + */ + githubPrPollingEnabled: + Boolean(process.env.GITHUB_TOKEN) && + Boolean(process.env.GITHUB_OWNER) && + Boolean(process.env.GITHUB_REPO) && + Boolean(githubPrAnnounceChannelId), + + githubAssigneePollingEnabled: + Boolean(process.env.GITHUB_TOKEN) && + Boolean(process.env.GITHUB_OWNER) && + Boolean(process.env.GITHUB_REPO) && + Boolean(githubAssigneeAnnounceChannelId), + + /** + * Helper for GitHub-only code paths. + * + * Call this ONLY inside GitHub feature implementations + * (commands, pollers, handlers). + * + * This ensures: + * - The bot can start without GitHub config + * - GitHub features fail loudly and clearly when misconfigured + */ + requireGithubToken(): string { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN is required for this GitHub feature"); + } + return token; + }, +}; diff --git a/data/backup-20260117-145403/src-backup/registerCommands.ts b/data/backup-20260117-145403/src-backup/registerCommands.ts new file mode 100644 index 00000000..48c2db37 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/registerCommands.ts @@ -0,0 +1,130 @@ +// src/registerCommands.ts + +import { REST, Routes } from "discord.js"; +import fs from "fs"; +import path from "path"; +import { pathToFileURL } from "url"; +import { env } from "./config/env.js"; +import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord-api-types/v10"; +import { logger } from "./utils/logger.js"; + +/* + * Read all compiled command definitions and prepare them for registration + * with the Discord API. + * + * Only registers REAL slash command modules: + * - must export `data` (SlashCommandBuilder) + * - must export `execute` (function) + * + * Helper files (services.js, store.js, _shared.js, etc.) are skipped. + */ +async function loadCommandData(): Promise< + RESTPostAPIChatInputApplicationCommandsJSONBody[] +> { + const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + + const basePath = path.join(process.cwd(), "dist", "commands"); + + if (!fs.existsSync(basePath)) { + logger.warn({ basePath }, "dist/commands not found. Did you run build?"); + return commands; + } + + // Read command groups (folders) safely + const groupEntries = fs.readdirSync(basePath, { withFileTypes: true }); + + for (const groupEntry of groupEntries) { + if (!groupEntry.isDirectory()) continue; + + const group = groupEntry.name; + const groupPath = path.join(basePath, group); + + const fileEntries = fs.readdirSync(groupPath, { withFileTypes: true }); + + for (const fileEntry of fileEntries) { + if (!fileEntry.isFile()) continue; + + const file = fileEntry.name; + if (!file.endsWith(".js")) continue; + + const modulePath = path.join(groupPath, file); + const moduleUrl = pathToFileURL(modulePath).href; + + try { + const mod = await import(moduleUrl); + + if (mod?.data && typeof mod.execute === "function") { + const json = mod.data.toJSON(); + + logger.info( + { + command: json.name, + group, + file, + }, + "Registering slash command", + ); + + commands.push(json); + } else { + logger.debug( + { file: `${group}/${file}` }, + "Skipping non-command module (missing data or execute)", + ); + } + } catch (err) { + logger.warn({ err, file: `${group}/${file}` }, "Failed to import command module"); + } + } + } + + return commands; +} + +/* + * Register all slash commands with Discord for the configured application. + * + * If DISCORD_GUILD_ID is set, we register to that guild (fast iteration). + * Otherwise we register globally (can take longer to propagate). + */ +async function register(): Promise { + const rest = new REST({ version: "10" }).setToken(env.token); + + const commands = await loadCommandData(); + + logger.info( + { + count: commands.length, + commands: commands.map((c) => c.name), + }, + "Final slash command payload", + ); + + logger.info( + { + mode: env.guildId ? "guild" : "global", + guildId: env.guildId ?? undefined, + }, + "Slash command registration mode", + ); + + if (env.guildId) { + await rest.put(Routes.applicationGuildCommands(env.appId, env.guildId), { + body: commands, + }); + + logger.info({ guildId: env.guildId }, "Commands registered to guild."); + return; + } + + await rest.put(Routes.applicationCommands(env.appId), { + body: commands, + }); + + logger.info("Commands registered globally."); +} + +register().catch((err) => { + logger.error(err, "Failed to register commands"); + process.exit(1); +}); diff --git a/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts b/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts new file mode 100644 index 00000000..db2e7683 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts @@ -0,0 +1,86 @@ +// src/services/cache/simpleCache.ts +import { logger } from "../../utils/logger.js"; + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class SimpleCache { + private cache = new Map>(); + private hits = 0; + private misses = 0; + + set(key: string, value: T, ttlSeconds: number): void { + this.cache.set(key, { + value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + logger.debug({ key, ttlSeconds }, "Cache SET"); + } + + get(key: string): T | null { + const entry = this.cache.get(key); + + if (!entry) { + this.misses++; + logger.debug({ key }, "Cache MISS"); + return null; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + this.misses++; + logger.debug({ key }, "Cache MISS (expired)"); + return null; + } + + this.hits++; + logger.debug({ key }, "Cache HIT"); + return entry.value; + } + + clear(): void { + this.cache.clear(); + this.hits = 0; + this.misses = 0; + logger.info("Cache cleared"); + } + + cleanup(): number { + const now = Date.now(); + let removed = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key); + removed++; + } + } + + return removed; + } + + getStats() { + let valid = 0; + let expired = 0; + const now = Date.now(); + + for (const entry of this.cache.values()) { + if (now > entry.expiresAt) expired++; + else valid++; + } + + const total = this.hits + this.misses; + const hitRate = total > 0 ? (this.hits / total) * 100 : 0; + + return { + size: this.cache.size, + valid, + expired, + hits: this.hits, + misses: this.misses, + hitRate: hitRate.toFixed(1) + "%", + }; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts b/data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts new file mode 100644 index 00000000..f8371327 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts @@ -0,0 +1,83 @@ +// src/services/config/guildConfigStore.ts + +import fs from "fs"; +import path from "path"; +import { + DEFAULT_GUILD_CONFIG, + type GuildConfig, + type GuildConfigPatch, +} from "./types.js"; + +/** + * Simple JSON-backed store for guild configuration. + * + * Design goals: + * - Extremely easy to run locally + * - No external dependencies (DB) + * - Durable across restarts + * + * Notes: + * - This implementation reads/writes the file per call. + * That’s fine for small bots. If you scale, add caching + periodic flush. + */ + +const DATA_DIR = path.join(process.cwd(), "data"); +const FILE_PATH = path.join(DATA_DIR, "guild-config.json"); + +/** + * Stored JSON shape: + * { + * "": { welcomeEnabled: true, welcomeChannelId: "..." }, + * "": { ... } + * } + */ +type StoreShape = Record>; + +function ensureDataDir(): void { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function readStore(): StoreShape { + try { + const raw = fs.readFileSync(FILE_PATH, "utf8"); + const parsed = JSON.parse(raw) as StoreShape; + return parsed ?? {}; + } catch { + // Missing file, invalid JSON, etc. + return {}; + } +} + +function writeStore(store: StoreShape): void { + ensureDataDir(); + fs.writeFileSync(FILE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +/** + * Get the effective config for a guild. + * Always returns a fully-populated config object (defaults applied). + */ +export function getGuildConfig(guildId: string): GuildConfig { + const store = readStore(); + const saved = store[guildId] ?? {}; + return { guildId, ...DEFAULT_GUILD_CONFIG, ...saved }; +} + +/** + * Update the stored config for a guild by patching values. + * Returns the updated effective config (defaults applied). + */ +export function setGuildConfig(guildId: string, patch: GuildConfigPatch): GuildConfig { + const store = readStore(); + + // Apply defaults for new guilds, then patch on top. + const current = store[guildId] ?? DEFAULT_GUILD_CONFIG; + const next: Omit = { ...current, ...patch }; + + store[guildId] = next; + writeStore(store); + + return { guildId, ...next }; +} diff --git a/data/backup-20260117-145403/src-backup/services/config/index.ts b/data/backup-20260117-145403/src-backup/services/config/index.ts new file mode 100644 index 00000000..7df69e30 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/config/index.ts @@ -0,0 +1,14 @@ +// src/services/config/index.ts + +/** + * Public API barrel for config services. + * + * Keeps imports cleaner: + * import { getGuildConfig } from "../config/index.js"; + * instead of: + * import { getGuildConfig } from "../config/guildConfigStore.js"; + */ + +export { getGuildConfig, setGuildConfig } from "./guildConfigStore.js"; +export { DEFAULT_GUILD_CONFIG } from "./types.js"; +export type { GuildConfig, GuildConfigPatch } from "./types.js"; diff --git a/data/backup-20260117-145403/src-backup/services/config/types.ts b/data/backup-20260117-145403/src-backup/services/config/types.ts new file mode 100644 index 00000000..7c3ca528 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/config/types.ts @@ -0,0 +1,43 @@ +// src/services/config/types.ts + +/** + * GuildConfig + * + * Per-guild configuration for OmegaBot. + * This is intentionally small and focused so we can expand safely over time. + */ +export type GuildConfig = { + /** Discord guild (server) id */ + guildId: string; + + /* ------------------------------------------------------------------ */ + /* Welcome / onboarding */ + /* ------------------------------------------------------------------ */ + + /** + * Master enable/disable for welcome messages in this guild. + * Defaults to true. + */ + welcomeEnabled: boolean; + + /** + * Preferred channel for welcome messages. + * If null, we fall back to system channel, then first text channel. + */ + welcomeChannelId: string | null; +}; + +/** + * Patch type used to update config values without requiring the full object. + * guildId is excluded intentionally (immutable key). + */ +export type GuildConfigPatch = Partial>; + +/** + * Default config for new guilds. + * Applied automatically when no saved config exists. + */ +export const DEFAULT_GUILD_CONFIG: Omit = { + welcomeEnabled: true, + welcomeChannelId: null, +}; diff --git a/data/backup-20260117-145403/src-backup/services/database/db.ts b/data/backup-20260117-145403/src-backup/services/database/db.ts new file mode 100644 index 00000000..88ec131b --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/database/db.ts @@ -0,0 +1,89 @@ +// 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, + answer 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 initialized"); + 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); +} diff --git a/data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts b/data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts new file mode 100644 index 00000000..30e1a699 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts @@ -0,0 +1,149 @@ +// src/services/discord/commandLoader.ts + +import fs from "fs"; +import path from "path"; +import { pathToFileURL } from "url"; +import { + Client, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Contract that every slash command module must follow. + * + * - `data` describes the command to Discord (name, description, options) + * - `execute` runs when a user invokes the command + */ +export interface SlashCommand { + data: SlashCommandBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; +} + +/** + * Discord Client extended with a command registry. + */ +export type CommandClient = Client & { + commands: Map; +}; + +/** + * Load all compiled command modules from dist/commands and register them into client.commands. + * + * Notes: + * - We load from dist/ because the bot runs compiled JS. + * - A single broken command should not crash the entire bot. + * - Helper modules may exist alongside commands and will be skipped. + */ +export async function loadCommands(client: CommandClient): Promise { + const basePath = path.join(process.cwd(), "dist", "commands"); + + // Diagnostics + let loadedCount = 0; + let skippedCount = 0; + let failedCount = 0; + + const loadedNames: string[] = []; + const skippedFiles: string[] = []; + const failedFiles: string[] = []; + + if (!fs.existsSync(basePath)) { + logger.error( + { basePath }, + "Command loader base path not found. Did you build the project?", + ); + logger.info({ loadedCount: 0 }, "Commands loaded"); + return; + } + + const groups = fs.readdirSync(basePath); + + for (const group of groups) { + const groupPath = path.join(basePath, group); + if (!fs.statSync(groupPath).isDirectory()) continue; + + const files = fs.readdirSync(groupPath); + + for (const file of files) { + if (!file.endsWith(".js")) continue; + + const fullPath = path.join(groupPath, file); + const relFile = `${group}/${file}`; + + try { + // ESM-safe import path + const moduleUrl = pathToFileURL(fullPath).href; + const mod = (await import(moduleUrl)) as Partial; + + // Helpers are expected to be skipped + if (!mod.data || !mod.execute) { + skippedCount += 1; + skippedFiles.push(relFile); + + logger.debug( + { file: relFile }, + "Skipping non-command module (missing data or execute)", + ); + continue; + } + + const name = mod.data.name; + + if (!name || typeof name !== "string") { + skippedCount += 1; + skippedFiles.push(relFile); + + logger.warn( + { file: relFile }, + "Skipping command module (invalid command name)", + ); + continue; + } + + // Avoid silent overwrites if two commands share the same name + if (client.commands.has(name)) { + skippedCount += 1; + skippedFiles.push(relFile); + + logger.warn( + { name, file: relFile }, + "Duplicate command name detected. Skipping this module.", + ); + continue; + } + + client.commands.set(name, mod as SlashCommand); + loadedCount += 1; + loadedNames.push(name); + } catch (err) { + failedCount += 1; + failedFiles.push(relFile); + + logger.warn({ err, file: relFile, fullPath }, "Failed to import command module"); + } + } + } + + // Summary (info) + logger.info( + { + loadedCount, + loadedNames, + skippedCount, + failedCount, + }, + "Commands loaded", + ); + + // Details only when useful + if (failedCount > 0) { + logger.warn( + { failedCount, failedFiles }, + "One or more command modules failed to load", + ); + } + + // Keep skip list debug-only to avoid noise + logger.debug({ skippedCount, skippedFiles }, "Non-command modules skipped"); +} diff --git a/data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts b/data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts new file mode 100644 index 00000000..3c23224e --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts @@ -0,0 +1,94 @@ +// src/services/discord/commandMeta.ts + +/** + * Minimal metadata shape used by /help. + * + * This is intentionally defensive: it tries to extract command names and + * descriptions from whatever your loader stores in `client.commands`. + */ + +export type CommandListItem = { + name: string; + description: string; + group: string; + adminOnly: boolean; +}; + +type MaybeCommandModule = { + data?: { + name?: string; + description?: string; + toJSON?: () => { name?: string; description?: string }; + default_member_permissions?: string | null; + }; + meta?: Partial>; +}; + +type MaybeHasCommandsMap = { + commands?: Map; +}; + +/** + * Extract a list of commands from a Discord client object. + * + * Supports common shapes: + * - client.commands = Map + * - client.commands = Map + * + * If nothing matches, returns an empty array and /help remains static. + */ +export function extractCommandList(client: unknown): CommandListItem[] { + const c = client as MaybeHasCommandsMap; + + if (!c?.commands || !(c.commands instanceof Map)) return []; + + const items: CommandListItem[] = []; + + for (const [fallbackName, modUnknown] of c.commands.entries()) { + const mod = modUnknown as MaybeCommandModule; + + const fromJson = safeToJson(mod?.data); + const name = (mod?.data?.name ?? fromJson?.name ?? fallbackName)?.trim(); + if (!name) continue; + + const description = (mod?.data?.description ?? fromJson?.description ?? "").trim(); + + // Best-effort group: use explicit meta.group if provided, else infer. + // (You can improve this later by having the loader attach folder name.) + const group = (mod?.meta?.group ?? inferGroupFromName(name)).trim(); + + // Best-effort adminOnly: + // - If command module sets meta.adminOnly, trust it + // - Else if default_member_permissions is set on command data, consider it adminOnly + const adminOnly = + Boolean(mod?.meta?.adminOnly) || Boolean(mod?.data?.default_member_permissions); + + items.push({ + name, + description, + group, + adminOnly, + }); + } + + return items; +} + +function safeToJson(data: unknown): { name?: string; description?: string } | null { + try { + const d = data as { toJSON?: () => unknown }; + if (typeof d?.toJSON !== "function") return null; + const j = d.toJSON() as { name?: string; description?: string }; + return j ?? null; + } catch { + return null; + } +} + +function inferGroupFromName(name: string): string { + // Minimal inference, keeps output readable until the loader tags groups. + if (name === "help") return "general"; + if (name === "config") return "admin"; + if (name.includes("github")) return "github"; + return "other"; +} diff --git a/data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts b/data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts new file mode 100644 index 00000000..fa0a966b --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts @@ -0,0 +1,58 @@ +// src/services/discord/safeReply.ts +// +// Safe reply helper for Discord interactions. +// +// Goal: +// - Avoid "Interaction already replied" and "Unknown interaction" pitfalls +// - Let callers write one consistent "respond" call +// +// Behavior: +// - If interaction already has a reply or was deferred, use editReply(string) +// - Otherwise use reply({ content, flags? }) +// +// Notes: +// - Discord does not allow setting Ephemeral on editReply. +// So we only apply ephemeral flags on the initial reply. + +import { MessageFlags, type InteractionReplyOptions } from "discord.js"; + +export type SafeReplyOptions = { + content: string; + ephemeral?: boolean; +}; + +function toReplyOptions(opts: SafeReplyOptions): InteractionReplyOptions { + if (opts.ephemeral) { + return { content: opts.content, flags: MessageFlags.Ephemeral }; + } + return { content: opts.content }; +} + +/** + * The minimal shape we need from an interaction. + * + * Important: + * - editReply only accepts a string (or edit options), and cannot set Ephemeral. + * - We only ever edit with a string, so keep the type strict to avoid mismatches. + */ +type SafeInteraction = { + replied: boolean; + deferred: boolean; + reply: (options: InteractionReplyOptions) => Promise; + editReply: (content: string) => Promise; +}; + +/** + * Safely respond to an interaction exactly once. + */ +export async function safeReply( + interaction: SafeInteraction, + opts: SafeReplyOptions, +): Promise { + if (interaction.replied || interaction.deferred) { + await interaction.editReply(opts.content); + return; + } + + await interaction.reply(toReplyOptions(opts)); +} diff --git a/data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts b/data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts new file mode 100644 index 00000000..0ea00aaf --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts @@ -0,0 +1,69 @@ +// src/services/discord/fetchChannelMessages.ts + +import type { TextBasedChannel, Message } from "discord.js"; +import { logger } from "../../utils/logger.js"; + +export type FetchMessagesOptions = { + count?: number; + before?: string; + after?: string; +}; + +/** + * Fetch messages from a text-based Discord channel. + * + * Supports optional: + * - count → max number of messages to fetch + * - before → message ID cursor + * - after → message ID cursor + * + * Returns messages sorted oldest → newest. + * + * This function performs network I/O and therefore: + * - Wraps fetch in try/catch + * - Logs failures with context + */ +export async function fetchChannelMessages( + channel: TextBasedChannel, + options: FetchMessagesOptions = {}, +): Promise { + const { count = 50, before, after } = options; + + const fetchOptions: { + limit: number; + before?: string; + after?: string; + } = { + limit: count, + }; + + if (before) fetchOptions.before = before; + if (after) fetchOptions.after = after; + + try { + const collection = await channel.messages.fetch(fetchOptions); + + // Convert Collection → Array and sort chronologically + return Array.from(collection.values()).sort( + (a, b) => a.createdTimestamp - b.createdTimestamp, + ); + } catch (err) { + logger.error( + { + err, + channelId: channel.id, + count, + before, + after, + }, + "Failed to fetch channel messages", + ); + + /** + * Fail safe: + * - Return empty list instead of crashing caller + * - Callers can decide how to handle missing messages + */ + return []; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts b/data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts new file mode 100644 index 00000000..c3fb2f1e --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts @@ -0,0 +1,104 @@ +// src/services/discord/interactionHandler.ts +// +// Central interaction router for Discord. +// +// Responsibilities: +// - Route slash commands to their registered handlers +// - Handle execution errors safely +// - Ensure users always receive a response +// +// IMPORTANT DESIGN NOTE: +// - Slash commands are handled globally here +// - Buttons and other component interactions are handled locally +// by the command that created them (via collectors) +// +// This separation is REQUIRED for confirmation flows (ex: /faq remove) + +import type { Interaction, ChatInputCommandInteraction } from "discord.js"; +import type { CommandClient } from "./commandLoader.js"; +import { logger } from "../../utils/logger.js"; +import { safeReply } from "./safeReply.js"; + +/** + * Handle a single Discord interaction. + * + * This function is intentionally conservative: + * - It only routes slash commands + * - It explicitly ignores buttons so collectors can handle them + */ +export async function handleInteraction( + interaction: Interaction, + client: CommandClient, +): Promise { + /** + * IMPORTANT: + * Button interactions (confirm / cancel, etc.) are NOT routed here. + * + * They are handled by local collectors created by the command itself + * (for example: /faq remove confirmation buttons). + * + * If we process buttons here, those flows will break. + */ + if (interaction.isButton()) { + return; + } + + /** + * We only care about slash commands in this router. + * Other interaction types are intentionally ignored. + */ + if (!interaction.isChatInputCommand()) return; + + /** + * Look up the registered command handler. + */ + const command = client.commands.get(interaction.commandName); + + /** + * No handler registered for this command. + * Usually indicates a stale deploy or command mismatch. + */ + if (!command) { + logger.warn( + { + commandName: interaction.commandName, + user: interaction.user?.username, + }, + "Received interaction for unknown command", + ); + + await safeReply(interaction, { + content: "Command not found.", + ephemeral: true, + }); + return; + } + + /** + * Execute the command safely. + * + * Any error thrown by the command: + * - Is logged with context + * - Results in a generic user-facing error + * + * Internal details are never leaked to Discord. + */ + try { + await command.execute(interaction as ChatInputCommandInteraction); + } catch (err) { + logger.error( + { + err, + commandName: interaction.commandName, + user: interaction.user?.username, + guildId: interaction.guildId, + }, + "Slash command execution failed", + ); + + await safeReply(interaction, { + content: "Something went wrong while running this command.", + ephemeral: true, + }); + } +} diff --git a/data/backup-20260117-145403/src-backup/services/discord/safeReply.ts b/data/backup-20260117-145403/src-backup/services/discord/safeReply.ts new file mode 100644 index 00000000..c525b85e --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/discord/safeReply.ts @@ -0,0 +1,48 @@ +// src/services/discord/safeReply.ts +// +// Safe reply helper for Discord interactions. +// +// Goal: +// - Avoid "Interaction already replied" and "Unknown interaction" pitfalls +// - Let callers write one consistent "respond" call +// +// Behavior: +// - If interaction already has a reply or was deferred, use editReply(content) +// - Otherwise use reply({ content, flags? }) +// +// Notes: +// - Discord does not allow setting Ephemeral on editReply. +// So we only apply ephemeral flags on the initial reply. + +import { + MessageFlags, + type InteractionReplyOptions, + type RepliableInteraction, +} from "discord.js"; + +export type SafeReplyOptions = { + content: string; + ephemeral?: boolean; +}; + +function toReplyOptions(opts: SafeReplyOptions): InteractionReplyOptions { + if (opts.ephemeral) { + return { content: opts.content, flags: MessageFlags.Ephemeral }; + } + return { content: opts.content }; +} + +/** + * Safely respond to an interaction exactly once. + */ +export async function safeReply( + interaction: RepliableInteraction, + opts: SafeReplyOptions, +): Promise { + if (interaction.replied || interaction.deferred) { + await interaction.editReply(opts.content); + return; + } + + await interaction.reply(toReplyOptions(opts)); +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/_shared.ts b/data/backup-20260117-145403/src-backup/services/faq/_shared.ts new file mode 100644 index 00000000..1e218be8 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/_shared.ts @@ -0,0 +1,238 @@ +// src/commands/faq/subcommands/_shared.ts +// +// Shared helpers for /faq subcommands. +// +// Goals: +// - Keep each subcommand tiny and consistent +// - Centralize common parsing, formatting, and guardrails +// - Provide one place to evolve list filtering and output style +// +// Non-goals: +// - No file I/O +// - No persistence logic +// - No Discord lifecycle (defer/reply) decisions + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../utils/logger.js"; + +import { canFaqAction } from "./permissions.js"; +import { MAX_KEY_LEN } from "./types.js"; +import type { FaqEntry } from "./types.js"; + +export type FaqAction = "add" | "get" | "list" | "remove"; + +/** + * Optional list filtering and display preferences. + * + * - query: substring search across key/title/body/tags (case-insensitive) + * - tag: filter to entries that include this tag (case-insensitive) + * - limit: max entries to render + * - full: if true, render full FAQ bodies instead of one-line rows + * - sort: how to order results + */ +export type FaqListFilter = { + query?: string; + tag?: string; + limit?: number; + full?: boolean; + sort?: "usage" | "updated" | "key"; +}; + +export async function guardFaqAction( + interaction: ChatInputCommandInteraction, + action: FaqAction, +): Promise { + const perm = canFaqAction(interaction, action); + if (perm.ok) return true; + + await interaction.editReply(`❌ ${"reason" in perm ? perm.reason : "Access denied"}`); + return false; +} + +/** + * Read and minimally validate a required key option. + * + * Notes: + * - This is intentionally a light guardrail for user experience. + * - The service layer is still the source of truth for normalization/validation. + */ +export async function readRequiredKey( + interaction: ChatInputCommandInteraction, + optionName = "key", +): Promise { + const rawKey = interaction.options.getString(optionName, true); + const key = rawKey.trim(); + + if (key.length === 0) { + await interaction.editReply("❌ Key cannot be empty."); + return null; + } + + if (key.length > MAX_KEY_LEN) { + await interaction.editReply(`❌ Key is too long (max ${MAX_KEY_LEN} characters).`); + return null; + } + + return key; +} + +/** + * Parse a comma-separated tags string into a clean array. + * Example: "devops, ci/cd, bot " -> ["devops","ci/cd","bot"] + */ +export function parseTags(raw: string | null): string[] { + if (!raw) return []; + return raw + .split(",") + .map((t) => t.trim()) + .filter(Boolean); +} + +/** + * Format a single FAQ entry for Discord. + * + * Output style: + * - Key (bold) + * - Title (bold) + * - Body + * - Tags line only when present + */ +export function formatFaqEntry(entry: FaqEntry): string { + const tagsLine = entry.tags?.length ? `🏷️ ${entry.tags.join(", ")}` : null; + + return [`📌 **${entry.key}**`, `**${entry.title}**`, entry.body, tagsLine] + .filter(Boolean) + .join("\n"); +} + +/** + * Format a list view. + * + * Default behavior: + * - Returns a scannable list of keys with a small hint (first tag, usage) + * + * If filter.full is true: + * - Returns full entry blocks (key/title/body/tags) for each match + */ +export function formatFaqList(entries: FaqEntry[], filter: FaqListFilter = {}): string { + if (entries.length === 0) { + return "No FAQs yet. Add one with `/faq add`."; + } + + const limit = typeof filter.limit === "number" ? filter.limit : 25; + + const filtered = applyListFilters(entries, filter); + if (filtered.length === 0) { + const bits: string[] = ["No FAQs matched."]; + if (filter.tag) bits.push(`Tag filter: **${filter.tag}**`); + if (filter.query) bits.push(`Query: **${filter.query}**`); + return bits.join("\n"); + } + + const sorted = applyListSort(filtered, filter.sort); + + const shown = sorted.slice(0, limit); + const remaining = sorted.length - shown.length; + + // Full mode: show each full entry block + if (filter.full) { + const blocks = shown.map((e) => formatFaqEntry(e)); + const footer = + remaining > 0 ? `\n\n…plus ${remaining} more. Narrow filters to see more.` : ""; + return [`📚 **FAQ entries** (${filtered.length})`, "", ...joinBlocks(blocks), footer] + .filter(Boolean) + .join("\n"); + } + + // Compact mode: one line per entry + const lines = shown.map((e) => { + const tagHint = e.tags?.length ? ` — ${e.tags[0]}` : ""; + const used = typeof e.usageCount === "number" ? ` • ${e.usageCount} uses` : ""; + return `• **${e.key}**${tagHint}${used}`; + }); + + const moreLine = remaining > 0 ? `\n…plus ${remaining} more.` : ""; + + return [`📚 **FAQ entries** (${filtered.length})`, ...lines, moreLine] + .filter(Boolean) + .join("\n"); +} + +/** + * Centralized error handler for subcommands. + * Keeps user messaging consistent and logs useful context. + */ +export async function handleFaqSubcommandError( + interaction: ChatInputCommandInteraction, + err: unknown, + tag: string, +): Promise { + logger.error({ err }, tag); + await interaction.editReply("❌ Something went wrong. Try again in a bit."); +} + +/* -------------------------------------------------------------------------- */ +/* Internal helpers */ +/* -------------------------------------------------------------------------- */ + +function applyListFilters(entries: FaqEntry[], filter: FaqListFilter): FaqEntry[] { + let out = entries; + + // Tag filter (case-insensitive exact match against any tag) + if (filter.tag && filter.tag.trim().length > 0) { + const wanted = filter.tag.trim().toLowerCase(); + out = out.filter((e) => (e.tags ?? []).some((t) => t.toLowerCase() === wanted)); + } + + // Query filter (case-insensitive substring match) + if (filter.query && filter.query.trim().length > 0) { + const q = filter.query.trim().toLowerCase(); + + out = out.filter((e) => { + const hay = [e.key, e.title, e.body, ...(e.tags ?? [])] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return hay.includes(q); + }); + } + + return out; +} + +function applyListSort(entries: FaqEntry[], sort: FaqListFilter["sort"]): FaqEntry[] { + const mode = sort ?? "usage"; + + if (mode === "key") { + return [...entries].sort((a, b) => a.key.localeCompare(b.key)); + } + + if (mode === "updated") { + return [...entries].sort((a, b) => + (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""), + ); + } + + // default: usage desc, then updated desc, then key asc + return [...entries].sort((a, b) => { + const u = (b.usageCount ?? 0) - (a.usageCount ?? 0); + if (u !== 0) return u; + + const t = (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); + if (t !== 0) return t; + + return a.key.localeCompare(b.key); + }); +} + +function joinBlocks(blocks: string[]): string[] { + // Add a simple divider between full entries for readability. + // Discord does not have a native horizontal rule, so we use a line. + const out: string[] = []; + blocks.forEach((b, idx) => { + if (idx > 0) out.push("────────"); + out.push(b); + }); + return out; +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/faqService.ts b/data/backup-20260117-145403/src-backup/services/faq/faqService.ts new file mode 100644 index 00000000..32b59ae7 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/faqService.ts @@ -0,0 +1,176 @@ +// src/services/faq/services.ts + +import { loadStore, saveStore } from "./store.js"; +import { MAX_KEY_LEN, type CreateFaqInput, type FaqEntry } from "./types.js"; + +/* -------------------------------------------------------------------------- */ +/* Public API */ +/* -------------------------------------------------------------------------- */ + +/** + * Return all FAQ entries. + * + * Note: + * - Returns values only (does not guarantee order). + * - Caller can sort if needed (ex: by usageCount or updatedAt). + */ +export function getAll(): FaqEntry[] { + const store = loadStore(); + return Object.values(store.entries); +} + +/** + * Lookup an FAQ by key. + * + * The key is normalized the same way as create/update/remove. + */ +export function getByKey(key: string): FaqEntry | null { + const store = loadStore(); + const k = assertValidKey(key); + return store.entries[k] ?? null; +} + +/** + * Create a new FAQ entry. + * + * Throws if: + * - key normalizes to empty + * - key is too long + * - key already exists + */ +export function create(input: CreateFaqInput): FaqEntry { + const store = loadStore(); + + const key = assertValidKey(input.key); + if (store.entries[key]) { + throw new Error(`FAQ with this key already exists: ${key}`); + } + + const now = new Date().toISOString(); + + const entry: FaqEntry = { + key, + title: input.title.trim(), + body: input.body.trim(), + tags: normalizeTags(input.tags ?? []), + createdAt: now, + updatedAt: now, + createdBy: input.actor, + updatedBy: input.actor, + usageCount: 0, + }; + + store.entries[key] = entry; + saveStore(store); + + return entry; +} + +/** + * Allowed update fields for an FAQ. + * actor is required so we can attribute updatedBy. + */ +export type UpdateFaqPatch = Partial> & { + actor: string; +}; + +/** + * Update an existing FAQ entry by key. + * + * Throws if: + * - key invalid + * - entry not found + */ +export function update(key: string, patch: UpdateFaqPatch): FaqEntry { + const k = assertValidKey(key); + + const store = loadStore(); + const existing = store.entries[k]; + + if (!existing) { + throw new Error(`FAQ not found: ${k}`); + } + + // Apply patch (only touch provided fields). + if (patch.title !== undefined) existing.title = patch.title.trim(); + if (patch.body !== undefined) existing.body = patch.body.trim(); + if (patch.tags !== undefined) existing.tags = normalizeTags(patch.tags); + + existing.updatedAt = new Date().toISOString(); + existing.updatedBy = patch.actor; + + store.entries[k] = existing; + saveStore(store); + + return existing; +} + +/** + * Remove an FAQ by key. + * + * Returns: + * - true if removed + * - false if it did not exist + */ +export function remove(key: string): boolean { + const store = loadStore(); + const k = assertValidKey(key); + + if (!store.entries[k]) return false; + + delete store.entries[k]; + saveStore(store); + return true; +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * Normalize an FAQ key into a stable URL-ish slug. + * + * Example: + * "How do I reset my token?" -> "how-do-i-reset-my-token" + */ +function normalizeKey(key: string): string { + return key + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-_]/g, ""); +} + +/** + * Validate and return the normalized key. + * + * Keep this throwing: + * - Callers can catch at the command layer and reply nicely. + * - Services stay strict and predictable. + */ +function assertValidKey(key: string): string { + const k = normalizeKey(key); + + if (k.length === 0) { + throw new Error("FAQ key is empty after normalization"); + } + + if (k.length > MAX_KEY_LEN) { + throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); + } + + return k; +} + +/** + * Normalize tags for consistency: + * - trim + * - lowercase + * - remove empties + * - de-dupe + */ +function normalizeTags(tags: string[]): string[] { + const cleaned = tags.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0); + + return Array.from(new Set(cleaned)); +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/permissions.ts b/data/backup-20260117-145403/src-backup/services/faq/permissions.ts new file mode 100644 index 00000000..fef3534a --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/permissions.ts @@ -0,0 +1,47 @@ +// src/services/faq/permissions.ts +// +// FAQ permission policy. +// +// Goals: +// - Keep permission rules centralized and boring. +// - Command files call canFaqAction() and show a friendly reason. +// +// You can evolve this later to support: +// - per-guild config +// - role allowlists +// - per-action overrides + +import type { ChatInputCommandInteraction } from "discord.js"; +import { PermissionsBitField } from "discord.js"; + +export type FaqAction = "add" | "get" | "list" | "remove"; + +export type PermissionResult = { ok: true } | { ok: false; reason: string }; + +export function canFaqAction( + interaction: ChatInputCommandInteraction, + action: FaqAction, +): PermissionResult { + // get/list can be used anywhere for now + if (action === "get" || action === "list") return { ok: true }; + + // add/remove should be guild-only if you want permissions to matter + if (!interaction.inGuild()) { + return { ok: false, reason: "`/faq add/remove` can only be used in a server." }; + } + + const perms = interaction.memberPermissions; + if (!perms) { + return { ok: false, reason: "Could not resolve your permissions for this server." }; + } + + const ok = + perms.has(PermissionsBitField.Flags.ManageGuild) || + perms.has(PermissionsBitField.Flags.Administrator); + + if (!ok) { + return { ok: false, reason: "You need Manage Server or Administrator to do that." }; + } + + return { ok: true }; +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/services.test.ts b/data/backup-20260117-145403/src-backup/services/faq/services.test.ts new file mode 100644 index 00000000..d1252205 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/services.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { create } from "./services.js"; + +describe("FAQ service", () => { + it("throws when key is empty", () => { + expect(() => + create({ + key: " ", + title: "Test title", + body: "Test body", + actor: "test-user", + }), + ).toThrow(); + }); +}); diff --git a/data/backup-20260117-145403/src-backup/services/faq/services.ts b/data/backup-20260117-145403/src-backup/services/faq/services.ts new file mode 100644 index 00000000..7d9a74ed --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/services.ts @@ -0,0 +1,229 @@ +// src/services/faq/services.ts +// +// FAQ business logic layer. +// +// Responsibilities: +// - Validate + normalize inputs (keys, title/body lengths, tags) +// - Read/write the persistent store via store.ts +// - Return domain objects (FaqEntry) to command handlers +// +// Non-goals: +// - Discord interaction handling (that stays in commands) +// - Low-level file I/O details (that stays in store.ts) + +import { loadStore, saveStore } from "./store.js"; +import { logger } from "../../utils/logger.js"; +import { + MAX_KEY_LEN, + MAX_TITLE_LEN, + MAX_BODY_LEN, + MAX_TAGS, + MAX_TAG_LEN, +} from "./types.js"; +import type { FaqEntry, CreateFaqInput, UpdateFaqPatch } from "./types.js"; + +/** + * Return all FAQ entries (unordered). + */ +export function getAll(): FaqEntry[] { + const store = loadStore(); + return Object.values(store.entries); +} + +/** + * Find an entry by key. Returns null when missing. + */ +export function getByKey(rawKey: string): FaqEntry | null { + const key = assertValidKey(rawKey); + const store = loadStore(); + return store.entries[key] ?? null; +} + +/** + * Create a new FAQ entry. + * Throws if duplicate key or invalid fields. + */ +export function create(input: CreateFaqInput): FaqEntry { + const key = assertValidKey(input.key); + + const title = input.title.trim(); + if (title.length === 0) throw new Error("FAQ title cannot be empty"); + if (title.length > MAX_TITLE_LEN) { + throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); + } + + const body = input.body.trim(); + if (body.length === 0) throw new Error("FAQ body cannot be empty"); + if (body.length > MAX_BODY_LEN) { + throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); + } + + const tags = normalizeTags(input.tags); + + const store = loadStore(); + if (store.entries[key]) { + throw new Error("FAQ with this key already exists"); + } + + const now = new Date().toISOString(); + + const entry: FaqEntry = { + key, + title, + body, + tags, + createdAt: now, + updatedAt: now, + createdBy: input.actor, + updatedBy: input.actor, + usageCount: 0, + }; + + store.entries[key] = entry; + saveStore(store); + + logger.info({ key, actor: input.actor }, "[faq] created"); + return entry; +} + +/** + * Update an existing FAQ entry (title/body/tags). + * Throws if key not found or patch fields invalid. + */ +export function update(rawKey: string, patch: UpdateFaqPatch): FaqEntry { + const key = assertValidKey(rawKey); + + const store = loadStore(); + const entry = store.entries[key]; + if (!entry) throw new Error(`FAQ not found: ${key}`); + + const now = new Date().toISOString(); + + // Title (optional) + if (patch.title !== undefined) { + const title = patch.title.trim(); + if (title.length === 0) throw new Error("FAQ title cannot be empty"); + if (title.length > MAX_TITLE_LEN) { + throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); + } + entry.title = title; + } + + // Body (optional) + if (patch.body !== undefined) { + const body = patch.body.trim(); + if (body.length === 0) throw new Error("FAQ body cannot be empty"); + if (body.length > MAX_BODY_LEN) { + throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); + } + entry.body = body; + } + + // Tags (optional) + if (patch.tags !== undefined) { + entry.tags = normalizeTags(patch.tags); + } + + // Always update audit fields on a successful update call. + entry.updatedAt = now; + entry.updatedBy = patch.actor; + + store.entries[key] = entry; + saveStore(store); + + logger.info({ key, actor: patch.actor }, "[faq] updated"); + return entry; +} + +/** + * Remove an entry by key. + * Returns false if missing. + */ +export function remove(rawKey: string): boolean { + const key = assertValidKey(rawKey); + + const store = loadStore(); + if (!store.entries[key]) return false; + + delete store.entries[key]; + saveStore(store); + + logger.info({ key }, "[faq] removed"); + return true; +} + +/** + * Increment usage count for an entry (for analytics/ranking). + * Returns false if missing. + */ +export function incrementUsage(rawKey: string): boolean { + const key = assertValidKey(rawKey); + + const store = loadStore(); + const entry = store.entries[key]; + if (!entry) return false; + + entry.usageCount += 1; + entry.updatedAt = new Date().toISOString(); + + store.entries[key] = entry; + saveStore(store); + + return true; +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * Normalize keys so users can type variations but store is consistent. + * Example: " How To Deploy?! " -> "how-to-deploy" + */ +function normalizeKey(rawKey: string): string { + return rawKey + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-_]/g, ""); +} + +/** + * Validate and return a normalized key (single source of truth). + */ +function assertValidKey(rawKey: string): string { + const key = normalizeKey(rawKey); + if (key.length === 0) throw new Error("FAQ key cannot be empty"); + if (key.length > MAX_KEY_LEN) { + throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); + } + return key; +} + +/** + * Normalize tags: + * - optional input -> [] + * - trim + * - drop empties + * - enforce per-tag max length + * - de-dupe + * - enforce max number of tags + * + * Note: casing is preserved. If you want lowercased tags, add `.toLowerCase()`. + */ +function normalizeTags(tags?: string[]): string[] { + if (!tags) return []; + + const cleaned = tags + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.length > MAX_TAG_LEN ? t.slice(0, MAX_TAG_LEN) : t)); + + const deduped = Array.from(new Set(cleaned)); + + if (deduped.length > MAX_TAGS) { + return deduped.slice(0, MAX_TAGS); + } + + return deduped; +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.test.ts b/data/backup-20260117-145403/src-backup/services/faq/store.test.ts new file mode 100644 index 00000000..d2be28ed --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/store.test.ts @@ -0,0 +1,115 @@ +// src/services/faq/store.test.ts +// +// Tests for the FAQ persistence layer (store.ts). +// +// Goals: +// - Never touch your real repo ./data folder +// - Exercise real filesystem behavior in an isolated temp directory +// - Verify safe fallbacks when the store file is missing or malformed + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +type StoreModule = typeof import("./store.js"); + +let originalCwd = ""; +let tempRoot = ""; +let storePath = ""; + +/** + * Import store.ts AFTER we switch cwd. + * store.ts computes STORE_PATH at import time using process.cwd(). + */ +async function importStoreFresh(): Promise { + vi.resetModules(); + return await import("./store.js"); +} + +function rmIfExists(p: string): void { + if (!fs.existsSync(p)) return; + fs.rmSync(p, { recursive: true, force: true }); +} + +beforeAll(() => { + originalCwd = process.cwd(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omegabot-faq-store-")); + process.chdir(tempRoot); + + storePath = path.join(process.cwd(), "data", "faqs.json"); +}); + +afterAll(() => { + process.chdir(originalCwd); + rmIfExists(tempRoot); +}); + +beforeEach(() => { + // Each test starts with a clean tempRoot/data folder. + rmIfExists(path.join(process.cwd(), "data")); +}); + +describe("faq store", () => { + it("creates the store file if missing", async () => { + const { loadStore } = await importStoreFresh(); + + expect(fs.existsSync(storePath)).toBe(false); + + const store = loadStore(); + + expect(fs.existsSync(storePath)).toBe(true); + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("falls back to empty store when JSON is malformed", async () => { + const { loadStore, ensureStoreFile } = await importStoreFresh(); + + ensureStoreFile(); + fs.writeFileSync(storePath, "not json", "utf8"); + + const store = loadStore(); + + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("falls back to empty store when shape is invalid", async () => { + const { loadStore, ensureStoreFile } = await importStoreFresh(); + + ensureStoreFile(); + + // Wrong version + missing entries + fs.writeFileSync(storePath, JSON.stringify({ version: 999 }, null, 2), "utf8"); + + const store = loadStore(); + + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("saveStore writes valid JSON that loadStore can read back", async () => { + const { loadStore, saveStore } = await importStoreFresh(); + + const store = loadStore(); + + store.entries["hello"] = { + key: "hello", + title: "Hello", + body: "World", + tags: ["test"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: "system", + updatedBy: "system", + usageCount: 0, + }; + + saveStore(store); + + const reread = loadStore(); + expect(reread.entries["hello"]?.title).toBe("Hello"); + expect(reread.entries["hello"]?.tags).toEqual(["test"]); + }); +}); diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled b/data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled new file mode 100644 index 00000000..d2be28ed --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled @@ -0,0 +1,115 @@ +// src/services/faq/store.test.ts +// +// Tests for the FAQ persistence layer (store.ts). +// +// Goals: +// - Never touch your real repo ./data folder +// - Exercise real filesystem behavior in an isolated temp directory +// - Verify safe fallbacks when the store file is missing or malformed + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +type StoreModule = typeof import("./store.js"); + +let originalCwd = ""; +let tempRoot = ""; +let storePath = ""; + +/** + * Import store.ts AFTER we switch cwd. + * store.ts computes STORE_PATH at import time using process.cwd(). + */ +async function importStoreFresh(): Promise { + vi.resetModules(); + return await import("./store.js"); +} + +function rmIfExists(p: string): void { + if (!fs.existsSync(p)) return; + fs.rmSync(p, { recursive: true, force: true }); +} + +beforeAll(() => { + originalCwd = process.cwd(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omegabot-faq-store-")); + process.chdir(tempRoot); + + storePath = path.join(process.cwd(), "data", "faqs.json"); +}); + +afterAll(() => { + process.chdir(originalCwd); + rmIfExists(tempRoot); +}); + +beforeEach(() => { + // Each test starts with a clean tempRoot/data folder. + rmIfExists(path.join(process.cwd(), "data")); +}); + +describe("faq store", () => { + it("creates the store file if missing", async () => { + const { loadStore } = await importStoreFresh(); + + expect(fs.existsSync(storePath)).toBe(false); + + const store = loadStore(); + + expect(fs.existsSync(storePath)).toBe(true); + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("falls back to empty store when JSON is malformed", async () => { + const { loadStore, ensureStoreFile } = await importStoreFresh(); + + ensureStoreFile(); + fs.writeFileSync(storePath, "not json", "utf8"); + + const store = loadStore(); + + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("falls back to empty store when shape is invalid", async () => { + const { loadStore, ensureStoreFile } = await importStoreFresh(); + + ensureStoreFile(); + + // Wrong version + missing entries + fs.writeFileSync(storePath, JSON.stringify({ version: 999 }, null, 2), "utf8"); + + const store = loadStore(); + + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("saveStore writes valid JSON that loadStore can read back", async () => { + const { loadStore, saveStore } = await importStoreFresh(); + + const store = loadStore(); + + store.entries["hello"] = { + key: "hello", + title: "Hello", + body: "World", + tags: ["test"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: "system", + updatedBy: "system", + usageCount: 0, + }; + + saveStore(store); + + const reread = loadStore(); + expect(reread.entries["hello"]?.title).toBe("Hello"); + expect(reread.entries["hello"]?.tags).toEqual(["test"]); + }); +}); diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.ts b/data/backup-20260117-145403/src-backup/services/faq/store.ts new file mode 100644 index 00000000..8addf4b9 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/store.ts @@ -0,0 +1,62 @@ +// src/services/faq/store.ts +import { getDb } from "../database/db.js"; +import type { FaqEntry, FaqStoreV1 } from "./types.js"; + +export function loadStore(): FaqStoreV1 { + const db = getDb(); + const stmt = db.prepare("SELECT * FROM faqs"); + const rows = stmt.all() as any[]; + + const entries: Record = {}; + for (const row of rows) { + entries[row.key] = { + key: row.key, + title: row.title || "", + body: row.body || row.answer, + tags: row.tags ? JSON.parse(row.tags) : [], + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + usageCount: row.usage_count || 0, + createdBy: row.created_by || "unknown", + updatedBy: row.updated_by || "unknown", + }; + } + + return { + version: 1, + entries, + }; +} + +export function saveStore(store: FaqStoreV1): void { + const db = getDb(); + + db.transaction(() => { + db.prepare("DELETE FROM faqs").run(); + + const stmt = db.prepare(` + INSERT INTO faqs (key, title, body, tags, answer, created_at, updated_at, usage_count, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const faq of Object.values(store.entries)) { + const answer = faq.title ? `**${faq.title}**\n\n${faq.body}` : faq.body; + stmt.run( + faq.key, + faq.title, + faq.body, + JSON.stringify(faq.tags || []), + answer, + new Date(faq.createdAt).getTime(), + new Date(faq.updatedAt).getTime(), + faq.usageCount || 0, + faq.createdBy || "unknown", + faq.updatedBy || "unknown" + ); + } + })(); +} + +export function ensureStoreFile(): void { + // No-op for SQLite +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.ts.backup b/data/backup-20260117-145403/src-backup/services/faq/store.ts.backup new file mode 100644 index 00000000..7dadacac --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/store.ts.backup @@ -0,0 +1,57 @@ +// src/services/faq/store.ts +import { getDb } from "../database/db.js"; +import type { FaqEntry } from "./types.js"; + +export function loadStore(): Record { + const db = getDb(); + const stmt = db.prepare("SELECT * FROM faqs"); + const rows = stmt.all() as any[]; + + const entries: Record = {}; + for (const row of rows) { + entries[row.key] = { + key: row.key, + title: row.title || "", + body: row.answer, + tags: row.tags ? JSON.parse(row.tags) : [], + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + usageCount: row.usage_count || 0, + createdBy: row.created_by || "unknown", + updatedBy: row.updated_by || "unknown", + }; + } + + return entries; +} + +export function saveStore(entries: Record): void { + const db = getDb(); + + db.transaction(() => { + db.prepare("DELETE FROM faqs").run(); + + const stmt = db.prepare(` + INSERT INTO faqs (key, answer, title, tags, created_at, updated_at, usage_count, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const faq of Object.values(entries)) { + stmt.run( + faq.key, + faq.body, + faq.title, + JSON.stringify(faq.tags || []), + new Date(faq.createdAt).getTime(), + new Date(faq.updatedAt).getTime(), + faq.usageCount || 0, + faq.createdBy || "unknown", + faq.updatedBy || "unknown" + ); + } + })(); +} + +export function ensureStoreFile(): void { + // No-op for SQLite +} diff --git a/data/backup-20260117-145403/src-backup/services/faq/types.ts b/data/backup-20260117-145403/src-backup/services/faq/types.ts new file mode 100644 index 00000000..e9f68467 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/faq/types.ts @@ -0,0 +1,110 @@ +// src/services/faq/types.ts + +/* -------------------------------------------------------------------------- */ +/* Constants */ +/* -------------------------------------------------------------------------- */ + +/** + * Maximum length of an FAQ key after normalization. + * + * Keys are used as: + * - slash command arguments + * - map keys in the JSON store + * - human-readable identifiers + * + * Keeping this short prevents abuse and awkward UX. + */ +export const MAX_KEY_LEN = 48; + +/** + * Max title length after trimming. + * Keep this short so list views stay readable. + */ +export const MAX_TITLE_LEN = 80; + +/** + * Max body length after trimming. + * 4000 is a reasonable starting point and stays under typical Discord limits + * once you add formatting and headers. + */ +export const MAX_BODY_LEN = 4000; + +/** + * Max number of tags allowed on an entry. + * Prevents spam and keeps list filters usable. + */ +export const MAX_TAGS = 10; + +/** + * Max length per tag after trimming. + * Keeps tags compact for list output. + */ +export const MAX_TAG_LEN = 24; + +/* -------------------------------------------------------------------------- */ +/* Core Types */ +/* -------------------------------------------------------------------------- */ + +/** + * A single FAQ entry as stored and returned by the service layer. + * + * Notes: + * - `key` is normalized (lowercase, kebab-case). + * - timestamps are ISO strings for easy JSON storage. + * - usageCount is incremented when an FAQ is retrieved. + */ +export type FaqEntry = { + key: string; + title: string; + body: string; + tags: string[]; + + createdAt: string; + updatedAt: string; + + createdBy: string; // user id or "system" + updatedBy: string; // user id or "system" + + usageCount: number; +}; + +/** + * On-disk FAQ store format (v1). + * + * Versioning lets us migrate later without breaking users. + */ +export type FaqStoreV1 = { + version: 1; + entries: Record; +}; + +/* -------------------------------------------------------------------------- */ +/* Input Types */ +/* -------------------------------------------------------------------------- */ + +/** + * Input required to create a new FAQ. + * + * Validation rules: + * - key is normalized and validated by the service layer + * - title/body are trimmed + * - tags are optional and normalized if provided + */ +export type CreateFaqInput = { + key: string; + title: string; + body: string; + tags?: string[]; + actor: string; // user id or "system" +}; + +/** + * Allowed fields when updating an FAQ entry. + * + * Notes: + * - actor is required so updatedBy is always attributable. + * - patch fields are optional and validated by the service layer if present. + */ +export type UpdateFaqPatch = Partial> & { + actor: string; +}; diff --git a/data/backup-20260117-145403/src-backup/services/fun/coinStore.ts b/data/backup-20260117-145403/src-backup/services/fun/coinStore.ts new file mode 100644 index 00000000..96026ba1 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/fun/coinStore.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, "coin-store.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 }; +} diff --git a/data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts b/data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts new file mode 100644 index 00000000..8d0e14bb --- /dev/null +++ b/data/backup-20260117-145403/src-backup/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/data/backup-20260117-145403/src-backup/services/fun/pollStore.ts b/data/backup-20260117-145403/src-backup/services/fun/pollStore.ts new file mode 100644 index 00000000..1e97f6cb --- /dev/null +++ b/data/backup-20260117-145403/src-backup/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 }; +} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubApi.ts b/data/backup-20260117-145403/src-backup/services/github/githubApi.ts new file mode 100644 index 00000000..d38a50d0 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/githubApi.ts @@ -0,0 +1,248 @@ +// src/services/github/githubApi.ts + +import { githubRequest, GitHubApiError } from "./githubClient.js"; +import { logger } from "../../utils/logger.js"; +import type { + GitHubIssue, + GitHubPullRequest, + GitHubIssueSummary, + GitHubPrSummary, +} from "./types.js"; + +/* ------------------------------------------------------------------ */ +/* Path helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Build the base repo API path. + */ +function repoBase(owner: string, repo: string): string { + return `/repos/${owner}/${repo}`; +} + +function issuePath(owner: string, repo: string, number: number): string { + return `${repoBase(owner, repo)}/issues/${number}`; +} + +function prPath(owner: string, repo: string, number: number): string { + return `${repoBase(owner, repo)}/pulls/${number}`; +} + +export type ListIssuesOptions = { + state?: "open" | "closed" | "all"; + limit?: number; + labels?: string[]; +}; + +function listIssuesPath( + owner: string, + repo: string, + options?: ListIssuesOptions, +): string { + const params = new URLSearchParams(); + + // GitHub default is "open", but keep explicit if the caller sets it. + if (options?.state) params.set("state", options.state); + + // GitHub uses per_page for page size. + if (options?.limit) params.set("per_page", String(options.limit)); + + // Comma-separated labels. + if (options?.labels?.length) params.set("labels", options.labels.join(",")); + + // Default sorting: most recently updated first. + params.set("sort", "updated"); + params.set("direction", "desc"); + + const query = params.toString(); + return `${repoBase(owner, repo)}/issues${query ? `?${query}` : ""}`; +} + +export type ListPrOptions = { + state?: "open" | "closed" | "all"; + limit?: number; +}; + +function listPullRequestsPath( + owner: string, + repo: string, + options?: ListPrOptions, +): string { + const params = new URLSearchParams(); + + params.set("state", options?.state ?? "open"); + params.set("per_page", String(options?.limit ?? 20)); + + // For pulls: GitHub supports sort=updated + params.set("sort", "updated"); + params.set("direction", "desc"); + + return `${repoBase(owner, repo)}/pulls?${params.toString()}`; +} + +/* ------------------------------------------------------------------ */ +/* Error helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Narrow unknown errors into a GitHubApiError with status info. + * This lets callers do 404 fallback safely. + */ +function isGitHubApiError(err: unknown): err is GitHubApiError { + return err instanceof GitHubApiError; +} + +function is404(err: unknown): err is GitHubApiError { + return isGitHubApiError(err) && err.status === 404; +} + +/* ------------------------------------------------------------------ */ +/* Single item fetchers */ +/* ------------------------------------------------------------------ */ + +/** + * Fetch a single issue by number. + */ +export async function getIssue( + owner: string, + repo: string, + number: number, +): Promise { + try { + return await githubRequest(issuePath(owner, repo, number)); + } catch (err) { + if (isGitHubApiError(err)) { + logger.warn( + { owner, repo, number, status: err.status, url: err.url }, + "getIssue failed", + ); + } else { + logger.error({ err, owner, repo, number }, "getIssue threw"); + } + throw err; + } +} + +/** + * Fetch a single pull request by number. + */ +export async function getPullRequest( + owner: string, + repo: string, + number: number, +): Promise { + try { + return await githubRequest(prPath(owner, repo, number)); + } catch (err) { + if (isGitHubApiError(err)) { + logger.warn( + { owner, repo, number, status: err.status, url: err.url }, + "getPullRequest failed", + ); + } else { + logger.error({ err, owner, repo, number }, "getPullRequest threw"); + } + throw err; + } +} + +/** + * Try PR first, fallback to issue if PR endpoint returns 404. + */ +export async function getIssueOrPr( + owner: string, + repo: string, + number: number, +): Promise { + try { + return await getPullRequest(owner, repo, number); + } catch (err) { + if (is404(err)) { + return await getIssue(owner, repo, number); + } + throw err; + } +} + +/* ------------------------------------------------------------------ */ +/* List helpers (command-facing) */ +/* ------------------------------------------------------------------ */ + +/** + * List issues (issues-only; PRs filtered out) + * + * Note: + * GitHub's /issues endpoint can include PRs. + * PRs contain `pull_request` marker. We filter those out here. + */ +export async function listIssues( + owner: string, + repo: string, + options?: ListIssuesOptions, +): Promise { + try { + const data = await githubRequest(listIssuesPath(owner, repo, options)); + + return data + .filter((i) => !i.pull_request) + .map((i) => ({ + number: i.number, + title: i.title, + html_url: i.html_url, + state: i.state, + user: { login: i.user.login }, + comments: i.comments, + })); + } catch (err) { + if (isGitHubApiError(err)) { + logger.warn( + { owner, repo, status: err.status, url: err.url, options }, + "listIssues failed", + ); + } else { + logger.error({ err, owner, repo, options }, "listIssues threw"); + } + throw err; + } +} + +/** + * List pull requests (summary view) + * + * This is what the poller should consume because it includes: + * - number, title, url, author + * - updated_at for last-seen comparisons + */ +export async function listPullRequests( + owner: string, + repo: string, + options?: ListPrOptions, +): Promise { + try { + const data = await githubRequest( + listPullRequestsPath(owner, repo, options), + ); + + return data.map((pr) => ({ + number: pr.number, + title: pr.title, + html_url: pr.html_url, + state: pr.state, + merged_at: pr.merged_at, + updated_at: pr.updated_at, + user: { login: pr.user.login }, + comments: pr.comments, + review_comments: pr.review_comments, + })); + } catch (err) { + if (isGitHubApiError(err)) { + logger.warn( + { owner, repo, status: err.status, url: err.url, options }, + "listPullRequests failed", + ); + } else { + logger.error({ err, owner, repo, options }, "listPullRequests threw"); + } + throw err; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubCache.ts b/data/backup-20260117-145403/src-backup/services/github/githubCache.ts new file mode 100644 index 00000000..b47fad34 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/githubCache.ts @@ -0,0 +1,37 @@ +// src/services/github/githubCache.ts +import { SimpleCache } from "../cache/simpleCache.js"; +import { logger } from "../../utils/logger.js"; + +const cache = new SimpleCache(); +const CACHE_TTL = 300; // 5 minutes + +export async function cachedGitHubRequest( + key: string, + fetcher: () => Promise +): Promise { + const cached = cache.get(key); + if (cached !== null) { + logger.debug({ key }, "GitHub cache HIT"); + return cached as T; + } + + logger.debug({ key }, "GitHub cache MISS"); + + try { + const data = await fetcher(); + cache.set(key, data, CACHE_TTL); + return data; + } catch (error) { + logger.error({ error, key }, "GitHub API request failed"); + throw error; + } +} + +export function clearGitHubCache(): void { + cache.clear(); + logger.info("GitHub cache cleared"); +} + +export function getGitHubCacheStats() { + return cache.getStats(); +} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubClient.ts b/data/backup-20260117-145403/src-backup/services/github/githubClient.ts new file mode 100644 index 00000000..4d62e23d --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/githubClient.ts @@ -0,0 +1,85 @@ +// src/services/github/githubClient.ts + +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Base URL for GitHub REST API v3 + */ +const GITHUB_API_BASE = "https://api.github.com"; + +/** + * Error shape thrown when GitHub responds with a non-2xx status. + * Callers can inspect `status` to decide what to do (ex: 404 fallback). + */ +export class GitHubApiError extends Error { + status: number; + url: string; + + constructor(message: string, status: number, url: string) { + super(message); + this.name = "GitHubApiError"; + this.status = status; + this.url = url; + } +} + +/** + * Low-level GitHub request helper. + * + * Responsibilities: + * - Validate auth is configured (only when GitHub features are invoked) + * - Add auth headers + * - Perform fetch + * - Handle non-OK responses + * - Return typed JSON + * + * Higher-level logic (issues, PRs, fallback behavior) lives in githubApi.ts. + * + * Note: + * This function SHOULD NOT be called at startup unless GitHub features are enabled. + * The bot must be able to run without GitHub tokens/config. + */ +export async function githubRequest(path: string): Promise { + const url = `${GITHUB_API_BASE}${path}`; + + // Enforce token only when a GitHub code path is actually executed. + const token = env.requireGithubToken(); + + try { + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + let message = res.statusText; + + // GitHub usually returns JSON with { message: "...", ... } + try { + const body = (await res.json()) as { message?: string }; + if (body?.message) message = body.message; + } catch { + // Ignore parse failures, keep statusText + } + + logger.warn( + { status: res.status, url, path, message }, + "GitHub API request failed", + ); + + throw new GitHubApiError(message, res.status, url); + } + + return (await res.json()) as T; + } catch (err) { + if (err instanceof GitHubApiError) throw err; + + logger.error({ err, url, path }, "GitHub API request threw"); + throw err; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts b/data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts new file mode 100644 index 00000000..0d27afe2 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts @@ -0,0 +1,17 @@ +import { GitHubApiError } from "./githubClient.js"; + +export function getGitHubUserMessage(err: unknown): string | null { + if (!(err instanceof GitHubApiError)) return null; + + switch (err.status) { + case 404: + return "I could not find that PR. Double check the number and repo."; + case 401: + case 403: + return "I cannot access that repo with the current GitHub token. Check token and repo permissions."; + case 429: + return "GitHub rate limited me. Try again in a bit."; + default: + return null; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts new file mode 100644 index 00000000..2ce1153f --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts @@ -0,0 +1,334 @@ +// src/services/github/issueAssigneePoller.ts + +import type { Client } from "discord.js"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +type PollArgs = { + client: Client; + owner: string; + repo: string; + announceChannelId: string; +}; + +type GitHubAssignee = { login: string }; + +type GitHubIssueItem = { + number: number; + title: string; + html_url: string; + assignees?: GitHubAssignee[]; + // Present on PRs returned in the issues list + pull_request?: unknown; +}; + +type TrackedItem = { + kind: "PR" | "Issue"; + title: string; + url: string; + assignees: string[]; +}; + +type StateFile = { + /** + * Back-compat: + * Older state files might only have assigneesByNumber. + * We’ll load them and upgrade in-memory automatically. + */ + assigneesByNumber?: Record; + itemsByNumber?: Record; + initializedAt: string; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); + +function uniqSorted(list: string[]): string[] { + return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => + a.localeCompare(b), + ); +} + +function diff(prev: string[], next: string[]) { + const prevSet = new Set(prev); + const nextSet = new Set(next); + + const added = next.filter((x) => !prevSet.has(x)); + const removed = prev.filter((x) => !nextSet.has(x)); + + return { added, removed, changed: added.length > 0 || removed.length > 0 }; +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as StateFile; + if (!parsed || typeof parsed !== "object") return null; + + // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) + const hasOld = + !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; + const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; + + if (!hasOld && !hasNew) return null; + if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; + + return parsed; + } catch { + return null; + } +} + +async function saveState(state: StateFile): Promise { + await ensureDataDir(); + await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); +} + +async function getAnnounceChannel( + client: Client, + channelId: string, +): Promise<{ send: (content: string) => Promise }> { + const ch = await client.channels.fetch(channelId); + + if (!ch || !ch.isTextBased()) { + throw new Error(`Announce channel is not a text channel: ${channelId}`); + } + + // TypeScript guard: not all TextBasedChannel unions guarantee send() + if (!("send" in ch) || typeof ch.send !== "function") { + throw new Error(`Announce channel does not support send(): ${channelId}`); + } + + return ch; +} + +async function githubFetchJson(url: string, token: string): Promise { + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "OmegaBot", + Authorization: `token ${token}`, + }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, + ); + } + + return (await res.json()) as T; +} + +function stateToItems(existing: StateFile): Record { + // New format available + if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { + return existing.itemsByNumber; + } + + // Upgrade old format in-memory (no title/url/kind available) + const old = existing.assigneesByNumber ?? {}; + const upgraded: Record = {}; + for (const [num, assignees] of Object.entries(old)) { + upgraded[num] = { + kind: "Issue", + title: "(unknown title)", + url: "(unknown url)", + assignees: uniqSorted(assignees ?? []), + }; + } + return upgraded; +} + +/** + * Poll open issues (includes PRs) and notify on: + * - assignee changes (add/remove/replace/multi/unassign) + * - issues/PRs that are no longer open (closed/merged/etc) + * + * Notes: + * - Baseline-first: first successful run saves state and does NOT notify. + * - State is persisted to ./data/github-assignees.json + */ +export async function pollIssueAssigneesOnce(args: PollArgs): Promise { + const { client, owner, repo, announceChannelId } = args; + + let token: string; + try { + token = env.requireGithubToken(); + } catch (err) { + logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); + return; + } + + let channel: { send: (content: string) => Promise }; + try { + channel = await getAnnounceChannel(client, announceChannelId); + } catch (err) { + logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); + return; + } + + // Load state (or init) + const existing = (await loadState()) ?? { + assigneesByNumber: {}, + initializedAt: new Date().toISOString(), + }; + + const existingItemsByNumber = stateToItems(existing); + const hadExistingState = Object.keys(existingItemsByNumber).length > 0; + + // Fetch open issues (includes PRs) + const url = `https://api.github.com/repos/${encodeURIComponent( + owner, + )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; + + let items: GitHubIssueItem[]; + try { + items = await githubFetchJson(url, token); + } catch (err) { + logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); + return; + } + + const nextItemsByNumber: Record = {}; + const notifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + added: string[]; + removed: string[]; + }> = []; + + for (const item of items) { + const numberKey = String(item.number); + + const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; + const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); + + nextItemsByNumber[numberKey] = { + kind, + title: item.title, + url: item.html_url, + assignees: nextAssignees, + }; + + const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); + const { added, removed, changed } = diff(prevAssignees, nextAssignees); + + if (hadExistingState && changed) { + notifications.push({ + kind, + number: item.number, + title: item.title, + url: item.html_url, + added, + removed, + }); + } + } + + // Detect items that were previously open but are no longer open + const closedNotifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + }> = []; + + if (hadExistingState) { + const prevNumbers = new Set(Object.keys(existingItemsByNumber)); + const nextNumbers = new Set(Object.keys(nextItemsByNumber)); + + for (const num of prevNumbers) { + if (!nextNumbers.has(num)) { + const prev = existingItemsByNumber[num]; + closedNotifications.push({ + kind: prev?.kind ?? "Issue", + number: Number(num), + title: prev?.title ?? "(unknown title)", + url: prev?.url ?? "(unknown url)", + }); + } + } + } + + // Save next state (drops closed issues automatically) + const nextState: StateFile = { + itemsByNumber: nextItemsByNumber, + initializedAt: existing.initializedAt ?? new Date().toISOString(), + }; + + try { + await saveState(nextState); + } catch (err) { + logger.error({ err }, "[github/assignees] failed to save state"); + } + + // Baseline-first: no notifications on first successful run + if (!hadExistingState) { + logger.info( + { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, + "[github/assignees] baseline saved (no notifications on first run)", + ); + return; + } + + // Announce assignee changes + const issueNotifications = notifications.filter(n => n.kind === "Issue"); + + for (const n of issueNotifications) { + const parts: string[] = []; + parts.push(`Issue # assignees updated`); + parts.push(n.title); + parts.push(n.url); + + if (n.added.length) { + parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); + } + if (n.removed.length) { + parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); + } + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, + "[github/assignees] announced", + ); + } catch (err) { + logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); + } + } + + // Announce closures (issues + PRs) + const closedIssues = closedNotifications.filter(c => c.kind === "Issue"); + + for (const c of closedIssues) { + const parts: string[] = []; + parts.push(`Issue # closed`); + parts.push(c.title); + parts.push(c.url); + parts.push("(Closed/merged/etc — detected via polling)"); + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: c.number, kind: c.kind }, + "[github/assignees] closure announced", + ); + } catch (err) { + logger.error( + { err, number: c.number }, + "[github/assignees] failed to announce closure", + ); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup new file mode 100644 index 00000000..2ce1153f --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup @@ -0,0 +1,334 @@ +// src/services/github/issueAssigneePoller.ts + +import type { Client } from "discord.js"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +type PollArgs = { + client: Client; + owner: string; + repo: string; + announceChannelId: string; +}; + +type GitHubAssignee = { login: string }; + +type GitHubIssueItem = { + number: number; + title: string; + html_url: string; + assignees?: GitHubAssignee[]; + // Present on PRs returned in the issues list + pull_request?: unknown; +}; + +type TrackedItem = { + kind: "PR" | "Issue"; + title: string; + url: string; + assignees: string[]; +}; + +type StateFile = { + /** + * Back-compat: + * Older state files might only have assigneesByNumber. + * We’ll load them and upgrade in-memory automatically. + */ + assigneesByNumber?: Record; + itemsByNumber?: Record; + initializedAt: string; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); + +function uniqSorted(list: string[]): string[] { + return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => + a.localeCompare(b), + ); +} + +function diff(prev: string[], next: string[]) { + const prevSet = new Set(prev); + const nextSet = new Set(next); + + const added = next.filter((x) => !prevSet.has(x)); + const removed = prev.filter((x) => !nextSet.has(x)); + + return { added, removed, changed: added.length > 0 || removed.length > 0 }; +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as StateFile; + if (!parsed || typeof parsed !== "object") return null; + + // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) + const hasOld = + !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; + const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; + + if (!hasOld && !hasNew) return null; + if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; + + return parsed; + } catch { + return null; + } +} + +async function saveState(state: StateFile): Promise { + await ensureDataDir(); + await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); +} + +async function getAnnounceChannel( + client: Client, + channelId: string, +): Promise<{ send: (content: string) => Promise }> { + const ch = await client.channels.fetch(channelId); + + if (!ch || !ch.isTextBased()) { + throw new Error(`Announce channel is not a text channel: ${channelId}`); + } + + // TypeScript guard: not all TextBasedChannel unions guarantee send() + if (!("send" in ch) || typeof ch.send !== "function") { + throw new Error(`Announce channel does not support send(): ${channelId}`); + } + + return ch; +} + +async function githubFetchJson(url: string, token: string): Promise { + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "OmegaBot", + Authorization: `token ${token}`, + }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, + ); + } + + return (await res.json()) as T; +} + +function stateToItems(existing: StateFile): Record { + // New format available + if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { + return existing.itemsByNumber; + } + + // Upgrade old format in-memory (no title/url/kind available) + const old = existing.assigneesByNumber ?? {}; + const upgraded: Record = {}; + for (const [num, assignees] of Object.entries(old)) { + upgraded[num] = { + kind: "Issue", + title: "(unknown title)", + url: "(unknown url)", + assignees: uniqSorted(assignees ?? []), + }; + } + return upgraded; +} + +/** + * Poll open issues (includes PRs) and notify on: + * - assignee changes (add/remove/replace/multi/unassign) + * - issues/PRs that are no longer open (closed/merged/etc) + * + * Notes: + * - Baseline-first: first successful run saves state and does NOT notify. + * - State is persisted to ./data/github-assignees.json + */ +export async function pollIssueAssigneesOnce(args: PollArgs): Promise { + const { client, owner, repo, announceChannelId } = args; + + let token: string; + try { + token = env.requireGithubToken(); + } catch (err) { + logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); + return; + } + + let channel: { send: (content: string) => Promise }; + try { + channel = await getAnnounceChannel(client, announceChannelId); + } catch (err) { + logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); + return; + } + + // Load state (or init) + const existing = (await loadState()) ?? { + assigneesByNumber: {}, + initializedAt: new Date().toISOString(), + }; + + const existingItemsByNumber = stateToItems(existing); + const hadExistingState = Object.keys(existingItemsByNumber).length > 0; + + // Fetch open issues (includes PRs) + const url = `https://api.github.com/repos/${encodeURIComponent( + owner, + )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; + + let items: GitHubIssueItem[]; + try { + items = await githubFetchJson(url, token); + } catch (err) { + logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); + return; + } + + const nextItemsByNumber: Record = {}; + const notifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + added: string[]; + removed: string[]; + }> = []; + + for (const item of items) { + const numberKey = String(item.number); + + const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; + const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); + + nextItemsByNumber[numberKey] = { + kind, + title: item.title, + url: item.html_url, + assignees: nextAssignees, + }; + + const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); + const { added, removed, changed } = diff(prevAssignees, nextAssignees); + + if (hadExistingState && changed) { + notifications.push({ + kind, + number: item.number, + title: item.title, + url: item.html_url, + added, + removed, + }); + } + } + + // Detect items that were previously open but are no longer open + const closedNotifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + }> = []; + + if (hadExistingState) { + const prevNumbers = new Set(Object.keys(existingItemsByNumber)); + const nextNumbers = new Set(Object.keys(nextItemsByNumber)); + + for (const num of prevNumbers) { + if (!nextNumbers.has(num)) { + const prev = existingItemsByNumber[num]; + closedNotifications.push({ + kind: prev?.kind ?? "Issue", + number: Number(num), + title: prev?.title ?? "(unknown title)", + url: prev?.url ?? "(unknown url)", + }); + } + } + } + + // Save next state (drops closed issues automatically) + const nextState: StateFile = { + itemsByNumber: nextItemsByNumber, + initializedAt: existing.initializedAt ?? new Date().toISOString(), + }; + + try { + await saveState(nextState); + } catch (err) { + logger.error({ err }, "[github/assignees] failed to save state"); + } + + // Baseline-first: no notifications on first successful run + if (!hadExistingState) { + logger.info( + { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, + "[github/assignees] baseline saved (no notifications on first run)", + ); + return; + } + + // Announce assignee changes + const issueNotifications = notifications.filter(n => n.kind === "Issue"); + + for (const n of issueNotifications) { + const parts: string[] = []; + parts.push(`Issue # assignees updated`); + parts.push(n.title); + parts.push(n.url); + + if (n.added.length) { + parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); + } + if (n.removed.length) { + parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); + } + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, + "[github/assignees] announced", + ); + } catch (err) { + logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); + } + } + + // Announce closures (issues + PRs) + const closedIssues = closedNotifications.filter(c => c.kind === "Issue"); + + for (const c of closedIssues) { + const parts: string[] = []; + parts.push(`Issue # closed`); + parts.push(c.title); + parts.push(c.url); + parts.push("(Closed/merged/etc — detected via polling)"); + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: c.number, kind: c.kind }, + "[github/assignees] closure announced", + ); + } catch (err) { + logger.error( + { err, number: c.number }, + "[github/assignees] failed to announce closure", + ); + } + } +} diff --git a/data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts b/data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts new file mode 100644 index 00000000..c6e99cdf --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts @@ -0,0 +1,136 @@ +// src/services/github/lastSeenStore.ts + +import fs from "fs"; +import path from "path"; + +/** + * Absolute path to the persistent store for GitHub announcement state. + * + * This file is intentionally: + * - JSON (human-readable, debuggable) + * - Stored outside src/ so it survives rebuilds + * + * Example contents: + * { + * "owner/repo": 1713291823000 + * } + */ +const STORE_PATH = path.join(process.cwd(), "data", "last-seen.json"); + +/** + * Internal storage shape. + * + * Key → "owner/repo" + * Value → Unix timestamp in milliseconds (Number) + * + * We store numbers so comparisons are cheap and timezone-agnostic. + */ +type Store = Record; + +/** + * Get the last stored timestamp for a repo. + * + * Returns: + * - unix millis number if present + * - null if the repo has never been seen + */ +export function getLastSeen(owner: string, repo: string): number | null { + const store = loadStore(); + const key = makeRepoKey(owner, repo); + return key in store ? store[key] : null; +} + +/** + * Save the "last seen" timestamp for PR announcements. + * + * We store unix millis (Number) to make comparisons cheap and timezone-agnostic. + * + * @param updatedAtIso ISO timestamp string from GitHub API (ex: pr.updated_at) + */ +export function setLastSeenPr(owner: string, repo: string, updatedAtIso: string): void { + const store = loadStore(); + const key = makeRepoKey(owner, repo); + + const ms = Date.parse(updatedAtIso); + if (Number.isNaN(ms)) { + throw new Error(`Invalid updatedAt timestamp: ${updatedAtIso}`); + } + + store[key] = ms; + saveStore(store); +} + +/** + * Remove any stored "last seen" value for a repo. + * + * Safe to call even if the repo was never stored. + * Useful for development resets and testing. + */ +export function clearLastSeen(owner: string, repo: string): void { + const store = loadStore(); + const key = makeRepoKey(owner, repo); + + if (key in store) { + delete store[key]; + saveStore(store); + } +} + +/** + * Build a stable storage key for a repo. + * + * We intentionally normalize on "owner/repo" because: + * - It is human-readable + * - Matches GitHub's canonical naming + * - Avoids nested JSON structures + */ +function makeRepoKey(owner: string, repo: string): string { + return `${owner}/${repo}`; +} + +/** + * Load the last-seen store from disk. + * + * Behavior: + * - If the file does not exist → return empty store + * - If the file exists → parse JSON + * + * This function is synchronous by design: + * - It runs rarely (poll job / command invocation) + * - Simpler failure modes + * - Fewer edge cases in a single-process bot + */ +function loadStore(): Store { + if (!fs.existsSync(STORE_PATH)) { + return {}; + } + + const raw = fs.readFileSync(STORE_PATH, "utf8"); + + try { + return JSON.parse(raw) as Store; + } catch { + // If JSON is corrupted, fail safe to empty. + // Worst case: PRs may be re-announced once. + return {}; + } +} + +/** + * Persist the last-seen store to disk. + * + * Guarantees: + * - Parent directory exists + * - JSON is pretty-printed for easy debugging + * + * We overwrite the file entirely to keep writes simple. + */ +function saveStore(store: Store): void { + const dir = path.dirname(STORE_PATH); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} diff --git a/data/backup-20260117-145403/src-backup/services/github/prFormatter.ts b/data/backup-20260117-145403/src-backup/services/github/prFormatter.ts new file mode 100644 index 00000000..a50fc05d --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/prFormatter.ts @@ -0,0 +1,53 @@ +// src/services/github/prFormatter.ts + +import type { GitHubPullRequest } from "./types.js"; + +/* ------------------------------------------------------------------ */ +/* Formatting helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Format a single GitHub pull request into a Discord-friendly message. + * + * Design goals: + * - Pure function (no I/O, no Discord client usage) + * - Safe to reuse in commands and background pollers + * - Stable output for future diffing / testing + * + * IMPORTANT: + * This file must NOT import itself or re-export via a barrel, + * otherwise TypeScript will create circular alias errors. + */ + +/** + * Format a pull request announcement message. + */ +export function formatPullRequest(pr: GitHubPullRequest): string { + const author = pr.user?.login ? ` by @${pr.user.login}` : ""; + const state = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open"; + + const updatedAt = pr.updated_at ? ` (updated ${formatTimestamp(pr.updated_at)})` : ""; + + return [ + `**PR #${pr.number}** ${pr.title}`, + `${state}${author}${updatedAt}`, + pr.html_url, + ].join("\n"); +} + +/* ------------------------------------------------------------------ */ +/* Internal utilities */ +/* ------------------------------------------------------------------ */ + +/** + * Format an ISO timestamp into a short, readable form. + * + * We intentionally avoid locale-specific formatting so output + * is consistent across environments. + */ +function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + + return date.toISOString().replace("T", " ").replace("Z", " UTC"); +} diff --git a/data/backup-20260117-145403/src-backup/services/github/prPoller.ts b/data/backup-20260117-145403/src-backup/services/github/prPoller.ts new file mode 100644 index 00000000..9c662b13 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/prPoller.ts @@ -0,0 +1,95 @@ +// src/services/github/prPoller.ts + +import type { Client } from "discord.js"; +import { listPullRequests } from "./githubApi.js"; +import { getLastSeen, setLastSeenPr } from "./lastSeenStore.js"; +import { formatPullRequest } from "./prFormatter.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Minimal shape we need from the GitHub PR list for polling announcements. + * githubApi.listPullRequests must return objects with `created_at`. + */ +type PrForPolling = { + created_at: string; +}; + +/** + * Poll GitHub for NEW PRs (created since last seen) and announce them in a Discord channel. + * + * Design goals: + * - Never throw (background job safety) + * - Log failures once with context + * - Skip quietly when nothing to do + * - Baseline-first: first successful run stores lastSeen and does NOT announce + */ +export async function pollPullRequestsOnce(args: { + client: Client; + owner: string; + repo: string; + announceChannelId: string; + limit?: number; +}): Promise { + const { client, owner, repo, announceChannelId, limit = 20 } = args; + + try { + // Pull newest-first (based on githubApi query params) + // IMPORTANT: We only announce PRs based on created_at (not updated_at) + const prs = (await listPullRequests(owner, repo, { + state: "open", + limit, + })) as unknown as (PrForPolling & Parameters[0])[]; + + if (prs.length === 0) return; + + const lastSeen = getLastSeen(owner, repo) ?? 0; + + // Baseline-first: if we've never seen anything, set marker and do not announce + if (lastSeen === 0) { + const newestCreated = prs + .map((pr) => Date.parse(pr.created_at)) + .filter((ms) => !Number.isNaN(ms)) + .sort((a, b) => b - a)[0]; + + if (newestCreated && newestCreated > 0) { + // Store the timestamp as an ISO string via setLastSeenPr + setLastSeenPr(owner, repo, new Date(newestCreated).toISOString()); + logger.info({ owner, repo }, "PR poller baseline saved (no announcements)"); + } + + return; + } + + // Keep only PRs created after our stored timestamp + const fresh = prs.filter((pr) => { + const ms = Date.parse(pr.created_at); + return !Number.isNaN(ms) && ms > lastSeen; + }); + + if (fresh.length === 0) return; + + const fetched = await client.channels.fetch(announceChannelId); + if (!fetched || !fetched.isTextBased()) { + logger.warn({ announceChannelId }, "PR poller could not resolve announce channel"); + return; + } + + // Extra runtime guard + if (!("send" in fetched) || typeof fetched.send !== "function") return; + + // Post oldest-first so announcements read naturally + const oldestFirst = [...fresh].sort( + (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at), + ); + + for (const pr of oldestFirst) { + await fetched.send(formatPullRequest(pr)); + } + + // Update last-seen to newest PR we announced (by created_at) + const newest = oldestFirst[oldestFirst.length - 1]; + setLastSeenPr(owner, repo, newest.created_at); + } catch (err) { + logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed"); + } +} diff --git a/data/backup-20260117-145403/src-backup/services/github/types.ts b/data/backup-20260117-145403/src-backup/services/github/types.ts new file mode 100644 index 00000000..b34ee99e --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/github/types.ts @@ -0,0 +1,130 @@ +/** + * GitHub API Types + * + * This file defines the data contracts used by OmegaBot when interacting + * with the GitHub REST API. + * + * Design goals: + * - Keep types explicit and readable + * - Separate "full API shapes" from "command-friendly views" + * - Avoid over-modeling fields we don’t actually use + */ + +/* ------------------------------------------------------------------ */ +/* Shared base types */ +/* ------------------------------------------------------------------ */ + +export type GitHubUser = { + login: string; + html_url: string; +}; + +export type GitHubLabel = { + name: string; + color: string; +}; + +/* ------------------------------------------------------------------ */ +/* Full GitHub API response types */ +/* ------------------------------------------------------------------ */ + +/** + * GitHub Issue + * + * NOTE: + * - Pull Requests also appear in the `/issues` API. + * - When an issue is a PR, it includes a `pull_request` field. + */ +export type GitHubIssue = { + id: number; + number: number; + title: string; + body: string | null; + state: "open" | "closed"; + html_url: string; + + user: GitHubUser; + labels: GitHubLabel[]; + + comments: number; + + created_at: string; + updated_at: string; + closed_at: string | null; + + pull_request?: { + html_url: string; + }; +}; + +/** + * GitHub Pull Request + * + * Returned from the `/pulls` API. + * Contains additional fields not present on issues. + */ +export type GitHubPullRequest = { + id: number; + number: number; + title: string; + body: string | null; + state: "open" | "closed"; + html_url: string; + + user: GitHubUser; + + merged: boolean; + mergeable: boolean | null; + + comments: number; + review_comments: number; + commits: number; + additions: number; + deletions: number; + changed_files: number; + + created_at: string; + updated_at: string; + closed_at: string | null; + merged_at: string | null; +}; + +export type GitHubRepo = { + full_name: string; + description: string | null; + html_url: string; + stargazers_count: number; + forks_count: number; + open_issues_count: number; +}; + +/* ------------------------------------------------------------------ */ +/* Command-friendly / normalized response types */ +/* ------------------------------------------------------------------ */ + +export type GitHubIssueSummary = { + number: number; + title: string; + html_url: string; + state: "open" | "closed"; + user?: { login: string }; + comments?: number; +}; + +/** + * PR summary used by commands and the poller. + * + * Important: + * - includes `updated_at` so we can do "last seen" comparisons + */ +export type GitHubPrSummary = { + number: number; + title: string; + html_url: string; + state: "open" | "closed"; + merged_at: string | null; + updated_at: string; + user?: { login: string }; + comments?: number; + review_comments?: number; +}; diff --git a/data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts b/data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts new file mode 100644 index 00000000..3a729991 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts @@ -0,0 +1,63 @@ +import type { GuildMember } from "discord.js"; +import { PermissionFlagsBits } from "discord.js"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +export async function handleAutoRole(member: GuildMember): Promise { + if (!env.discordAutoRoleId) { + logger.warn("discordAutoRoleId not set; auto-role disabled"); + return; + } + + if (member.user.bot) { + logger.debug("auto-role skipped: bot user"); + return; + } + + const role = member.guild.roles.cache.get(env.discordAutoRoleId); + + if (!role) { + logger.warn( + { roleId: env.discordAutoRoleId, guildId: member.guild.id }, + "auto-role skipped: role not found in guild", + ); + return; + } + + const hasAutoRole = member.roles.cache.has(role.id); + + if (hasAutoRole) { + logger.debug( + { userId: member.user.id, roleId: role.id }, + "auto-role skipped: member already has role", + ); + return; + } + + const botMember = member.guild.members.me; + + if (!botMember) { + logger.warn("auto-role failed: could not resolve bot member in guild"); + return; + } + + if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { + logger.warn("auto-role failed: missing Manage Roles permission"); + return; + } + + if (role.position >= botMember.roles.highest.position) { + logger.warn("auto-role failed: role is higher than bot's highest role"); + return; + } + + try { + await member.roles.add(role); + logger.info({ userId: member.user.id, roleId: role.id }, "auto-role assigned"); + } catch (err) { + logger.error( + { err, userId: member.user.id, roleId: role.id }, + "auto-role failed during assignment", + ); + } +} diff --git a/data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts b/data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts new file mode 100644 index 00000000..c054a3e8 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts @@ -0,0 +1,64 @@ +import OpenAI from "openai"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +let client: OpenAI | null = null; + +/** + * Initialize OpenAI client only if an API key is present. + * This allows the bot to run in "local" summary mode without crashing. + */ +if (env.openAIKey) { + client = new OpenAI({ apiKey: env.openAIKey }); +} + +/** + * Generate a structured summary (Markdown) from a transcript using an LLM. + * + * Behavior: + * - If LLM mode is requested but no API key is configured, return a safe message + * - If the OpenAI request fails, log the error and return a user-friendly fallback + * + * We intentionally: + * - Do NOT log the transcript or prompt (privacy + noise) + * - Log only the failure signal for observability + */ +export async function llmSummary(text: string): Promise { + if (!client) { + return "LLM mode requested but no API key is configured."; + } + + const prompt = [ + "You are a Discord channel summarizer.", + "Given the transcript below, produce a structured Markdown output with these sections:", + "## Summary (2 to 5 bullets)", + "## Key points (3 to 8 bullets)", + "## Action items (if any, otherwise say 'None')", + "## Open questions (if any, otherwise say 'None')", + "", + "Rules:", + "- Do not include timestamps.", + "- Avoid quoting usernames; paraphrase instead unless absolutely necessary.", + "- Keep it concise and readable.", + "", + "Transcript:", + text, + ].join("\n"); + + try { + const response = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + + /** + * Extract model output, falling back safely if no content is returned. + */ + const content = response.choices?.[0]?.message?.content ?? "LLM returned no content."; + + return content.trim(); + } catch (err) { + logger.error({ err }, "LLM summary generation failed"); + return "Failed to generate summary via LLM."; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/summary/localSummary.ts b/data/backup-20260117-145403/src-backup/services/summary/localSummary.ts new file mode 100644 index 00000000..0f44f4c0 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/summary/localSummary.ts @@ -0,0 +1,72 @@ +/* + * Produce a simple heuristic summary of recent messages by counting: + * - total messages + * - unique participants + * - frequent words + * - the longest (most detailed) message + */ +export function localSummary(text: string): string { + const lines = text.split("\n"); + const total = lines.length; + + const users = new Set(); + let longest = ""; + const keywords: Record = {}; + + /* + * Iterate through each line and extract metadata for the summary. + * Expected format per line: "username: message text" + */ + for (const line of lines) { + /* + * Separate the username and message content. + */ + const [user, msg] = line.split(": "); + if (user) { + users.add(user); + } + + /* + * Track the longest message so we can surface it as the detailed example. + */ + if (msg && msg.length > longest.length) { + longest = msg; + } + + /* + * Break the message into lowercase words and count how often each appears. + */ + if (msg) { + msg.split(/\s+/).forEach((word) => { + const w = word.toLowerCase(); + if (!w) return; + if (!keywords[w]) { + keywords[w] = 0; + } + keywords[w]++; + }); + } + } + + /* + * Select the top 5 most frequent meaningful words to highlight in the summary. + * We ignore very short tokens (length <= 3) to skip things like "the", "and". + */ + const topWords = Object.entries(keywords) + .filter(([k]) => k.length > 3) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([w, c]) => `${w} (${c})`) + .join(", "); + + /* + * Produce the final formatted summary string returned to the user. + */ + return ( + "Summary of recent messages:\n" + + `Messages: ${total}\n` + + `Participants: ${users.size}\n` + + `Top words: ${topWords || "none"}\n\n` + + `Most detailed message:\n${longest}` + ); +} diff --git a/data/backup-20260117-145403/src-backup/services/summary/summarizer.ts b/data/backup-20260117-145403/src-backup/services/summary/summarizer.ts new file mode 100644 index 00000000..2c885bd9 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/summary/summarizer.ts @@ -0,0 +1,16 @@ +import { env } from "../../config/env.js"; +import { localSummary } from "./localSummary.js"; +import { llmSummary } from "./llmSummary.js"; + +/* + * Decide which summarization method to use (LLM or local) based on configuration. + */ +export async function summarize(text: string): Promise { + if (env.summaryMode === "llm") { + return llmSummary(text); + } + /* + * Use the lightweight local summarizer when LLM mode is disabled. + */ + return localSummary(text); +} diff --git a/data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts b/data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts new file mode 100644 index 00000000..3bd9acdf --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts @@ -0,0 +1,21 @@ +/** + * Format a Unix timestamp (ms) into a readable date/time string. + * + * - Uses 24-hour time (hour12: false) + * - Caller controls locale + timezone + * + * @param ts - Timestamp in milliseconds + * @param timeZone - IANA timezone (ex: "America/New_York") + * @param locale - Locale string (ex: "en-GB", "en-US") + */ +export function formatTimestamp(ts: number, timeZone: string, locale: string): string { + return new Date(ts).toLocaleString(locale, { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} diff --git a/data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts b/data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts new file mode 100644 index 00000000..3a5d7658 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts @@ -0,0 +1,28 @@ +// src/services/time/validateTimeZone.ts + +/** + * Validate whether a string is a valid IANA timezone. + * + * Examples of valid values: + * - "UTC" + * - "America/New_York" + * - "Europe/London" + * + * This does NOT guess timezones. + * It only validates exact IANA identifiers. + * + * Note: + * We intentionally do NOT log here. Invalid timezone input is normal user input, + * not an operational error. Callers can decide how to message the user. + */ +export function validateTimeZone(tz: string): boolean { + if (!tz || typeof tz !== "string") return false; + + try { + // Intl.DateTimeFormat will throw if the timezone is invalid. + Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} diff --git a/data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts b/data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts new file mode 100644 index 00000000..7de0aa54 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts @@ -0,0 +1,144 @@ +// src/services/timezone/timezoneStore.ts + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { logger } from "../../utils/logger.js"; + +export type StoredTimezone = { + version: 1; + userId: string; + guildId: string | null; // optional scoping; null means global + timezone: string; // IANA zone, ex: America/New_York + label?: string; // optional display label, ex: "EST" (never used for math) + createdAt: string; + updatedAt: string; +}; + +type TimezoneStoreFileV1 = { + version: 1; + updatedAt: string; + // Keyed by "guildId:userId" if guild-scoped, otherwise "global:userId" + zones: Record; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STORE_PATH = path.join(DATA_DIR, "timezones.json"); + +function nowIso(): string { + return new Date().toISOString(); +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +function emptyStore(): TimezoneStoreFileV1 { + return { + version: 1, + updatedAt: nowIso(), + zones: {}, + }; +} + +function makeKey(args: { + guildId: string | null; + userId: string; + scope: "guild" | "global"; +}): string { + if (args.scope === "guild") { + return `${args.guildId ?? "noguild"}:${args.userId}`; + } + return `global:${args.userId}`; +} + +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 zones = parsed.zones && typeof parsed.zones === "object" ? parsed.zones : {}; + + return { + version: 1, + updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(), + zones: zones as Record, + }; + } catch { + return emptyStore(); + } +} + +async function saveStore(store: TimezoneStoreFileV1): Promise { + await ensureDataDir(); + await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +export async function setUserTimezone(args: { + userId: string; + guildId: string | null; + scope: "guild" | "global"; + timezone: string; + label?: string; +}): Promise { + const store = await loadStore(); + const key = makeKey({ guildId: args.guildId, userId: args.userId, scope: args.scope }); + + const existing = store.zones[key]; + const createdAt = existing?.createdAt ?? nowIso(); + + const tz: StoredTimezone = { + version: 1, + userId: args.userId, + guildId: args.scope === "guild" ? args.guildId : null, + timezone: args.timezone, + label: args.label, + createdAt, + updatedAt: nowIso(), + }; + + store.zones[key] = tz; + store.updatedAt = nowIso(); + + try { + await saveStore(store); + } catch (err) { + logger.error({ err }, "[timezoneStore] failed to save"); + } + + return tz; +} + +export async function getUserTimezone(args: { + userId: string; + guildId: string | null; + scope: "guild" | "global"; +}): Promise { + const store = await loadStore(); + const key = makeKey({ guildId: args.guildId, userId: args.userId, scope: args.scope }); + return store.zones[key] ?? null; +} + +export async function clearUserTimezone(args: { + userId: string; + guildId: string | null; + scope: "guild" | "global"; +}): Promise { + const store = await loadStore(); + const key = makeKey({ guildId: args.guildId, userId: args.userId, scope: args.scope }); + + if (!store.zones[key]) return false; + + delete store.zones[key]; + store.updatedAt = nowIso(); + + try { + await saveStore(store); + } catch (err) { + logger.error({ err }, "[timezoneStore] failed to save after clear"); + } + + return true; +} diff --git a/data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts b/data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts new file mode 100644 index 00000000..dd306a8b --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts @@ -0,0 +1,101 @@ +// src/services/transcript/buildTranscript.ts + +import { formatTimestamp } from "../time/formatTimestamp.js"; + +/** + * Minimal shape required from a Discord message + * so this helper stays framework-agnostic. + */ +export type TranscriptMessage = { + createdTimestamp: number; + content: string; + author: { username: string }; +}; + +export type TranscriptOptions = { + includeTimestamp: boolean; + includeAuthor: boolean; + maxLines?: number; + maxChars?: number; + timeZone?: string; + locale?: string; +}; + +export type TranscriptResult = { + text: string; + lineCount: number; + truncated: boolean; + tooLong: boolean; +}; + +/** + * Builds a readable transcript from a list of messages. + * Responsible ONLY for formatting + truncation rules. + * + * This function intentionally: + * - Does not log + * - Does not throw + * - Does not mutate external state + * + * Callers decide how to handle results and failures. + */ +export function buildTranscript( + messages: TranscriptMessage[], + options: TranscriptOptions, +): TranscriptResult { + const { + includeTimestamp, + includeAuthor, + maxLines, + maxChars, + timeZone = "UTC", + locale = "en-GB", + } = options; + + const lines: string[] = []; + let truncated = false; + let tooLong = false; + + for (const message of messages) { + if (!message?.content) continue; + + const parts: string[] = []; + + if (includeTimestamp) { + const ts = formatTimestamp(message.createdTimestamp, timeZone, locale); + parts.push(`[${ts}]`); + } + + if (includeAuthor) { + parts.push(`${message.author.username}:`); + } + + parts.push(message.content.trim()); + + const line = parts.join(" "); + lines.push(line); + + if (maxLines && lines.length >= maxLines) { + truncated = true; + break; + } + + // Cheaper than joining every iteration: track length incrementally. + if (maxChars) { + const joinedLen = + lines.reduce((acc, l) => acc + l.length, 0) + Math.max(0, lines.length - 1); + + if (joinedLen >= maxChars) { + tooLong = true; + break; + } + } + } + + return { + text: lines.join("\n"), + lineCount: lines.length, + truncated, + tooLong, + }; +} diff --git a/data/backup-20260117-145403/src-backup/services/transcript/defaults.ts b/data/backup-20260117-145403/src-backup/services/transcript/defaults.ts new file mode 100644 index 00000000..f85911e9 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/transcript/defaults.ts @@ -0,0 +1,35 @@ +// src/services/transcript/defaults.ts + +import type { TranscriptOptions } from "./buildTranscript.js"; + +/** + * Discord hard limit is 2000 characters. + * Leave headroom so we never accidentally overflow. + */ +export const DISCORD_SAFE_TEXT_LIMIT = 1900; + +/** + * Defaults for /history + * History is meant to be readable playback. + */ +export const HISTORY_DEFAULTS: TranscriptOptions = { + includeTimestamp: true, + includeAuthor: true, + maxLines: 50, + maxChars: DISCORD_SAFE_TEXT_LIMIT, + timeZone: "UTC", + locale: "en-GB", +}; + +/** + * Defaults for /summary + * Summary prefers signal over noise. + */ +export const SUMMARY_DEFAULTS: TranscriptOptions = { + includeTimestamp: false, + includeAuthor: true, + maxLines: 50, + maxChars: DISCORD_SAFE_TEXT_LIMIT, + timeZone: "UTC", + locale: "en-GB", +}; diff --git a/data/backup-20260117-145403/src-backup/services/weather/forecast.ts b/data/backup-20260117-145403/src-backup/services/weather/forecast.ts new file mode 100644 index 00000000..6e2109b5 --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/weather/forecast.ts @@ -0,0 +1,274 @@ +// src/services/weather/forecast.ts + +import { logger } from "../../utils/logger.js"; +import type { TempUnit } from "../../services/weather/types.js"; + +/* -------------------------------------------------------------------------- */ +/* Types */ +/* -------------------------------------------------------------------------- */ + +type WeatherApiResponse = { + location?: { + name?: string; + region?: string; + country?: string; + tz_id?: string; + localtime?: string; // "2026-01-16 10:18" + }; + current?: { + last_updated?: string; + temp_f?: number; + temp_c?: number; + feelslike_f?: number; + feelslike_c?: number; + condition?: { text?: string }; + wind_mph?: number; + wind_kph?: number; + wind_dir?: string; + humidity?: number; + }; + forecast?: { + forecastday?: Array<{ + date?: string; + day?: { + maxtemp_f?: number; + maxtemp_c?: number; + mintemp_f?: number; + mintemp_c?: number; + daily_chance_of_rain?: number; + daily_chance_of_snow?: number; + condition?: { text?: string }; + }; + astro?: { + sunrise?: string; + sunset?: string; + }; + }>; + }; +}; + +// IMPORTANT: forecastday is optional, so strip undefined before indexing +type ForecastDay = NonNullable< + NonNullable["forecastday"] +>[number]; + +export type WeatherNow = { + temp: string; + feelsLike?: string; + condition: string; + asOf?: string; + humidity?: string; + wind?: string; +}; + +export type WeatherDay = { + label: string; + temp: string; + pop?: string; + condition: string; + sunrise?: string; + sunset?: string; +}; + +export type WeatherBundle = { + placeLabel: string; + tzId?: string; + localTime?: string; + now?: WeatherNow; + days: WeatherDay[]; +}; + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +function requireWeatherApiKey(): string { + const key = process.env.WEATHERAPI_KEY?.trim(); + if (!key) { + throw new Error("Missing WEATHERAPI_KEY. Set it in .env (WEATHERAPI_KEY=...)."); + } + return key; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +function readApiErrorMessage(data: unknown): string | null { + const root = isRecord(data) ? data : null; + const err = root && isRecord(root.error) ? root.error : null; + const msg = err && typeof err.message === "string" ? err.message : null; + return msg ?? null; +} + +function pickTemp(unit: TempUnit, f?: number, c?: number): string | null { + if (unit === "c") { + return typeof c === "number" && Number.isFinite(c) ? `${Math.round(c)}°C` : null; + } + return typeof f === "number" && Number.isFinite(f) ? `${Math.round(f)}°F` : null; +} + +function formatWind( + unit: TempUnit, + dir?: string, + mph?: number, + kph?: number, +): string | null { + const d = dir?.trim(); + + if (unit === "c") { + if (typeof kph === "number" && Number.isFinite(kph)) { + return d ? `${d} ${Math.round(kph)} kph` : `${Math.round(kph)} kph`; + } + return null; + } + + if (typeof mph === "number" && Number.isFinite(mph)) { + return d ? `${d} ${Math.round(mph)} mph` : `${Math.round(mph)} mph`; + } + return null; +} + +function buildPlaceLabel(loc: WeatherApiResponse["location"] | undefined): string { + const name = loc?.name?.trim(); + const region = loc?.region?.trim(); + const country = loc?.country?.trim(); + + const bits = [name, region].filter(Boolean); + if (bits.length) return bits.join(", "); + + return country ?? "Unknown location"; +} + +function dailyPopString(item: ForecastDay | undefined): string | null { + if (!item?.day) return null; + + const rain = item.day.daily_chance_of_rain; + const snow = item.day.daily_chance_of_snow; + + const r = typeof rain === "number" && Number.isFinite(rain) ? rain : null; + const s = typeof snow === "number" && Number.isFinite(snow) ? snow : null; + + const best = [r, s] + .filter((x): x is number => typeof x === "number") + .sort((a, b) => b - a)[0]; + + return typeof best === "number" ? `${Math.round(best)}%` : null; +} + +/* -------------------------------------------------------------------------- */ +/* Main fetch */ +/* -------------------------------------------------------------------------- */ + +export async function fetchWeatherBundle(args: { + location: string; + unit: TempUnit; + days: number; +}): Promise { + const key = requireWeatherApiKey(); + + const q = args.location.trim(); + if (!q) throw new Error("Location cannot be empty."); + + const safeDays = Math.max(1, Math.min(args.days, 10)); + + const url = + `https://api.weatherapi.com/v1/forecast.json` + + `?key=${encodeURIComponent(key)}` + + `&q=${encodeURIComponent(q)}` + + `&days=${encodeURIComponent(String(safeDays))}` + + `&aqi=no&alerts=no`; + + const res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + const raw = await res.text(); + let parsed: WeatherApiResponse | null = null; + + try { + parsed = JSON.parse(raw) as WeatherApiResponse; + } catch { + parsed = null; + } + + if (!res.ok) { + const msg = readApiErrorMessage(parsed) ?? res.statusText ?? "Unknown error"; + + if (res.status === 401 || res.status === 403) { + throw new Error( + `WeatherAPI auth error (${res.status}). Check WEATHERAPI_KEY. ${msg}`, + ); + } + if (res.status === 400) { + throw new Error(`WeatherAPI rejected the location. ${msg}`); + } + if (res.status === 429) { + throw new Error("WeatherAPI rate limit hit. Try again in a bit."); + } + + throw new Error(`WeatherAPI error (${res.status}): ${msg}`); + } + + const placeLabel = buildPlaceLabel(parsed?.location); + const tzId = parsed?.location?.tz_id; + const localTime = parsed?.location?.localtime; + + const nowTemp = pickTemp(args.unit, parsed?.current?.temp_f, parsed?.current?.temp_c); + const feels = pickTemp( + args.unit, + parsed?.current?.feelslike_f, + parsed?.current?.feelslike_c, + ); + + const now: WeatherNow | undefined = nowTemp + ? { + temp: nowTemp, + feelsLike: feels ?? undefined, + condition: parsed?.current?.condition?.text?.trim() ?? "Unknown", + asOf: parsed?.current?.last_updated, + humidity: + typeof parsed?.current?.humidity === "number" + ? `${parsed.current.humidity}%` + : undefined, + wind: + formatWind( + args.unit, + parsed?.current?.wind_dir, + parsed?.current?.wind_mph, + parsed?.current?.wind_kph, + ) ?? undefined, + } + : undefined; + + const days: WeatherDay[] = []; + const fd: ForecastDay[] = parsed?.forecast?.forecastday ?? []; + + for (let i = 0; i < fd.length; i += 1) { + const item = fd[i]; + const label = i === 0 ? "Today" : (item.date ?? `Day ${i + 1}`); + + const max = pickTemp(args.unit, item.day?.maxtemp_f, item.day?.maxtemp_c); + const min = pickTemp(args.unit, item.day?.mintemp_f, item.day?.mintemp_c); + const temp = max && min ? `${min} to ${max}` : (max ?? min ?? "N/A"); + + days.push({ + label, + temp, + condition: item.day?.condition?.text?.trim() ?? "Forecast unavailable", + pop: dailyPopString(item) ?? undefined, + sunrise: item.astro?.sunrise, + sunset: item.astro?.sunset, + }); + } + + logger.debug( + { q, placeLabel, tzId, localTime, days: days.length }, + "[weather] weather bundle fetched", + ); + + return { placeLabel, tzId, localTime, now, days }; +} diff --git a/data/backup-20260117-145403/src-backup/services/weather/types.ts b/data/backup-20260117-145403/src-backup/services/weather/types.ts new file mode 100644 index 00000000..aebba69d --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/weather/types.ts @@ -0,0 +1,47 @@ +// src/services/weather/types.ts + +/** + * Temperature unit preference. + */ +export type TempUnit = "f" | "c"; + +/** + * Execution modes for the weather command. + */ +export type WeatherMode = + | { kind: "daily"; location: string; unit: TempUnit } + | { kind: "7day"; location: string; unit: TempUnit }; + +/* -------------------------------------------------------------------------- */ +/* Legacy (NWS) */ +/* Kept intentionally so old code references do not break during transition. */ +/* Not used by the WeatherAPI.com path. */ +/* -------------------------------------------------------------------------- */ + +export type WeatherPoint = { + lat: number; + lon: number; + label: string; + + /** + * Fully qualified NWS forecast endpoint URL. + * Example: + * https://api.weather.gov/gridpoints/BOX/65,72/forecast + */ + forecastUrl: string; +}; + +export type NwsForecastResponse = { + properties?: { + periods?: Array<{ + name?: string; + startTime?: string; + isDaytime?: boolean; + temperature?: number; + temperatureUnit?: string; // usually "F" or "C" + shortForecast?: string; + detailedForecast?: string; + probabilityOfPrecipitation?: { value: number | null } | null; + }>; + }; +}; diff --git a/data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts b/data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts new file mode 100644 index 00000000..03464a7e --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts @@ -0,0 +1,86 @@ +// src/services/welcome/welcomeHandler.ts + +import type { Guild, GuildMember, TextBasedChannel } from "discord.js"; +import { logger } from "../../utils/logger.js"; +import { buildWelcomeMessage } from "./welcomeMessage.js"; +import { getGuildConfig } from "../config/index.js"; + +/** + * Type guard to ensure a text-based channel supports `.send()`. + * + * This avoids `any` and satisfies ESLint while keeping runtime safety. + */ +function isSendableChannel( + channel: TextBasedChannel, +): channel is TextBasedChannel & { send: (content: unknown) => Promise } { + return typeof (channel as { send?: unknown }).send === "function"; +} + +/** + * Resolve the best channel to post onboarding messages. + * + * Order: + * 1) Guild config (welcomeEnabled / welcomeChannelId) + * 2) Guild system channel + * 3) First available text-based channel + */ +async function resolveWelcomeChannel(guild: Guild): Promise { + const cfg = getGuildConfig(guild.id); + + // Allow per-guild disabling of welcome messages. + if (!cfg.welcomeEnabled) return null; + + // Prefer configured welcome channel. + if (cfg.welcomeChannelId) { + const ch = await guild.channels.fetch(cfg.welcomeChannelId).catch(() => null); + if (ch?.isTextBased()) return ch; + } + + // Fallback to system channel. + if (guild.systemChannel?.isTextBased()) { + return guild.systemChannel; + } + + // Final fallback: first text-based channel found. + const channels = await guild.channels.fetch().catch(() => null); + if (!channels) return null; + + for (const [, ch] of channels) { + if (ch?.isTextBased()) return ch; + } + + return null; +} + +/** + * Event handler for new members joining a guild. + * Never throws — background event safety. + */ +export async function onGuildMemberAdd(member: GuildMember): Promise { + try { + const channel = await resolveWelcomeChannel(member.guild); + + if (!channel) { + logger.info( + { guildId: member.guild.id }, + "Welcome handler skipped (no channel resolved or disabled)", + ); + return; + } + + if (!isSendableChannel(channel)) { + logger.warn( + { guildId: member.guild.id }, + "Resolved welcome channel is not sendable", + ); + return; + } + + await channel.send(buildWelcomeMessage(member)); + } catch (err) { + logger.error( + { err, guildId: member.guild.id, userId: member.user.id }, + "Welcome handler failed", + ); + } +} diff --git a/data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts b/data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts new file mode 100644 index 00000000..a4a9b97d --- /dev/null +++ b/data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts @@ -0,0 +1,25 @@ +// src/services/welcome/welcomeMessage.ts + +import type { GuildMember } from "discord.js"; + +/** + * Build a welcome message for a new member. + * + * Keep this PURE: + * - no Discord calls + * - no env lookups + * - easy to tweak copy later + */ +export function buildWelcomeMessage(member: GuildMember): string { + const username = member.user.username; + + return [ + `👋 Welcome, ${username}!`, + ``, + `Here’s how to get started:`, + `📌 Check the server rules`, + `📖 Read the pinned messages`, + ``, + `❓ If you're not sure where to go, just ask — someone will help you out.`, + ].join("\n"); +} diff --git a/data/backup-20260117-145403/src-backup/utils/logger.ts b/data/backup-20260117-145403/src-backup/utils/logger.ts new file mode 100644 index 00000000..24d109df --- /dev/null +++ b/data/backup-20260117-145403/src-backup/utils/logger.ts @@ -0,0 +1,49 @@ +// src/utils/logger.ts + +import pino, { type Logger } from "pino"; + +/** + * Central logger for OmegaBot. + * + * Why a single logger module? + * - Consistent log format across the whole app + * - One place to change log level / destinations later + * - Easy to swap to JSON logs in prod and pretty logs in dev + * + * Notes: + * - We use `pino-pretty` only for local development readability. + * - If `pino-pretty` is not installed, we gracefully fall back to JSON logs. + */ + +const isProd = process.env.NODE_ENV === "production"; +const prettyEnabled = + !isProd && (process.env.LOG_PRETTY ?? "true").toLowerCase() === "true"; + +function createLogger(): Logger { + const level = process.env.LOG_LEVEL ?? (isProd ? "info" : "debug"); + + // Safe default everywhere + const base = pino({ level }); + + if (!prettyEnabled) { + return base; + } + + try { + const transport = pino.transport({ + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:standard", + ignore: "pid,hostname", + }, + }); + + return pino({ level }, transport); + } catch { + // Dev-only dependency missing, fall back cleanly + return base; + } +} + +export const logger = createLogger(); diff --git a/fix-pr-noise-simple.sh b/fix-pr-noise-simple.sh new file mode 100755 index 00000000..f2980cc6 --- /dev/null +++ b/fix-pr-noise-simple.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -e + +echo "🔇 Fixing PR Notification Noise" +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"; } + +FILE="src/services/github/issueAssigneePoller.ts" + +if [ ! -f "$FILE" ]; then + print_error "$FILE not found" + exit 1 +fi + +echo "Backing up..." +cp "$FILE" "$FILE.backup" +print_success "Created backup" + +echo "" +echo "Applying fixes..." + +# Fix 1: Filter assignee notifications to Issues only +perl -i -pe 's/for \(const n of notifications\) \{/const issueNotifications = notifications.filter(n => n.kind === "Issue");\n\n for (const n of issueNotifications) {/' "$FILE" + +# Fix 2: Simplify message (remove **Kind** since it's always Issue) +perl -i -pe 's/parts\.push\(`\*\*\$\{n\.kind\} #\$\{n\.number\}\*\* assignees updated`\);/parts.push(`Issue #${n.number} assignees updated`);/' "$FILE" + +# Fix 3: Filter closed notifications to Issues only +perl -i -pe 's/for \(const c of closedNotifications\) \{/const closedIssues = closedNotifications.filter(c => c.kind === "Issue");\n\n for (const c of closedIssues) {/' "$FILE" + +# Fix 4: Simplify closed message +perl -i -pe 's/parts\.push\(`\*\*\$\{c\.kind\} #\$\{c\.number\}\*\* is no longer open`\);/parts.push(`Issue #${c.number} closed`);/' "$FILE" + +print_success "Fixes applied" + +echo "" +echo "Building..." +npm run build + +if [ $? -eq 0 ]; then + echo "" + echo "╔════════════════════════════════════════════════════╗" + echo "║ ✅ Fixed! Build Successful! ║" + echo "╚════════════════════════════════════════════════════╝" + echo "" + echo "Changes:" + echo " ✅ Only Issues trigger notifications (no PR noise)" + echo " ✅ Cleaner message format" + echo "" + echo "What gets announced now:" + echo " ✅ Issue #60 assignees updated" + echo " ✅ Issue #60 closed" + echo "" + echo "What is now silent:" + echo " 🔇 PR assignee changes" + echo " 🔇 PR merges/closes" + echo "" + echo "Restart your bot:" + echo " npm start" + echo "" + echo "To rollback:" + echo " cp $FILE.backup $FILE" + echo " npm run build" + echo "" +else + echo "" + print_error "Build failed" + echo "Restoring backup..." + cp "$FILE.backup" "$FILE" + exit 1 +fi diff --git a/scripts/migrate-all-data.ts b/scripts/migrate-all-data.ts new file mode 100644 index 00000000..1181bfe0 --- /dev/null +++ b/scripts/migrate-all-data.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env node +import { readFileSync, existsSync, writeFileSync } from 'fs'; +import { initDatabase, getDb } from '../services/database/db.js'; +import { logger } from '../utils/logger.js'; + +console.log('🔄 Migrating ALL data to SQLite...\n'); + +initDatabase(); +const db = getDb(); + +let totalMigrated = 0; + +// ============================================================ +// Migrate FAQs +// ============================================================ +if (existsSync('data/faqs.json')) { + console.log('📝 Migrating FAQs...'); + const faqData = JSON.parse(readFileSync('data/faqs.json', 'utf-8')); + const faqs = Object.values(faqData.entries || {}) as any[]; + + db.transaction(() => { + const stmt = db.prepare(` + INSERT OR REPLACE INTO faqs + (key, title, body, tags, answer, created_at, updated_at, usage_count, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const faq of faqs) { + const answer = faq.title ? `**${faq.title}**\n\n${faq.body}` : faq.body; + stmt.run( + faq.key, + faq.title || '', + faq.body || '', + JSON.stringify(faq.tags || []), + answer, + new Date(faq.createdAt).getTime(), + new Date(faq.updatedAt).getTime(), + faq.usageCount || 0, + faq.createdBy || 'unknown', + faq.updatedBy || 'unknown' + ); + } + })(); + + writeFileSync('data/faqs.json.migrated', readFileSync('data/faqs.json')); + console.log(` ✓ Migrated ${faqs.length} FAQs`); + totalMigrated += faqs.length; +} + +// ============================================================ +// Migrate Timezones +// ============================================================ +if (existsSync('data/timezones.json')) { + console.log('🌍 Migrating timezones...'); + const tzData = JSON.parse(readFileSync('data/timezones.json', 'utf-8')); + + db.transaction(() => { + const stmt = db.prepare(` + INSERT OR REPLACE INTO user_timezones (user_id, timezone, updated_at) + VALUES (?, ?, ?) + `); + + for (const [userId, tz] of Object.entries(tzData)) { + stmt.run(userId, tz as string, Date.now()); + } + })(); + + const count = Object.keys(tzData).length; + writeFileSync('data/timezones.json.migrated', readFileSync('data/timezones.json')); + console.log(` ✓ Migrated ${count} timezones`); + totalMigrated += count; +} + +// ============================================================ +// Migrate Guild Config +// ============================================================ +if (existsSync('data/guild-config.json')) { + console.log('⚙️ Migrating guild configurations...'); + const configData = JSON.parse(readFileSync('data/guild-config.json', 'utf-8')); + + db.transaction(() => { + const stmt = db.prepare(` + INSERT OR REPLACE INTO guild_config (guild_id, config, updated_at) + VALUES (?, ?, ?) + `); + + for (const [guildId, config] of Object.entries(configData)) { + stmt.run(guildId, JSON.stringify(config), Date.now()); + } + })(); + + const count = Object.keys(configData).length; + writeFileSync('data/guild-config.json.migrated', readFileSync('data/guild-config.json')); + console.log(` ✓ Migrated ${count} guild configs`); + totalMigrated += count; +} + +// ============================================================ +// Migrate Fun Usage +// ============================================================ +if (existsSync('data/fun-usage.json')) { + console.log('🎮 Migrating fun command usage...'); + const funData = JSON.parse(readFileSync('data/fun-usage.json', 'utf-8')); + + db.transaction(() => { + const stmt = db.prepare(` + INSERT INTO fun_usage (user_id, command, timestamp) + VALUES (?, ?, ?) + `); + + let count = 0; + for (const [userId, commands] of Object.entries(funData)) { + if (typeof commands === 'object' && commands !== null) { + for (const [command, usageCount] of Object.entries(commands)) { + // Add entries for each usage (approximated with current timestamp) + for (let i = 0; i < (usageCount as number); i++) { + stmt.run(userId, command, Date.now() - (i * 1000)); + count++; + } + } + } + } + })(); + + writeFileSync('data/fun-usage.json.migrated', readFileSync('data/fun-usage.json')); + console.log(` ✓ Migrated fun usage data`); +} + +// ============================================================ +// Migrate GitHub Last Seen +// ============================================================ +if (existsSync('data/github-assignees.json')) { + console.log('📦 Migrating GitHub state...'); + const ghData = JSON.parse(readFileSync('data/github-assignees.json', 'utf-8')); + + if (ghData.itemsByNumber) { + db.transaction(() => { + const stmt = db.prepare(` + INSERT OR REPLACE INTO github_last_seen (repo_key, last_seen_timestamp, entity_type) + VALUES (?, ?, ?) + `); + + for (const [number, item] of Object.entries(ghData.itemsByNumber)) { + const typedItem = item as any; + stmt.run( + `item_${number}`, + Date.now(), + typedItem.kind || 'Issue' + ); + } + })(); + } + + writeFileSync('data/github-assignees.json.migrated', readFileSync('data/github-assignees.json')); + console.log(` ✓ Migrated GitHub state`); +} + +console.log(`\n✅ Migration complete! Migrated ${totalMigrated} total items`); +console.log('\nOriginal files renamed to *.migrated for safety'); diff --git a/src/services/github/issueAssigneePoller.ts b/src/services/github/issueAssigneePoller.ts index 7a928e1c..2ce1153f 100644 --- a/src/services/github/issueAssigneePoller.ts +++ b/src/services/github/issueAssigneePoller.ts @@ -282,9 +282,11 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Announce assignee changes - for (const n of notifications) { + const issueNotifications = notifications.filter(n => n.kind === "Issue"); + + for (const n of issueNotifications) { const parts: string[] = []; - parts.push(`**${n.kind} #${n.number}** assignees updated`); + parts.push(`Issue # assignees updated`); parts.push(n.title); parts.push(n.url); @@ -307,9 +309,11 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Announce closures (issues + PRs) - for (const c of closedNotifications) { + const closedIssues = closedNotifications.filter(c => c.kind === "Issue"); + + for (const c of closedIssues) { const parts: string[] = []; - parts.push(`**${c.kind} #${c.number}** is no longer open`); + parts.push(`Issue # closed`); parts.push(c.title); parts.push(c.url); parts.push("(Closed/merged/etc — detected via polling)"); diff --git a/src/services/github/issueAssigneePoller.ts.backup b/src/services/github/issueAssigneePoller.ts.backup new file mode 100644 index 00000000..7a928e1c --- /dev/null +++ b/src/services/github/issueAssigneePoller.ts.backup @@ -0,0 +1,330 @@ +// src/services/github/issueAssigneePoller.ts + +import type { Client } from "discord.js"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +type PollArgs = { + client: Client; + owner: string; + repo: string; + announceChannelId: string; +}; + +type GitHubAssignee = { login: string }; + +type GitHubIssueItem = { + number: number; + title: string; + html_url: string; + assignees?: GitHubAssignee[]; + // Present on PRs returned in the issues list + pull_request?: unknown; +}; + +type TrackedItem = { + kind: "PR" | "Issue"; + title: string; + url: string; + assignees: string[]; +}; + +type StateFile = { + /** + * Back-compat: + * Older state files might only have assigneesByNumber. + * We’ll load them and upgrade in-memory automatically. + */ + assigneesByNumber?: Record; + itemsByNumber?: Record; + initializedAt: string; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); + +function uniqSorted(list: string[]): string[] { + return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => + a.localeCompare(b), + ); +} + +function diff(prev: string[], next: string[]) { + const prevSet = new Set(prev); + const nextSet = new Set(next); + + const added = next.filter((x) => !prevSet.has(x)); + const removed = prev.filter((x) => !nextSet.has(x)); + + return { added, removed, changed: added.length > 0 || removed.length > 0 }; +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as StateFile; + if (!parsed || typeof parsed !== "object") return null; + + // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) + const hasOld = + !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; + const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; + + if (!hasOld && !hasNew) return null; + if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; + + return parsed; + } catch { + return null; + } +} + +async function saveState(state: StateFile): Promise { + await ensureDataDir(); + await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); +} + +async function getAnnounceChannel( + client: Client, + channelId: string, +): Promise<{ send: (content: string) => Promise }> { + const ch = await client.channels.fetch(channelId); + + if (!ch || !ch.isTextBased()) { + throw new Error(`Announce channel is not a text channel: ${channelId}`); + } + + // TypeScript guard: not all TextBasedChannel unions guarantee send() + if (!("send" in ch) || typeof ch.send !== "function") { + throw new Error(`Announce channel does not support send(): ${channelId}`); + } + + return ch; +} + +async function githubFetchJson(url: string, token: string): Promise { + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "OmegaBot", + Authorization: `token ${token}`, + }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, + ); + } + + return (await res.json()) as T; +} + +function stateToItems(existing: StateFile): Record { + // New format available + if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { + return existing.itemsByNumber; + } + + // Upgrade old format in-memory (no title/url/kind available) + const old = existing.assigneesByNumber ?? {}; + const upgraded: Record = {}; + for (const [num, assignees] of Object.entries(old)) { + upgraded[num] = { + kind: "Issue", + title: "(unknown title)", + url: "(unknown url)", + assignees: uniqSorted(assignees ?? []), + }; + } + return upgraded; +} + +/** + * Poll open issues (includes PRs) and notify on: + * - assignee changes (add/remove/replace/multi/unassign) + * - issues/PRs that are no longer open (closed/merged/etc) + * + * Notes: + * - Baseline-first: first successful run saves state and does NOT notify. + * - State is persisted to ./data/github-assignees.json + */ +export async function pollIssueAssigneesOnce(args: PollArgs): Promise { + const { client, owner, repo, announceChannelId } = args; + + let token: string; + try { + token = env.requireGithubToken(); + } catch (err) { + logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); + return; + } + + let channel: { send: (content: string) => Promise }; + try { + channel = await getAnnounceChannel(client, announceChannelId); + } catch (err) { + logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); + return; + } + + // Load state (or init) + const existing = (await loadState()) ?? { + assigneesByNumber: {}, + initializedAt: new Date().toISOString(), + }; + + const existingItemsByNumber = stateToItems(existing); + const hadExistingState = Object.keys(existingItemsByNumber).length > 0; + + // Fetch open issues (includes PRs) + const url = `https://api.github.com/repos/${encodeURIComponent( + owner, + )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; + + let items: GitHubIssueItem[]; + try { + items = await githubFetchJson(url, token); + } catch (err) { + logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); + return; + } + + const nextItemsByNumber: Record = {}; + const notifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + added: string[]; + removed: string[]; + }> = []; + + for (const item of items) { + const numberKey = String(item.number); + + const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; + const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); + + nextItemsByNumber[numberKey] = { + kind, + title: item.title, + url: item.html_url, + assignees: nextAssignees, + }; + + const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); + const { added, removed, changed } = diff(prevAssignees, nextAssignees); + + if (hadExistingState && changed) { + notifications.push({ + kind, + number: item.number, + title: item.title, + url: item.html_url, + added, + removed, + }); + } + } + + // Detect items that were previously open but are no longer open + const closedNotifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + }> = []; + + if (hadExistingState) { + const prevNumbers = new Set(Object.keys(existingItemsByNumber)); + const nextNumbers = new Set(Object.keys(nextItemsByNumber)); + + for (const num of prevNumbers) { + if (!nextNumbers.has(num)) { + const prev = existingItemsByNumber[num]; + closedNotifications.push({ + kind: prev?.kind ?? "Issue", + number: Number(num), + title: prev?.title ?? "(unknown title)", + url: prev?.url ?? "(unknown url)", + }); + } + } + } + + // Save next state (drops closed issues automatically) + const nextState: StateFile = { + itemsByNumber: nextItemsByNumber, + initializedAt: existing.initializedAt ?? new Date().toISOString(), + }; + + try { + await saveState(nextState); + } catch (err) { + logger.error({ err }, "[github/assignees] failed to save state"); + } + + // Baseline-first: no notifications on first successful run + if (!hadExistingState) { + logger.info( + { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, + "[github/assignees] baseline saved (no notifications on first run)", + ); + return; + } + + // Announce assignee changes + for (const n of notifications) { + const parts: string[] = []; + parts.push(`**${n.kind} #${n.number}** assignees updated`); + parts.push(n.title); + parts.push(n.url); + + if (n.added.length) { + parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); + } + if (n.removed.length) { + parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); + } + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, + "[github/assignees] announced", + ); + } catch (err) { + logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); + } + } + + // Announce closures (issues + PRs) + for (const c of closedNotifications) { + const parts: string[] = []; + parts.push(`**${c.kind} #${c.number}** is no longer open`); + parts.push(c.title); + parts.push(c.url); + parts.push("(Closed/merged/etc — detected via polling)"); + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: c.number, kind: c.kind }, + "[github/assignees] closure announced", + ); + } catch (err) { + logger.error( + { err, number: c.number }, + "[github/assignees] failed to announce closure", + ); + } + } +} From 7f39431fc90312425c606ed4466d1b69edd56d76 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sat, 17 Jan 2026 15:10:43 -0500 Subject: [PATCH 2/5] style: auto-format with Prettier [skip-precheck] --- .../src-backup/services/cache/simpleCache.ts | 2 +- .../src-backup/services/database/db.ts | 2 +- .../src-backup/services/faq/store.ts | 12 +-- .../src-backup/services/github/githubCache.ts | 4 +- .../services/github/issueAssigneePoller.ts | 4 +- scripts/migrate-all-data.ts | 94 ++++++++++--------- src/services/github/issueAssigneePoller.ts | 4 +- 7 files changed, 62 insertions(+), 60 deletions(-) diff --git a/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts b/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts index db2e7683..ef086fd6 100644 --- a/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts +++ b/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts @@ -21,7 +21,7 @@ export class SimpleCache { get(key: string): T | null { const entry = this.cache.get(key); - + if (!entry) { this.misses++; logger.debug({ key }, "Cache MISS"); diff --git a/data/backup-20260117-145403/src-backup/services/database/db.ts b/data/backup-20260117-145403/src-backup/services/database/db.ts index 88ec131b..6c3ee38f 100644 --- a/data/backup-20260117-145403/src-backup/services/database/db.ts +++ b/data/backup-20260117-145403/src-backup/services/database/db.ts @@ -11,7 +11,7 @@ export function initDatabase(): Database.Database { const databasePath = process.env.DATABASE_PATH || "data/omegabot.db"; const dbDir = path.dirname(databasePath); - + if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.ts b/data/backup-20260117-145403/src-backup/services/faq/store.ts index 8addf4b9..e7cd9871 100644 --- a/data/backup-20260117-145403/src-backup/services/faq/store.ts +++ b/data/backup-20260117-145403/src-backup/services/faq/store.ts @@ -6,7 +6,7 @@ export function loadStore(): FaqStoreV1 { const db = getDb(); const stmt = db.prepare("SELECT * FROM faqs"); const rows = stmt.all() as any[]; - + const entries: Record = {}; for (const row of rows) { entries[row.key] = { @@ -21,7 +21,7 @@ export function loadStore(): FaqStoreV1 { updatedBy: row.updated_by || "unknown", }; } - + return { version: 1, entries, @@ -30,15 +30,15 @@ export function loadStore(): FaqStoreV1 { export function saveStore(store: FaqStoreV1): void { const db = getDb(); - + db.transaction(() => { db.prepare("DELETE FROM faqs").run(); - + const stmt = db.prepare(` INSERT INTO faqs (key, title, body, tags, answer, created_at, updated_at, usage_count, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - + for (const faq of Object.values(store.entries)) { const answer = faq.title ? `**${faq.title}**\n\n${faq.body}` : faq.body; stmt.run( @@ -51,7 +51,7 @@ export function saveStore(store: FaqStoreV1): void { new Date(faq.updatedAt).getTime(), faq.usageCount || 0, faq.createdBy || "unknown", - faq.updatedBy || "unknown" + faq.updatedBy || "unknown", ); } })(); diff --git a/data/backup-20260117-145403/src-backup/services/github/githubCache.ts b/data/backup-20260117-145403/src-backup/services/github/githubCache.ts index b47fad34..31bbffa9 100644 --- a/data/backup-20260117-145403/src-backup/services/github/githubCache.ts +++ b/data/backup-20260117-145403/src-backup/services/github/githubCache.ts @@ -7,7 +7,7 @@ const CACHE_TTL = 300; // 5 minutes export async function cachedGitHubRequest( key: string, - fetcher: () => Promise + fetcher: () => Promise, ): Promise { const cached = cache.get(key); if (cached !== null) { @@ -16,7 +16,7 @@ export async function cachedGitHubRequest( } logger.debug({ key }, "GitHub cache MISS"); - + try { const data = await fetcher(); cache.set(key, data, CACHE_TTL); diff --git a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts index 2ce1153f..d3261878 100644 --- a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts +++ b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts @@ -282,7 +282,7 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Announce assignee changes - const issueNotifications = notifications.filter(n => n.kind === "Issue"); + const issueNotifications = notifications.filter((n) => n.kind === "Issue"); for (const n of issueNotifications) { const parts: string[] = []; @@ -309,7 +309,7 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Announce closures (issues + PRs) - const closedIssues = closedNotifications.filter(c => c.kind === "Issue"); + const closedIssues = closedNotifications.filter((c) => c.kind === "Issue"); for (const c of closedIssues) { const parts: string[] = []; diff --git a/scripts/migrate-all-data.ts b/scripts/migrate-all-data.ts index 1181bfe0..137276da 100644 --- a/scripts/migrate-all-data.ts +++ b/scripts/migrate-all-data.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node -import { readFileSync, existsSync, writeFileSync } from 'fs'; -import { initDatabase, getDb } from '../services/database/db.js'; -import { logger } from '../utils/logger.js'; +import { readFileSync, existsSync, writeFileSync } from "fs"; +import { initDatabase, getDb } from "../services/database/db.js"; +import { logger } from "../utils/logger.js"; -console.log('🔄 Migrating ALL data to SQLite...\n'); +console.log("🔄 Migrating ALL data to SQLite...\n"); initDatabase(); const db = getDb(); @@ -13,11 +13,11 @@ let totalMigrated = 0; // ============================================================ // Migrate FAQs // ============================================================ -if (existsSync('data/faqs.json')) { - console.log('📝 Migrating FAQs...'); - const faqData = JSON.parse(readFileSync('data/faqs.json', 'utf-8')); +if (existsSync("data/faqs.json")) { + console.log("📝 Migrating FAQs..."); + const faqData = JSON.parse(readFileSync("data/faqs.json", "utf-8")); const faqs = Object.values(faqData.entries || {}) as any[]; - + db.transaction(() => { const stmt = db.prepare(` INSERT OR REPLACE INTO faqs @@ -29,20 +29,20 @@ if (existsSync('data/faqs.json')) { const answer = faq.title ? `**${faq.title}**\n\n${faq.body}` : faq.body; stmt.run( faq.key, - faq.title || '', - faq.body || '', + faq.title || "", + faq.body || "", JSON.stringify(faq.tags || []), answer, new Date(faq.createdAt).getTime(), new Date(faq.updatedAt).getTime(), faq.usageCount || 0, - faq.createdBy || 'unknown', - faq.updatedBy || 'unknown' + faq.createdBy || "unknown", + faq.updatedBy || "unknown", ); } })(); - - writeFileSync('data/faqs.json.migrated', readFileSync('data/faqs.json')); + + writeFileSync("data/faqs.json.migrated", readFileSync("data/faqs.json")); console.log(` ✓ Migrated ${faqs.length} FAQs`); totalMigrated += faqs.length; } @@ -50,10 +50,10 @@ if (existsSync('data/faqs.json')) { // ============================================================ // Migrate Timezones // ============================================================ -if (existsSync('data/timezones.json')) { - console.log('🌍 Migrating timezones...'); - const tzData = JSON.parse(readFileSync('data/timezones.json', 'utf-8')); - +if (existsSync("data/timezones.json")) { + console.log("🌍 Migrating timezones..."); + const tzData = JSON.parse(readFileSync("data/timezones.json", "utf-8")); + db.transaction(() => { const stmt = db.prepare(` INSERT OR REPLACE INTO user_timezones (user_id, timezone, updated_at) @@ -64,9 +64,9 @@ if (existsSync('data/timezones.json')) { stmt.run(userId, tz as string, Date.now()); } })(); - + const count = Object.keys(tzData).length; - writeFileSync('data/timezones.json.migrated', readFileSync('data/timezones.json')); + writeFileSync("data/timezones.json.migrated", readFileSync("data/timezones.json")); console.log(` ✓ Migrated ${count} timezones`); totalMigrated += count; } @@ -74,10 +74,10 @@ if (existsSync('data/timezones.json')) { // ============================================================ // Migrate Guild Config // ============================================================ -if (existsSync('data/guild-config.json')) { - console.log('⚙️ Migrating guild configurations...'); - const configData = JSON.parse(readFileSync('data/guild-config.json', 'utf-8')); - +if (existsSync("data/guild-config.json")) { + console.log("⚙️ Migrating guild configurations..."); + const configData = JSON.parse(readFileSync("data/guild-config.json", "utf-8")); + db.transaction(() => { const stmt = db.prepare(` INSERT OR REPLACE INTO guild_config (guild_id, config, updated_at) @@ -88,9 +88,12 @@ if (existsSync('data/guild-config.json')) { stmt.run(guildId, JSON.stringify(config), Date.now()); } })(); - + const count = Object.keys(configData).length; - writeFileSync('data/guild-config.json.migrated', readFileSync('data/guild-config.json')); + writeFileSync( + "data/guild-config.json.migrated", + readFileSync("data/guild-config.json"), + ); console.log(` ✓ Migrated ${count} guild configs`); totalMigrated += count; } @@ -98,10 +101,10 @@ if (existsSync('data/guild-config.json')) { // ============================================================ // Migrate Fun Usage // ============================================================ -if (existsSync('data/fun-usage.json')) { - console.log('🎮 Migrating fun command usage...'); - const funData = JSON.parse(readFileSync('data/fun-usage.json', 'utf-8')); - +if (existsSync("data/fun-usage.json")) { + console.log("🎮 Migrating fun command usage..."); + const funData = JSON.parse(readFileSync("data/fun-usage.json", "utf-8")); + db.transaction(() => { const stmt = db.prepare(` INSERT INTO fun_usage (user_id, command, timestamp) @@ -110,29 +113,29 @@ if (existsSync('data/fun-usage.json')) { let count = 0; for (const [userId, commands] of Object.entries(funData)) { - if (typeof commands === 'object' && commands !== null) { + if (typeof commands === "object" && commands !== null) { for (const [command, usageCount] of Object.entries(commands)) { // Add entries for each usage (approximated with current timestamp) for (let i = 0; i < (usageCount as number); i++) { - stmt.run(userId, command, Date.now() - (i * 1000)); + stmt.run(userId, command, Date.now() - i * 1000); count++; } } } } })(); - - writeFileSync('data/fun-usage.json.migrated', readFileSync('data/fun-usage.json')); + + writeFileSync("data/fun-usage.json.migrated", readFileSync("data/fun-usage.json")); console.log(` ✓ Migrated fun usage data`); } // ============================================================ // Migrate GitHub Last Seen // ============================================================ -if (existsSync('data/github-assignees.json')) { - console.log('📦 Migrating GitHub state...'); - const ghData = JSON.parse(readFileSync('data/github-assignees.json', 'utf-8')); - +if (existsSync("data/github-assignees.json")) { + console.log("📦 Migrating GitHub state..."); + const ghData = JSON.parse(readFileSync("data/github-assignees.json", "utf-8")); + if (ghData.itemsByNumber) { db.transaction(() => { const stmt = db.prepare(` @@ -142,18 +145,17 @@ if (existsSync('data/github-assignees.json')) { for (const [number, item] of Object.entries(ghData.itemsByNumber)) { const typedItem = item as any; - stmt.run( - `item_${number}`, - Date.now(), - typedItem.kind || 'Issue' - ); + stmt.run(`item_${number}`, Date.now(), typedItem.kind || "Issue"); } })(); } - - writeFileSync('data/github-assignees.json.migrated', readFileSync('data/github-assignees.json')); + + writeFileSync( + "data/github-assignees.json.migrated", + readFileSync("data/github-assignees.json"), + ); console.log(` ✓ Migrated GitHub state`); } console.log(`\n✅ Migration complete! Migrated ${totalMigrated} total items`); -console.log('\nOriginal files renamed to *.migrated for safety'); +console.log("\nOriginal files renamed to *.migrated for safety"); diff --git a/src/services/github/issueAssigneePoller.ts b/src/services/github/issueAssigneePoller.ts index 2ce1153f..d3261878 100644 --- a/src/services/github/issueAssigneePoller.ts +++ b/src/services/github/issueAssigneePoller.ts @@ -282,7 +282,7 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Announce assignee changes - const issueNotifications = notifications.filter(n => n.kind === "Issue"); + const issueNotifications = notifications.filter((n) => n.kind === "Issue"); for (const n of issueNotifications) { const parts: string[] = []; @@ -309,7 +309,7 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { } // Announce closures (issues + PRs) - const closedIssues = closedNotifications.filter(c => c.kind === "Issue"); + const closedIssues = closedNotifications.filter((c) => c.kind === "Issue"); for (const c of closedIssues) { const parts: string[] = []; From 627a13bde2d2d803061ce51296f42135b3ae8f41 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sat, 17 Jan 2026 15:11:07 -0500 Subject: [PATCH 3/5] Adding in changes' --- data/backup-20260117-145403/src-backup/bot.ts | 150 ----- .../commands/changelog/changelog.ts | 48 -- .../src-backup/commands/config/config.ts | 124 ---- .../src-backup/commands/faq/faq.ts | 173 ------ .../commands/faq/subcommands/add.ts | 64 --- .../commands/faq/subcommands/get.ts | 45 -- .../commands/faq/subcommands/list.ts | 66 --- .../commands/faq/subcommands/remove.ts | 110 ---- .../src-backup/commands/fun/fun.ts | 364 ------------ .../commands/fun/subcommands/chucknorris.ts | 220 ------- .../commands/fun/subcommands/coinflip.ts | 36 -- .../commands/fun/subcommands/dadjoke.ts | 218 ------- .../commands/fun/subcommands/dice.ts | 98 ---- .../commands/fun/subcommands/java.ts | 21 - .../commands/fun/subcommands/leaderboard.ts | 233 -------- .../commands/fun/subcommands/poll.ts | 211 ------- .../commands/fun/subcommands/weather.ts | 142 ----- .../src-backup/commands/general/ping.ts | 68 --- .../src-backup/commands/github/gh.ts | 200 ------- .../src-backup/commands/github/pr.ts | 56 -- .../src-backup/commands/github/status.ts | 51 -- .../src-backup/commands/help/help.ts | 74 --- .../src-backup/commands/help/helpText.ts | 320 ----------- .../src-backup/commands/history/history.ts | 281 --------- .../commands/pagination/pagination.ts | 199 ------- .../src-backup/commands/playback/playback.ts | 179 ------ .../src-backup/commands/summary/summary.ts | 153 ----- .../src-backup/commands/timezone/timezone.ts | 543 ------------------ .../src-backup/config/env.ts | 225 -------- .../src-backup/registerCommands.ts | 130 ----- .../src-backup/services/cache/simpleCache.ts | 86 --- .../services/config/guildConfigStore.ts | 83 --- .../src-backup/services/config/index.ts | 14 - .../src-backup/services/config/types.ts | 43 -- .../src-backup/services/database/db.ts | 89 --- .../services/discord/commandLoader.ts | 149 ----- .../services/discord/commandMeta.ts | 94 --- .../src-backup/services/discord/cooldowns.ts | 58 -- .../services/discord/fetchChannelMessages.ts | 69 --- .../services/discord/interactionHandler.ts | 104 ---- .../src-backup/services/discord/safeReply.ts | 48 -- .../src-backup/services/faq/_shared.ts | 238 -------- .../src-backup/services/faq/faqService.ts | 176 ------ .../src-backup/services/faq/permissions.ts | 47 -- .../src-backup/services/faq/services.test.ts | 15 - .../src-backup/services/faq/services.ts | 229 -------- .../src-backup/services/faq/store.test.ts | 115 ---- .../services/faq/store.test.ts.disabled | 115 ---- .../src-backup/services/faq/store.ts | 62 -- .../src-backup/services/faq/store.ts.backup | 57 -- .../src-backup/services/faq/types.ts | 110 ---- .../src-backup/services/fun/coinStore.ts | 146 ----- .../src-backup/services/fun/funUsageStore.ts | 200 ------- .../src-backup/services/fun/pollStore.ts | 146 ----- .../src-backup/services/github/githubApi.ts | 248 -------- .../src-backup/services/github/githubCache.ts | 37 -- .../services/github/githubClient.ts | 85 --- .../services/github/githubErrorMessage.ts | 17 - .../services/github/issueAssigneePoller.ts | 334 ----------- .../github/issueAssigneePoller.ts.backup | 334 ----------- .../services/github/lastSeenStore.ts | 136 ----- .../src-backup/services/github/prFormatter.ts | 53 -- .../src-backup/services/github/prPoller.ts | 95 --- .../src-backup/services/github/types.ts | 130 ----- .../services/roles/autoRoleHandler.ts | 63 -- .../src-backup/services/summary/llmSummary.ts | 64 --- .../services/summary/localSummary.ts | 72 --- .../src-backup/services/summary/summarizer.ts | 16 - .../services/time/formatTimestamp.ts | 21 - .../services/time/validateTimezone.ts | 28 - .../services/timezone/timezoneStore.ts | 144 ----- .../services/transcript/buildTranscript.ts | 101 ---- .../services/transcript/defaults.ts | 35 -- .../src-backup/services/weather/forecast.ts | 274 --------- .../src-backup/services/weather/types.ts | 47 -- .../services/welcome/welcomeHandler.ts | 86 --- .../services/welcome/welcomeMessage.ts | 25 - .../src-backup/utils/logger.ts | 49 -- 78 files changed, 9789 deletions(-) delete mode 100644 data/backup-20260117-145403/src-backup/bot.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/config/config.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/faq/faq.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/fun.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/general/ping.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/github/gh.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/github/pr.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/github/status.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/help/help.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/help/helpText.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/history/history.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/playback/playback.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/summary/summary.ts delete mode 100644 data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts delete mode 100644 data/backup-20260117-145403/src-backup/config/env.ts delete mode 100644 data/backup-20260117-145403/src-backup/registerCommands.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/config/index.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/config/types.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/database/db.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/discord/safeReply.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/_shared.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/faqService.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/permissions.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/services.test.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/services.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.test.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/store.ts.backup delete mode 100644 data/backup-20260117-145403/src-backup/services/faq/types.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/fun/coinStore.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/fun/pollStore.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/githubApi.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/githubCache.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/githubClient.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup delete mode 100644 data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/prFormatter.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/prPoller.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/github/types.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/summary/localSummary.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/summary/summarizer.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/transcript/defaults.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/weather/forecast.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/weather/types.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts delete mode 100644 data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts delete mode 100644 data/backup-20260117-145403/src-backup/utils/logger.ts diff --git a/data/backup-20260117-145403/src-backup/bot.ts b/data/backup-20260117-145403/src-backup/bot.ts deleted file mode 100644 index b2cf7516..00000000 --- a/data/backup-20260117-145403/src-backup/bot.ts +++ /dev/null @@ -1,150 +0,0 @@ -// src/bot.ts -import { initDatabase, closeDatabase } from "./services/database/db.js"; - -import { Client, GatewayIntentBits } from "discord.js"; -import { loadCommands, type CommandClient } from "./services/discord/commandLoader.js"; -import { handleInteraction } from "./services/discord/interactionHandler.js"; -import { pollPullRequestsOnce } from "./services/github/prPoller.js"; -import { pollIssueAssigneesOnce } from "./services/github/issueAssigneePoller.js"; -import { handleAutoRole } from "./services/roles/autoRoleHandler.js"; -import { onGuildMemberAdd } from "./services/welcome/welcomeHandler.js"; -import { env } from "./config/env.js"; -import { logger } from "./utils/logger.js"; - -/** - * Create the Discord client. - * - * Required intents: - * - Guilds: base guild access, slash commands - * - GuildMembers: REQUIRED for guildMemberAdd (welcome messages) - */ -const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], -}) as CommandClient; - -/** - * Command registry populated by the command loader. - */ -client.commands = new Map(); - -/** - * Load compiled slash command modules. - */ -await loadCommands(client); - -/** - * Handle slash command interactions. - */ -client.on("interactionCreate", async (interaction) => { - await handleInteraction(interaction, client); -}); - -/** - * Welcome handler for new guild members. - * - * This will ONLY fire if: - * - Server Members Intent is enabled in the portal - * - GatewayIntentBits.GuildMembers is requested here - */ -client.on("guildMemberAdd", async (member) => { - logger.info( - { - guildId: member.guild.id, - userId: member.user.id, - username: member.user.username, - }, - "guildMemberAdd event fired", - ); - - // Auto-assign a default role on join (if configured) - await handleAutoRole(member); - - await onGuildMemberAdd(member); -}); - -/** - * Optional GitHub polling. - * Each stream is enabled only when all required env vars are present. - */ -const githubPrPollingEnabled = env.githubPrPollingEnabled; -const githubAssigneePollingEnabled = env.githubAssigneePollingEnabled; - -/** - * Log once when the bot is ready. - */ -client.once("clientReady", () => { - logger.info("OmegaBot is online"); - - if (githubPrPollingEnabled) { - logger.info( - { - owner: env.githubOwner, - repo: env.githubRepo, - channelId: env.githubPrAnnounceChannelId, - intervalMs: env.githubPollIntervalMs, - }, - "GitHub PR polling enabled (new PRs)", - ); - } else { - logger.info("GitHub PR polling disabled"); - } - - if (githubAssigneePollingEnabled) { - logger.info( - { - owner: env.githubOwner, - repo: env.githubRepo, - channelId: env.githubAssigneeAnnounceChannelId, - intervalMs: env.githubPollIntervalMs, - }, - "GitHub assignee polling enabled (assignee changes)", - ); - } else { - logger.info("GitHub assignee polling disabled"); - } - - if (!githubPrPollingEnabled && !githubAssigneePollingEnabled) { - logger.info("GitHub polling disabled"); - } -}); - -/** - * Start the bot. - */ -initDatabase(); -logger.info("Database initialized"); - -process.on("SIGINT", () => { - logger.info("Shutting down..."); - closeDatabase(); - process.exit(0); -}); - -void client.login(env.token); - -/** - * Schedule GitHub polling (if enabled). - */ -if (githubPrPollingEnabled || githubAssigneePollingEnabled) { - setInterval(() => { - // 1) PR creation polling (new PR detection) - if (githubPrPollingEnabled) { - void pollPullRequestsOnce({ - client, - owner: env.githubOwner!, - repo: env.githubRepo!, - announceChannelId: env.githubPrAnnounceChannelId!, - }); - } - - // 2) Assignee change polling (issues and PRs) - if (githubAssigneePollingEnabled) { - void pollIssueAssigneesOnce({ - client, - owner: env.githubOwner!, - repo: env.githubRepo!, - announceChannelId: env.githubAssigneeAnnounceChannelId!, - }); - } - }, env.githubPollIntervalMs); -} diff --git a/data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts b/data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts deleted file mode 100644 index ac1f7f59..00000000 --- a/data/backup-20260117-145403/src-backup/commands/changelog/changelog.ts +++ /dev/null @@ -1,48 +0,0 @@ -// src/commands/changelog/changelog.ts - -import fs from "fs"; -import path from "path"; -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { logger } from "../../utils/logger.js"; - -/** - * /changelog command - * - * Shows a short preview of CHANGELOG.md from the repo root. - * Intended as a lightweight reference, not a full file dump. - */ -export const data = new SlashCommandBuilder() - .setName("changelog") - .setDescription("Show a preview of CHANGELOG.md"); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const filePath = path.join(process.cwd(), "CHANGELOG.md"); - - if (!fs.existsSync(filePath)) { - logger.warn({ userId: interaction.user.id }, "[changelog] CHANGELOG.md not found"); - - await interaction.editReply("No CHANGELOG.md found at the repo root."); - return; - } - - try { - const preview = fs.readFileSync(filePath, "utf8").split("\n").slice(0, 40).join("\n"); - - await interaction.editReply(`\`\`\`md\n${preview}\n\`\`\``); - - logger.debug({ userId: interaction.user.id }, "[changelog] Preview sent"); - } catch (err) { - logger.error( - { err, userId: interaction.user.id }, - "[changelog] Failed to read CHANGELOG.md", - ); - - await interaction.editReply("Failed to read CHANGELOG.md."); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/config/config.ts b/data/backup-20260117-145403/src-backup/commands/config/config.ts deleted file mode 100644 index f65023e9..00000000 --- a/data/backup-20260117-145403/src-backup/commands/config/config.ts +++ /dev/null @@ -1,124 +0,0 @@ -// src/commands/config/config.ts - -import { - SlashCommandBuilder, - PermissionFlagsBits, - ChannelType, - type ChatInputCommandInteraction, -} from "discord.js"; -import { - setGuildConfig, - getGuildConfig, -} from "../../services/config/guildConfigStore.js"; -import { logger } from "../../utils/logger.js"; - -/** - * Guild configuration command. - * - * This is intentionally small and focused: - * - Set/clear the welcome channel for onboarding messages - * - * Notes: - * - Requires "Manage Server" to prevent random users changing guild config - * - Only allows guild text channels (not DMs, not threads) - */ -export const data = new SlashCommandBuilder() - .setName("config") - .setDescription("Configure OmegaBot settings for this server") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommandGroup((group) => - group - .setName("welcome-channel") - .setDescription("Configure where welcome messages are posted") - .addSubcommand((sub) => - sub - .setName("set") - .setDescription("Set the channel used for welcome messages") - .addChannelOption((opt) => - opt - .setName("channel") - .setDescription("Channel to post welcome messages in") - .setRequired(true) - // Only allow normal guild text channels. - .addChannelTypes(ChannelType.GuildText), - ), - ) - .addSubcommand((sub) => - sub.setName("clear").setDescription("Clear the configured welcome channel"), - ), - ); - -export async function execute(interaction: ChatInputCommandInteraction) { - // Only valid inside a guild. - if (!interaction.guildId) { - await interaction.reply({ - content: "This command can only be used in a server (not in DMs).", - ephemeral: true, - }); - return; - } - - const group = interaction.options.getSubcommandGroup(); - const sub = interaction.options.getSubcommand(); - - if (group !== "welcome-channel") { - await interaction.reply({ content: "Unknown config group.", ephemeral: true }); - return; - } - - if (sub === "set") { - const channel = interaction.options.getChannel("channel", true); - - // Extra safety: ensure it’s a guild text channel. - if (channel.type !== ChannelType.GuildText) { - await interaction.reply({ - content: "Please choose a normal text channel (not a thread or DM).", - ephemeral: true, - }); - return; - } - - const updated = setGuildConfig(interaction.guildId, { - welcomeChannelId: channel.id, - welcomeEnabled: true, - }); - - logger.info( - { guildId: interaction.guildId, welcomeChannelId: channel.id }, - "Updated guild welcome channel", - ); - - await interaction.reply({ - content: `✅ Welcome messages will be posted in <#${updated.welcomeChannelId}>.`, - ephemeral: true, - }); - return; - } - - if (sub === "clear") { - const current = getGuildConfig(interaction.guildId); - - // If nothing is set, still respond clearly. - if (!current.welcomeChannelId) { - await interaction.reply({ - content: - "Welcome channel is already not set. I will use the system channel or first text channel as a fallback.", - ephemeral: true, - }); - return; - } - - // Clear configured channel and fall back to system/first text channel. - setGuildConfig(interaction.guildId, { - welcomeChannelId: null, - }); - - logger.info({ guildId: interaction.guildId }, "Cleared guild welcome channel"); - - await interaction.reply({ - content: - "✅ Cleared the welcome channel. I will use the system channel or first text channel as a fallback.", - ephemeral: true, - }); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/faq.ts b/data/backup-20260117-145403/src-backup/commands/faq/faq.ts deleted file mode 100644 index 2a0a33f7..00000000 --- a/data/backup-20260117-145403/src-backup/commands/faq/faq.ts +++ /dev/null @@ -1,173 +0,0 @@ -// src/commands/faq/faq.ts -// -// Base /faq slash command. -// -// Responsibilities: -// - Define the /faq command and subcommands (Discord API surface) -// - Own the interaction lifecycle (deferReply + final reply) -// - Route execution to subcommand handlers -// -// Non-goals: -// - No FAQ business logic (validation, persistence, permissions, filtering logic) -// - No file I/O -// -// Subcommands live in ./subcommands/*.ts and delegate to src/services/faq/*. - -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { logger } from "../../utils/logger.js"; - -import { run as runAdd } from "./subcommands/add.js"; -import { run as runGet } from "./subcommands/get.js"; -import { run as runList } from "./subcommands/list.js"; -import { run as runRemove } from "./subcommands/remove.js"; - -/** - * Slash command definition. - * - * This is the public "contract" Discord registers. - * Keep this file focused on: - * - option names/types/descriptions - * - routing to subcommand handlers - */ -export const data = new SlashCommandBuilder() - .setName("faq") - .setDescription("FAQ commands") - - // /faq add - .addSubcommand((s) => - s - .setName("add") - .setDescription("Add a FAQ entry") - .addStringOption((o) => - o.setName("key").setDescription("Unique key").setRequired(true), - ) - .addStringOption((o) => - o.setName("title").setDescription("Short title").setRequired(true), - ) - .addStringOption((o) => - o.setName("body").setDescription("Answer text").setRequired(true), - ) - .addStringOption((o) => - o - .setName("tags") - .setDescription("Comma-separated tags (optional)") - .setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ) - - // /faq get - .addSubcommand((s) => - s - .setName("get") - .setDescription("Get a FAQ entry by key") - .addStringOption((o) => - o.setName("key").setDescription("Key to fetch").setRequired(true), - ) - .addBooleanOption((o) => - o.setName("full").setDescription("Show the full answer text").setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ) - - // /faq list - .addSubcommand((s) => - s - .setName("list") - .setDescription("List FAQ entries") - .addStringOption((o) => - o - .setName("query") - .setDescription("Search in key/title/body (optional)") - .setRequired(false), - ) - .addStringOption((o) => - o.setName("tag").setDescription("Filter by tag (optional)").setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("full") - .setDescription("Show full entries (not just a compact list)") - .setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ) - - // /faq remove - .addSubcommand((s) => - s - .setName("remove") - .setDescription("Remove a FAQ entry by key") - .addStringOption((o) => - o.setName("key").setDescription("Key to remove").setRequired(true), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ); - -/** - * Command execution entry point. - * - * Pattern: - * - Determine subcommand - * - Defer reply immediately (avoids the 3s Discord timeout) - * - Route to handler - */ -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const sub = interaction.options.getSubcommand(true); - const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; - - // Parent owns the interaction lifecycle: always defer first. - await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); - - try { - if (sub === "add") { - await runAdd(interaction); - return; - } - - if (sub === "get") { - await runGet(interaction); - return; - } - - if (sub === "list") { - await runList(interaction); - return; - } - - if (sub === "remove") { - await runRemove(interaction); - return; - } - - // Safety net in case Discord sends something unexpected - await interaction.editReply("Unknown subcommand."); - } catch (err) { - logger.error({ err, sub }, "[faq] subcommand failed"); - await interaction.editReply("Something went wrong. Try again in a bit."); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts deleted file mode 100644 index d4e5b1b1..00000000 --- a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/add.ts +++ /dev/null @@ -1,64 +0,0 @@ -// src/commands/faq/subcommands/add.ts -// -// /faq add -// -// Responsibilities: -// - Read slash command options -// - Run shared guardrails (permissions, basic input checks) -// - Delegate creation to FAQ service layer -// -// IMPORTANT: -// - This file is NOT a slash command by itself -// - It must NOT call reply() or deferReply() -// - The parent command (faq.ts) owns the interaction lifecycle - -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; -import { create } from "../../../services/faq/services.js"; - -import { - guardFaqAction, - parseTags, - handleFaqSubcommandError, -} from "../../../services/faq/_shared.js"; - -export async function run(interaction: ChatInputCommandInteraction): Promise { - try { - if (!(await guardFaqAction(interaction, "add"))) return; - - const key = interaction.options.getString("key", true).trim(); - const title = interaction.options.getString("title", true).trim(); - const body = interaction.options.getString("body", true).trim(); - const tagsRaw = interaction.options.getString("tags", false); - - // Keep command-layer checks tiny. Service layer is strict. - if (!key) { - await interaction.editReply("❌ Key cannot be empty."); - return; - } - if (!title) { - await interaction.editReply("❌ Title cannot be empty."); - return; - } - if (!body) { - await interaction.editReply("❌ Body cannot be empty."); - return; - } - - const tags = parseTags(tagsRaw); - - const entry = create({ - key, - title, - body, - tags, - actor: interaction.user.id, - }); - - await interaction.editReply(`✅ Added FAQ **${entry.key}**`); - - logger.info({ userId: interaction.user.id, key: entry.key }, "[faq/add] created"); - } catch (err) { - await handleFaqSubcommandError(interaction, err, "[faq/add] failed"); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts deleted file mode 100644 index 0095f446..00000000 --- a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/get.ts +++ /dev/null @@ -1,45 +0,0 @@ -// src/commands/faq/subcommands/get.ts -// -// /faq get -// -// Responsibilities: -// - Read key -// - Fetch from service layer -// - Increment usage (optional analytics) -// - Format a clean response - -import type { ChatInputCommandInteraction } from "discord.js"; -import { getByKey, incrementUsage } from "../../../services/faq/services.js"; - -import { - guardFaqAction, - readRequiredKey, - formatFaqEntry, - handleFaqSubcommandError, -} from "../../../services/faq/_shared.js"; - -export async function run(interaction: ChatInputCommandInteraction): Promise { - try { - if (!(await guardFaqAction(interaction, "get"))) return; - - const key = await readRequiredKey(interaction, "key"); - if (!key) return; - - const entry = getByKey(key); - if (!entry) { - await interaction.editReply(`❌ FAQ not found: **${key}**`); - return; - } - - // Optional analytics. If it fails, we still show the FAQ. - try { - incrementUsage(entry.key); - } catch { - // ignore - } - - await interaction.editReply(formatFaqEntry(entry)); - } catch (err) { - await handleFaqSubcommandError(interaction, err, "[faq/get] failed"); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts deleted file mode 100644 index f0e83eff..00000000 --- a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/list.ts +++ /dev/null @@ -1,66 +0,0 @@ -// src/commands/faq/subcommands/list.ts -// -// /faq list -// -// Responsibilities: -// - Enforce permissions via guardFaqAction -// - Parse lightweight UI options (query, tag, sort, full, limit) -// - Fetch entries from the FAQ service -// - Delegate filtering + formatting to _shared helpers -// -// Non-goals: -// - No persistence logic (store.ts) -// - No business rules (services.ts) -// -// Notes: -// - Keep this file thin so it stays easy to change the Discord UX later. - -import type { ChatInputCommandInteraction } from "discord.js"; -import { getAll } from "../../../services/faq/services.js"; -import { - guardFaqAction, - formatFaqList, - handleFaqSubcommandError, -} from "../../../services/faq/_shared.js"; - -export async function run(interaction: ChatInputCommandInteraction): Promise { - try { - // Guardrails first: permissions and consistent error messaging live in _shared.ts - if (!(await guardFaqAction(interaction, "list"))) return; - - // Optional filters. These options must exist in faq.ts builder to work. - const query = interaction.options.getString("query")?.trim() ?? ""; - const tag = interaction.options.getString("tag")?.trim() ?? ""; - - // Small, safe defaults to avoid Discord message spam. - const full = interaction.options.getBoolean("full") ?? false; - const limitRaw = interaction.options.getInteger("limit"); - const limit = clampInt(limitRaw ?? (full ? 5 : 25), 1, full ? 10 : 50); - - // Fetch all entries from the service layer. - // Filtering and sorting happens in formatFaqList (via _shared.ts). - const entries = getAll(); - - const text = formatFaqList(entries, { - query: query.length ? query : undefined, - tag: tag.length ? tag : undefined, - limit, - full, - }); - - await interaction.editReply(text); - } catch (err) { - await handleFaqSubcommandError(interaction, err, "[faq/list] failed"); - } -} - -/** - * Clamp an integer to a safe range. - * Keeps user input from producing huge outputs or weird negatives. - */ -function clampInt(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - if (value < min) return min; - if (value > max) return max; - return value; -} diff --git a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts b/data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts deleted file mode 100644 index 248a4af9..00000000 --- a/data/backup-20260117-145403/src-backup/commands/faq/subcommands/remove.ts +++ /dev/null @@ -1,110 +0,0 @@ -// src/commands/faq/subcommands/remove.ts -// -// /faq remove -// -// Responsibilities: -// - Enforce permissions (via shared guard) -// - Confirm intent (button) before deleting -// - Delegate all persistence + rules to the FAQ service layer -// -// IMPORTANT: -// - This file is NOT a slash command by itself -// - It must NOT call reply() or deferReply() -// - The parent command (faq.ts) owns the interaction lifecycle - -import type { ChatInputCommandInteraction, Message, ButtonInteraction } from "discord.js"; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js"; - -import { logger } from "../../../utils/logger.js"; -import { getByKey, remove } from "../../../services/faq/services.js"; -import { - guardFaqAction, - readRequiredKey, - handleFaqSubcommandError, -} from "../../../services/faq/_shared.js"; - -export async function run(interaction: ChatInputCommandInteraction): Promise { - try { - // Permissions only make sense in a guild context - if (!interaction.inGuild()) { - // Type assertion needed due to TypeScript narrowing issue - await (interaction as ChatInputCommandInteraction).editReply( - "❌ `/faq remove` can only be used in a server.", - ); - return; - } - - // Centralized permission gate (ManageGuild/Admin or whatever your policy is) - if (!(await guardFaqAction(interaction, "remove"))) return; - - // Read + minimal validate key from options - const rawKey = await readRequiredKey(interaction, "key"); - if (!rawKey) return; - - // Lookup entry (service normalizes internally too, but we keep messaging clean here) - const existing = getByKey(rawKey); - if (!existing) { - await interaction.editReply(`❌ FAQ not found: **${rawKey}**`); - return; - } - - // Build a confirmation UI - const confirmId = `faq:remove:confirm:${existing.key}:${interaction.user.id}`; - const cancelId = `faq:remove:cancel:${existing.key}:${interaction.user.id}`; - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(confirmId) - .setLabel("Delete") - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(cancelId) - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary), - ); - - // Show confirmation prompt (keep it very explicit) - const replied = (await interaction.editReply({ - content: - `🗑️ **Confirm delete**\n` + - `Key: **${existing.key}**\n` + - `Title: **${existing.title}**\n\n` + - `This cannot be undone.`, - components: [row], - })) as unknown as Message; - - // Wait for the requester's button click - const clicked = (await replied.awaitMessageComponent({ - componentType: ComponentType.Button, - time: 20_000, - filter: (i: ButtonInteraction) => i.user.id === interaction.user.id, - })) as ButtonInteraction; - - // Cancel path - if (clicked.customId === cancelId) { - await clicked.update({ - content: "✅ Cancelled. Nothing was deleted.", - components: [], - }); - return; - } - - // Confirm path - const ok = remove(existing.key); - - await clicked.update({ - content: ok - ? `✅ Deleted FAQ **${existing.key}**` - : `❌ Could not delete FAQ **${existing.key}** (it may have already been removed).`, - components: [], - }); - - logger.info( - { userId: interaction.user.id, key: existing.key }, - "[faq/remove] removed", - ); - } catch (err: unknown) { - // No `any` here. Keep the handler strict and log the raw error. - await handleFaqSubcommandError(interaction, err, "[faq/remove] failed"); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/fun.ts b/data/backup-20260117-145403/src-backup/commands/fun/fun.ts deleted file mode 100644 index eb1f5982..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/fun.ts +++ /dev/null @@ -1,364 +0,0 @@ -// src/commands/fun/fun.ts - -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { logger } from "../../utils/logger.js"; - -import { run as runChuckNorris } from "./subcommands/chucknorris.js"; -import type { ChuckNorrisMode } from "./subcommands/chucknorris.js"; - -import { run as runDadJoke } from "./subcommands/dadjoke.js"; -import type { DadJokeMode } from "./subcommands/dadjoke.js"; - -import { run as runDice } from "./subcommands/dice.js"; - -import { run as runWeather } from "./subcommands/weather.js"; -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 "../../services/fun/funUsageStore.js"; - -function parseTempUnit(raw: string | null): TempUnit { - return raw?.toLowerCase() === "c" ? "c" : "f"; -} - -export const data = new SlashCommandBuilder() - .setName("fun") - .setDescription("Fun commands") - - // /fun chucknorris - .addSubcommand((s) => - s - .setName("chucknorris") - .setDescription("Chuck Norris facts (random, category, or search)") - .addStringOption((o) => - o.setName("category").setDescription("Category (optional)").setRequired(false), - ) - .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 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 - .setName("dice") - .setDescription("Roll some dice") - .addIntegerOption((o) => - o - .setName("sides") - .setDescription("Number of sides on each die") - .setMinValue(2) - .setMaxValue(100) - .setRequired(false), - ) - .addIntegerOption((o) => - o - .setName("count") - .setDescription("How many dice to roll") - .setMinValue(1) - .setMaxValue(10) - .setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ) - - // /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 - .setName("weather") - .setDescription("Weather for a location (includes current conditions)") - .addStringOption((o) => - o - .setName("location") - .setDescription('City, "City, ST", ZIP, etc.') - .setRequired(true), - ) - .addStringOption((o) => - o - .setName("unit") - .setDescription("Temperature unit") - .addChoices({ name: "F", value: "f" }, { name: "C", value: "c" }) - .setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .setDescription("Only show the result to you") - .setRequired(false), - ), - ) - - // /fun weather7 (7-day) - .addSubcommand((s) => - s - .setName("weather7") - .setDescription("7-day forecast (includes current conditions)") - .addStringOption((o) => - o - .setName("location") - .setDescription('City, "City, ST", ZIP, etc.') - .setRequired(true), - ) - .addStringOption((o) => - o - .setName("unit") - .setDescription("Temperature unit") - .addChoices({ name: "F", value: "f" }, { name: "C", value: "c" }) - .setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("ephemeral") - .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) { - logger.warn({ err, sub }, "[fun] failed to record usage"); - } -} - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const sub = interaction.options.getSubcommand(true); - - // Only some subcommands have the "ephemeral" option (poll does not). - const supportsEphemeral = sub !== "poll"; - const ephemeral = supportsEphemeral - ? (interaction.options.getBoolean("ephemeral") ?? false) - : false; - - await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); - - try { - if (sub === "chucknorris") { - const category = interaction.options.getString("category")?.trim(); - const query = interaction.options.getString("query")?.trim(); - - const mode: ChuckNorrisMode = query - ? { kind: "search", query } - : category - ? { kind: "category", category } - : { kind: "random" }; - - await runChuckNorris(interaction, mode); - await maybeRecordUsage(interaction, sub); - 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); - 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") { - const location = interaction.options.getString("location", true).trim(); - const unit = parseTempUnit(interaction.options.getString("unit") ?? "f"); - - const mode: WeatherMode = - sub === "weather" - ? { kind: "daily", location, unit } - : { kind: "7day", location, unit }; - - await runWeather(interaction, mode); - await maybeRecordUsage(interaction, sub); - return; - } - - await interaction.editReply("Unknown subcommand."); - } catch (err) { - logger.error({ err, sub }, "[fun] subcommand failed"); - await interaction.editReply("Something went wrong. Try again in a bit."); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts deleted file mode 100644 index 6b3ce038..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/chucknorris.ts +++ /dev/null @@ -1,220 +0,0 @@ -// src/commands/fun/subcommands/chucknorris.ts - -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; - -type ChuckNorrisApiResponse = { - value: string; -}; - -type ChuckNorrisSearchResponse = { - total?: number; - result?: ChuckNorrisApiResponse[]; -}; - -export type ChuckNorrisMode = - | { kind: "random" } - | { kind: "category"; category: string } - | { kind: "search"; query: string }; - -/** - * Run handler for /fun chucknorris - * - * 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: ChuckNorrisMode, -): Promise { - try { - const joke = await fetchChuckNorris(mode); - - await interaction.editReply(joke); - - logger.debug( - { userId: interaction.user.id, mode: mode.kind }, - "[fun/chucknorris] sent", - ); - } catch (err) { - const userMessage = toUserMessage(err); - - logger.error({ err, mode, userMessage }, "[fun/chucknorris] failed"); - await interaction.editReply(userMessage); - } -} - -class UserFacingError extends Error { - constructor(message: string) { - super(message); - this.name = "UserFacingError"; - } -} - -function toUserMessage(err: unknown): string { - if (err instanceof UserFacingError) return err.message; - - // Keep the fun fallback, but also provide a tiny bit of “why” for non-user errors. - if (err instanceof Error) { - // Avoid leaking raw internals; give a short category of failure. - if (err.message.toLowerCase().includes("network")) { - return "I couldn’t reach the Chuck Norris API (network issue). Try again in a bit."; - } - if (err.message.toLowerCase().includes("api error")) { - return "The Chuck Norris API returned an error. Try again later."; - } - } - - return "Chuck Norris is currently roundhouse kicking the API. Try again later."; -} - -async function fetchChuckNorris(mode: ChuckNorrisMode): Promise { - if (mode.kind === "random") - return fetchSingle("https://api.chucknorris.io/jokes/random"); - - if (mode.kind === "category") { - const category = mode.category.trim(); - if (!category) { - throw new UserFacingError("Please provide a category."); - } - - const url = `https://api.chucknorris.io/jokes/random?category=${encodeURIComponent( - category, - )}`; - - // fetchSingle throws on non-OK; we intercept 404 here to explain “why”. - try { - return await fetchSingle(url); - } catch (err) { - if (isHttpError(err, 404)) { - const categories = await safeFetchCategories(); - const hint = categories?.length - ? `Valid categories include: ${categories - .slice(0, 12) - .map((c) => `\`${c}\``) - .join(", ")}` - : "Try something like `dev`, `movie`, or `science`."; - - throw new UserFacingError( - `That category was not found: \`${category}\`. ${hint}`, - ); - } - throw err; - } - } - - // mode.kind === "search" - const query = mode.query.trim(); - if (!query) { - throw new UserFacingError("Please provide a search query."); - } - - const url = `https://api.chucknorris.io/jokes/search?query=${encodeURIComponent( - query, - )}`; - - const res = await fetch(url, { - headers: { - Accept: "application/json", - "User-Agent": "OmegaBot", - }, - }); - - if (!res.ok) { - // For search, a 400 usually means bad query; treat as user-facing - if (res.status === 400) { - throw new UserFacingError( - "That search query didn’t work. Try a simpler word or phrase.", - ); - } - throw new Error(`Chuck Norris API error: ${res.status} ${res.statusText}`); - } - - const data = (await res.json()) as ChuckNorrisSearchResponse; - const results = Array.isArray(data.result) ? data.result : []; - - if (!results.length) { - throw new UserFacingError(`No results for \`${query}\`. Try something broader.`); - } - - // Pick a random result so it feels fresh - const pick = results[Math.floor(Math.random() * results.length)]; - const joke = pick?.value; - - if (!joke || typeof joke !== "string") { - throw new Error("Chuck Norris API returned an unexpected response shape"); - } - - return joke.trim(); -} - -async function fetchSingle(url: string): Promise { - let res: Response; - - try { - res = await fetch(url, { - headers: { - Accept: "application/json", - "User-Agent": "OmegaBot", - }, - }); - } catch (err) { - throw new Error( - `Network error calling Chuck Norris API: ${(err as Error)?.message ?? String(err)}`, - ); - } - - if (!res.ok) { - throw new HttpError( - `Chuck Norris API error: ${res.status} ${res.statusText}`, - res.status, - ); - } - - const data = (await res.json()) as ChuckNorrisApiResponse; - - if (!data?.value || typeof data.value !== "string") { - throw new Error("Chuck Norris API returned an unexpected response shape"); - } - - return data.value.trim(); -} - -class HttpError extends Error { - constructor( - message: string, - public status: number, - ) { - super(message); - this.name = "HttpError"; - } -} - -function isHttpError(err: unknown, status: number): boolean { - return err instanceof HttpError && err.status === status; -} - -async function safeFetchCategories(): Promise { - try { - const res = await fetch("https://api.chucknorris.io/jokes/categories", { - headers: { - Accept: "application/json", - "User-Agent": "OmegaBot", - }, - }); - - if (!res.ok) return null; - - const data = (await res.json()) as unknown; - - if (!Array.isArray(data) || !data.every((x) => typeof x === "string")) { - return null; - } - - return data as string[]; - } catch { - return null; - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts deleted file mode 100644 index 513c1897..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/coinflip.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function run(interaction: ChatInputCommandInteraction): Promise { - // Parent (fun.ts) owns deferReply(). We only editReply() here. - - // Simple spinning animation - const frames = ["|", "/", "-", "\\", "|", "/", "-", "\\"]; - for (const f of frames) { - await interaction.editReply(`🪙 Flipping ${f}`); - await sleep(140); - } - - // Small pause before reveal - await interaction.editReply("🪙 Tossed…"); - await sleep(260); - - const isHeads = Math.random() < 0.5; - - // Clean final result (no duplicate coin emoji) - const result = isHeads ? "🟡 **HEADS**" : "⚪ **TAILS**"; - - await interaction.editReply(result); - - logger.debug( - { - userId: interaction.user.id, - result: isHeads ? "heads" : "tails", - }, - "[fun/coinflip] result sent", - ); -} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dadjoke.ts deleted file mode 100644 index ce1a73a6..00000000 --- a/data/backup-20260117-145403/src-backup/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/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts deleted file mode 100644 index 6d2f0ed5..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/dice.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; - -/** - * Dice faces for a standard d6. - * Used only when sides === 6. - */ -const D6_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"]; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function clampInt(n: number, min: number, max: number): number { - return Math.min(Math.max(n, min), max); -} - -function rollDie(sides: number): number { - return Math.floor(Math.random() * sides) + 1; -} - -function formatOneRoll(sides: number, roll: number): string { - // If the rolled value is between 1–6, show emoji + number - if (roll >= 1 && roll <= 6) { - const face = D6_FACES[roll - 1] ?? "🎲"; - return `${face} (${roll})`; - } - - // Otherwise just show the number - return `${roll}`; -} - -async function rollAnimation( - interaction: ChatInputCommandInteraction, - sides: number, - count: number, -): Promise { - const frames = 8; - - for (let i = 0; i < frames; i += 1) { - let frameText = ""; - - if (sides === 6) { - // show a few random faces when rolling multiple dice - const shown = Math.min(count, 5); - const faces: string[] = []; - for (let j = 0; j < shown; j += 1) { - faces.push(D6_FACES[Math.floor(Math.random() * D6_FACES.length)] ?? "🎲"); - } - frameText = faces.join(" "); - } else { - // show one changing number for non-d6 - frameText = String(rollDie(sides)); - } - - await interaction.editReply(`🎲 Rolling… ${frameText}`); - await sleep(120); - } -} - -export async function run(interaction: ChatInputCommandInteraction): Promise { - const sidesRaw = interaction.options.getInteger("sides") ?? 6; - const countRaw = interaction.options.getInteger("count") ?? 1; - - const sides = clampInt(sidesRaw, 2, 100); - const count = clampInt(countRaw, 1, 10); - - try { - await rollAnimation(interaction, sides, count); - - const rolls: number[] = []; - for (let i = 0; i < count; i += 1) { - rolls.push(rollDie(sides)); - } - - const total = rolls.reduce((a, b) => a + b, 0); - - // Stacked output like you showed - const display = rolls.map((r) => formatOneRoll(sides, r)).join(", "); - - const lines: string[] = []; - lines.push(`You rolled (d${sides}): ${display}`); - - if (count > 1) { - lines.push(`Total: ${total}`); - } - - await interaction.editReply(lines.join("\n")); - - logger.debug( - { userId: interaction.user.id, sides, count, rolls, total }, - "[fun/dice] roll complete", - ); - } catch (err) { - logger.error({ err }, "[fun/dice] failed"); - await interaction.editReply("🎲 The dice fell off the table. Try again."); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts deleted file mode 100644 index 0dc4b976..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/java.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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/data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts deleted file mode 100644 index c0ff550f..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/leaderboard.ts +++ /dev/null @@ -1,233 +0,0 @@ -// src/commands/fun/subcommands/leaderboard.ts - -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 PerCommandCounts = Record; -type ByUserByCommand = Record; - -function toCount(v: unknown): number { - return typeof v === "number" && Number.isFinite(v) ? v : 0; -} - -function clampLimit(n: number, min: number, max: number): number { - if (!Number.isFinite(n)) return min; - return Math.max(min, Math.min(max, n)); -} - -function topFromPerCmd( - 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 formatPerCmdInline(perCmd: PerCommandCounts | undefined, limit: number): string { - const top = topFromPerCmd(perCmd, limit); - if (!top.length) return ""; - // Example: "dadjoke 3x, coinflip 2x, chucknorris 1x" - return top.map((x) => `${x.command} ${x.count}x`).join(", "); -} - -function formatPerCmdLines(perCmd: PerCommandCounts | undefined): string[] { - const top = topFromPerCmd(perCmd, 50); - if (!top.length) return ["No per-command data yet."]; - - // Keep per-command breakdown easy to scan - return top.map((x) => `/fun ${x.command} ${x.count}x`); -} - -function rankLabel(idx: number): string { - // 0-based idx - const medals = ["🥇", "🥈", "🥉"]; - if (idx < medals.length) return medals[idx]; - - // 4th+ as emoji digits when possible; fallback to plain number. - const n = idx + 1; - const digitEmoji: Record = { - "0": "0️⃣", - "1": "1️⃣", - "2": "2️⃣", - "3": "3️⃣", - "4": "4️⃣", - "5": "5️⃣", - "6": "6️⃣", - "7": "7️⃣", - "8": "8️⃣", - "9": "9️⃣", - }; - - const s = String(n); - const allDigits = [...s].every((c) => c >= "0" && c <= "9"); - if (!allDigits) return `${n}.`; - - // Works great up through 9. For 10+ it becomes "1️⃣0️⃣" which is still readable. - return [...s].map((c) => digitEmoji[c] ?? c).join(""); -} - -async function safeFetchUser( - interaction: ChatInputCommandInteraction, - userId: string, -): Promise { - try { - return await interaction.client.users.fetch(userId); - } catch (err) { - logger.debug({ err, userId }, "[fun/leaderboard] failed to fetch user"); - return null; - } -} - -export async function run( - interaction: ChatInputCommandInteraction, - mode: LeaderboardMode, -): Promise { - const snapshot = await getFunUsageSnapshot(); - - // Defensive casts (JSON file can drift) - const totalsByUserRaw = (snapshot as unknown as { totalsByUser?: unknown }) - .totalsByUser; - const totalsByCommandRaw = (snapshot as unknown as { totalsByCommand?: unknown }) - .totalsByCommand; - const byUserByCommandRaw = (snapshot as unknown as { byUserByCommand?: unknown }) - .byUserByCommand; - - const totalsByUser = (totalsByUserRaw ?? {}) as Record; - const totalsByCommand = (totalsByCommandRaw ?? {}) as Record; - const byUserByCommand = (byUserByCommandRaw ?? {}) as ByUserByCommand; - - const anyUserUsage = Object.keys(totalsByUser).length > 0; - const anyCommandUsage = (Object.values(totalsByCommand) as unknown[]).some( - (n) => toCount(n) > 0, - ); - - const updatedAt = - (snapshot as unknown as { updatedAt?: string }).updatedAt ?? "unknown"; - - const embed = new EmbedBuilder().setFooter({ text: `Updated: ${updatedAt}` }); - - if (!anyUserUsage && !anyCommandUsage) { - embed.setTitle("Fun Leaderboard"); - embed.setDescription( - "No fun command usage recorded yet. Try `/fun dadjoke` to get started.", - ); - await interaction.editReply({ embeds: [embed] }); - return; - } - - const limit = clampLimit("limit" in mode ? mode.limit : 10, 1, 25); - - // ---- Top Commands view ---- - if (mode.kind === "commands") { - 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, limit); - - embed.setTitle("Fun Leaderboard: Top Commands"); - - if (!items.length) { - embed.setDescription("No command usage yet."); - await interaction.editReply({ embeds: [embed] }); - return; - } - - // Emoji ranks, no bullets, no dashes - const lines = items.map((x, idx) => `${rankLabel(idx)} /fun ${x.cmd} ${x.count}x`); - embed.setDescription(lines.join("\n")); - await interaction.editReply({ embeds: [embed] }); - return; - } - - // ---- Single User view (avatar + top command) ---- - if (mode.kind === "user") { - const userId = mode.userId; - - const total = toCount(totalsByUser[userId]); - const perCmd = byUserByCommand[userId] ?? {}; - - const user = await safeFetchUser(interaction, userId); - const name = user?.username ?? `User ${userId}`; - - // Better resolution avatar - // - request a larger size (1024 or 2048 is plenty) - // - force png for consistency - const avatar = - user?.displayAvatarURL({ - extension: "png", - size: 2048, - }) ?? null; - - embed.setTitle(`Fun Usage: ${name}`); - if (avatar) embed.setThumbnail(avatar); - - const top1 = topFromPerCmd(perCmd, 1)[0] ?? null; - const topLine = top1 ? `Top command: /fun ${top1.command} ${top1.count}x` : ""; - - const lines = formatPerCmdLines(perCmd); - - // Keep this clean, but still readable - embed.setDescription( - [ - `User: <@${userId}>`, - `Total uses: ${total}x`, - topLine, - "", - "Per-command breakdown:", - ...lines, - ] - .filter(Boolean) - .join("\n"), - ); - - await interaction.editReply({ embeds: [embed] }); - return; - } - - // ---- 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, limit); - - embed.setTitle("Fun Leaderboard: Top Users"); - - if (!userItems.length) { - embed.setDescription("No user usage yet."); - await interaction.editReply({ embeds: [embed] }); - return; - } - - // Emoji ranks + “user X ran command Y this many times” - // No bullets, no dash separators - const out: string[] = []; - - for (let idx = 0; idx < userItems.length; idx += 1) { - const item = userItems[idx]!; - const perCmd = byUserByCommand[item.userId] ?? {}; - const breakdown = formatPerCmdInline(perCmd, 3); - - const prefix = rankLabel(idx); - if (breakdown) { - out.push(`${prefix} <@${item.userId}> ${item.total}x (${breakdown})`); - } else { - out.push(`${prefix} <@${item.userId}> ${item.total}x`); - } - } - - embed.setDescription(out.join("\n")); - await interaction.editReply({ embeds: [embed] }); -} diff --git a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts deleted file mode 100644 index d48eedc9..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/poll.ts +++ /dev/null @@ -1,211 +0,0 @@ -// src/commands/fun/subcommands/poll.ts - -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 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}` }); -} - -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), - ); - } - - 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); - } - - // 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 unique.map((label: string, index: number) => ({ label, index })); -} - -export async function run(interaction: ChatInputCommandInteraction): Promise { - const question = interaction.options.getString("question", true).trim(); - - let opts: PollOption[]; - try { - opts = parseOptions(interaction); - } catch (err) { - const msg = err instanceof Error ? err.message : "Invalid poll options."; - await interaction.editReply(msg); - return; - } - - // 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 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/data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts b/data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts deleted file mode 100644 index e120bc49..00000000 --- a/data/backup-20260117-145403/src-backup/commands/fun/subcommands/weather.ts +++ /dev/null @@ -1,142 +0,0 @@ -// src/commands/fun/subcommands/weather.ts - -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../../utils/logger.js"; -import type { WeatherMode } from "../../../services/weather/types.js"; -import { fetchWeatherBundle } from "../../../services/weather/forecast.js"; - -function weatherEmoji(text: string): string { - const t = text.toLowerCase(); - if (t.includes("thunder")) return "🌩️"; - if (t.includes("snow") || t.includes("sleet") || t.includes("blizzard")) return "❄️"; - if (t.includes("rain") || t.includes("shower") || t.includes("drizzle")) return "🌧️"; - if (t.includes("fog") || t.includes("mist") || t.includes("haze")) return "🌫️"; - if (t.includes("cloud") || t.includes("overcast")) return "☁️"; - if (t.includes("sun") || t.includes("clear")) return "☀️"; - return "🌤️"; -} - -function friendlyWeatherError(err: unknown): string { - const msg = err instanceof Error ? err.message : "Unknown error"; - - if (msg.toLowerCase().includes("missing weatherapi_key")) { - return "Weather is not configured (missing WEATHERAPI_KEY). Ask an admin to set it in `.env`."; - } - - if (msg.toLowerCase().includes("auth error")) { - return "Weather is misconfigured (bad WEATHERAPI_KEY). Ask an admin to fix `.env`."; - } - - if (msg.toLowerCase().includes("rejected the location")) { - return "I could not find that location. Try a ZIP, city, or `City, ST`."; - } - - if (msg.toLowerCase().includes("rate limit")) { - return "WeatherAPI rate limit hit. Try again in a bit."; - } - - return "Weather API is being dramatic. Try again later."; -} - -export async function run( - interaction: ChatInputCommandInteraction, - mode: WeatherMode, -): Promise { - try { - const days = mode.kind === "daily" ? 1 : 7; - - const bundle = await fetchWeatherBundle({ - location: mode.location, - unit: mode.unit, - days, - }); - - const header = `📍 **${bundle.placeLabel}**`; - - const nowBlock: string[] = []; - if (bundle.now) { - const nowEmoji = weatherEmoji(bundle.now.condition); - const wind = bundle.now.wind ? ` • Wind: ${bundle.now.wind}` : ""; - const hum = bundle.now.humidity ? ` • Humidity: ${bundle.now.humidity}` : ""; - const feels = bundle.now.feelsLike ? ` • Feels like: ${bundle.now.feelsLike}` : ""; - - nowBlock.push( - `${nowEmoji} Now: **${bundle.now.temp}** • ${bundle.now.condition}${feels}${wind}${hum}`, - ); - if (bundle.now.asOf) { - nowBlock.push( - `As of: ${bundle.now.asOf}${bundle.tzId ? ` (${bundle.tzId})` : ""}`, - ); - } - } - - if (mode.kind === "daily") { - const today = bundle.days[0]; - if (!today) { - await interaction.editReply(`${header}\nNo forecast data available right now.`); - return; - } - - const emoji = weatherEmoji(today.condition); - const pop = today.pop ? `☔ ${today.pop}` : ""; - const astro = - today.sunrise || today.sunset - ? `Sunrise: ${today.sunrise ?? "N/A"} • Sunset: ${today.sunset ?? "N/A"}` - : ""; - - const lines: string[] = []; - lines.push(header); - lines.push(""); - if (nowBlock.length) { - lines.push(...nowBlock); - lines.push(""); - } - lines.push(`${emoji} **Today**`); - lines.push(`🌡️ ${today.temp}${pop ? ` • ${pop}` : ""}`); - if (astro) lines.push(astro); - - await interaction.editReply(lines.join("\n")); - return; - } - - // 7-day - const lines: string[] = []; - lines.push(header); - lines.push(""); - if (nowBlock.length) { - lines.push(...nowBlock); - lines.push(""); - } - - lines.push("📆 **7-Day Forecast**"); - lines.push(""); - - for (const d of bundle.days.slice(0, 7)) { - const emoji = weatherEmoji(d.condition); - const pop = d.pop ? ` • ☔ ${d.pop}` : ""; - lines.push(`${emoji} **${d.label}**: ${d.temp}${pop}`); - if (d.sunrise || d.sunset) { - lines.push(`Sunrise: ${d.sunrise ?? "N/A"} • Sunset: ${d.sunset ?? "N/A"}`); - } - lines.push(""); - } - - // remove trailing blank line - while (lines.length && lines[lines.length - 1] === "") lines.pop(); - - await interaction.editReply(lines.join("\n")); - - logger.debug( - { - userId: interaction.user.id, - mode: mode.kind, - unit: mode.unit, - location: mode.location, - }, - "[fun/weather] sent", - ); - } catch (err) { - logger.error({ err, mode }, "[fun/weather] failed"); - await interaction.editReply(friendlyWeatherError(err)); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/general/ping.ts b/data/backup-20260117-145403/src-backup/commands/general/ping.ts deleted file mode 100644 index f4d27571..00000000 --- a/data/backup-20260117-145403/src-backup/commands/general/ping.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../utils/logger.js"; - -/** - * Defines the /ping command. - * Used to verify that the bot is responding and to measure latency. - */ -export const data = new SlashCommandBuilder() - .setName("ping") - .setDescription("Ping test with latency"); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - try { - /** - * Send the initial reply. - * We avoid deprecated fetchReply option and instead fetch the reply after. - */ - await interaction.reply("Pinging..."); - - /** - * Fetch the bot's reply message so we can compute latency. - */ - const sent = await interaction.fetchReply(); - - /** - * Measure latency: - * - interaction.createdTimestamp is when Discord received the slash command - * - sent.createdTimestamp is when Discord created the bot's response - */ - const latency = sent.createdTimestamp - interaction.createdTimestamp; - - /** - * WebSocket heartbeat is the current ping between the bot and Discord's gateway. - * Lower numbers mean a healthier connection. - */ - const wsPing = interaction.client.ws.ping; - - /** - * Debug-level logging so we can observe bot health without spamming logs. - */ - logger.debug( - { - latency, - wsPing, - userId: interaction.user.id, - }, - "[ping] latency check", - ); - - /** - * Edit the original message to show actual latency numbers. - */ - await interaction.editReply( - `Pong. Round trip latency is ${latency}ms. WebSocket heartbeat is ${wsPing}ms.`, - ); - } catch (err) { - /** - * Extremely unlikely path, but included for consistency with other commands. - */ - logger.error({ err }, "[ping] command failed"); - - if (interaction.replied || interaction.deferred) { - await interaction.editReply("Ping failed unexpectedly."); - } else { - await interaction.reply("Ping failed unexpectedly."); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/github/gh.ts b/data/backup-20260117-145403/src-backup/commands/github/gh.ts deleted file mode 100644 index 356d70af..00000000 --- a/data/backup-20260117-145403/src-backup/commands/github/gh.ts +++ /dev/null @@ -1,200 +0,0 @@ -// src/commands/github/gh.ts - -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { env } from "../../config/env.js"; -import { - getIssue, - listIssues, - listPullRequests, -} from "../../services/github/githubApi.js"; -import { getGitHubUserMessage } from "../../services/github/githubErrorMessage.js"; -import { logger } from "../../utils/logger.js"; - -/** - * /gh command - * GitHub helpers (issues + PRs) - */ -export const data = new SlashCommandBuilder() - .setName("gh") - .setDescription("GitHub helpers") - - .addSubcommand((s) => - s - .setName("status") - .setDescription("Show GitHub integration status for this bot") - .addStringOption((o) => - o.setName("owner").setDescription("Org/user (optional; defaults to env)"), - ) - .addStringOption((o) => - o.setName("repo").setDescription("Repo (optional; defaults to env)"), - ), - ) - - .addSubcommand((s) => - s - .setName("issue") - .setDescription("Fetch a GitHub issue by number") - .addStringOption((o) => - o.setName("owner").setDescription("Org/user").setRequired(true), - ) - .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) - .addIntegerOption((o) => - o.setName("number").setDescription("Issue #").setRequired(true), - ), - ) - - .addSubcommand((s) => - s - .setName("issues") - .setDescription("List open issues") - .addStringOption((o) => - o.setName("owner").setDescription("Org/user").setRequired(true), - ) - .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) - .addIntegerOption((o) => - o - .setName("limit") - .setDescription("How many to show (default 5, max 20)") - .setMinValue(1) - .setMaxValue(20), - ), - ) - - .addSubcommand((s) => - s - .setName("prs") - .setDescription("List open pull requests") - .addStringOption((o) => - o.setName("owner").setDescription("Org/user").setRequired(true), - ) - .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)) - .addIntegerOption((o) => - o - .setName("limit") - .setDescription("How many to show (default 5, max 20)") - .setMinValue(1) - .setMaxValue(20), - ), - ); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const sub = interaction.options.getSubcommand(true); - - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - try { - if (sub === "status") { - const owner = - interaction.options.getString("owner") ?? env.githubOwner ?? "(unset)"; - const repo = interaction.options.getString("repo") ?? env.githubRepo ?? "(unset)"; - - const lines: string[] = []; - lines.push("**GitHub Status**"); - lines.push(""); - - // Config (never print secrets) - lines.push(`Token: ${env.githubToken ? "configured" : "missing"}`); - lines.push(`Default repo: ${owner}/${repo}`); - lines.push(`Poll interval: ${env.githubPollIntervalMs}ms`); - lines.push(""); - - // Polling streams - lines.push("**Polling streams**"); - lines.push( - `- PR announcements: ${env.githubPrPollingEnabled ? "enabled" : "disabled"}`, - ); - lines.push(` - Channel: ${env.githubPrAnnounceChannelId ?? "(unset)"}`); - lines.push( - `- Assignee activity: ${env.githubAssigneePollingEnabled ? "enabled" : "disabled"}`, - ); - lines.push(` - Channel: ${env.githubAssigneeAnnounceChannelId ?? "(unset)"}`); - - lines.push(""); - lines.push( - "_Tip: If polling is disabled, set GITHUB_TOKEN + GITHUB_OWNER + GITHUB_REPO + the channel env var(s)._", - ); - - await interaction.editReply(lines.join("\n")); - return; - } - - // The remaining subcommands require explicit owner/repo options - const owner = interaction.options.getString("owner", true); - const repo = interaction.options.getString("repo", true); - - if (sub === "issue") { - const number = interaction.options.getInteger("number", true); - const issue = await getIssue(owner, repo, number); - - await interaction.editReply( - [ - `**${owner}/${repo}#${issue.number}**`, - issue.title, - `State: ${issue.state}`, - `Author: ${issue.user?.login ?? "unknown"}`, - issue.html_url, - ].join("\n"), - ); - return; - } - - if (sub === "issues") { - const limit = interaction.options.getInteger("limit") ?? 5; - const issues = await listIssues(owner, repo, { state: "open", limit }); - - if (!issues.length) { - await interaction.editReply("No open issues found."); - return; - } - - await interaction.editReply( - [ - `**Open issues for ${owner}/${repo}**`, - "", - ...issues.map( - (i) => - `#${i.number} ${i.title} (by ${i.user?.login ?? "unknown"})\n${i.html_url}`, - ), - ].join("\n"), - ); - return; - } - - if (sub === "prs") { - const limit = interaction.options.getInteger("limit") ?? 5; - const prs = await listPullRequests(owner, repo, { state: "open", limit }); - - if (!prs.length) { - await interaction.editReply("No open pull requests found."); - return; - } - - await interaction.editReply( - [ - `**Open PRs for ${owner}/${repo}**`, - "", - ...prs.map( - (p) => - `#${p.number} ${p.title} (by ${p.user?.login ?? "unknown"})\n${p.html_url}`, - ), - ].join("\n"), - ); - return; - } - - await interaction.editReply("Unknown subcommand."); - } catch (err) { - const msg = getGitHubUserMessage(err); - if (msg) { - await interaction.editReply(msg); - return; - } - - logger.warn({ err, sub }, "[gh] GitHub request failed"); - await interaction.editReply("GitHub request failed. Please try again in a bit."); - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/github/pr.ts b/data/backup-20260117-145403/src-backup/commands/github/pr.ts deleted file mode 100644 index 9d1c60c5..00000000 --- a/data/backup-20260117-145403/src-backup/commands/github/pr.ts +++ /dev/null @@ -1,56 +0,0 @@ -// src/commands/github/pr.ts - -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { getPullRequest } from "../../services/github/githubApi.js"; -import { getGitHubUserMessage } from "../../services/github/githubErrorMessage.js"; -import { logger } from "../../utils/logger.js"; - -/** - * /pr command - * Fetch a single PR by number. - */ -export const data = new SlashCommandBuilder() - .setName("pr") - .setDescription("Fetch a GitHub pull request by number") - .addIntegerOption((o) => o.setName("number").setDescription("PR #").setRequired(true)) - .addStringOption((o) => o.setName("owner").setDescription("Org/user").setRequired(true)) - .addStringOption((o) => o.setName("repo").setDescription("Repo").setRequired(true)); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const owner = interaction.options.getString("owner", true); - const repo = interaction.options.getString("repo", true); - const number = interaction.options.getInteger("number", true); - - try { - const pr = await getPullRequest(owner, repo, number); - - const mergedLabel = pr.merged ? "merged" : "not merged"; - - const body = [ - `**${owner}/${repo} PR #${pr.number}**`, - pr.title, - `State: ${pr.state} (${mergedLabel})`, - `Author: ${pr.user?.login ?? "unknown"}`, - pr.html_url, - ].join("\n"); - - await interaction.editReply(body); - return; - } catch (err) { - const msg = getGitHubUserMessage(err); - if (msg) { - await interaction.editReply(msg); - return; - } - - logger.warn({ err, owner, repo, number }, "[pr] GitHub request failed"); - await interaction.editReply("GitHub request failed. Please try again in a bit."); - return; - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/github/status.ts b/data/backup-20260117-145403/src-backup/commands/github/status.ts deleted file mode 100644 index 0f3d8774..00000000 --- a/data/backup-20260117-145403/src-backup/commands/github/status.ts +++ /dev/null @@ -1,51 +0,0 @@ -// src/commands/github/status.ts - -import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -/** - * /github status command - * - * Reports current GitHub-related configuration and which GitHub pollers are enabled. - * This command does not call the GitHub API. - */ -export const data = new SlashCommandBuilder() - .setName("status") - .setDescription("Show GitHub integration status for this bot"); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - try { - const lines: string[] = []; - - // High level config (do not print secrets) - lines.push("**GitHub Status**"); - lines.push(`Repo: ${env.githubOwner ?? "(unset)"}/${env.githubRepo ?? "(unset)"}`); - lines.push(`Token: ${env.githubToken ? "present" : "missing"}`); - lines.push(`Poll interval: ${env.githubPollIntervalMs} ms`); - lines.push(""); - - // PR polling - lines.push("**PR announcements**"); - lines.push(`Enabled: ${env.githubPrPollingEnabled ? "yes" : "no"}`); - lines.push(`Channel: ${env.githubPrAnnounceChannelId ?? "(unset)"}`); - lines.push(""); - - // Assignee/issue activity polling - lines.push("**Assignee + issue activity**"); - lines.push(`Enabled: ${env.githubAssigneePollingEnabled ? "yes" : "no"}`); - lines.push(`Channel: ${env.githubAssigneeAnnounceChannelId ?? "(unset)"}`); - - await interaction.reply({ content: lines.join("\n"), ephemeral: true }); - } catch (err) { - logger.error({ err }, "[/status] failed"); - - // Best-effort reply (avoid throwing twice) - const msg = "Something went wrong while building GitHub status."; - if (interaction.deferred || interaction.replied) { - await interaction.editReply({ content: msg }); - } else { - await interaction.reply({ content: msg, ephemeral: true }); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/help/help.ts b/data/backup-20260117-145403/src-backup/commands/help/help.ts deleted file mode 100644 index 873a0b2d..00000000 --- a/data/backup-20260117-145403/src-backup/commands/help/help.ts +++ /dev/null @@ -1,74 +0,0 @@ -// src/commands/help/help.ts - -import { - SlashCommandBuilder, - PermissionFlagsBits, - type ChatInputCommandInteraction, -} from "discord.js"; -import { buildHelpText, type HelpTopic } from "./helpText.js"; -import { - extractCommandList, - type CommandListItem, -} from "../../services/discord/commandMeta.js"; - -/** - * /help - * - * Uses topics to keep output readable and under Discord limits. - */ -export const data = new SlashCommandBuilder() - .setName("help") - .setDescription("Show what OmegaBot can do and how to get started") - .addStringOption((o) => - o - .setName("topic") - .setDescription("Choose a help topic") - .setRequired(false) - .addChoices( - { name: "Overview", value: "overview" }, - { name: "Fun", value: "fun" }, - { name: "GitHub", value: "github" }, - { name: "Summary", value: "summary" }, - { name: "Timezone", value: "timezone" }, - { name: "Admin", value: "admin" }, - { name: "Commands", value: "commands" }, - ), - ); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const isAdmin = - interaction.inGuild() && - Boolean(interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)); - - const commands: CommandListItem[] = extractCommandList(interaction.client); - - const rawTopic = interaction.options.getString("topic") ?? "overview"; - - // Keep runtime safe: only allow known topics - const allowedTopics: HelpTopic[] = [ - "overview", - "fun", - "github", - "summary", - "timezone", - "admin", - "commands", - ]; - - const topic: HelpTopic = allowedTopics.includes(rawTopic as HelpTopic) - ? (rawTopic as HelpTopic) - : "overview"; - - // If someone requests admin help but isn't admin, still show the admin topic - // (it will explain they need Manage Server) - const text = buildHelpText({ - isAdmin, - commands, - topic, - }); - - await interaction.reply({ - content: text, - ephemeral: true, - }); -} diff --git a/data/backup-20260117-145403/src-backup/commands/help/helpText.ts b/data/backup-20260117-145403/src-backup/commands/help/helpText.ts deleted file mode 100644 index 971a2419..00000000 --- a/data/backup-20260117-145403/src-backup/commands/help/helpText.ts +++ /dev/null @@ -1,320 +0,0 @@ -// src/commands/help/helpText.ts - -import type { CommandListItem } from "../../services/discord/commandMeta.js"; - -export type HelpTopic = - | "overview" - | "fun" - | "github" - | "summary" - | "timezone" - | "admin" - | "commands"; - -/** - * Help text builder for /help. - * - * Goal: stay readable and under Discord limits by splitting into topics. - * Style: no emojis on the left, no " - " separators. - */ -export function buildHelpText(args: { - isAdmin: boolean; - commands: CommandListItem[]; - topic: HelpTopic; -}): string { - const { isAdmin, commands, topic } = args; - - switch (topic) { - case "fun": - return buildFunHelp(); - - case "github": - return buildGitHubHelp(); - - case "summary": - return buildSummaryHelp(); - - case "timezone": - return buildTimezoneHelp(); - - case "admin": - return buildAdminHelp({ isAdmin }); - - case "commands": - return buildCommandsHelp({ isAdmin, commands }); - - case "overview": - default: - return buildOverviewHelp({ isAdmin }); - } -} - -function buildOverviewHelp(args: { isAdmin: boolean }): string { - const { isAdmin } = args; - - const lines: string[] = []; - - lines.push("**OmegaBot Help**"); - lines.push(""); - 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("**Topics**"); - lines.push("Use `/help topic:`"); - lines.push( - `overview, fun, github, summary, timezone${isAdmin ? ", admin" : ""}, commands`, - ); - lines.push(""); - lines.push("**Quick picks**"); - lines.push("`/fun dadjoke` Random dad joke"); - lines.push("`/fun poll` Create a quick poll"); - lines.push("`/fun weather` Weather for a location"); - lines.push("`/gh status` Check GitHub integration status"); - lines.push("`/summary` Summarize recent messages"); - 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.", - ); - - return lines.join("\n"); -} - -function buildFunHelp(): string { - const lines: string[] = []; - - lines.push("**Help: Fun**"); - lines.push(""); - lines.push("All fun commands live under `/fun`."); - lines.push( - "Most fun commands support `ephemeral:true` to only show the result to you.", - ); - lines.push(""); - - lines.push("**Chuck Norris**"); - lines.push("`/fun chucknorris`"); - lines.push("Examples"); - lines.push("`/fun chucknorris`"); - lines.push("`/fun chucknorris category:dev`"); - lines.push("`/fun chucknorris query:roundhouse`"); - lines.push("`/fun chucknorris query:docker ephemeral:true`"); - lines.push(""); - - lines.push("**Dad Joke**"); - lines.push("`/fun dadjoke`"); - lines.push("Examples"); - lines.push("`/fun dadjoke`"); - lines.push("`/fun dadjoke query:coffee`"); - lines.push("`/fun dadjoke query:kubernetes ephemeral:true`"); - lines.push(""); - - lines.push("**Coin Flip**"); - lines.push("`/fun coinflip`"); - lines.push("Examples"); - lines.push("`/fun coinflip`"); - lines.push("`/fun coinflip ephemeral:true`"); - lines.push(""); - - lines.push("**Dice**"); - lines.push("`/fun dice`"); - lines.push("Options"); - lines.push("`sides` 2–100 (default 6)"); - lines.push("`count` 1–10 (default 1)"); - lines.push("Examples"); - lines.push("`/fun dice`"); - lines.push("`/fun dice sides:20`"); - lines.push("`/fun dice sides:6 count:10`"); - lines.push(""); - - lines.push("**Poll**"); - lines.push("`/fun poll`"); - lines.push("Notes"); - lines.push("2–4 options"); - lines.push("One vote per user"); - lines.push("Examples"); - lines.push("`/fun poll question:Best pizza? option1:NY option2:Chicago`"); - lines.push("`/fun poll question:Tonight? option1:R6 option2:Netflix option3:Gym`"); - lines.push(""); - - lines.push("**Weather**"); - lines.push("`/fun weather` Current conditions + today"); - lines.push("`/fun weather7` Current conditions + 7-day forecast"); - lines.push("Options"); - lines.push("`location` required"); - lines.push("`unit` f or c (default f)"); - lines.push("Examples"); - lines.push("`/fun weather location:Sharon, MA`"); - lines.push("`/fun weather location:Boston, MA unit:c`"); - lines.push("`/fun weather7 location:02110`"); - lines.push(""); - - lines.push("**Leaderboard**"); - lines.push("`/fun leaderboard`"); - lines.push("Options"); - lines.push("`view` users | commands | user (default users)"); - lines.push("`user` only used when view:user (defaults to you)"); - lines.push("`limit` 1–25 (default 10)"); - lines.push("Examples"); - lines.push("`/fun leaderboard`"); - lines.push("`/fun leaderboard view:commands limit:10`"); - lines.push("`/fun leaderboard view:user`"); - lines.push("`/fun leaderboard view:user user:@Someone`"); - - return lines.join("\n"); -} - -function buildGitHubHelp(): string { - const lines: string[] = []; - - lines.push("**Help: GitHub**"); - lines.push(""); - 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 integration status (config, polling, channels)"); - lines.push("`/pr` Fetch a single pull request by number (legacy shortcut)"); - lines.push(""); - lines.push("Tip: if `/gh status` shows misconfiguration, check `.env` and setup docs."); - - return lines.join("\n"); -} - -function buildSummaryHelp(): string { - const lines: string[] = []; - - lines.push("**Help: Summary & History**"); - lines.push(""); - 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"); - - return lines.join("\n"); -} - -function buildTimezoneHelp(): string { - const lines: string[] = []; - - lines.push("**Help: Timezone**"); - lines.push(""); - lines.push( - "Save your timezone once, then compare times with other users or locations.", - ); - lines.push( - "We show both the IANA timezone and an offset like UTC-05:00 when possible.", - ); - lines.push(""); - - lines.push("**Commands**"); - lines.push("`/timezone set` Save your IANA timezone"); - lines.push("`/timezone show` Display your current saved timezone"); - lines.push("`/timezone clear` Remove your saved timezone"); - lines.push("`/timezone now` Show the current time in a timezone or location"); - lines.push("`/timezone convert` Convert a time from one timezone to another"); - lines.push("`/timezone compare` Compare your time with another user (if both set)"); - lines.push(""); - - lines.push("**Examples**"); - lines.push("`/timezone set tz:America/New_York`"); - lines.push("`/timezone show`"); - lines.push("`/timezone now tz:America/Los_Angeles`"); - lines.push("`/timezone now location:02110`"); - lines.push("`/timezone convert time:14:30 from:America/New_York to:America/Chicago`"); - lines.push("`/timezone compare user:@Someone`"); - - return lines.join("\n"); -} - -function buildAdminHelp(args: { isAdmin: boolean }): string { - const { isAdmin } = args; - - const lines: string[] = []; - - lines.push("**Help: Admin**"); - lines.push(""); - - if (isAdmin) { - lines.push("Welcome config"); - lines.push("`/config welcome-channel set channel:#your-channel`"); - lines.push("`/config welcome-channel clear`"); - lines.push(""); - lines.push("Notes"); - lines.push("You need Manage Server to run admin config commands."); - } else { - lines.push("You do not have Manage Server permissions."); - lines.push("Ask a server admin to configure the welcome channel:"); - lines.push("`/config welcome-channel set channel:#your-channel`"); - } - - return lines.join("\n"); -} - -function buildCommandsHelp(args: { - isAdmin: boolean; - commands: CommandListItem[]; -}): string { - const { isAdmin, commands } = args; - - const lines: string[] = []; - - lines.push("**Help: Commands**"); - lines.push(""); - lines.push("Sanity list of top-level commands currently loaded."); - lines.push(""); - - const pretty = formatCommandList(commands, { isAdmin }); - - if (!pretty.length) { - lines.push("No commands found."); - lines.push("If this is unexpected, check your command loader and build output."); - return lines.join("\n"); - } - - lines.push(...pretty); - - return lines.join("\n"); -} - -function formatCommandList( - commands: CommandListItem[], - opts: { isAdmin: boolean }, -): string[] { - if (!commands.length) return []; - - const visible = commands.filter((c) => (opts.isAdmin ? true : !c.adminOnly)); - - visible.sort((a, b) => { - const g = a.group.localeCompare(b.group); - if (g !== 0) return g; - return a.name.localeCompare(b.name); - }); - - const out: string[] = []; - let currentGroup: string | null = null; - - for (const cmd of visible) { - const groupLabel = titleCase(cmd.group); - - if (currentGroup !== groupLabel) { - currentGroup = groupLabel; - out.push(""); - out.push(`**${currentGroup}**`); - } - - const desc = cmd.description ? ` ${cmd.description}` : ""; - out.push(`/${cmd.name}${desc ? ` ${desc}` : ""}`); - } - - while (out.length && out[0] === "") out.shift(); - return out; -} - -function titleCase(s: string): string { - if (!s) return "Other"; - return s - .split(/[-_\s]+/g) - .filter(Boolean) - .map((w) => w[0].toUpperCase() + w.slice(1)) - .join(" "); -} diff --git a/data/backup-20260117-145403/src-backup/commands/history/history.ts b/data/backup-20260117-145403/src-backup/commands/history/history.ts deleted file mode 100644 index 5d3a975a..00000000 --- a/data/backup-20260117-145403/src-backup/commands/history/history.ts +++ /dev/null @@ -1,281 +0,0 @@ -// src/services/weather/forecast.ts - -import { logger } from "../../utils/logger.js"; -import type { TempUnit } from "../../services/weather/types.js"; - -type WeatherApiError = { - error?: { - code?: number; - message?: string; - }; -}; - -type WeatherApiResponse = { - location?: { - name?: string; - region?: string; - country?: string; - tz_id?: string; - localtime?: string; // "2026-01-16 10:18" - }; - current?: { - last_updated?: string; // "2026-01-16 10:15" - temp_f?: number; - temp_c?: number; - condition?: { text?: string }; - wind_mph?: number; - wind_kph?: number; - wind_dir?: string; - humidity?: number; - feelslike_f?: number; - feelslike_c?: number; - }; - forecast?: { - forecastday?: Array<{ - date?: string; // "2026-01-16" - day?: { - maxtemp_f?: number; - maxtemp_c?: number; - mintemp_f?: number; - mintemp_c?: number; - daily_chance_of_rain?: number; - daily_chance_of_snow?: number; - condition?: { text?: string }; - }; - astro?: { - sunrise?: string; - sunset?: string; - }; - }>; - }; -}; - -// Safe “unwrap optional forecast.forecastday” type -type ForecastDay = NonNullable< - NonNullable["forecastday"] ->[number]; - -export type WeatherNow = { - temp: string; - feelsLike?: string; - condition: string; - asOf?: string; - humidity?: string; - wind?: string; -}; - -export type WeatherDay = { - label: string; - temp: string; - pop?: string; - condition: string; - sunrise?: string; - sunset?: string; -}; - -export type WeatherBundle = { - placeLabel: string; - tzId?: string; - localTime?: string; - now?: WeatherNow; - days: WeatherDay[]; -}; - -function requireWeatherApiKey(): string { - const key = process.env.WEATHERAPI_KEY?.trim(); - if (!key) { - throw new Error("Missing WEATHERAPI_KEY. Set it in .env (WEATHERAPI_KEY=...)."); - } - return key; -} - -function isRecord(v: unknown): v is Record { - return typeof v === "object" && v !== null; -} - -function readApiErrorMessage(data: unknown): string | null { - const root = isRecord(data) ? data : null; - const err = - root && isRecord(root.error) ? (root.error as WeatherApiError["error"]) : null; - const msg = err && typeof err.message === "string" ? err.message : null; - return msg ?? null; -} - -function pickTemp(unit: TempUnit, f?: number, c?: number): string | null { - if (unit === "c") { - return typeof c === "number" && Number.isFinite(c) ? `${Math.round(c)}°C` : null; - } - return typeof f === "number" && Number.isFinite(f) ? `${Math.round(f)}°F` : null; -} - -function formatWind( - unit: TempUnit, - dir?: string, - mph?: number, - kph?: number, -): string | null { - const d = dir?.trim(); - - if (unit === "c") { - if (typeof kph === "number" && Number.isFinite(kph)) { - return d ? `${d} ${Math.round(kph)} kph` : `${Math.round(kph)} kph`; - } - return null; - } - - if (typeof mph === "number" && Number.isFinite(mph)) { - return d ? `${d} ${Math.round(mph)} mph` : `${Math.round(mph)} mph`; - } - return null; -} - -function buildPlaceLabel(loc: WeatherApiResponse["location"] | undefined): string { - const name = loc?.name?.trim(); - const region = loc?.region?.trim(); - const country = loc?.country?.trim(); - - const bits = [name, region].filter(Boolean); - if (bits.length) return bits.join(", "); - - return country ? country : "Unknown location"; -} - -function dailyPopString(item: ForecastDay | undefined): string | null { - if (!item?.day) return null; - - const rain = item.day.daily_chance_of_rain; - const snow = item.day.daily_chance_of_snow; - - const r = typeof rain === "number" && Number.isFinite(rain) ? rain : null; - const s = typeof snow === "number" && Number.isFinite(snow) ? snow : null; - - const best = [r, s] - .filter((x) => x !== null) - .sort((a, b) => (b as number) - (a as number))[0] as number | undefined; - - return typeof best === "number" ? `${Math.round(best)}%` : null; -} - -/** - * Fetch current + forecast (and astro) from WeatherAPI.com - * Uses /forecast.json which includes current + forecast days. - */ -export async function fetchWeatherBundle(args: { - location: string; - unit: TempUnit; - days: number; // 1..10 (WeatherAPI plan dependent) -}): Promise { - const key = requireWeatherApiKey(); - - const q = args.location.trim(); - if (!q) { - throw new Error("Location cannot be empty."); - } - - const safeDays = Math.max(1, Math.min(args.days, 10)); - - const url = - `https://api.weatherapi.com/v1/forecast.json` + - `?key=${encodeURIComponent(key)}` + - `&q=${encodeURIComponent(q)}` + - `&days=${encodeURIComponent(String(safeDays))}` + - `&aqi=no&alerts=no`; - - const res = await fetch(url, { - headers: { - Accept: "application/json", - "User-Agent": "OmegaBot", - }, - }); - - const text = await res.text(); - let data: unknown = null; - try { - data = JSON.parse(text) as unknown; - } catch { - data = null; - } - - if (!res.ok) { - const msg = readApiErrorMessage(data) ?? res.statusText ?? "Unknown error"; - - if (res.status === 401 || res.status === 403) { - throw new Error( - `WeatherAPI auth error (${res.status}). Check WEATHERAPI_KEY. ${msg}`, - ); - } - if (res.status === 400) { - throw new Error(`WeatherAPI rejected the location. ${msg}`); - } - if (res.status === 429) { - throw new Error("WeatherAPI rate limit hit. Try again in a bit."); - } - - throw new Error(`WeatherAPI error (${res.status}): ${msg}`); - } - - const parsed = (data ?? {}) as WeatherApiResponse; - - const placeLabel = buildPlaceLabel(parsed.location); - const tzId = parsed.location?.tz_id; - const localTime = parsed.location?.localtime; - - const nowTemp = pickTemp(args.unit, parsed.current?.temp_f, parsed.current?.temp_c); - const feels = pickTemp( - args.unit, - parsed.current?.feelslike_f, - parsed.current?.feelslike_c, - ); - const cond = parsed.current?.condition?.text?.trim() ?? "Unknown"; - - const now: WeatherNow | undefined = nowTemp - ? { - temp: nowTemp, - feelsLike: feels ?? undefined, - condition: cond, - asOf: parsed.current?.last_updated, - humidity: - typeof parsed.current?.humidity === "number" - ? `${parsed.current.humidity}%` - : undefined, - wind: - formatWind( - args.unit, - parsed.current?.wind_dir, - parsed.current?.wind_mph, - parsed.current?.wind_kph, - ) ?? undefined, - } - : undefined; - - const fd = parsed.forecast?.forecastday ?? []; - const days: WeatherDay[] = []; - - for (let i = 0; i < fd.length; i += 1) { - const item = fd[i]; - const label = i === 0 ? "Today" : (item.date ?? `Day ${i + 1}`); - - const max = pickTemp(args.unit, item.day?.maxtemp_f, item.day?.maxtemp_c); - const min = pickTemp(args.unit, item.day?.mintemp_f, item.day?.mintemp_c); - const temp = max && min ? `${min} to ${max}` : (max ?? min ?? "N/A"); - - const condition = item.day?.condition?.text?.trim() ?? "Forecast unavailable"; - const pop = dailyPopString(item) ?? undefined; - - days.push({ - label, - temp, - pop, - condition, - sunrise: item.astro?.sunrise, - sunset: item.astro?.sunset, - }); - } - - logger.debug( - { q, placeLabel, tzId, localTime, days: days.length }, - "[weather] weather bundle fetched", - ); - - return { placeLabel, tzId, localTime, now, days }; -} diff --git a/data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts b/data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts deleted file mode 100644 index 830140fe..00000000 --- a/data/backup-20260117-145403/src-backup/commands/pagination/pagination.ts +++ /dev/null @@ -1,199 +0,0 @@ -// src/commands/pagination/pagination.ts - -import { - SlashCommandBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - ComponentType, - type ChatInputCommandInteraction, -} from "discord.js"; -import { logger } from "../../utils/logger.js"; - -/** - * /pagination command - * MVP: fetch recent user messages, format into a transcript, split into pages, - * then let the requester page through it with buttons. - */ -export const data = new SlashCommandBuilder() - .setName("pagination") - .setDescription("Page through recent messages in this channel") - .addIntegerOption((opt) => - opt - .setName("count") - .setDescription("How many messages to fetch") - .setMinValue(10) - .setMaxValue(100), - ); - -const MAX_PAGE_CHARS = 1800; // leave room for header + safety -const COLLECTOR_MS = 90_000; - -function chunkByChars(text: string, maxChars: number): string[] { - const lines = text.split("\n"); - const pages: string[] = []; - let current = ""; - - for (const line of lines) { - // +1 for newline we may add - const nextLen = current.length + (current ? 1 : 0) + line.length; - - // If a single line is huge, hard-split it - if (!current && line.length > maxChars) { - for (let i = 0; i < line.length; i += maxChars) { - pages.push(line.slice(i, i + maxChars)); - } - continue; - } - - if (nextLen > maxChars) { - if (current) pages.push(current); - current = line; - continue; - } - - current = current ? `${current}\n${line}` : line; - } - - if (current) pages.push(current); - return pages.length ? pages : ["(no content)"]; -} - -function buildRow(pageIndex: number, pageCount: number, disabledAll = false) { - const prev = new ButtonBuilder() - .setCustomId("pagination_prev") - .setLabel("Prev") - .setStyle(ButtonStyle.Secondary) - .setDisabled(disabledAll || pageIndex <= 0); - - const next = new ButtonBuilder() - .setCustomId("pagination_next") - .setLabel("Next") - .setStyle(ButtonStyle.Primary) - .setDisabled(disabledAll || pageIndex >= pageCount - 1); - - const stop = new ButtonBuilder() - .setCustomId("pagination_stop") - .setLabel("Stop") - .setStyle(ButtonStyle.Danger) - .setDisabled(disabledAll); - - return new ActionRowBuilder().addComponents(prev, next, stop); -} - -function renderPage(pages: string[], pageIndex: number) { - const header = `**Playback** (page ${pageIndex + 1}/${pages.length})`; - return `${header}\n\n${pages[pageIndex]}`; -} - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const count = interaction.options.getInteger("count") ?? 50; - - try { - // Not ephemeral: buttons + paging is easier as a normal message. - await interaction.deferReply(); - - if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply("This channel does not support pagination."); - return; - } - - const messages = await interaction.channel.messages.fetch({ limit: count }); - - const userMessages = messages - .filter((m) => !m.author.bot && m.content) - .sort((a, b) => a.createdTimestamp - b.createdTimestamp); - - if (userMessages.size === 0) { - await interaction.editReply("No usable messages found."); - return; - } - - const transcript = userMessages - .map((m) => `${m.author.username}: ${m.content}`) - .join("\n"); - - const pages = chunkByChars(transcript, MAX_PAGE_CHARS); - let pageIndex = 0; - - const replyMessage = await interaction.editReply({ - content: renderPage(pages, pageIndex), - components: [buildRow(pageIndex, pages.length)], - }); - - const collector = replyMessage.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: COLLECTOR_MS, - filter: (i) => i.user.id === interaction.user.id, - }); - - collector.on("collect", async (i) => { - try { - if (i.customId === "pagination_stop") { - collector.stop("stopped"); - await i.update({ - content: renderPage(pages, pageIndex), - components: [buildRow(pageIndex, pages.length, true)], - }); - return; - } - - if (i.customId === "pagination_prev") { - pageIndex = Math.max(0, pageIndex - 1); - } - - if (i.customId === "pagination_next") { - pageIndex = Math.min(pages.length - 1, pageIndex + 1); - } - - await i.update({ - content: renderPage(pages, pageIndex), - components: [buildRow(pageIndex, pages.length)], - }); - } catch (err) { - logger.warn( - { err, userId: interaction.user.id }, - "[pagination] button update failed", - ); - } - }); - - collector.on("end", async (collected, reason) => { - try { - await replyMessage.edit({ - content: renderPage(pages, pageIndex), - components: [buildRow(pageIndex, pages.length, true)], - }); - - logger.debug( - { - reason, - clicks: collected.size, - userId: interaction.user.id, - }, - "[pagination] collector ended", - ); - } catch (err) { - logger.warn({ err }, "[pagination] failed to disable buttons"); - } - }); - } catch (err) { - logger.error( - { err, command: "pagination", userId: interaction.user.id }, - "[pagination] command failed", - ); - - try { - if (interaction.replied || interaction.deferred) { - await interaction.editReply("Something went wrong during pagination."); - } else { - await interaction.reply("Something went wrong during pagination."); - } - } catch (replyErr) { - logger.error( - { err: replyErr }, - "[pagination] failed to send fallback error message", - ); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/playback/playback.ts b/data/backup-20260117-145403/src-backup/commands/playback/playback.ts deleted file mode 100644 index f1aef268..00000000 --- a/data/backup-20260117-145403/src-backup/commands/playback/playback.ts +++ /dev/null @@ -1,179 +0,0 @@ -// src/commands/playback/playback.ts - -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; -import { buildTranscript } from "../../services/transcript/buildTranscript.js"; -import { - HISTORY_DEFAULTS, - DISCORD_SAFE_TEXT_LIMIT, -} from "../../services/transcript/defaults.js"; -import { logger } from "../../utils/logger.js"; - -function chunkText(text: string, maxChars: number): string[] { - if (text.length <= maxChars) return [text]; - - const lines = text.split("\n"); - const chunks: string[] = []; - - let buf = ""; - for (const line of lines) { - const next = buf.length === 0 ? line : `${buf}\n${line}`; - if (next.length > maxChars) { - chunks.push(buf); - buf = line; - } else { - buf = next; - } - } - if (buf) chunks.push(buf); - - return chunks; -} - -export const data = new SlashCommandBuilder() - .setName("playback") - .setDescription("Page through recent messages with buttons") - .addIntegerOption((opt) => - opt - .setName("count") - .setDescription("How many messages to fetch") - .setMinValue(10) - .setMaxValue(100), - ) - .addStringOption((opt) => - opt - .setName("before") - .setDescription("Message ID: show messages before this ID") - .setRequired(false), - ) - .addStringOption((opt) => - opt - .setName("after") - .setDescription("Message ID: show messages after this ID") - .setRequired(false), - ); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const count = interaction.options.getInteger("count") ?? 50; - const before = interaction.options.getString("before") ?? undefined; - const after = interaction.options.getString("after") ?? undefined; - - try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply("This channel does not support playback."); - return; - } - - const msgs = await fetchChannelMessages(interaction.channel, { - count, - before, - after, - }); - - if (msgs.length === 0) { - await interaction.editReply("No usable messages found for playback."); - return; - } - - const transcript = buildTranscript(msgs, { - ...HISTORY_DEFAULTS, - // For pagination, do not truncate by maxChars here, we chunk instead. - maxChars: undefined, - maxLines: undefined, - }); - - const pages = chunkText(transcript.text, DISCORD_SAFE_TEXT_LIMIT); - - let index = 0; - - const makeRow = (i: number) => - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("pb_prev") - .setStyle(ButtonStyle.Secondary) - .setLabel("Prev") - .setDisabled(i <= 0), - new ButtonBuilder() - .setCustomId("pb_next") - .setStyle(ButtonStyle.Secondary) - .setLabel("Next") - .setDisabled(i >= pages.length - 1), - new ButtonBuilder() - .setCustomId("pb_close") - .setStyle(ButtonStyle.Danger) - .setLabel("Close"), - ); - - await interaction.editReply({ - content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, - components: [makeRow(index)], - }); - - const msg = await interaction.fetchReply(); - - const collector = msg.createMessageComponentCollector({ - time: 5 * 60_000, - filter: (i) => i.user.id === interaction.user.id, - }); - - collector.on("collect", async (btn) => { - try { - if (btn.customId === "pb_close") { - collector.stop("closed"); - await btn.update({ content: "Playback closed.", components: [] }); - return; - } - - if (btn.customId === "pb_prev") index = Math.max(0, index - 1); - if (btn.customId === "pb_next") index = Math.min(pages.length - 1, index + 1); - - await btn.update({ - content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, - components: [makeRow(index)], - }); - } catch (err) { - logger.warn( - { err, userId: interaction.user.id }, - "[playback] button update failed", - ); - } - }); - - collector.on("end", async () => { - try { - // disable buttons after timeout - await interaction.editReply({ components: [] }); - } catch (err) { - // ignore - logger.debug({ err }, "[playback] cleanup after collector end failed"); - } - }); - } catch (err) { - logger.error( - { err, command: "playback", userId: interaction.user.id }, - "[playback] command failed", - ); - - try { - if (interaction.replied || interaction.deferred) { - await interaction.editReply("Something went wrong during playback."); - } else { - await interaction.reply({ - content: "Something went wrong during playback.", - flags: MessageFlags.Ephemeral, - }); - } - } catch (replyErr) { - logger.error({ err: replyErr }, "[playback] failed to send fallback error message"); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/summary/summary.ts b/data/backup-20260117-145403/src-backup/commands/summary/summary.ts deleted file mode 100644 index 55b5cf6e..00000000 --- a/data/backup-20260117-145403/src-backup/commands/summary/summary.ts +++ /dev/null @@ -1,153 +0,0 @@ -// src/commands/summary/summary.ts - -import { - SlashCommandBuilder, - AttachmentBuilder, - MessageFlags, - type ChatInputCommandInteraction, -} from "discord.js"; -import { summarize } from "../../services/summary/summarizer.js"; -import { logger } from "../../utils/logger.js"; - -/** - * Defines the /summary command. - * - * Summarizes recent messages from the current channel - * and delivers the result privately via DM. - */ -export const data = new SlashCommandBuilder() - .setName("summary") - .setDescription("Summarize recent messages and DM it to you") - .addIntegerOption((opt) => - opt - .setName("count") - .setDescription("How many messages to fetch") - .setMinValue(10) - .setMaxValue(100), - ); - -/** - * Handler for the /summary command. - * - * Flow: - * 1. Defer an ephemeral reply so the user sees feedback immediately. - * 2. Validate the channel supports messages. - * 3. Fetch and filter recent user messages. - * 4. Build a transcript. - * 5. Generate a summary (local or LLM). - * 6. Deliver the result via DM (text or file). - * 7. Handle failures gracefully. - */ -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const count = interaction.options.getInteger("count") ?? 50; - - try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - /** - * Guard: only text-capable channels can be summarized. - */ - if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply("This channel does not support summarizing messages."); - return; - } - - const messages = await interaction.channel.messages.fetch({ limit: count }); - - if (messages.size === 0) { - await interaction.editReply("No messages found to summarize."); - return; - } - - /** - * Filter out bot messages and empty content, - * then sort oldest → newest for readability. - */ - const userMessages = messages - .filter((m) => !m.author.bot && m.content) - .sort((a, b) => a.createdTimestamp - b.createdTimestamp); - - if (userMessages.size === 0) { - await interaction.editReply("No usable messages found to summarize."); - return; - } - - /** - * Build a simple transcript the summarizer can consume. - */ - const text = userMessages.map((m) => `${m.author.username}: ${m.content}`).join("\n"); - - const output = await summarize(text); - - if (!output || output.trim().length === 0) { - await interaction.editReply("Summary came back empty."); - return; - } - - /** - * If the summary exceeds Discord message limits, - * send it as a file attachment instead. - */ - if (output.length > 2000) { - const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { - name: "summary.txt", - }); - - try { - await interaction.user.send({ - content: "Here is your summary (too long to send as a message):", - files: [file], - }); - - await interaction.editReply("Summary sent to your DMs."); - } catch (err) { - logger.warn( - { err, userId: interaction.user.id }, - "[summary] DM file send failed", - ); - - await interaction.editReply( - "I generated the summary, but your DMs appear to be closed.", - ); - } - - return; - } - - /** - * Normal-sized summary: send as plain DM text. - */ - try { - await interaction.user.send(output); - await interaction.editReply("Summary sent to your DMs."); - } catch (err) { - logger.warn({ err, userId: interaction.user.id }, "[summary] DM text send failed"); - - await interaction.editReply( - "I generated the summary, but could not DM you. Your DMs may be closed.", - ); - } - } catch (err) { - /** - * Top-level failure handler. - * We log the error and attempt to notify the user once. - */ - logger.error( - { err, command: "summary", userId: interaction.user.id }, - "[summary] Summary generation failed", - ); - - try { - if (interaction.replied || interaction.deferred) { - await interaction.editReply("Something went wrong while generating the summary."); - } else { - await interaction.reply({ - content: "Something went wrong while generating the summary.", - flags: MessageFlags.Ephemeral, - }); - } - } catch (replyErr) { - logger.error({ err: replyErr }, "[summary] Failed to send fallback error message"); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts b/data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts deleted file mode 100644 index 5ea9cfe4..00000000 --- a/data/backup-20260117-145403/src-backup/commands/timezone/timezone.ts +++ /dev/null @@ -1,543 +0,0 @@ -// src/commands/timezone/timezone.ts - -import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../utils/logger.js"; -import { - clearUserTimezone, - getUserTimezone, - setUserTimezone, -} from "../../services/timezone/timezoneStore.js"; - -export const data = new SlashCommandBuilder() - .setName("timezone") - .setDescription("Set and compare timezones, and convert times") - - // /timezone set - .addSubcommand((s) => - s - .setName("set") - .setDescription( - "Save your timezone (IANA like America/New_York or short name like ET)", - ) - .addStringOption((o) => - o - .setName("zone") - .setDescription('Examples: "US Eastern", "ET", "America/New_York"') - .setRequired(true), - ) - .addBooleanOption((o) => - o - .setName("guild") - .setDescription("If true, store per-server (otherwise global)") - .setRequired(false), - ), - ) - - // /timezone show - .addSubcommand((s) => - s - .setName("show") - .setDescription("Show your saved timezone") - .addBooleanOption((o) => - o - .setName("guild") - .setDescription("If true, read the per-server timezone") - .setRequired(false), - ), - ) - - // /timezone clear - .addSubcommand((s) => - s - .setName("clear") - .setDescription("Remove your saved timezone") - .addBooleanOption((o) => - o - .setName("guild") - .setDescription("If true, clear the per-server timezone") - .setRequired(false), - ), - ) - - // /timezone compare - .addSubcommand((s) => - s - .setName("compare") - .setDescription("Compare your time with another user") - .addUserOption((o) => - o.setName("user").setDescription("User to compare with").setRequired(true), - ) - .addBooleanOption((o) => - o - .setName("guild") - .setDescription("Use per-server timezones if set") - .setRequired(false), - ), - ) - - // /timezone convert - .addSubcommand((s) => - s - .setName("convert") - .setDescription("Convert a time from your timezone to another zone") - .addStringOption((o) => - o - .setName("time") - .setDescription('Time like "7:30pm" or "19:30"') - .setRequired(true), - ) - .addStringOption((o) => - o - .setName("to") - .setDescription( - 'Target zone (examples: "US Pacific", "PT", "America/Los_Angeles")', - ) - .setRequired(true), - ) - .addStringOption((o) => - o - .setName("from") - .setDescription("Optional from-zone (defaults to your saved timezone)") - .setRequired(false), - ) - .addBooleanOption((o) => - o - .setName("guild") - .setDescription("Use per-server timezone for default 'from'") - .setRequired(false), - ), - ) - .setDMPermission(true); - -export async function execute(interaction: ChatInputCommandInteraction): Promise { - const sub = interaction.options.getSubcommand(true); - - await interaction.deferReply({ ephemeral: true }); - - try { - if (sub === "set") return await handleSet(interaction); - if (sub === "show") return await handleShow(interaction); - if (sub === "clear") return await handleClear(interaction); - if (sub === "compare") return await handleCompare(interaction); - if (sub === "convert") return await handleConvert(interaction); - - await interaction.editReply("Unknown subcommand."); - } catch (err) { - logger.error({ err, sub }, "[timezone] failed"); - await interaction.editReply("Timezone command failed. Try again in a bit."); - } -} - -/* -------------------------------------------------------------------------- */ -/* Timezone input normalization */ -/* -------------------------------------------------------------------------- */ - -/** - * Small curated set of friendly names -> IANA zones. - * This avoids exposing the full IANA list while covering common needs. - */ -const COMMON_TIMEZONES: Array<{ name: string; tz: string; label?: string }> = [ - { name: "US Eastern", tz: "America/New_York", label: "ET" }, - { name: "US Central", tz: "America/Chicago", label: "CT" }, - { name: "US Mountain", tz: "America/Denver", label: "MT" }, - { name: "US Pacific", tz: "America/Los_Angeles", label: "PT" }, - - { name: "UTC", tz: "Etc/UTC", label: "UTC" }, - - { name: "UK", tz: "Europe/London" }, - { name: "Central Europe", tz: "Europe/Berlin" }, - - { name: "India", tz: "Asia/Kolkata" }, - { name: "Japan", tz: "Asia/Tokyo" }, - { name: "Australia East", tz: "Australia/Sydney" }, -]; - -/** - * Common abbreviations people actually type. - * Note: abbreviations are ambiguous globally, but this is a pragmatic bot UX choice. - */ -const ALIAS_TO_IANA: Record = { - // US / common - et: { tz: "America/New_York", label: "ET" }, - est: { tz: "America/New_York", label: "ET" }, - edt: { tz: "America/New_York", label: "ET" }, - - ct: { tz: "America/Chicago", label: "CT" }, - cst: { tz: "America/Chicago", label: "CT" }, - cdt: { tz: "America/Chicago", label: "CT" }, - - mt: { tz: "America/Denver", label: "MT" }, - mst: { tz: "America/Denver", label: "MT" }, - mdt: { tz: "America/Denver", label: "MT" }, - - pt: { tz: "America/Los_Angeles", label: "PT" }, - pst: { tz: "America/Los_Angeles", label: "PT" }, - pdt: { tz: "America/Los_Angeles", label: "PT" }, - - // UTC-ish - utc: { tz: "Etc/UTC", label: "UTC" }, - gmt: { tz: "Etc/UTC", label: "UTC" }, -}; - -function isValidIanaZone(tz: string): boolean { - try { - new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date()); - return true; - } catch { - return false; - } -} - -function normalizeKey(s: string): string { - return s.trim().toLowerCase().replace(/\s+/g, " "); -} - -function normalizeZoneInput(raw: string): { tz: string; label?: string } | null { - const v = raw.trim(); - if (!v) return null; - - const key = normalizeKey(v); - - // Friendly names: "us eastern", "central europe", etc. - for (const z of COMMON_TIMEZONES) { - if (normalizeKey(z.name) === key) return { tz: z.tz, label: z.label }; - } - - // Allow a couple shorthand friendly variants people type - if (key === "eastern" || key === "east") return { tz: "America/New_York", label: "ET" }; - if (key === "central" || key === "midwest") - return { tz: "America/Chicago", label: "CT" }; - if (key === "mountain") return { tz: "America/Denver", label: "MT" }; - if (key === "pacific" || key === "west") - return { tz: "America/Los_Angeles", label: "PT" }; - - // Abbreviations: "ET", "PST", etc. - const alias = ALIAS_TO_IANA[key.replace(/\./g, "")]; - if (alias) return { tz: alias.tz, label: alias.label }; - - // Power user path: accept IANA directly - if (isValidIanaZone(v)) return { tz: v }; - - return null; -} - -function formatLocalTime(date: Date, tz: string): string { - return new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).format(date); -} - -function utcOffsetMinutes(date: Date, tz: string): number { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - timeZoneName: "shortOffset" as unknown as "short", - }).formatToParts(date); - - const name = parts.find((p) => p.type === "timeZoneName")?.value ?? "UTC+0"; - const m = name.match(/([+-])\s*(\d{1,2})(?::(\d{2}))?$/); - if (!m) return 0; - - const sign = m[1] === "-" ? -1 : 1; - const hh = Number(m[2]); - const mm = Number(m[3] ?? 0); - - return sign * (hh * 60 + mm); -} - -function formatUtcOffset(date: Date, tz: string): string { - const mins = utcOffsetMinutes(date, tz); - const sign = mins < 0 ? "-" : "+"; - const abs = Math.abs(mins); - const hh = String(Math.floor(abs / 60)).padStart(2, "0"); - const mm = String(abs % 60).padStart(2, "0"); - return `UTC${sign}${hh}:${mm}`; -} - -function formatDeltaRelative(deltaMinutes: number): string { - if (deltaMinutes === 0) return "same as you"; - - const ahead = deltaMinutes > 0; - const abs = Math.abs(deltaMinutes); - const hours = Math.floor(abs / 60); - const mins = abs % 60; - - const hPart = hours > 0 ? `${hours}h` : ""; - const mPart = mins > 0 ? `${mins}m` : ""; - const space = hPart && mPart ? " " : ""; - - return `${hPart}${space}${mPart} ${ahead ? "ahead" : "behind"}`.trim(); -} - -function scopeFromBool(guildFlag: boolean | null | undefined): "guild" | "global" { - return guildFlag ? "guild" : "global"; -} - -async function getTzOrNull( - interaction: ChatInputCommandInteraction, - userId: string, - scope: "guild" | "global", -) { - const guildId = interaction.inGuild() ? interaction.guildId : null; - return getUserTimezone({ userId, guildId, scope }); -} - -function shortHint(): string { - return [ - "Examples:", - '`/timezone set zone:"US Eastern"`', - "`/timezone set zone:ET`", - "`/timezone set zone:America/New_York`", - "", - "Common zones: US Eastern, US Central, US Mountain, US Pacific, UTC, UK, Central Europe, India, Japan, Australia East", - ].join("\n"); -} - -/* -------------------------------------------------------------------------- */ -/* Handlers */ -/* -------------------------------------------------------------------------- */ - -async function handleSet(interaction: ChatInputCommandInteraction): Promise { - const raw = interaction.options.getString("zone", true); - const guildFlag = interaction.options.getBoolean("guild") ?? false; - const scope = scopeFromBool(guildFlag); - - const normalized = normalizeZoneInput(raw); - if (!normalized) { - await interaction.editReply( - ["I could not understand that timezone.", "", shortHint()].join("\n"), - ); - return; - } - - const guildId = interaction.inGuild() ? interaction.guildId : null; - const saved = await setUserTimezone({ - userId: interaction.user.id, - guildId, - scope, - timezone: normalized.tz, - label: normalized.label, - }); - - const now = new Date(); - const local = formatLocalTime(now, saved.timezone); - const offset = formatUtcOffset(now, saved.timezone); - - await interaction.editReply( - [ - "Saved your timezone.", - "", - `Zone: ${saved.timezone}${saved.label ? ` (${saved.label})` : ""}`, - `Now: ${local} | ${offset}`, - `Scope: ${scope === "guild" ? "this server" : "global"}`, - ].join("\n"), - ); -} - -async function handleShow(interaction: ChatInputCommandInteraction): Promise { - const guildFlag = interaction.options.getBoolean("guild") ?? false; - const scope = scopeFromBool(guildFlag); - - const tz = await getTzOrNull(interaction, interaction.user.id, scope); - if (!tz) { - await interaction.editReply(["No timezone saved yet.", "", shortHint()].join("\n")); - return; - } - - const now = new Date(); - const local = formatLocalTime(now, tz.timezone); - const offset = formatUtcOffset(now, tz.timezone); - - await interaction.editReply( - [ - "Your timezone:", - `Zone: ${tz.timezone}${tz.label ? ` (${tz.label})` : ""}`, - `Now: ${local} | ${offset}`, - `Scope: ${scope === "guild" ? "this server" : "global"}`, - ].join("\n"), - ); -} - -async function handleClear(interaction: ChatInputCommandInteraction): Promise { - const guildFlag = interaction.options.getBoolean("guild") ?? false; - const scope = scopeFromBool(guildFlag); - const guildId = interaction.inGuild() ? interaction.guildId : null; - - const ok = await clearUserTimezone({ userId: interaction.user.id, guildId, scope }); - await interaction.editReply( - ok ? "Cleared your saved timezone." : "No saved timezone to clear.", - ); -} - -async function handleCompare(interaction: ChatInputCommandInteraction): Promise { - const target = interaction.options.getUser("user", true); - const guildFlag = interaction.options.getBoolean("guild") ?? false; - const scope = scopeFromBool(guildFlag); - - const [a, b] = await Promise.all([ - getTzOrNull(interaction, interaction.user.id, scope), - getTzOrNull(interaction, target.id, scope), - ]); - - if (!a) { - await interaction.editReply( - ["You have no timezone saved.", "Run `/timezone set` first.", "", shortHint()].join( - "\n", - ), - ); - return; - } - - if (!b) { - await interaction.editReply( - "That user has no timezone saved. Ask them to run `/timezone set` first.", - ); - return; - } - - const now = new Date(); - const aNow = formatLocalTime(now, a.timezone); - const bNow = formatLocalTime(now, b.timezone); - const aOff = formatUtcOffset(now, a.timezone); - const bOff = formatUtcOffset(now, b.timezone); - - const delta = utcOffsetMinutes(now, b.timezone) - utcOffsetMinutes(now, a.timezone); - const rel = formatDeltaRelative(delta); - - await interaction.editReply( - [ - "Timezone compare:", - "", - `${interaction.user.username}: ${aNow} (${a.timezone}) | ${aOff}`, - `${target.username}: ${bNow} (${b.timezone}) | ${bOff}`, - "", - `${target.username} is ${rel}.`, - ].join("\n"), - ); -} - -function parseTimeString(raw: string): { hours: number; minutes: number } | null { - const s = raw.trim().toLowerCase(); - if (!s) return null; - - // Match "19:30" or "7:30pm" or "7pm" - const m = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i); - if (!m) return null; - - let hh = Number(m[1]); - const mm = Number(m[2] ?? 0); - const ap = (m[3] ?? "").toLowerCase(); - - if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null; - if (mm < 0 || mm > 59) return null; - - if (ap) { - if (hh < 1 || hh > 12) return null; - if (ap === "pm" && hh !== 12) hh += 12; - if (ap === "am" && hh === 12) hh = 0; - } else { - if (hh < 0 || hh > 23) return null; - } - - return { hours: hh, minutes: mm }; -} - -function formatTimeForZone(date: Date, tz: string): string { - return new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour: "2-digit", - minute: "2-digit", - }).format(date); -} - -function guessDateInZone(now: Date, tz: string, h: number, m: number): Date { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(now); - - const y = parts.find((p) => p.type === "year")?.value ?? "1970"; - const mo = parts.find((p) => p.type === "month")?.value ?? "01"; - const d = parts.find((p) => p.type === "day")?.value ?? "01"; - - const assumedUtc = new Date( - `${y}-${mo}-${d}T${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00.000Z`, - ); - - const offMin = utcOffsetMinutes(assumedUtc, tz); - return new Date(assumedUtc.getTime() - offMin * 60_000); -} - -async function handleConvert(interaction: ChatInputCommandInteraction): Promise { - const rawTime = interaction.options.getString("time", true); - const rawTo = interaction.options.getString("to", true); - const rawFrom = interaction.options.getString("from") ?? ""; - const guildFlag = interaction.options.getBoolean("guild") ?? false; - const scope = scopeFromBool(guildFlag); - - const toNorm = normalizeZoneInput(rawTo); - if (!toNorm) { - await interaction.editReply( - ["I could not understand the **to** timezone.", "", shortHint()].join("\n"), - ); - return; - } - - let fromTz: string | null = null; - let fromLabel: string | undefined; - - if (rawFrom.trim()) { - const fromNorm = normalizeZoneInput(rawFrom); - if (!fromNorm) { - await interaction.editReply( - ["I could not understand the **from** timezone.", "", shortHint()].join("\n"), - ); - return; - } - fromTz = fromNorm.tz; - fromLabel = fromNorm.label; - } else { - const saved = await getTzOrNull(interaction, interaction.user.id, scope); - if (!saved) { - await interaction.editReply( - "No saved timezone found for you. Either run `/timezone set` first, or pass `from:`.", - ); - return; - } - fromTz = saved.timezone; - fromLabel = saved.label; - } - - const parsed = parseTimeString(rawTime); - if (!parsed) { - await interaction.editReply('Time must look like "7:30pm" or "19:30".'); - return; - } - - const now = new Date(); - const instant = guessDateInZone(now, fromTz, parsed.hours, parsed.minutes); - - const fromTime = formatTimeForZone(instant, fromTz); - const toTime = formatTimeForZone(instant, toNorm.tz); - - const fromOff = formatUtcOffset(instant, fromTz); - const toOff = formatUtcOffset(instant, toNorm.tz); - - const delta = utcOffsetMinutes(instant, toNorm.tz) - utcOffsetMinutes(instant, fromTz); - const rel = formatDeltaRelative(delta); - - await interaction.editReply( - [ - "Time conversion:", - "", - `From: ${fromTime} (${fromTz}${fromLabel ? `, ${fromLabel}` : ""}) | ${fromOff}`, - `To: ${toTime} (${toNorm.tz}${toNorm.label ? `, ${toNorm.label}` : ""}) | ${toOff}`, - "", - `That is ${rel}.`, - ].join("\n"), - ); -} diff --git a/data/backup-20260117-145403/src-backup/config/env.ts b/data/backup-20260117-145403/src-backup/config/env.ts deleted file mode 100644 index 9876cc78..00000000 --- a/data/backup-20260117-145403/src-backup/config/env.ts +++ /dev/null @@ -1,225 +0,0 @@ -// src/config/env.ts - -import dotenv from "dotenv"; -dotenv.config({ path: ".env" }); - -/** - * Helper to enforce that required environment variables are present. - * - * If a variable is missing, the bot fails fast at startup instead of - * crashing later at runtime in unpredictable ways. - */ -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is not set in environment`); - } - return value; -} - -/** - * Parse an integer env var safely. - * Falls back to `defaultValue` if missing/invalid. - */ -function envInt(name: string, defaultValue: number): number { - const raw = process.env[name]; - if (!raw) return defaultValue; - - const n = Number(raw); - if (!Number.isFinite(n) || !Number.isInteger(n)) return defaultValue; - if (n <= 0) return defaultValue; - - return n; -} - -type SummaryMode = "local" | "llm"; - -/** - * Centralized environment configuration for OmegaBot. - * - * Keeping this configuration in one place: - * - Prevents scattered process.env lookups - * - Makes configuration explicit and auditable - * - Allows optional features to be gated cleanly - * - * ------------------------------------------------------------------ - * Required (core bot functionality) - * ------------------------------------------------------------------ - * - * - DISCORD_TOKEN - * Bot authentication token used to connect to Discord. - * - * - DISCORD_APP_ID - * Application ID required for slash command registration. - * - * - DISCORD_GUILD_ID - * OPTIONAL: Guild where development commands are registered. - * If not set, commands should be registered globally instead. - * - * ------------------------------------------------------------------ - * Optional (feature flags / enhancements) - * ------------------------------------------------------------------ - * - * - SUMMARY_MODE - * "local" (default) or "llm" - * Determines which summarizer implementation is used. - * - * - OPENAI_API_KEY - * Required only when SUMMARY_MODE === "llm". - * - * ------------------------------------------------------------------ - * GitHub integration - * ------------------------------------------------------------------ - * - * - GITHUB_TOKEN - * Personal Access Token used for authenticated GitHub REST calls. - * Required only when GitHub-backed features are enabled. - * - * - GITHUB_OWNER - * Default repository owner for polling (optional). - * - * - GITHUB_REPO - * Default repository name for polling (optional). - * - * - GITHUB_ANNOUNCE_CHANNEL_ID - * Legacy: single channel where GitHub announcements are posted (optional). - * - * - GITHUB_PR_ANNOUNCE_CHANNEL_ID - * NEW: channel where PR creation announcements are posted (optional). - * Falls back to GITHUB_ANNOUNCE_CHANNEL_ID if unset. - * - * - GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID - * NEW: channel where assignee change announcements are posted (optional). - * Falls back to GITHUB_ANNOUNCE_CHANNEL_ID if unset. - * - * - GITHUB_POLL_INTERVAL_MS - * Polling interval for GitHub polling. - * Defaults to 60 seconds if not provided. - * - * All GitHub-related fields are OPTIONAL so the bot can run - * without any GitHub configuration or tokens. - */ -const summaryMode = (process.env.SUMMARY_MODE ?? "local") as SummaryMode; - -// Fail fast ONLY when LLM summaries are explicitly enabled -if (summaryMode === "llm" && !process.env.OPENAI_API_KEY) { - throw new Error("OPENAI_API_KEY is required when SUMMARY_MODE=llm"); -} - -const legacyGithubAnnounceChannelId = process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null; - -// New split channels (fall back to legacy) -const githubPrAnnounceChannelId = - process.env.GITHUB_PR_ANNOUNCE_CHANNEL_ID ?? legacyGithubAnnounceChannelId; - -const githubAssigneeAnnounceChannelId = - process.env.GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID ?? legacyGithubAnnounceChannelId; - -export const env = { - /* ---------------------------------------------------------------- */ - /* Discord (required) */ - /* ---------------------------------------------------------------- */ - - token: requireEnv("DISCORD_TOKEN"), - appId: requireEnv("DISCORD_APP_ID"), - - /** - * Optional: - * Used for faster slash-command iteration during development. - * If unset, you can register commands globally instead. - */ - guildId: process.env.DISCORD_GUILD_ID ?? null, - - /** - * Optional: - * Discord role ID automatically assigned to new members on join. - * - * If unset, auto-role assignment is disabled. - * - * IMPORTANT: - * - This must be a ROLE ID, not a role name - * - The bot must have permission to manage this role - * - The role must be lower than the bot’s highest role - */ - discordAutoRoleId: process.env.DISCORD_AUTO_ROLE_ID ?? null, - - /* ---------------------------------------------------------------- */ - /* Summaries */ - /* ---------------------------------------------------------------- */ - - // Defaults to local so the bot runs without AI keys. - summaryMode, - - // Only required when summaryMode === "llm" - openAIKey: process.env.OPENAI_API_KEY ?? null, - - /* ---------------------------------------------------------------- */ - /* GitHub */ - /* ---------------------------------------------------------------- */ - - // Auth token for GitHub REST API (optional) - githubToken: process.env.GITHUB_TOKEN ?? null, - - // Default repo configuration for polling (optional) - githubOwner: process.env.GITHUB_OWNER ?? null, - githubRepo: process.env.GITHUB_REPO ?? null, - - // Legacy: Where GitHub announcements should be posted (optional) - githubAnnounceChannelId: legacyGithubAnnounceChannelId, - - // NEW: split announcement channels (optional; fall back to legacy) - githubPrAnnounceChannelId, - githubAssigneeAnnounceChannelId, - - // Polling interval (ms). Defaults to 60s. - githubPollIntervalMs: envInt("GITHUB_POLL_INTERVAL_MS", 60_000), - - /** - * Legacy feature gate: - * - * GitHub announcement polling is enabled ONLY when all required - * configuration is present. This prevents the bot from attempting - * GitHub API calls (or requiring tokens) at startup. - */ - githubAnnouncementsEnabled: - Boolean(process.env.GITHUB_TOKEN) && - Boolean(process.env.GITHUB_OWNER) && - Boolean(process.env.GITHUB_REPO) && - Boolean(process.env.GITHUB_ANNOUNCE_CHANNEL_ID), - - /** - * NEW feature gates: - * - * Separate enablement for each GitHub polling stream. - * These fall back to the legacy channel var automatically. - */ - githubPrPollingEnabled: - Boolean(process.env.GITHUB_TOKEN) && - Boolean(process.env.GITHUB_OWNER) && - Boolean(process.env.GITHUB_REPO) && - Boolean(githubPrAnnounceChannelId), - - githubAssigneePollingEnabled: - Boolean(process.env.GITHUB_TOKEN) && - Boolean(process.env.GITHUB_OWNER) && - Boolean(process.env.GITHUB_REPO) && - Boolean(githubAssigneeAnnounceChannelId), - - /** - * Helper for GitHub-only code paths. - * - * Call this ONLY inside GitHub feature implementations - * (commands, pollers, handlers). - * - * This ensures: - * - The bot can start without GitHub config - * - GitHub features fail loudly and clearly when misconfigured - */ - requireGithubToken(): string { - const token = process.env.GITHUB_TOKEN; - if (!token) { - throw new Error("GITHUB_TOKEN is required for this GitHub feature"); - } - return token; - }, -}; diff --git a/data/backup-20260117-145403/src-backup/registerCommands.ts b/data/backup-20260117-145403/src-backup/registerCommands.ts deleted file mode 100644 index 48c2db37..00000000 --- a/data/backup-20260117-145403/src-backup/registerCommands.ts +++ /dev/null @@ -1,130 +0,0 @@ -// src/registerCommands.ts - -import { REST, Routes } from "discord.js"; -import fs from "fs"; -import path from "path"; -import { pathToFileURL } from "url"; -import { env } from "./config/env.js"; -import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord-api-types/v10"; -import { logger } from "./utils/logger.js"; - -/* - * Read all compiled command definitions and prepare them for registration - * with the Discord API. - * - * Only registers REAL slash command modules: - * - must export `data` (SlashCommandBuilder) - * - must export `execute` (function) - * - * Helper files (services.js, store.js, _shared.js, etc.) are skipped. - */ -async function loadCommandData(): Promise< - RESTPostAPIChatInputApplicationCommandsJSONBody[] -> { - const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; - - const basePath = path.join(process.cwd(), "dist", "commands"); - - if (!fs.existsSync(basePath)) { - logger.warn({ basePath }, "dist/commands not found. Did you run build?"); - return commands; - } - - // Read command groups (folders) safely - const groupEntries = fs.readdirSync(basePath, { withFileTypes: true }); - - for (const groupEntry of groupEntries) { - if (!groupEntry.isDirectory()) continue; - - const group = groupEntry.name; - const groupPath = path.join(basePath, group); - - const fileEntries = fs.readdirSync(groupPath, { withFileTypes: true }); - - for (const fileEntry of fileEntries) { - if (!fileEntry.isFile()) continue; - - const file = fileEntry.name; - if (!file.endsWith(".js")) continue; - - const modulePath = path.join(groupPath, file); - const moduleUrl = pathToFileURL(modulePath).href; - - try { - const mod = await import(moduleUrl); - - if (mod?.data && typeof mod.execute === "function") { - const json = mod.data.toJSON(); - - logger.info( - { - command: json.name, - group, - file, - }, - "Registering slash command", - ); - - commands.push(json); - } else { - logger.debug( - { file: `${group}/${file}` }, - "Skipping non-command module (missing data or execute)", - ); - } - } catch (err) { - logger.warn({ err, file: `${group}/${file}` }, "Failed to import command module"); - } - } - } - - return commands; -} - -/* - * Register all slash commands with Discord for the configured application. - * - * If DISCORD_GUILD_ID is set, we register to that guild (fast iteration). - * Otherwise we register globally (can take longer to propagate). - */ -async function register(): Promise { - const rest = new REST({ version: "10" }).setToken(env.token); - - const commands = await loadCommandData(); - - logger.info( - { - count: commands.length, - commands: commands.map((c) => c.name), - }, - "Final slash command payload", - ); - - logger.info( - { - mode: env.guildId ? "guild" : "global", - guildId: env.guildId ?? undefined, - }, - "Slash command registration mode", - ); - - if (env.guildId) { - await rest.put(Routes.applicationGuildCommands(env.appId, env.guildId), { - body: commands, - }); - - logger.info({ guildId: env.guildId }, "Commands registered to guild."); - return; - } - - await rest.put(Routes.applicationCommands(env.appId), { - body: commands, - }); - - logger.info("Commands registered globally."); -} - -register().catch((err) => { - logger.error(err, "Failed to register commands"); - process.exit(1); -}); diff --git a/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts b/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts deleted file mode 100644 index ef086fd6..00000000 --- a/data/backup-20260117-145403/src-backup/services/cache/simpleCache.ts +++ /dev/null @@ -1,86 +0,0 @@ -// src/services/cache/simpleCache.ts -import { logger } from "../../utils/logger.js"; - -interface CacheEntry { - value: T; - expiresAt: number; -} - -export class SimpleCache { - private cache = new Map>(); - private hits = 0; - private misses = 0; - - set(key: string, value: T, ttlSeconds: number): void { - this.cache.set(key, { - value, - expiresAt: Date.now() + ttlSeconds * 1000, - }); - logger.debug({ key, ttlSeconds }, "Cache SET"); - } - - get(key: string): T | null { - const entry = this.cache.get(key); - - if (!entry) { - this.misses++; - logger.debug({ key }, "Cache MISS"); - return null; - } - - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - this.misses++; - logger.debug({ key }, "Cache MISS (expired)"); - return null; - } - - this.hits++; - logger.debug({ key }, "Cache HIT"); - return entry.value; - } - - clear(): void { - this.cache.clear(); - this.hits = 0; - this.misses = 0; - logger.info("Cache cleared"); - } - - cleanup(): number { - const now = Date.now(); - let removed = 0; - - for (const [key, entry] of this.cache.entries()) { - if (now > entry.expiresAt) { - this.cache.delete(key); - removed++; - } - } - - return removed; - } - - getStats() { - let valid = 0; - let expired = 0; - const now = Date.now(); - - for (const entry of this.cache.values()) { - if (now > entry.expiresAt) expired++; - else valid++; - } - - const total = this.hits + this.misses; - const hitRate = total > 0 ? (this.hits / total) * 100 : 0; - - return { - size: this.cache.size, - valid, - expired, - hits: this.hits, - misses: this.misses, - hitRate: hitRate.toFixed(1) + "%", - }; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts b/data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts deleted file mode 100644 index f8371327..00000000 --- a/data/backup-20260117-145403/src-backup/services/config/guildConfigStore.ts +++ /dev/null @@ -1,83 +0,0 @@ -// src/services/config/guildConfigStore.ts - -import fs from "fs"; -import path from "path"; -import { - DEFAULT_GUILD_CONFIG, - type GuildConfig, - type GuildConfigPatch, -} from "./types.js"; - -/** - * Simple JSON-backed store for guild configuration. - * - * Design goals: - * - Extremely easy to run locally - * - No external dependencies (DB) - * - Durable across restarts - * - * Notes: - * - This implementation reads/writes the file per call. - * That’s fine for small bots. If you scale, add caching + periodic flush. - */ - -const DATA_DIR = path.join(process.cwd(), "data"); -const FILE_PATH = path.join(DATA_DIR, "guild-config.json"); - -/** - * Stored JSON shape: - * { - * "": { welcomeEnabled: true, welcomeChannelId: "..." }, - * "": { ... } - * } - */ -type StoreShape = Record>; - -function ensureDataDir(): void { - if (!fs.existsSync(DATA_DIR)) { - fs.mkdirSync(DATA_DIR, { recursive: true }); - } -} - -function readStore(): StoreShape { - try { - const raw = fs.readFileSync(FILE_PATH, "utf8"); - const parsed = JSON.parse(raw) as StoreShape; - return parsed ?? {}; - } catch { - // Missing file, invalid JSON, etc. - return {}; - } -} - -function writeStore(store: StoreShape): void { - ensureDataDir(); - fs.writeFileSync(FILE_PATH, JSON.stringify(store, null, 2), "utf8"); -} - -/** - * Get the effective config for a guild. - * Always returns a fully-populated config object (defaults applied). - */ -export function getGuildConfig(guildId: string): GuildConfig { - const store = readStore(); - const saved = store[guildId] ?? {}; - return { guildId, ...DEFAULT_GUILD_CONFIG, ...saved }; -} - -/** - * Update the stored config for a guild by patching values. - * Returns the updated effective config (defaults applied). - */ -export function setGuildConfig(guildId: string, patch: GuildConfigPatch): GuildConfig { - const store = readStore(); - - // Apply defaults for new guilds, then patch on top. - const current = store[guildId] ?? DEFAULT_GUILD_CONFIG; - const next: Omit = { ...current, ...patch }; - - store[guildId] = next; - writeStore(store); - - return { guildId, ...next }; -} diff --git a/data/backup-20260117-145403/src-backup/services/config/index.ts b/data/backup-20260117-145403/src-backup/services/config/index.ts deleted file mode 100644 index 7df69e30..00000000 --- a/data/backup-20260117-145403/src-backup/services/config/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// src/services/config/index.ts - -/** - * Public API barrel for config services. - * - * Keeps imports cleaner: - * import { getGuildConfig } from "../config/index.js"; - * instead of: - * import { getGuildConfig } from "../config/guildConfigStore.js"; - */ - -export { getGuildConfig, setGuildConfig } from "./guildConfigStore.js"; -export { DEFAULT_GUILD_CONFIG } from "./types.js"; -export type { GuildConfig, GuildConfigPatch } from "./types.js"; diff --git a/data/backup-20260117-145403/src-backup/services/config/types.ts b/data/backup-20260117-145403/src-backup/services/config/types.ts deleted file mode 100644 index 7c3ca528..00000000 --- a/data/backup-20260117-145403/src-backup/services/config/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -// src/services/config/types.ts - -/** - * GuildConfig - * - * Per-guild configuration for OmegaBot. - * This is intentionally small and focused so we can expand safely over time. - */ -export type GuildConfig = { - /** Discord guild (server) id */ - guildId: string; - - /* ------------------------------------------------------------------ */ - /* Welcome / onboarding */ - /* ------------------------------------------------------------------ */ - - /** - * Master enable/disable for welcome messages in this guild. - * Defaults to true. - */ - welcomeEnabled: boolean; - - /** - * Preferred channel for welcome messages. - * If null, we fall back to system channel, then first text channel. - */ - welcomeChannelId: string | null; -}; - -/** - * Patch type used to update config values without requiring the full object. - * guildId is excluded intentionally (immutable key). - */ -export type GuildConfigPatch = Partial>; - -/** - * Default config for new guilds. - * Applied automatically when no saved config exists. - */ -export const DEFAULT_GUILD_CONFIG: Omit = { - welcomeEnabled: true, - welcomeChannelId: null, -}; diff --git a/data/backup-20260117-145403/src-backup/services/database/db.ts b/data/backup-20260117-145403/src-backup/services/database/db.ts deleted file mode 100644 index 6c3ee38f..00000000 --- a/data/backup-20260117-145403/src-backup/services/database/db.ts +++ /dev/null @@ -1,89 +0,0 @@ -// 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, - answer 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 initialized"); - 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); -} diff --git a/data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts b/data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts deleted file mode 100644 index 30e1a699..00000000 --- a/data/backup-20260117-145403/src-backup/services/discord/commandLoader.ts +++ /dev/null @@ -1,149 +0,0 @@ -// src/services/discord/commandLoader.ts - -import fs from "fs"; -import path from "path"; -import { pathToFileURL } from "url"; -import { - Client, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { logger } from "../../utils/logger.js"; - -/** - * Contract that every slash command module must follow. - * - * - `data` describes the command to Discord (name, description, options) - * - `execute` runs when a user invokes the command - */ -export interface SlashCommand { - data: SlashCommandBuilder; - execute: (interaction: ChatInputCommandInteraction) => Promise; -} - -/** - * Discord Client extended with a command registry. - */ -export type CommandClient = Client & { - commands: Map; -}; - -/** - * Load all compiled command modules from dist/commands and register them into client.commands. - * - * Notes: - * - We load from dist/ because the bot runs compiled JS. - * - A single broken command should not crash the entire bot. - * - Helper modules may exist alongside commands and will be skipped. - */ -export async function loadCommands(client: CommandClient): Promise { - const basePath = path.join(process.cwd(), "dist", "commands"); - - // Diagnostics - let loadedCount = 0; - let skippedCount = 0; - let failedCount = 0; - - const loadedNames: string[] = []; - const skippedFiles: string[] = []; - const failedFiles: string[] = []; - - if (!fs.existsSync(basePath)) { - logger.error( - { basePath }, - "Command loader base path not found. Did you build the project?", - ); - logger.info({ loadedCount: 0 }, "Commands loaded"); - return; - } - - const groups = fs.readdirSync(basePath); - - for (const group of groups) { - const groupPath = path.join(basePath, group); - if (!fs.statSync(groupPath).isDirectory()) continue; - - const files = fs.readdirSync(groupPath); - - for (const file of files) { - if (!file.endsWith(".js")) continue; - - const fullPath = path.join(groupPath, file); - const relFile = `${group}/${file}`; - - try { - // ESM-safe import path - const moduleUrl = pathToFileURL(fullPath).href; - const mod = (await import(moduleUrl)) as Partial; - - // Helpers are expected to be skipped - if (!mod.data || !mod.execute) { - skippedCount += 1; - skippedFiles.push(relFile); - - logger.debug( - { file: relFile }, - "Skipping non-command module (missing data or execute)", - ); - continue; - } - - const name = mod.data.name; - - if (!name || typeof name !== "string") { - skippedCount += 1; - skippedFiles.push(relFile); - - logger.warn( - { file: relFile }, - "Skipping command module (invalid command name)", - ); - continue; - } - - // Avoid silent overwrites if two commands share the same name - if (client.commands.has(name)) { - skippedCount += 1; - skippedFiles.push(relFile); - - logger.warn( - { name, file: relFile }, - "Duplicate command name detected. Skipping this module.", - ); - continue; - } - - client.commands.set(name, mod as SlashCommand); - loadedCount += 1; - loadedNames.push(name); - } catch (err) { - failedCount += 1; - failedFiles.push(relFile); - - logger.warn({ err, file: relFile, fullPath }, "Failed to import command module"); - } - } - } - - // Summary (info) - logger.info( - { - loadedCount, - loadedNames, - skippedCount, - failedCount, - }, - "Commands loaded", - ); - - // Details only when useful - if (failedCount > 0) { - logger.warn( - { failedCount, failedFiles }, - "One or more command modules failed to load", - ); - } - - // Keep skip list debug-only to avoid noise - logger.debug({ skippedCount, skippedFiles }, "Non-command modules skipped"); -} diff --git a/data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts b/data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts deleted file mode 100644 index 3c23224e..00000000 --- a/data/backup-20260117-145403/src-backup/services/discord/commandMeta.ts +++ /dev/null @@ -1,94 +0,0 @@ -// src/services/discord/commandMeta.ts - -/** - * Minimal metadata shape used by /help. - * - * This is intentionally defensive: it tries to extract command names and - * descriptions from whatever your loader stores in `client.commands`. - */ - -export type CommandListItem = { - name: string; - description: string; - group: string; - adminOnly: boolean; -}; - -type MaybeCommandModule = { - data?: { - name?: string; - description?: string; - toJSON?: () => { name?: string; description?: string }; - default_member_permissions?: string | null; - }; - meta?: Partial>; -}; - -type MaybeHasCommandsMap = { - commands?: Map; -}; - -/** - * Extract a list of commands from a Discord client object. - * - * Supports common shapes: - * - client.commands = Map - * - client.commands = Map - * - * If nothing matches, returns an empty array and /help remains static. - */ -export function extractCommandList(client: unknown): CommandListItem[] { - const c = client as MaybeHasCommandsMap; - - if (!c?.commands || !(c.commands instanceof Map)) return []; - - const items: CommandListItem[] = []; - - for (const [fallbackName, modUnknown] of c.commands.entries()) { - const mod = modUnknown as MaybeCommandModule; - - const fromJson = safeToJson(mod?.data); - const name = (mod?.data?.name ?? fromJson?.name ?? fallbackName)?.trim(); - if (!name) continue; - - const description = (mod?.data?.description ?? fromJson?.description ?? "").trim(); - - // Best-effort group: use explicit meta.group if provided, else infer. - // (You can improve this later by having the loader attach folder name.) - const group = (mod?.meta?.group ?? inferGroupFromName(name)).trim(); - - // Best-effort adminOnly: - // - If command module sets meta.adminOnly, trust it - // - Else if default_member_permissions is set on command data, consider it adminOnly - const adminOnly = - Boolean(mod?.meta?.adminOnly) || Boolean(mod?.data?.default_member_permissions); - - items.push({ - name, - description, - group, - adminOnly, - }); - } - - return items; -} - -function safeToJson(data: unknown): { name?: string; description?: string } | null { - try { - const d = data as { toJSON?: () => unknown }; - if (typeof d?.toJSON !== "function") return null; - const j = d.toJSON() as { name?: string; description?: string }; - return j ?? null; - } catch { - return null; - } -} - -function inferGroupFromName(name: string): string { - // Minimal inference, keeps output readable until the loader tags groups. - if (name === "help") return "general"; - if (name === "config") return "admin"; - if (name.includes("github")) return "github"; - return "other"; -} diff --git a/data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts b/data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts deleted file mode 100644 index fa0a966b..00000000 --- a/data/backup-20260117-145403/src-backup/services/discord/cooldowns.ts +++ /dev/null @@ -1,58 +0,0 @@ -// src/services/discord/safeReply.ts -// -// Safe reply helper for Discord interactions. -// -// Goal: -// - Avoid "Interaction already replied" and "Unknown interaction" pitfalls -// - Let callers write one consistent "respond" call -// -// Behavior: -// - If interaction already has a reply or was deferred, use editReply(string) -// - Otherwise use reply({ content, flags? }) -// -// Notes: -// - Discord does not allow setting Ephemeral on editReply. -// So we only apply ephemeral flags on the initial reply. - -import { MessageFlags, type InteractionReplyOptions } from "discord.js"; - -export type SafeReplyOptions = { - content: string; - ephemeral?: boolean; -}; - -function toReplyOptions(opts: SafeReplyOptions): InteractionReplyOptions { - if (opts.ephemeral) { - return { content: opts.content, flags: MessageFlags.Ephemeral }; - } - return { content: opts.content }; -} - -/** - * The minimal shape we need from an interaction. - * - * Important: - * - editReply only accepts a string (or edit options), and cannot set Ephemeral. - * - We only ever edit with a string, so keep the type strict to avoid mismatches. - */ -type SafeInteraction = { - replied: boolean; - deferred: boolean; - reply: (options: InteractionReplyOptions) => Promise; - editReply: (content: string) => Promise; -}; - -/** - * Safely respond to an interaction exactly once. - */ -export async function safeReply( - interaction: SafeInteraction, - opts: SafeReplyOptions, -): Promise { - if (interaction.replied || interaction.deferred) { - await interaction.editReply(opts.content); - return; - } - - await interaction.reply(toReplyOptions(opts)); -} diff --git a/data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts b/data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts deleted file mode 100644 index 0ea00aaf..00000000 --- a/data/backup-20260117-145403/src-backup/services/discord/fetchChannelMessages.ts +++ /dev/null @@ -1,69 +0,0 @@ -// src/services/discord/fetchChannelMessages.ts - -import type { TextBasedChannel, Message } from "discord.js"; -import { logger } from "../../utils/logger.js"; - -export type FetchMessagesOptions = { - count?: number; - before?: string; - after?: string; -}; - -/** - * Fetch messages from a text-based Discord channel. - * - * Supports optional: - * - count → max number of messages to fetch - * - before → message ID cursor - * - after → message ID cursor - * - * Returns messages sorted oldest → newest. - * - * This function performs network I/O and therefore: - * - Wraps fetch in try/catch - * - Logs failures with context - */ -export async function fetchChannelMessages( - channel: TextBasedChannel, - options: FetchMessagesOptions = {}, -): Promise { - const { count = 50, before, after } = options; - - const fetchOptions: { - limit: number; - before?: string; - after?: string; - } = { - limit: count, - }; - - if (before) fetchOptions.before = before; - if (after) fetchOptions.after = after; - - try { - const collection = await channel.messages.fetch(fetchOptions); - - // Convert Collection → Array and sort chronologically - return Array.from(collection.values()).sort( - (a, b) => a.createdTimestamp - b.createdTimestamp, - ); - } catch (err) { - logger.error( - { - err, - channelId: channel.id, - count, - before, - after, - }, - "Failed to fetch channel messages", - ); - - /** - * Fail safe: - * - Return empty list instead of crashing caller - * - Callers can decide how to handle missing messages - */ - return []; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts b/data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts deleted file mode 100644 index c3fb2f1e..00000000 --- a/data/backup-20260117-145403/src-backup/services/discord/interactionHandler.ts +++ /dev/null @@ -1,104 +0,0 @@ -// src/services/discord/interactionHandler.ts -// -// Central interaction router for Discord. -// -// Responsibilities: -// - Route slash commands to their registered handlers -// - Handle execution errors safely -// - Ensure users always receive a response -// -// IMPORTANT DESIGN NOTE: -// - Slash commands are handled globally here -// - Buttons and other component interactions are handled locally -// by the command that created them (via collectors) -// -// This separation is REQUIRED for confirmation flows (ex: /faq remove) - -import type { Interaction, ChatInputCommandInteraction } from "discord.js"; -import type { CommandClient } from "./commandLoader.js"; -import { logger } from "../../utils/logger.js"; -import { safeReply } from "./safeReply.js"; - -/** - * Handle a single Discord interaction. - * - * This function is intentionally conservative: - * - It only routes slash commands - * - It explicitly ignores buttons so collectors can handle them - */ -export async function handleInteraction( - interaction: Interaction, - client: CommandClient, -): Promise { - /** - * IMPORTANT: - * Button interactions (confirm / cancel, etc.) are NOT routed here. - * - * They are handled by local collectors created by the command itself - * (for example: /faq remove confirmation buttons). - * - * If we process buttons here, those flows will break. - */ - if (interaction.isButton()) { - return; - } - - /** - * We only care about slash commands in this router. - * Other interaction types are intentionally ignored. - */ - if (!interaction.isChatInputCommand()) return; - - /** - * Look up the registered command handler. - */ - const command = client.commands.get(interaction.commandName); - - /** - * No handler registered for this command. - * Usually indicates a stale deploy or command mismatch. - */ - if (!command) { - logger.warn( - { - commandName: interaction.commandName, - user: interaction.user?.username, - }, - "Received interaction for unknown command", - ); - - await safeReply(interaction, { - content: "Command not found.", - ephemeral: true, - }); - return; - } - - /** - * Execute the command safely. - * - * Any error thrown by the command: - * - Is logged with context - * - Results in a generic user-facing error - * - * Internal details are never leaked to Discord. - */ - try { - await command.execute(interaction as ChatInputCommandInteraction); - } catch (err) { - logger.error( - { - err, - commandName: interaction.commandName, - user: interaction.user?.username, - guildId: interaction.guildId, - }, - "Slash command execution failed", - ); - - await safeReply(interaction, { - content: "Something went wrong while running this command.", - ephemeral: true, - }); - } -} diff --git a/data/backup-20260117-145403/src-backup/services/discord/safeReply.ts b/data/backup-20260117-145403/src-backup/services/discord/safeReply.ts deleted file mode 100644 index c525b85e..00000000 --- a/data/backup-20260117-145403/src-backup/services/discord/safeReply.ts +++ /dev/null @@ -1,48 +0,0 @@ -// src/services/discord/safeReply.ts -// -// Safe reply helper for Discord interactions. -// -// Goal: -// - Avoid "Interaction already replied" and "Unknown interaction" pitfalls -// - Let callers write one consistent "respond" call -// -// Behavior: -// - If interaction already has a reply or was deferred, use editReply(content) -// - Otherwise use reply({ content, flags? }) -// -// Notes: -// - Discord does not allow setting Ephemeral on editReply. -// So we only apply ephemeral flags on the initial reply. - -import { - MessageFlags, - type InteractionReplyOptions, - type RepliableInteraction, -} from "discord.js"; - -export type SafeReplyOptions = { - content: string; - ephemeral?: boolean; -}; - -function toReplyOptions(opts: SafeReplyOptions): InteractionReplyOptions { - if (opts.ephemeral) { - return { content: opts.content, flags: MessageFlags.Ephemeral }; - } - return { content: opts.content }; -} - -/** - * Safely respond to an interaction exactly once. - */ -export async function safeReply( - interaction: RepliableInteraction, - opts: SafeReplyOptions, -): Promise { - if (interaction.replied || interaction.deferred) { - await interaction.editReply(opts.content); - return; - } - - await interaction.reply(toReplyOptions(opts)); -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/_shared.ts b/data/backup-20260117-145403/src-backup/services/faq/_shared.ts deleted file mode 100644 index 1e218be8..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/_shared.ts +++ /dev/null @@ -1,238 +0,0 @@ -// src/commands/faq/subcommands/_shared.ts -// -// Shared helpers for /faq subcommands. -// -// Goals: -// - Keep each subcommand tiny and consistent -// - Centralize common parsing, formatting, and guardrails -// - Provide one place to evolve list filtering and output style -// -// Non-goals: -// - No file I/O -// - No persistence logic -// - No Discord lifecycle (defer/reply) decisions - -import type { ChatInputCommandInteraction } from "discord.js"; -import { logger } from "../../utils/logger.js"; - -import { canFaqAction } from "./permissions.js"; -import { MAX_KEY_LEN } from "./types.js"; -import type { FaqEntry } from "./types.js"; - -export type FaqAction = "add" | "get" | "list" | "remove"; - -/** - * Optional list filtering and display preferences. - * - * - query: substring search across key/title/body/tags (case-insensitive) - * - tag: filter to entries that include this tag (case-insensitive) - * - limit: max entries to render - * - full: if true, render full FAQ bodies instead of one-line rows - * - sort: how to order results - */ -export type FaqListFilter = { - query?: string; - tag?: string; - limit?: number; - full?: boolean; - sort?: "usage" | "updated" | "key"; -}; - -export async function guardFaqAction( - interaction: ChatInputCommandInteraction, - action: FaqAction, -): Promise { - const perm = canFaqAction(interaction, action); - if (perm.ok) return true; - - await interaction.editReply(`❌ ${"reason" in perm ? perm.reason : "Access denied"}`); - return false; -} - -/** - * Read and minimally validate a required key option. - * - * Notes: - * - This is intentionally a light guardrail for user experience. - * - The service layer is still the source of truth for normalization/validation. - */ -export async function readRequiredKey( - interaction: ChatInputCommandInteraction, - optionName = "key", -): Promise { - const rawKey = interaction.options.getString(optionName, true); - const key = rawKey.trim(); - - if (key.length === 0) { - await interaction.editReply("❌ Key cannot be empty."); - return null; - } - - if (key.length > MAX_KEY_LEN) { - await interaction.editReply(`❌ Key is too long (max ${MAX_KEY_LEN} characters).`); - return null; - } - - return key; -} - -/** - * Parse a comma-separated tags string into a clean array. - * Example: "devops, ci/cd, bot " -> ["devops","ci/cd","bot"] - */ -export function parseTags(raw: string | null): string[] { - if (!raw) return []; - return raw - .split(",") - .map((t) => t.trim()) - .filter(Boolean); -} - -/** - * Format a single FAQ entry for Discord. - * - * Output style: - * - Key (bold) - * - Title (bold) - * - Body - * - Tags line only when present - */ -export function formatFaqEntry(entry: FaqEntry): string { - const tagsLine = entry.tags?.length ? `🏷️ ${entry.tags.join(", ")}` : null; - - return [`📌 **${entry.key}**`, `**${entry.title}**`, entry.body, tagsLine] - .filter(Boolean) - .join("\n"); -} - -/** - * Format a list view. - * - * Default behavior: - * - Returns a scannable list of keys with a small hint (first tag, usage) - * - * If filter.full is true: - * - Returns full entry blocks (key/title/body/tags) for each match - */ -export function formatFaqList(entries: FaqEntry[], filter: FaqListFilter = {}): string { - if (entries.length === 0) { - return "No FAQs yet. Add one with `/faq add`."; - } - - const limit = typeof filter.limit === "number" ? filter.limit : 25; - - const filtered = applyListFilters(entries, filter); - if (filtered.length === 0) { - const bits: string[] = ["No FAQs matched."]; - if (filter.tag) bits.push(`Tag filter: **${filter.tag}**`); - if (filter.query) bits.push(`Query: **${filter.query}**`); - return bits.join("\n"); - } - - const sorted = applyListSort(filtered, filter.sort); - - const shown = sorted.slice(0, limit); - const remaining = sorted.length - shown.length; - - // Full mode: show each full entry block - if (filter.full) { - const blocks = shown.map((e) => formatFaqEntry(e)); - const footer = - remaining > 0 ? `\n\n…plus ${remaining} more. Narrow filters to see more.` : ""; - return [`📚 **FAQ entries** (${filtered.length})`, "", ...joinBlocks(blocks), footer] - .filter(Boolean) - .join("\n"); - } - - // Compact mode: one line per entry - const lines = shown.map((e) => { - const tagHint = e.tags?.length ? ` — ${e.tags[0]}` : ""; - const used = typeof e.usageCount === "number" ? ` • ${e.usageCount} uses` : ""; - return `• **${e.key}**${tagHint}${used}`; - }); - - const moreLine = remaining > 0 ? `\n…plus ${remaining} more.` : ""; - - return [`📚 **FAQ entries** (${filtered.length})`, ...lines, moreLine] - .filter(Boolean) - .join("\n"); -} - -/** - * Centralized error handler for subcommands. - * Keeps user messaging consistent and logs useful context. - */ -export async function handleFaqSubcommandError( - interaction: ChatInputCommandInteraction, - err: unknown, - tag: string, -): Promise { - logger.error({ err }, tag); - await interaction.editReply("❌ Something went wrong. Try again in a bit."); -} - -/* -------------------------------------------------------------------------- */ -/* Internal helpers */ -/* -------------------------------------------------------------------------- */ - -function applyListFilters(entries: FaqEntry[], filter: FaqListFilter): FaqEntry[] { - let out = entries; - - // Tag filter (case-insensitive exact match against any tag) - if (filter.tag && filter.tag.trim().length > 0) { - const wanted = filter.tag.trim().toLowerCase(); - out = out.filter((e) => (e.tags ?? []).some((t) => t.toLowerCase() === wanted)); - } - - // Query filter (case-insensitive substring match) - if (filter.query && filter.query.trim().length > 0) { - const q = filter.query.trim().toLowerCase(); - - out = out.filter((e) => { - const hay = [e.key, e.title, e.body, ...(e.tags ?? [])] - .filter(Boolean) - .join(" ") - .toLowerCase(); - - return hay.includes(q); - }); - } - - return out; -} - -function applyListSort(entries: FaqEntry[], sort: FaqListFilter["sort"]): FaqEntry[] { - const mode = sort ?? "usage"; - - if (mode === "key") { - return [...entries].sort((a, b) => a.key.localeCompare(b.key)); - } - - if (mode === "updated") { - return [...entries].sort((a, b) => - (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""), - ); - } - - // default: usage desc, then updated desc, then key asc - return [...entries].sort((a, b) => { - const u = (b.usageCount ?? 0) - (a.usageCount ?? 0); - if (u !== 0) return u; - - const t = (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); - if (t !== 0) return t; - - return a.key.localeCompare(b.key); - }); -} - -function joinBlocks(blocks: string[]): string[] { - // Add a simple divider between full entries for readability. - // Discord does not have a native horizontal rule, so we use a line. - const out: string[] = []; - blocks.forEach((b, idx) => { - if (idx > 0) out.push("────────"); - out.push(b); - }); - return out; -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/faqService.ts b/data/backup-20260117-145403/src-backup/services/faq/faqService.ts deleted file mode 100644 index 32b59ae7..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/faqService.ts +++ /dev/null @@ -1,176 +0,0 @@ -// src/services/faq/services.ts - -import { loadStore, saveStore } from "./store.js"; -import { MAX_KEY_LEN, type CreateFaqInput, type FaqEntry } from "./types.js"; - -/* -------------------------------------------------------------------------- */ -/* Public API */ -/* -------------------------------------------------------------------------- */ - -/** - * Return all FAQ entries. - * - * Note: - * - Returns values only (does not guarantee order). - * - Caller can sort if needed (ex: by usageCount or updatedAt). - */ -export function getAll(): FaqEntry[] { - const store = loadStore(); - return Object.values(store.entries); -} - -/** - * Lookup an FAQ by key. - * - * The key is normalized the same way as create/update/remove. - */ -export function getByKey(key: string): FaqEntry | null { - const store = loadStore(); - const k = assertValidKey(key); - return store.entries[k] ?? null; -} - -/** - * Create a new FAQ entry. - * - * Throws if: - * - key normalizes to empty - * - key is too long - * - key already exists - */ -export function create(input: CreateFaqInput): FaqEntry { - const store = loadStore(); - - const key = assertValidKey(input.key); - if (store.entries[key]) { - throw new Error(`FAQ with this key already exists: ${key}`); - } - - const now = new Date().toISOString(); - - const entry: FaqEntry = { - key, - title: input.title.trim(), - body: input.body.trim(), - tags: normalizeTags(input.tags ?? []), - createdAt: now, - updatedAt: now, - createdBy: input.actor, - updatedBy: input.actor, - usageCount: 0, - }; - - store.entries[key] = entry; - saveStore(store); - - return entry; -} - -/** - * Allowed update fields for an FAQ. - * actor is required so we can attribute updatedBy. - */ -export type UpdateFaqPatch = Partial> & { - actor: string; -}; - -/** - * Update an existing FAQ entry by key. - * - * Throws if: - * - key invalid - * - entry not found - */ -export function update(key: string, patch: UpdateFaqPatch): FaqEntry { - const k = assertValidKey(key); - - const store = loadStore(); - const existing = store.entries[k]; - - if (!existing) { - throw new Error(`FAQ not found: ${k}`); - } - - // Apply patch (only touch provided fields). - if (patch.title !== undefined) existing.title = patch.title.trim(); - if (patch.body !== undefined) existing.body = patch.body.trim(); - if (patch.tags !== undefined) existing.tags = normalizeTags(patch.tags); - - existing.updatedAt = new Date().toISOString(); - existing.updatedBy = patch.actor; - - store.entries[k] = existing; - saveStore(store); - - return existing; -} - -/** - * Remove an FAQ by key. - * - * Returns: - * - true if removed - * - false if it did not exist - */ -export function remove(key: string): boolean { - const store = loadStore(); - const k = assertValidKey(key); - - if (!store.entries[k]) return false; - - delete store.entries[k]; - saveStore(store); - return true; -} - -/* -------------------------------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------------------------------- */ - -/** - * Normalize an FAQ key into a stable URL-ish slug. - * - * Example: - * "How do I reset my token?" -> "how-do-i-reset-my-token" - */ -function normalizeKey(key: string): string { - return key - .trim() - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-_]/g, ""); -} - -/** - * Validate and return the normalized key. - * - * Keep this throwing: - * - Callers can catch at the command layer and reply nicely. - * - Services stay strict and predictable. - */ -function assertValidKey(key: string): string { - const k = normalizeKey(key); - - if (k.length === 0) { - throw new Error("FAQ key is empty after normalization"); - } - - if (k.length > MAX_KEY_LEN) { - throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); - } - - return k; -} - -/** - * Normalize tags for consistency: - * - trim - * - lowercase - * - remove empties - * - de-dupe - */ -function normalizeTags(tags: string[]): string[] { - const cleaned = tags.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0); - - return Array.from(new Set(cleaned)); -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/permissions.ts b/data/backup-20260117-145403/src-backup/services/faq/permissions.ts deleted file mode 100644 index fef3534a..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/permissions.ts +++ /dev/null @@ -1,47 +0,0 @@ -// src/services/faq/permissions.ts -// -// FAQ permission policy. -// -// Goals: -// - Keep permission rules centralized and boring. -// - Command files call canFaqAction() and show a friendly reason. -// -// You can evolve this later to support: -// - per-guild config -// - role allowlists -// - per-action overrides - -import type { ChatInputCommandInteraction } from "discord.js"; -import { PermissionsBitField } from "discord.js"; - -export type FaqAction = "add" | "get" | "list" | "remove"; - -export type PermissionResult = { ok: true } | { ok: false; reason: string }; - -export function canFaqAction( - interaction: ChatInputCommandInteraction, - action: FaqAction, -): PermissionResult { - // get/list can be used anywhere for now - if (action === "get" || action === "list") return { ok: true }; - - // add/remove should be guild-only if you want permissions to matter - if (!interaction.inGuild()) { - return { ok: false, reason: "`/faq add/remove` can only be used in a server." }; - } - - const perms = interaction.memberPermissions; - if (!perms) { - return { ok: false, reason: "Could not resolve your permissions for this server." }; - } - - const ok = - perms.has(PermissionsBitField.Flags.ManageGuild) || - perms.has(PermissionsBitField.Flags.Administrator); - - if (!ok) { - return { ok: false, reason: "You need Manage Server or Administrator to do that." }; - } - - return { ok: true }; -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/services.test.ts b/data/backup-20260117-145403/src-backup/services/faq/services.test.ts deleted file mode 100644 index d1252205..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/services.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { create } from "./services.js"; - -describe("FAQ service", () => { - it("throws when key is empty", () => { - expect(() => - create({ - key: " ", - title: "Test title", - body: "Test body", - actor: "test-user", - }), - ).toThrow(); - }); -}); diff --git a/data/backup-20260117-145403/src-backup/services/faq/services.ts b/data/backup-20260117-145403/src-backup/services/faq/services.ts deleted file mode 100644 index 7d9a74ed..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/services.ts +++ /dev/null @@ -1,229 +0,0 @@ -// src/services/faq/services.ts -// -// FAQ business logic layer. -// -// Responsibilities: -// - Validate + normalize inputs (keys, title/body lengths, tags) -// - Read/write the persistent store via store.ts -// - Return domain objects (FaqEntry) to command handlers -// -// Non-goals: -// - Discord interaction handling (that stays in commands) -// - Low-level file I/O details (that stays in store.ts) - -import { loadStore, saveStore } from "./store.js"; -import { logger } from "../../utils/logger.js"; -import { - MAX_KEY_LEN, - MAX_TITLE_LEN, - MAX_BODY_LEN, - MAX_TAGS, - MAX_TAG_LEN, -} from "./types.js"; -import type { FaqEntry, CreateFaqInput, UpdateFaqPatch } from "./types.js"; - -/** - * Return all FAQ entries (unordered). - */ -export function getAll(): FaqEntry[] { - const store = loadStore(); - return Object.values(store.entries); -} - -/** - * Find an entry by key. Returns null when missing. - */ -export function getByKey(rawKey: string): FaqEntry | null { - const key = assertValidKey(rawKey); - const store = loadStore(); - return store.entries[key] ?? null; -} - -/** - * Create a new FAQ entry. - * Throws if duplicate key or invalid fields. - */ -export function create(input: CreateFaqInput): FaqEntry { - const key = assertValidKey(input.key); - - const title = input.title.trim(); - if (title.length === 0) throw new Error("FAQ title cannot be empty"); - if (title.length > MAX_TITLE_LEN) { - throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); - } - - const body = input.body.trim(); - if (body.length === 0) throw new Error("FAQ body cannot be empty"); - if (body.length > MAX_BODY_LEN) { - throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); - } - - const tags = normalizeTags(input.tags); - - const store = loadStore(); - if (store.entries[key]) { - throw new Error("FAQ with this key already exists"); - } - - const now = new Date().toISOString(); - - const entry: FaqEntry = { - key, - title, - body, - tags, - createdAt: now, - updatedAt: now, - createdBy: input.actor, - updatedBy: input.actor, - usageCount: 0, - }; - - store.entries[key] = entry; - saveStore(store); - - logger.info({ key, actor: input.actor }, "[faq] created"); - return entry; -} - -/** - * Update an existing FAQ entry (title/body/tags). - * Throws if key not found or patch fields invalid. - */ -export function update(rawKey: string, patch: UpdateFaqPatch): FaqEntry { - const key = assertValidKey(rawKey); - - const store = loadStore(); - const entry = store.entries[key]; - if (!entry) throw new Error(`FAQ not found: ${key}`); - - const now = new Date().toISOString(); - - // Title (optional) - if (patch.title !== undefined) { - const title = patch.title.trim(); - if (title.length === 0) throw new Error("FAQ title cannot be empty"); - if (title.length > MAX_TITLE_LEN) { - throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); - } - entry.title = title; - } - - // Body (optional) - if (patch.body !== undefined) { - const body = patch.body.trim(); - if (body.length === 0) throw new Error("FAQ body cannot be empty"); - if (body.length > MAX_BODY_LEN) { - throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); - } - entry.body = body; - } - - // Tags (optional) - if (patch.tags !== undefined) { - entry.tags = normalizeTags(patch.tags); - } - - // Always update audit fields on a successful update call. - entry.updatedAt = now; - entry.updatedBy = patch.actor; - - store.entries[key] = entry; - saveStore(store); - - logger.info({ key, actor: patch.actor }, "[faq] updated"); - return entry; -} - -/** - * Remove an entry by key. - * Returns false if missing. - */ -export function remove(rawKey: string): boolean { - const key = assertValidKey(rawKey); - - const store = loadStore(); - if (!store.entries[key]) return false; - - delete store.entries[key]; - saveStore(store); - - logger.info({ key }, "[faq] removed"); - return true; -} - -/** - * Increment usage count for an entry (for analytics/ranking). - * Returns false if missing. - */ -export function incrementUsage(rawKey: string): boolean { - const key = assertValidKey(rawKey); - - const store = loadStore(); - const entry = store.entries[key]; - if (!entry) return false; - - entry.usageCount += 1; - entry.updatedAt = new Date().toISOString(); - - store.entries[key] = entry; - saveStore(store); - - return true; -} - -/* -------------------------------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------------------------------- */ - -/** - * Normalize keys so users can type variations but store is consistent. - * Example: " How To Deploy?! " -> "how-to-deploy" - */ -function normalizeKey(rawKey: string): string { - return rawKey - .trim() - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-_]/g, ""); -} - -/** - * Validate and return a normalized key (single source of truth). - */ -function assertValidKey(rawKey: string): string { - const key = normalizeKey(rawKey); - if (key.length === 0) throw new Error("FAQ key cannot be empty"); - if (key.length > MAX_KEY_LEN) { - throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); - } - return key; -} - -/** - * Normalize tags: - * - optional input -> [] - * - trim - * - drop empties - * - enforce per-tag max length - * - de-dupe - * - enforce max number of tags - * - * Note: casing is preserved. If you want lowercased tags, add `.toLowerCase()`. - */ -function normalizeTags(tags?: string[]): string[] { - if (!tags) return []; - - const cleaned = tags - .map((t) => t.trim()) - .filter(Boolean) - .map((t) => (t.length > MAX_TAG_LEN ? t.slice(0, MAX_TAG_LEN) : t)); - - const deduped = Array.from(new Set(cleaned)); - - if (deduped.length > MAX_TAGS) { - return deduped.slice(0, MAX_TAGS); - } - - return deduped; -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.test.ts b/data/backup-20260117-145403/src-backup/services/faq/store.test.ts deleted file mode 100644 index d2be28ed..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/store.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// src/services/faq/store.test.ts -// -// Tests for the FAQ persistence layer (store.ts). -// -// Goals: -// - Never touch your real repo ./data folder -// - Exercise real filesystem behavior in an isolated temp directory -// - Verify safe fallbacks when the store file is missing or malformed - -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; -import fs from "fs"; -import path from "path"; -import os from "os"; - -type StoreModule = typeof import("./store.js"); - -let originalCwd = ""; -let tempRoot = ""; -let storePath = ""; - -/** - * Import store.ts AFTER we switch cwd. - * store.ts computes STORE_PATH at import time using process.cwd(). - */ -async function importStoreFresh(): Promise { - vi.resetModules(); - return await import("./store.js"); -} - -function rmIfExists(p: string): void { - if (!fs.existsSync(p)) return; - fs.rmSync(p, { recursive: true, force: true }); -} - -beforeAll(() => { - originalCwd = process.cwd(); - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omegabot-faq-store-")); - process.chdir(tempRoot); - - storePath = path.join(process.cwd(), "data", "faqs.json"); -}); - -afterAll(() => { - process.chdir(originalCwd); - rmIfExists(tempRoot); -}); - -beforeEach(() => { - // Each test starts with a clean tempRoot/data folder. - rmIfExists(path.join(process.cwd(), "data")); -}); - -describe("faq store", () => { - it("creates the store file if missing", async () => { - const { loadStore } = await importStoreFresh(); - - expect(fs.existsSync(storePath)).toBe(false); - - const store = loadStore(); - - expect(fs.existsSync(storePath)).toBe(true); - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("falls back to empty store when JSON is malformed", async () => { - const { loadStore, ensureStoreFile } = await importStoreFresh(); - - ensureStoreFile(); - fs.writeFileSync(storePath, "not json", "utf8"); - - const store = loadStore(); - - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("falls back to empty store when shape is invalid", async () => { - const { loadStore, ensureStoreFile } = await importStoreFresh(); - - ensureStoreFile(); - - // Wrong version + missing entries - fs.writeFileSync(storePath, JSON.stringify({ version: 999 }, null, 2), "utf8"); - - const store = loadStore(); - - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("saveStore writes valid JSON that loadStore can read back", async () => { - const { loadStore, saveStore } = await importStoreFresh(); - - const store = loadStore(); - - store.entries["hello"] = { - key: "hello", - title: "Hello", - body: "World", - tags: ["test"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - createdBy: "system", - updatedBy: "system", - usageCount: 0, - }; - - saveStore(store); - - const reread = loadStore(); - expect(reread.entries["hello"]?.title).toBe("Hello"); - expect(reread.entries["hello"]?.tags).toEqual(["test"]); - }); -}); diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled b/data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled deleted file mode 100644 index d2be28ed..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/store.test.ts.disabled +++ /dev/null @@ -1,115 +0,0 @@ -// src/services/faq/store.test.ts -// -// Tests for the FAQ persistence layer (store.ts). -// -// Goals: -// - Never touch your real repo ./data folder -// - Exercise real filesystem behavior in an isolated temp directory -// - Verify safe fallbacks when the store file is missing or malformed - -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; -import fs from "fs"; -import path from "path"; -import os from "os"; - -type StoreModule = typeof import("./store.js"); - -let originalCwd = ""; -let tempRoot = ""; -let storePath = ""; - -/** - * Import store.ts AFTER we switch cwd. - * store.ts computes STORE_PATH at import time using process.cwd(). - */ -async function importStoreFresh(): Promise { - vi.resetModules(); - return await import("./store.js"); -} - -function rmIfExists(p: string): void { - if (!fs.existsSync(p)) return; - fs.rmSync(p, { recursive: true, force: true }); -} - -beforeAll(() => { - originalCwd = process.cwd(); - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omegabot-faq-store-")); - process.chdir(tempRoot); - - storePath = path.join(process.cwd(), "data", "faqs.json"); -}); - -afterAll(() => { - process.chdir(originalCwd); - rmIfExists(tempRoot); -}); - -beforeEach(() => { - // Each test starts with a clean tempRoot/data folder. - rmIfExists(path.join(process.cwd(), "data")); -}); - -describe("faq store", () => { - it("creates the store file if missing", async () => { - const { loadStore } = await importStoreFresh(); - - expect(fs.existsSync(storePath)).toBe(false); - - const store = loadStore(); - - expect(fs.existsSync(storePath)).toBe(true); - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("falls back to empty store when JSON is malformed", async () => { - const { loadStore, ensureStoreFile } = await importStoreFresh(); - - ensureStoreFile(); - fs.writeFileSync(storePath, "not json", "utf8"); - - const store = loadStore(); - - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("falls back to empty store when shape is invalid", async () => { - const { loadStore, ensureStoreFile } = await importStoreFresh(); - - ensureStoreFile(); - - // Wrong version + missing entries - fs.writeFileSync(storePath, JSON.stringify({ version: 999 }, null, 2), "utf8"); - - const store = loadStore(); - - expect(store.version).toBe(1); - expect(store.entries).toEqual({}); - }); - - it("saveStore writes valid JSON that loadStore can read back", async () => { - const { loadStore, saveStore } = await importStoreFresh(); - - const store = loadStore(); - - store.entries["hello"] = { - key: "hello", - title: "Hello", - body: "World", - tags: ["test"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - createdBy: "system", - updatedBy: "system", - usageCount: 0, - }; - - saveStore(store); - - const reread = loadStore(); - expect(reread.entries["hello"]?.title).toBe("Hello"); - expect(reread.entries["hello"]?.tags).toEqual(["test"]); - }); -}); diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.ts b/data/backup-20260117-145403/src-backup/services/faq/store.ts deleted file mode 100644 index e7cd9871..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/store.ts +++ /dev/null @@ -1,62 +0,0 @@ -// src/services/faq/store.ts -import { getDb } from "../database/db.js"; -import type { FaqEntry, FaqStoreV1 } from "./types.js"; - -export function loadStore(): FaqStoreV1 { - const db = getDb(); - const stmt = db.prepare("SELECT * FROM faqs"); - const rows = stmt.all() as any[]; - - const entries: Record = {}; - for (const row of rows) { - entries[row.key] = { - key: row.key, - title: row.title || "", - body: row.body || row.answer, - tags: row.tags ? JSON.parse(row.tags) : [], - createdAt: new Date(row.created_at).toISOString(), - updatedAt: new Date(row.updated_at).toISOString(), - usageCount: row.usage_count || 0, - createdBy: row.created_by || "unknown", - updatedBy: row.updated_by || "unknown", - }; - } - - return { - version: 1, - entries, - }; -} - -export function saveStore(store: FaqStoreV1): void { - const db = getDb(); - - db.transaction(() => { - db.prepare("DELETE FROM faqs").run(); - - const stmt = db.prepare(` - INSERT INTO faqs (key, title, body, tags, answer, created_at, updated_at, usage_count, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - for (const faq of Object.values(store.entries)) { - const answer = faq.title ? `**${faq.title}**\n\n${faq.body}` : faq.body; - stmt.run( - faq.key, - faq.title, - faq.body, - JSON.stringify(faq.tags || []), - answer, - new Date(faq.createdAt).getTime(), - new Date(faq.updatedAt).getTime(), - faq.usageCount || 0, - faq.createdBy || "unknown", - faq.updatedBy || "unknown", - ); - } - })(); -} - -export function ensureStoreFile(): void { - // No-op for SQLite -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/store.ts.backup b/data/backup-20260117-145403/src-backup/services/faq/store.ts.backup deleted file mode 100644 index 7dadacac..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/store.ts.backup +++ /dev/null @@ -1,57 +0,0 @@ -// src/services/faq/store.ts -import { getDb } from "../database/db.js"; -import type { FaqEntry } from "./types.js"; - -export function loadStore(): Record { - const db = getDb(); - const stmt = db.prepare("SELECT * FROM faqs"); - const rows = stmt.all() as any[]; - - const entries: Record = {}; - for (const row of rows) { - entries[row.key] = { - key: row.key, - title: row.title || "", - body: row.answer, - tags: row.tags ? JSON.parse(row.tags) : [], - createdAt: new Date(row.created_at).toISOString(), - updatedAt: new Date(row.updated_at).toISOString(), - usageCount: row.usage_count || 0, - createdBy: row.created_by || "unknown", - updatedBy: row.updated_by || "unknown", - }; - } - - return entries; -} - -export function saveStore(entries: Record): void { - const db = getDb(); - - db.transaction(() => { - db.prepare("DELETE FROM faqs").run(); - - const stmt = db.prepare(` - INSERT INTO faqs (key, answer, title, tags, created_at, updated_at, usage_count, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - for (const faq of Object.values(entries)) { - stmt.run( - faq.key, - faq.body, - faq.title, - JSON.stringify(faq.tags || []), - new Date(faq.createdAt).getTime(), - new Date(faq.updatedAt).getTime(), - faq.usageCount || 0, - faq.createdBy || "unknown", - faq.updatedBy || "unknown" - ); - } - })(); -} - -export function ensureStoreFile(): void { - // No-op for SQLite -} diff --git a/data/backup-20260117-145403/src-backup/services/faq/types.ts b/data/backup-20260117-145403/src-backup/services/faq/types.ts deleted file mode 100644 index e9f68467..00000000 --- a/data/backup-20260117-145403/src-backup/services/faq/types.ts +++ /dev/null @@ -1,110 +0,0 @@ -// src/services/faq/types.ts - -/* -------------------------------------------------------------------------- */ -/* Constants */ -/* -------------------------------------------------------------------------- */ - -/** - * Maximum length of an FAQ key after normalization. - * - * Keys are used as: - * - slash command arguments - * - map keys in the JSON store - * - human-readable identifiers - * - * Keeping this short prevents abuse and awkward UX. - */ -export const MAX_KEY_LEN = 48; - -/** - * Max title length after trimming. - * Keep this short so list views stay readable. - */ -export const MAX_TITLE_LEN = 80; - -/** - * Max body length after trimming. - * 4000 is a reasonable starting point and stays under typical Discord limits - * once you add formatting and headers. - */ -export const MAX_BODY_LEN = 4000; - -/** - * Max number of tags allowed on an entry. - * Prevents spam and keeps list filters usable. - */ -export const MAX_TAGS = 10; - -/** - * Max length per tag after trimming. - * Keeps tags compact for list output. - */ -export const MAX_TAG_LEN = 24; - -/* -------------------------------------------------------------------------- */ -/* Core Types */ -/* -------------------------------------------------------------------------- */ - -/** - * A single FAQ entry as stored and returned by the service layer. - * - * Notes: - * - `key` is normalized (lowercase, kebab-case). - * - timestamps are ISO strings for easy JSON storage. - * - usageCount is incremented when an FAQ is retrieved. - */ -export type FaqEntry = { - key: string; - title: string; - body: string; - tags: string[]; - - createdAt: string; - updatedAt: string; - - createdBy: string; // user id or "system" - updatedBy: string; // user id or "system" - - usageCount: number; -}; - -/** - * On-disk FAQ store format (v1). - * - * Versioning lets us migrate later without breaking users. - */ -export type FaqStoreV1 = { - version: 1; - entries: Record; -}; - -/* -------------------------------------------------------------------------- */ -/* Input Types */ -/* -------------------------------------------------------------------------- */ - -/** - * Input required to create a new FAQ. - * - * Validation rules: - * - key is normalized and validated by the service layer - * - title/body are trimmed - * - tags are optional and normalized if provided - */ -export type CreateFaqInput = { - key: string; - title: string; - body: string; - tags?: string[]; - actor: string; // user id or "system" -}; - -/** - * Allowed fields when updating an FAQ entry. - * - * Notes: - * - actor is required so updatedBy is always attributable. - * - patch fields are optional and validated by the service layer if present. - */ -export type UpdateFaqPatch = Partial> & { - actor: string; -}; diff --git a/data/backup-20260117-145403/src-backup/services/fun/coinStore.ts b/data/backup-20260117-145403/src-backup/services/fun/coinStore.ts deleted file mode 100644 index 96026ba1..00000000 --- a/data/backup-20260117-145403/src-backup/services/fun/coinStore.ts +++ /dev/null @@ -1,146 +0,0 @@ -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, "coin-store.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 }; -} diff --git a/data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts b/data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts deleted file mode 100644 index 8d0e14bb..00000000 --- a/data/backup-20260117-145403/src-backup/services/fun/funUsageStore.ts +++ /dev/null @@ -1,200 +0,0 @@ -// 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/data/backup-20260117-145403/src-backup/services/fun/pollStore.ts b/data/backup-20260117-145403/src-backup/services/fun/pollStore.ts deleted file mode 100644 index 1e97f6cb..00000000 --- a/data/backup-20260117-145403/src-backup/services/fun/pollStore.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 }; -} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubApi.ts b/data/backup-20260117-145403/src-backup/services/github/githubApi.ts deleted file mode 100644 index d38a50d0..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/githubApi.ts +++ /dev/null @@ -1,248 +0,0 @@ -// src/services/github/githubApi.ts - -import { githubRequest, GitHubApiError } from "./githubClient.js"; -import { logger } from "../../utils/logger.js"; -import type { - GitHubIssue, - GitHubPullRequest, - GitHubIssueSummary, - GitHubPrSummary, -} from "./types.js"; - -/* ------------------------------------------------------------------ */ -/* Path helpers */ -/* ------------------------------------------------------------------ */ - -/** - * Build the base repo API path. - */ -function repoBase(owner: string, repo: string): string { - return `/repos/${owner}/${repo}`; -} - -function issuePath(owner: string, repo: string, number: number): string { - return `${repoBase(owner, repo)}/issues/${number}`; -} - -function prPath(owner: string, repo: string, number: number): string { - return `${repoBase(owner, repo)}/pulls/${number}`; -} - -export type ListIssuesOptions = { - state?: "open" | "closed" | "all"; - limit?: number; - labels?: string[]; -}; - -function listIssuesPath( - owner: string, - repo: string, - options?: ListIssuesOptions, -): string { - const params = new URLSearchParams(); - - // GitHub default is "open", but keep explicit if the caller sets it. - if (options?.state) params.set("state", options.state); - - // GitHub uses per_page for page size. - if (options?.limit) params.set("per_page", String(options.limit)); - - // Comma-separated labels. - if (options?.labels?.length) params.set("labels", options.labels.join(",")); - - // Default sorting: most recently updated first. - params.set("sort", "updated"); - params.set("direction", "desc"); - - const query = params.toString(); - return `${repoBase(owner, repo)}/issues${query ? `?${query}` : ""}`; -} - -export type ListPrOptions = { - state?: "open" | "closed" | "all"; - limit?: number; -}; - -function listPullRequestsPath( - owner: string, - repo: string, - options?: ListPrOptions, -): string { - const params = new URLSearchParams(); - - params.set("state", options?.state ?? "open"); - params.set("per_page", String(options?.limit ?? 20)); - - // For pulls: GitHub supports sort=updated - params.set("sort", "updated"); - params.set("direction", "desc"); - - return `${repoBase(owner, repo)}/pulls?${params.toString()}`; -} - -/* ------------------------------------------------------------------ */ -/* Error helpers */ -/* ------------------------------------------------------------------ */ - -/** - * Narrow unknown errors into a GitHubApiError with status info. - * This lets callers do 404 fallback safely. - */ -function isGitHubApiError(err: unknown): err is GitHubApiError { - return err instanceof GitHubApiError; -} - -function is404(err: unknown): err is GitHubApiError { - return isGitHubApiError(err) && err.status === 404; -} - -/* ------------------------------------------------------------------ */ -/* Single item fetchers */ -/* ------------------------------------------------------------------ */ - -/** - * Fetch a single issue by number. - */ -export async function getIssue( - owner: string, - repo: string, - number: number, -): Promise { - try { - return await githubRequest(issuePath(owner, repo, number)); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, number, status: err.status, url: err.url }, - "getIssue failed", - ); - } else { - logger.error({ err, owner, repo, number }, "getIssue threw"); - } - throw err; - } -} - -/** - * Fetch a single pull request by number. - */ -export async function getPullRequest( - owner: string, - repo: string, - number: number, -): Promise { - try { - return await githubRequest(prPath(owner, repo, number)); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, number, status: err.status, url: err.url }, - "getPullRequest failed", - ); - } else { - logger.error({ err, owner, repo, number }, "getPullRequest threw"); - } - throw err; - } -} - -/** - * Try PR first, fallback to issue if PR endpoint returns 404. - */ -export async function getIssueOrPr( - owner: string, - repo: string, - number: number, -): Promise { - try { - return await getPullRequest(owner, repo, number); - } catch (err) { - if (is404(err)) { - return await getIssue(owner, repo, number); - } - throw err; - } -} - -/* ------------------------------------------------------------------ */ -/* List helpers (command-facing) */ -/* ------------------------------------------------------------------ */ - -/** - * List issues (issues-only; PRs filtered out) - * - * Note: - * GitHub's /issues endpoint can include PRs. - * PRs contain `pull_request` marker. We filter those out here. - */ -export async function listIssues( - owner: string, - repo: string, - options?: ListIssuesOptions, -): Promise { - try { - const data = await githubRequest(listIssuesPath(owner, repo, options)); - - return data - .filter((i) => !i.pull_request) - .map((i) => ({ - number: i.number, - title: i.title, - html_url: i.html_url, - state: i.state, - user: { login: i.user.login }, - comments: i.comments, - })); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, status: err.status, url: err.url, options }, - "listIssues failed", - ); - } else { - logger.error({ err, owner, repo, options }, "listIssues threw"); - } - throw err; - } -} - -/** - * List pull requests (summary view) - * - * This is what the poller should consume because it includes: - * - number, title, url, author - * - updated_at for last-seen comparisons - */ -export async function listPullRequests( - owner: string, - repo: string, - options?: ListPrOptions, -): Promise { - try { - const data = await githubRequest( - listPullRequestsPath(owner, repo, options), - ); - - return data.map((pr) => ({ - number: pr.number, - title: pr.title, - html_url: pr.html_url, - state: pr.state, - merged_at: pr.merged_at, - updated_at: pr.updated_at, - user: { login: pr.user.login }, - comments: pr.comments, - review_comments: pr.review_comments, - })); - } catch (err) { - if (isGitHubApiError(err)) { - logger.warn( - { owner, repo, status: err.status, url: err.url, options }, - "listPullRequests failed", - ); - } else { - logger.error({ err, owner, repo, options }, "listPullRequests threw"); - } - throw err; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubCache.ts b/data/backup-20260117-145403/src-backup/services/github/githubCache.ts deleted file mode 100644 index 31bbffa9..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/githubCache.ts +++ /dev/null @@ -1,37 +0,0 @@ -// src/services/github/githubCache.ts -import { SimpleCache } from "../cache/simpleCache.js"; -import { logger } from "../../utils/logger.js"; - -const cache = new SimpleCache(); -const CACHE_TTL = 300; // 5 minutes - -export async function cachedGitHubRequest( - key: string, - fetcher: () => Promise, -): Promise { - const cached = cache.get(key); - if (cached !== null) { - logger.debug({ key }, "GitHub cache HIT"); - return cached as T; - } - - logger.debug({ key }, "GitHub cache MISS"); - - try { - const data = await fetcher(); - cache.set(key, data, CACHE_TTL); - return data; - } catch (error) { - logger.error({ error, key }, "GitHub API request failed"); - throw error; - } -} - -export function clearGitHubCache(): void { - cache.clear(); - logger.info("GitHub cache cleared"); -} - -export function getGitHubCacheStats() { - return cache.getStats(); -} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubClient.ts b/data/backup-20260117-145403/src-backup/services/github/githubClient.ts deleted file mode 100644 index 4d62e23d..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/githubClient.ts +++ /dev/null @@ -1,85 +0,0 @@ -// src/services/github/githubClient.ts - -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -/** - * Base URL for GitHub REST API v3 - */ -const GITHUB_API_BASE = "https://api.github.com"; - -/** - * Error shape thrown when GitHub responds with a non-2xx status. - * Callers can inspect `status` to decide what to do (ex: 404 fallback). - */ -export class GitHubApiError extends Error { - status: number; - url: string; - - constructor(message: string, status: number, url: string) { - super(message); - this.name = "GitHubApiError"; - this.status = status; - this.url = url; - } -} - -/** - * Low-level GitHub request helper. - * - * Responsibilities: - * - Validate auth is configured (only when GitHub features are invoked) - * - Add auth headers - * - Perform fetch - * - Handle non-OK responses - * - Return typed JSON - * - * Higher-level logic (issues, PRs, fallback behavior) lives in githubApi.ts. - * - * Note: - * This function SHOULD NOT be called at startup unless GitHub features are enabled. - * The bot must be able to run without GitHub tokens/config. - */ -export async function githubRequest(path: string): Promise { - const url = `${GITHUB_API_BASE}${path}`; - - // Enforce token only when a GitHub code path is actually executed. - const token = env.requireGithubToken(); - - try { - const res = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "OmegaBot", - }, - }); - - if (!res.ok) { - let message = res.statusText; - - // GitHub usually returns JSON with { message: "...", ... } - try { - const body = (await res.json()) as { message?: string }; - if (body?.message) message = body.message; - } catch { - // Ignore parse failures, keep statusText - } - - logger.warn( - { status: res.status, url, path, message }, - "GitHub API request failed", - ); - - throw new GitHubApiError(message, res.status, url); - } - - return (await res.json()) as T; - } catch (err) { - if (err instanceof GitHubApiError) throw err; - - logger.error({ err, url, path }, "GitHub API request threw"); - throw err; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts b/data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts deleted file mode 100644 index 0d27afe2..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/githubErrorMessage.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { GitHubApiError } from "./githubClient.js"; - -export function getGitHubUserMessage(err: unknown): string | null { - if (!(err instanceof GitHubApiError)) return null; - - switch (err.status) { - case 404: - return "I could not find that PR. Double check the number and repo."; - case 401: - case 403: - return "I cannot access that repo with the current GitHub token. Check token and repo permissions."; - case 429: - return "GitHub rate limited me. Try again in a bit."; - default: - return null; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts deleted file mode 100644 index d3261878..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts +++ /dev/null @@ -1,334 +0,0 @@ -// src/services/github/issueAssigneePoller.ts - -import type { Client } from "discord.js"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -type PollArgs = { - client: Client; - owner: string; - repo: string; - announceChannelId: string; -}; - -type GitHubAssignee = { login: string }; - -type GitHubIssueItem = { - number: number; - title: string; - html_url: string; - assignees?: GitHubAssignee[]; - // Present on PRs returned in the issues list - pull_request?: unknown; -}; - -type TrackedItem = { - kind: "PR" | "Issue"; - title: string; - url: string; - assignees: string[]; -}; - -type StateFile = { - /** - * Back-compat: - * Older state files might only have assigneesByNumber. - * We’ll load them and upgrade in-memory automatically. - */ - assigneesByNumber?: Record; - itemsByNumber?: Record; - initializedAt: string; -}; - -const DATA_DIR = path.join(process.cwd(), "data"); -const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); - -function uniqSorted(list: string[]): string[] { - return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => - a.localeCompare(b), - ); -} - -function diff(prev: string[], next: string[]) { - const prevSet = new Set(prev); - const nextSet = new Set(next); - - const added = next.filter((x) => !prevSet.has(x)); - const removed = prev.filter((x) => !nextSet.has(x)); - - return { added, removed, changed: added.length > 0 || removed.length > 0 }; -} - -async function ensureDataDir(): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }); -} - -async function loadState(): Promise { - try { - const raw = await fs.readFile(STATE_PATH, "utf8"); - const parsed = JSON.parse(raw) as StateFile; - if (!parsed || typeof parsed !== "object") return null; - - // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) - const hasOld = - !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; - const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; - - if (!hasOld && !hasNew) return null; - if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; - - return parsed; - } catch { - return null; - } -} - -async function saveState(state: StateFile): Promise { - await ensureDataDir(); - await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); -} - -async function getAnnounceChannel( - client: Client, - channelId: string, -): Promise<{ send: (content: string) => Promise }> { - const ch = await client.channels.fetch(channelId); - - if (!ch || !ch.isTextBased()) { - throw new Error(`Announce channel is not a text channel: ${channelId}`); - } - - // TypeScript guard: not all TextBasedChannel unions guarantee send() - if (!("send" in ch) || typeof ch.send !== "function") { - throw new Error(`Announce channel does not support send(): ${channelId}`); - } - - return ch; -} - -async function githubFetchJson(url: string, token: string): Promise { - const res = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "OmegaBot", - Authorization: `token ${token}`, - }, - }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error( - `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, - ); - } - - return (await res.json()) as T; -} - -function stateToItems(existing: StateFile): Record { - // New format available - if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { - return existing.itemsByNumber; - } - - // Upgrade old format in-memory (no title/url/kind available) - const old = existing.assigneesByNumber ?? {}; - const upgraded: Record = {}; - for (const [num, assignees] of Object.entries(old)) { - upgraded[num] = { - kind: "Issue", - title: "(unknown title)", - url: "(unknown url)", - assignees: uniqSorted(assignees ?? []), - }; - } - return upgraded; -} - -/** - * Poll open issues (includes PRs) and notify on: - * - assignee changes (add/remove/replace/multi/unassign) - * - issues/PRs that are no longer open (closed/merged/etc) - * - * Notes: - * - Baseline-first: first successful run saves state and does NOT notify. - * - State is persisted to ./data/github-assignees.json - */ -export async function pollIssueAssigneesOnce(args: PollArgs): Promise { - const { client, owner, repo, announceChannelId } = args; - - let token: string; - try { - token = env.requireGithubToken(); - } catch (err) { - logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); - return; - } - - let channel: { send: (content: string) => Promise }; - try { - channel = await getAnnounceChannel(client, announceChannelId); - } catch (err) { - logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); - return; - } - - // Load state (or init) - const existing = (await loadState()) ?? { - assigneesByNumber: {}, - initializedAt: new Date().toISOString(), - }; - - const existingItemsByNumber = stateToItems(existing); - const hadExistingState = Object.keys(existingItemsByNumber).length > 0; - - // Fetch open issues (includes PRs) - const url = `https://api.github.com/repos/${encodeURIComponent( - owner, - )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; - - let items: GitHubIssueItem[]; - try { - items = await githubFetchJson(url, token); - } catch (err) { - logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); - return; - } - - const nextItemsByNumber: Record = {}; - const notifications: Array<{ - kind: "PR" | "Issue"; - number: number; - title: string; - url: string; - added: string[]; - removed: string[]; - }> = []; - - for (const item of items) { - const numberKey = String(item.number); - - const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; - const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); - - nextItemsByNumber[numberKey] = { - kind, - title: item.title, - url: item.html_url, - assignees: nextAssignees, - }; - - const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); - const { added, removed, changed } = diff(prevAssignees, nextAssignees); - - if (hadExistingState && changed) { - notifications.push({ - kind, - number: item.number, - title: item.title, - url: item.html_url, - added, - removed, - }); - } - } - - // Detect items that were previously open but are no longer open - const closedNotifications: Array<{ - kind: "PR" | "Issue"; - number: number; - title: string; - url: string; - }> = []; - - if (hadExistingState) { - const prevNumbers = new Set(Object.keys(existingItemsByNumber)); - const nextNumbers = new Set(Object.keys(nextItemsByNumber)); - - for (const num of prevNumbers) { - if (!nextNumbers.has(num)) { - const prev = existingItemsByNumber[num]; - closedNotifications.push({ - kind: prev?.kind ?? "Issue", - number: Number(num), - title: prev?.title ?? "(unknown title)", - url: prev?.url ?? "(unknown url)", - }); - } - } - } - - // Save next state (drops closed issues automatically) - const nextState: StateFile = { - itemsByNumber: nextItemsByNumber, - initializedAt: existing.initializedAt ?? new Date().toISOString(), - }; - - try { - await saveState(nextState); - } catch (err) { - logger.error({ err }, "[github/assignees] failed to save state"); - } - - // Baseline-first: no notifications on first successful run - if (!hadExistingState) { - logger.info( - { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, - "[github/assignees] baseline saved (no notifications on first run)", - ); - return; - } - - // Announce assignee changes - const issueNotifications = notifications.filter((n) => n.kind === "Issue"); - - for (const n of issueNotifications) { - const parts: string[] = []; - parts.push(`Issue # assignees updated`); - parts.push(n.title); - parts.push(n.url); - - if (n.added.length) { - parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); - } - if (n.removed.length) { - parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); - } - - try { - await channel.send(parts.join("\n")); - logger.info( - { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, - "[github/assignees] announced", - ); - } catch (err) { - logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); - } - } - - // Announce closures (issues + PRs) - const closedIssues = closedNotifications.filter((c) => c.kind === "Issue"); - - for (const c of closedIssues) { - const parts: string[] = []; - parts.push(`Issue # closed`); - parts.push(c.title); - parts.push(c.url); - parts.push("(Closed/merged/etc — detected via polling)"); - - try { - await channel.send(parts.join("\n")); - logger.info( - { number: c.number, kind: c.kind }, - "[github/assignees] closure announced", - ); - } catch (err) { - logger.error( - { err, number: c.number }, - "[github/assignees] failed to announce closure", - ); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup b/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup deleted file mode 100644 index 2ce1153f..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/issueAssigneePoller.ts.backup +++ /dev/null @@ -1,334 +0,0 @@ -// src/services/github/issueAssigneePoller.ts - -import type { Client } from "discord.js"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -type PollArgs = { - client: Client; - owner: string; - repo: string; - announceChannelId: string; -}; - -type GitHubAssignee = { login: string }; - -type GitHubIssueItem = { - number: number; - title: string; - html_url: string; - assignees?: GitHubAssignee[]; - // Present on PRs returned in the issues list - pull_request?: unknown; -}; - -type TrackedItem = { - kind: "PR" | "Issue"; - title: string; - url: string; - assignees: string[]; -}; - -type StateFile = { - /** - * Back-compat: - * Older state files might only have assigneesByNumber. - * We’ll load them and upgrade in-memory automatically. - */ - assigneesByNumber?: Record; - itemsByNumber?: Record; - initializedAt: string; -}; - -const DATA_DIR = path.join(process.cwd(), "data"); -const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); - -function uniqSorted(list: string[]): string[] { - return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => - a.localeCompare(b), - ); -} - -function diff(prev: string[], next: string[]) { - const prevSet = new Set(prev); - const nextSet = new Set(next); - - const added = next.filter((x) => !prevSet.has(x)); - const removed = prev.filter((x) => !nextSet.has(x)); - - return { added, removed, changed: added.length > 0 || removed.length > 0 }; -} - -async function ensureDataDir(): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }); -} - -async function loadState(): Promise { - try { - const raw = await fs.readFile(STATE_PATH, "utf8"); - const parsed = JSON.parse(raw) as StateFile; - if (!parsed || typeof parsed !== "object") return null; - - // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) - const hasOld = - !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; - const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; - - if (!hasOld && !hasNew) return null; - if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; - - return parsed; - } catch { - return null; - } -} - -async function saveState(state: StateFile): Promise { - await ensureDataDir(); - await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); -} - -async function getAnnounceChannel( - client: Client, - channelId: string, -): Promise<{ send: (content: string) => Promise }> { - const ch = await client.channels.fetch(channelId); - - if (!ch || !ch.isTextBased()) { - throw new Error(`Announce channel is not a text channel: ${channelId}`); - } - - // TypeScript guard: not all TextBasedChannel unions guarantee send() - if (!("send" in ch) || typeof ch.send !== "function") { - throw new Error(`Announce channel does not support send(): ${channelId}`); - } - - return ch; -} - -async function githubFetchJson(url: string, token: string): Promise { - const res = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "OmegaBot", - Authorization: `token ${token}`, - }, - }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error( - `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, - ); - } - - return (await res.json()) as T; -} - -function stateToItems(existing: StateFile): Record { - // New format available - if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { - return existing.itemsByNumber; - } - - // Upgrade old format in-memory (no title/url/kind available) - const old = existing.assigneesByNumber ?? {}; - const upgraded: Record = {}; - for (const [num, assignees] of Object.entries(old)) { - upgraded[num] = { - kind: "Issue", - title: "(unknown title)", - url: "(unknown url)", - assignees: uniqSorted(assignees ?? []), - }; - } - return upgraded; -} - -/** - * Poll open issues (includes PRs) and notify on: - * - assignee changes (add/remove/replace/multi/unassign) - * - issues/PRs that are no longer open (closed/merged/etc) - * - * Notes: - * - Baseline-first: first successful run saves state and does NOT notify. - * - State is persisted to ./data/github-assignees.json - */ -export async function pollIssueAssigneesOnce(args: PollArgs): Promise { - const { client, owner, repo, announceChannelId } = args; - - let token: string; - try { - token = env.requireGithubToken(); - } catch (err) { - logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); - return; - } - - let channel: { send: (content: string) => Promise }; - try { - channel = await getAnnounceChannel(client, announceChannelId); - } catch (err) { - logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); - return; - } - - // Load state (or init) - const existing = (await loadState()) ?? { - assigneesByNumber: {}, - initializedAt: new Date().toISOString(), - }; - - const existingItemsByNumber = stateToItems(existing); - const hadExistingState = Object.keys(existingItemsByNumber).length > 0; - - // Fetch open issues (includes PRs) - const url = `https://api.github.com/repos/${encodeURIComponent( - owner, - )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; - - let items: GitHubIssueItem[]; - try { - items = await githubFetchJson(url, token); - } catch (err) { - logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); - return; - } - - const nextItemsByNumber: Record = {}; - const notifications: Array<{ - kind: "PR" | "Issue"; - number: number; - title: string; - url: string; - added: string[]; - removed: string[]; - }> = []; - - for (const item of items) { - const numberKey = String(item.number); - - const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; - const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); - - nextItemsByNumber[numberKey] = { - kind, - title: item.title, - url: item.html_url, - assignees: nextAssignees, - }; - - const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); - const { added, removed, changed } = diff(prevAssignees, nextAssignees); - - if (hadExistingState && changed) { - notifications.push({ - kind, - number: item.number, - title: item.title, - url: item.html_url, - added, - removed, - }); - } - } - - // Detect items that were previously open but are no longer open - const closedNotifications: Array<{ - kind: "PR" | "Issue"; - number: number; - title: string; - url: string; - }> = []; - - if (hadExistingState) { - const prevNumbers = new Set(Object.keys(existingItemsByNumber)); - const nextNumbers = new Set(Object.keys(nextItemsByNumber)); - - for (const num of prevNumbers) { - if (!nextNumbers.has(num)) { - const prev = existingItemsByNumber[num]; - closedNotifications.push({ - kind: prev?.kind ?? "Issue", - number: Number(num), - title: prev?.title ?? "(unknown title)", - url: prev?.url ?? "(unknown url)", - }); - } - } - } - - // Save next state (drops closed issues automatically) - const nextState: StateFile = { - itemsByNumber: nextItemsByNumber, - initializedAt: existing.initializedAt ?? new Date().toISOString(), - }; - - try { - await saveState(nextState); - } catch (err) { - logger.error({ err }, "[github/assignees] failed to save state"); - } - - // Baseline-first: no notifications on first successful run - if (!hadExistingState) { - logger.info( - { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, - "[github/assignees] baseline saved (no notifications on first run)", - ); - return; - } - - // Announce assignee changes - const issueNotifications = notifications.filter(n => n.kind === "Issue"); - - for (const n of issueNotifications) { - const parts: string[] = []; - parts.push(`Issue # assignees updated`); - parts.push(n.title); - parts.push(n.url); - - if (n.added.length) { - parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); - } - if (n.removed.length) { - parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); - } - - try { - await channel.send(parts.join("\n")); - logger.info( - { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, - "[github/assignees] announced", - ); - } catch (err) { - logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); - } - } - - // Announce closures (issues + PRs) - const closedIssues = closedNotifications.filter(c => c.kind === "Issue"); - - for (const c of closedIssues) { - const parts: string[] = []; - parts.push(`Issue # closed`); - parts.push(c.title); - parts.push(c.url); - parts.push("(Closed/merged/etc — detected via polling)"); - - try { - await channel.send(parts.join("\n")); - logger.info( - { number: c.number, kind: c.kind }, - "[github/assignees] closure announced", - ); - } catch (err) { - logger.error( - { err, number: c.number }, - "[github/assignees] failed to announce closure", - ); - } - } -} diff --git a/data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts b/data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts deleted file mode 100644 index c6e99cdf..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/lastSeenStore.ts +++ /dev/null @@ -1,136 +0,0 @@ -// src/services/github/lastSeenStore.ts - -import fs from "fs"; -import path from "path"; - -/** - * Absolute path to the persistent store for GitHub announcement state. - * - * This file is intentionally: - * - JSON (human-readable, debuggable) - * - Stored outside src/ so it survives rebuilds - * - * Example contents: - * { - * "owner/repo": 1713291823000 - * } - */ -const STORE_PATH = path.join(process.cwd(), "data", "last-seen.json"); - -/** - * Internal storage shape. - * - * Key → "owner/repo" - * Value → Unix timestamp in milliseconds (Number) - * - * We store numbers so comparisons are cheap and timezone-agnostic. - */ -type Store = Record; - -/** - * Get the last stored timestamp for a repo. - * - * Returns: - * - unix millis number if present - * - null if the repo has never been seen - */ -export function getLastSeen(owner: string, repo: string): number | null { - const store = loadStore(); - const key = makeRepoKey(owner, repo); - return key in store ? store[key] : null; -} - -/** - * Save the "last seen" timestamp for PR announcements. - * - * We store unix millis (Number) to make comparisons cheap and timezone-agnostic. - * - * @param updatedAtIso ISO timestamp string from GitHub API (ex: pr.updated_at) - */ -export function setLastSeenPr(owner: string, repo: string, updatedAtIso: string): void { - const store = loadStore(); - const key = makeRepoKey(owner, repo); - - const ms = Date.parse(updatedAtIso); - if (Number.isNaN(ms)) { - throw new Error(`Invalid updatedAt timestamp: ${updatedAtIso}`); - } - - store[key] = ms; - saveStore(store); -} - -/** - * Remove any stored "last seen" value for a repo. - * - * Safe to call even if the repo was never stored. - * Useful for development resets and testing. - */ -export function clearLastSeen(owner: string, repo: string): void { - const store = loadStore(); - const key = makeRepoKey(owner, repo); - - if (key in store) { - delete store[key]; - saveStore(store); - } -} - -/** - * Build a stable storage key for a repo. - * - * We intentionally normalize on "owner/repo" because: - * - It is human-readable - * - Matches GitHub's canonical naming - * - Avoids nested JSON structures - */ -function makeRepoKey(owner: string, repo: string): string { - return `${owner}/${repo}`; -} - -/** - * Load the last-seen store from disk. - * - * Behavior: - * - If the file does not exist → return empty store - * - If the file exists → parse JSON - * - * This function is synchronous by design: - * - It runs rarely (poll job / command invocation) - * - Simpler failure modes - * - Fewer edge cases in a single-process bot - */ -function loadStore(): Store { - if (!fs.existsSync(STORE_PATH)) { - return {}; - } - - const raw = fs.readFileSync(STORE_PATH, "utf8"); - - try { - return JSON.parse(raw) as Store; - } catch { - // If JSON is corrupted, fail safe to empty. - // Worst case: PRs may be re-announced once. - return {}; - } -} - -/** - * Persist the last-seen store to disk. - * - * Guarantees: - * - Parent directory exists - * - JSON is pretty-printed for easy debugging - * - * We overwrite the file entirely to keep writes simple. - */ -function saveStore(store: Store): void { - const dir = path.dirname(STORE_PATH); - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); -} diff --git a/data/backup-20260117-145403/src-backup/services/github/prFormatter.ts b/data/backup-20260117-145403/src-backup/services/github/prFormatter.ts deleted file mode 100644 index a50fc05d..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/prFormatter.ts +++ /dev/null @@ -1,53 +0,0 @@ -// src/services/github/prFormatter.ts - -import type { GitHubPullRequest } from "./types.js"; - -/* ------------------------------------------------------------------ */ -/* Formatting helpers */ -/* ------------------------------------------------------------------ */ - -/** - * Format a single GitHub pull request into a Discord-friendly message. - * - * Design goals: - * - Pure function (no I/O, no Discord client usage) - * - Safe to reuse in commands and background pollers - * - Stable output for future diffing / testing - * - * IMPORTANT: - * This file must NOT import itself or re-export via a barrel, - * otherwise TypeScript will create circular alias errors. - */ - -/** - * Format a pull request announcement message. - */ -export function formatPullRequest(pr: GitHubPullRequest): string { - const author = pr.user?.login ? ` by @${pr.user.login}` : ""; - const state = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open"; - - const updatedAt = pr.updated_at ? ` (updated ${formatTimestamp(pr.updated_at)})` : ""; - - return [ - `**PR #${pr.number}** ${pr.title}`, - `${state}${author}${updatedAt}`, - pr.html_url, - ].join("\n"); -} - -/* ------------------------------------------------------------------ */ -/* Internal utilities */ -/* ------------------------------------------------------------------ */ - -/** - * Format an ISO timestamp into a short, readable form. - * - * We intentionally avoid locale-specific formatting so output - * is consistent across environments. - */ -function formatTimestamp(iso: string): string { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return iso; - - return date.toISOString().replace("T", " ").replace("Z", " UTC"); -} diff --git a/data/backup-20260117-145403/src-backup/services/github/prPoller.ts b/data/backup-20260117-145403/src-backup/services/github/prPoller.ts deleted file mode 100644 index 9c662b13..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/prPoller.ts +++ /dev/null @@ -1,95 +0,0 @@ -// src/services/github/prPoller.ts - -import type { Client } from "discord.js"; -import { listPullRequests } from "./githubApi.js"; -import { getLastSeen, setLastSeenPr } from "./lastSeenStore.js"; -import { formatPullRequest } from "./prFormatter.js"; -import { logger } from "../../utils/logger.js"; - -/** - * Minimal shape we need from the GitHub PR list for polling announcements. - * githubApi.listPullRequests must return objects with `created_at`. - */ -type PrForPolling = { - created_at: string; -}; - -/** - * Poll GitHub for NEW PRs (created since last seen) and announce them in a Discord channel. - * - * Design goals: - * - Never throw (background job safety) - * - Log failures once with context - * - Skip quietly when nothing to do - * - Baseline-first: first successful run stores lastSeen and does NOT announce - */ -export async function pollPullRequestsOnce(args: { - client: Client; - owner: string; - repo: string; - announceChannelId: string; - limit?: number; -}): Promise { - const { client, owner, repo, announceChannelId, limit = 20 } = args; - - try { - // Pull newest-first (based on githubApi query params) - // IMPORTANT: We only announce PRs based on created_at (not updated_at) - const prs = (await listPullRequests(owner, repo, { - state: "open", - limit, - })) as unknown as (PrForPolling & Parameters[0])[]; - - if (prs.length === 0) return; - - const lastSeen = getLastSeen(owner, repo) ?? 0; - - // Baseline-first: if we've never seen anything, set marker and do not announce - if (lastSeen === 0) { - const newestCreated = prs - .map((pr) => Date.parse(pr.created_at)) - .filter((ms) => !Number.isNaN(ms)) - .sort((a, b) => b - a)[0]; - - if (newestCreated && newestCreated > 0) { - // Store the timestamp as an ISO string via setLastSeenPr - setLastSeenPr(owner, repo, new Date(newestCreated).toISOString()); - logger.info({ owner, repo }, "PR poller baseline saved (no announcements)"); - } - - return; - } - - // Keep only PRs created after our stored timestamp - const fresh = prs.filter((pr) => { - const ms = Date.parse(pr.created_at); - return !Number.isNaN(ms) && ms > lastSeen; - }); - - if (fresh.length === 0) return; - - const fetched = await client.channels.fetch(announceChannelId); - if (!fetched || !fetched.isTextBased()) { - logger.warn({ announceChannelId }, "PR poller could not resolve announce channel"); - return; - } - - // Extra runtime guard - if (!("send" in fetched) || typeof fetched.send !== "function") return; - - // Post oldest-first so announcements read naturally - const oldestFirst = [...fresh].sort( - (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at), - ); - - for (const pr of oldestFirst) { - await fetched.send(formatPullRequest(pr)); - } - - // Update last-seen to newest PR we announced (by created_at) - const newest = oldestFirst[oldestFirst.length - 1]; - setLastSeenPr(owner, repo, newest.created_at); - } catch (err) { - logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed"); - } -} diff --git a/data/backup-20260117-145403/src-backup/services/github/types.ts b/data/backup-20260117-145403/src-backup/services/github/types.ts deleted file mode 100644 index b34ee99e..00000000 --- a/data/backup-20260117-145403/src-backup/services/github/types.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * GitHub API Types - * - * This file defines the data contracts used by OmegaBot when interacting - * with the GitHub REST API. - * - * Design goals: - * - Keep types explicit and readable - * - Separate "full API shapes" from "command-friendly views" - * - Avoid over-modeling fields we don’t actually use - */ - -/* ------------------------------------------------------------------ */ -/* Shared base types */ -/* ------------------------------------------------------------------ */ - -export type GitHubUser = { - login: string; - html_url: string; -}; - -export type GitHubLabel = { - name: string; - color: string; -}; - -/* ------------------------------------------------------------------ */ -/* Full GitHub API response types */ -/* ------------------------------------------------------------------ */ - -/** - * GitHub Issue - * - * NOTE: - * - Pull Requests also appear in the `/issues` API. - * - When an issue is a PR, it includes a `pull_request` field. - */ -export type GitHubIssue = { - id: number; - number: number; - title: string; - body: string | null; - state: "open" | "closed"; - html_url: string; - - user: GitHubUser; - labels: GitHubLabel[]; - - comments: number; - - created_at: string; - updated_at: string; - closed_at: string | null; - - pull_request?: { - html_url: string; - }; -}; - -/** - * GitHub Pull Request - * - * Returned from the `/pulls` API. - * Contains additional fields not present on issues. - */ -export type GitHubPullRequest = { - id: number; - number: number; - title: string; - body: string | null; - state: "open" | "closed"; - html_url: string; - - user: GitHubUser; - - merged: boolean; - mergeable: boolean | null; - - comments: number; - review_comments: number; - commits: number; - additions: number; - deletions: number; - changed_files: number; - - created_at: string; - updated_at: string; - closed_at: string | null; - merged_at: string | null; -}; - -export type GitHubRepo = { - full_name: string; - description: string | null; - html_url: string; - stargazers_count: number; - forks_count: number; - open_issues_count: number; -}; - -/* ------------------------------------------------------------------ */ -/* Command-friendly / normalized response types */ -/* ------------------------------------------------------------------ */ - -export type GitHubIssueSummary = { - number: number; - title: string; - html_url: string; - state: "open" | "closed"; - user?: { login: string }; - comments?: number; -}; - -/** - * PR summary used by commands and the poller. - * - * Important: - * - includes `updated_at` so we can do "last seen" comparisons - */ -export type GitHubPrSummary = { - number: number; - title: string; - html_url: string; - state: "open" | "closed"; - merged_at: string | null; - updated_at: string; - user?: { login: string }; - comments?: number; - review_comments?: number; -}; diff --git a/data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts b/data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts deleted file mode 100644 index 3a729991..00000000 --- a/data/backup-20260117-145403/src-backup/services/roles/autoRoleHandler.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { GuildMember } from "discord.js"; -import { PermissionFlagsBits } from "discord.js"; -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -export async function handleAutoRole(member: GuildMember): Promise { - if (!env.discordAutoRoleId) { - logger.warn("discordAutoRoleId not set; auto-role disabled"); - return; - } - - if (member.user.bot) { - logger.debug("auto-role skipped: bot user"); - return; - } - - const role = member.guild.roles.cache.get(env.discordAutoRoleId); - - if (!role) { - logger.warn( - { roleId: env.discordAutoRoleId, guildId: member.guild.id }, - "auto-role skipped: role not found in guild", - ); - return; - } - - const hasAutoRole = member.roles.cache.has(role.id); - - if (hasAutoRole) { - logger.debug( - { userId: member.user.id, roleId: role.id }, - "auto-role skipped: member already has role", - ); - return; - } - - const botMember = member.guild.members.me; - - if (!botMember) { - logger.warn("auto-role failed: could not resolve bot member in guild"); - return; - } - - if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { - logger.warn("auto-role failed: missing Manage Roles permission"); - return; - } - - if (role.position >= botMember.roles.highest.position) { - logger.warn("auto-role failed: role is higher than bot's highest role"); - return; - } - - try { - await member.roles.add(role); - logger.info({ userId: member.user.id, roleId: role.id }, "auto-role assigned"); - } catch (err) { - logger.error( - { err, userId: member.user.id, roleId: role.id }, - "auto-role failed during assignment", - ); - } -} diff --git a/data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts b/data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts deleted file mode 100644 index c054a3e8..00000000 --- a/data/backup-20260117-145403/src-backup/services/summary/llmSummary.ts +++ /dev/null @@ -1,64 +0,0 @@ -import OpenAI from "openai"; -import { env } from "../../config/env.js"; -import { logger } from "../../utils/logger.js"; - -let client: OpenAI | null = null; - -/** - * Initialize OpenAI client only if an API key is present. - * This allows the bot to run in "local" summary mode without crashing. - */ -if (env.openAIKey) { - client = new OpenAI({ apiKey: env.openAIKey }); -} - -/** - * Generate a structured summary (Markdown) from a transcript using an LLM. - * - * Behavior: - * - If LLM mode is requested but no API key is configured, return a safe message - * - If the OpenAI request fails, log the error and return a user-friendly fallback - * - * We intentionally: - * - Do NOT log the transcript or prompt (privacy + noise) - * - Log only the failure signal for observability - */ -export async function llmSummary(text: string): Promise { - if (!client) { - return "LLM mode requested but no API key is configured."; - } - - const prompt = [ - "You are a Discord channel summarizer.", - "Given the transcript below, produce a structured Markdown output with these sections:", - "## Summary (2 to 5 bullets)", - "## Key points (3 to 8 bullets)", - "## Action items (if any, otherwise say 'None')", - "## Open questions (if any, otherwise say 'None')", - "", - "Rules:", - "- Do not include timestamps.", - "- Avoid quoting usernames; paraphrase instead unless absolutely necessary.", - "- Keep it concise and readable.", - "", - "Transcript:", - text, - ].join("\n"); - - try { - const response = await client.chat.completions.create({ - model: "gpt-4o-mini", - messages: [{ role: "user", content: prompt }], - }); - - /** - * Extract model output, falling back safely if no content is returned. - */ - const content = response.choices?.[0]?.message?.content ?? "LLM returned no content."; - - return content.trim(); - } catch (err) { - logger.error({ err }, "LLM summary generation failed"); - return "Failed to generate summary via LLM."; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/summary/localSummary.ts b/data/backup-20260117-145403/src-backup/services/summary/localSummary.ts deleted file mode 100644 index 0f44f4c0..00000000 --- a/data/backup-20260117-145403/src-backup/services/summary/localSummary.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Produce a simple heuristic summary of recent messages by counting: - * - total messages - * - unique participants - * - frequent words - * - the longest (most detailed) message - */ -export function localSummary(text: string): string { - const lines = text.split("\n"); - const total = lines.length; - - const users = new Set(); - let longest = ""; - const keywords: Record = {}; - - /* - * Iterate through each line and extract metadata for the summary. - * Expected format per line: "username: message text" - */ - for (const line of lines) { - /* - * Separate the username and message content. - */ - const [user, msg] = line.split(": "); - if (user) { - users.add(user); - } - - /* - * Track the longest message so we can surface it as the detailed example. - */ - if (msg && msg.length > longest.length) { - longest = msg; - } - - /* - * Break the message into lowercase words and count how often each appears. - */ - if (msg) { - msg.split(/\s+/).forEach((word) => { - const w = word.toLowerCase(); - if (!w) return; - if (!keywords[w]) { - keywords[w] = 0; - } - keywords[w]++; - }); - } - } - - /* - * Select the top 5 most frequent meaningful words to highlight in the summary. - * We ignore very short tokens (length <= 3) to skip things like "the", "and". - */ - const topWords = Object.entries(keywords) - .filter(([k]) => k.length > 3) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([w, c]) => `${w} (${c})`) - .join(", "); - - /* - * Produce the final formatted summary string returned to the user. - */ - return ( - "Summary of recent messages:\n" + - `Messages: ${total}\n` + - `Participants: ${users.size}\n` + - `Top words: ${topWords || "none"}\n\n` + - `Most detailed message:\n${longest}` - ); -} diff --git a/data/backup-20260117-145403/src-backup/services/summary/summarizer.ts b/data/backup-20260117-145403/src-backup/services/summary/summarizer.ts deleted file mode 100644 index 2c885bd9..00000000 --- a/data/backup-20260117-145403/src-backup/services/summary/summarizer.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { env } from "../../config/env.js"; -import { localSummary } from "./localSummary.js"; -import { llmSummary } from "./llmSummary.js"; - -/* - * Decide which summarization method to use (LLM or local) based on configuration. - */ -export async function summarize(text: string): Promise { - if (env.summaryMode === "llm") { - return llmSummary(text); - } - /* - * Use the lightweight local summarizer when LLM mode is disabled. - */ - return localSummary(text); -} diff --git a/data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts b/data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts deleted file mode 100644 index 3bd9acdf..00000000 --- a/data/backup-20260117-145403/src-backup/services/time/formatTimestamp.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Format a Unix timestamp (ms) into a readable date/time string. - * - * - Uses 24-hour time (hour12: false) - * - Caller controls locale + timezone - * - * @param ts - Timestamp in milliseconds - * @param timeZone - IANA timezone (ex: "America/New_York") - * @param locale - Locale string (ex: "en-GB", "en-US") - */ -export function formatTimestamp(ts: number, timeZone: string, locale: string): string { - return new Date(ts).toLocaleString(locale, { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); -} diff --git a/data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts b/data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts deleted file mode 100644 index 3a5d7658..00000000 --- a/data/backup-20260117-145403/src-backup/services/time/validateTimezone.ts +++ /dev/null @@ -1,28 +0,0 @@ -// src/services/time/validateTimeZone.ts - -/** - * Validate whether a string is a valid IANA timezone. - * - * Examples of valid values: - * - "UTC" - * - "America/New_York" - * - "Europe/London" - * - * This does NOT guess timezones. - * It only validates exact IANA identifiers. - * - * Note: - * We intentionally do NOT log here. Invalid timezone input is normal user input, - * not an operational error. Callers can decide how to message the user. - */ -export function validateTimeZone(tz: string): boolean { - if (!tz || typeof tz !== "string") return false; - - try { - // Intl.DateTimeFormat will throw if the timezone is invalid. - Intl.DateTimeFormat("en-US", { timeZone: tz }); - return true; - } catch { - return false; - } -} diff --git a/data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts b/data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts deleted file mode 100644 index 7de0aa54..00000000 --- a/data/backup-20260117-145403/src-backup/services/timezone/timezoneStore.ts +++ /dev/null @@ -1,144 +0,0 @@ -// src/services/timezone/timezoneStore.ts - -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { logger } from "../../utils/logger.js"; - -export type StoredTimezone = { - version: 1; - userId: string; - guildId: string | null; // optional scoping; null means global - timezone: string; // IANA zone, ex: America/New_York - label?: string; // optional display label, ex: "EST" (never used for math) - createdAt: string; - updatedAt: string; -}; - -type TimezoneStoreFileV1 = { - version: 1; - updatedAt: string; - // Keyed by "guildId:userId" if guild-scoped, otherwise "global:userId" - zones: Record; -}; - -const DATA_DIR = path.join(process.cwd(), "data"); -const STORE_PATH = path.join(DATA_DIR, "timezones.json"); - -function nowIso(): string { - return new Date().toISOString(); -} - -async function ensureDataDir(): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }); -} - -function emptyStore(): TimezoneStoreFileV1 { - return { - version: 1, - updatedAt: nowIso(), - zones: {}, - }; -} - -function makeKey(args: { - guildId: string | null; - userId: string; - scope: "guild" | "global"; -}): string { - if (args.scope === "guild") { - return `${args.guildId ?? "noguild"}:${args.userId}`; - } - return `global:${args.userId}`; -} - -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 zones = parsed.zones && typeof parsed.zones === "object" ? parsed.zones : {}; - - return { - version: 1, - updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(), - zones: zones as Record, - }; - } catch { - return emptyStore(); - } -} - -async function saveStore(store: TimezoneStoreFileV1): Promise { - await ensureDataDir(); - await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); -} - -export async function setUserTimezone(args: { - userId: string; - guildId: string | null; - scope: "guild" | "global"; - timezone: string; - label?: string; -}): Promise { - const store = await loadStore(); - const key = makeKey({ guildId: args.guildId, userId: args.userId, scope: args.scope }); - - const existing = store.zones[key]; - const createdAt = existing?.createdAt ?? nowIso(); - - const tz: StoredTimezone = { - version: 1, - userId: args.userId, - guildId: args.scope === "guild" ? args.guildId : null, - timezone: args.timezone, - label: args.label, - createdAt, - updatedAt: nowIso(), - }; - - store.zones[key] = tz; - store.updatedAt = nowIso(); - - try { - await saveStore(store); - } catch (err) { - logger.error({ err }, "[timezoneStore] failed to save"); - } - - return tz; -} - -export async function getUserTimezone(args: { - userId: string; - guildId: string | null; - scope: "guild" | "global"; -}): Promise { - const store = await loadStore(); - const key = makeKey({ guildId: args.guildId, userId: args.userId, scope: args.scope }); - return store.zones[key] ?? null; -} - -export async function clearUserTimezone(args: { - userId: string; - guildId: string | null; - scope: "guild" | "global"; -}): Promise { - const store = await loadStore(); - const key = makeKey({ guildId: args.guildId, userId: args.userId, scope: args.scope }); - - if (!store.zones[key]) return false; - - delete store.zones[key]; - store.updatedAt = nowIso(); - - try { - await saveStore(store); - } catch (err) { - logger.error({ err }, "[timezoneStore] failed to save after clear"); - } - - return true; -} diff --git a/data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts b/data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts deleted file mode 100644 index dd306a8b..00000000 --- a/data/backup-20260117-145403/src-backup/services/transcript/buildTranscript.ts +++ /dev/null @@ -1,101 +0,0 @@ -// src/services/transcript/buildTranscript.ts - -import { formatTimestamp } from "../time/formatTimestamp.js"; - -/** - * Minimal shape required from a Discord message - * so this helper stays framework-agnostic. - */ -export type TranscriptMessage = { - createdTimestamp: number; - content: string; - author: { username: string }; -}; - -export type TranscriptOptions = { - includeTimestamp: boolean; - includeAuthor: boolean; - maxLines?: number; - maxChars?: number; - timeZone?: string; - locale?: string; -}; - -export type TranscriptResult = { - text: string; - lineCount: number; - truncated: boolean; - tooLong: boolean; -}; - -/** - * Builds a readable transcript from a list of messages. - * Responsible ONLY for formatting + truncation rules. - * - * This function intentionally: - * - Does not log - * - Does not throw - * - Does not mutate external state - * - * Callers decide how to handle results and failures. - */ -export function buildTranscript( - messages: TranscriptMessage[], - options: TranscriptOptions, -): TranscriptResult { - const { - includeTimestamp, - includeAuthor, - maxLines, - maxChars, - timeZone = "UTC", - locale = "en-GB", - } = options; - - const lines: string[] = []; - let truncated = false; - let tooLong = false; - - for (const message of messages) { - if (!message?.content) continue; - - const parts: string[] = []; - - if (includeTimestamp) { - const ts = formatTimestamp(message.createdTimestamp, timeZone, locale); - parts.push(`[${ts}]`); - } - - if (includeAuthor) { - parts.push(`${message.author.username}:`); - } - - parts.push(message.content.trim()); - - const line = parts.join(" "); - lines.push(line); - - if (maxLines && lines.length >= maxLines) { - truncated = true; - break; - } - - // Cheaper than joining every iteration: track length incrementally. - if (maxChars) { - const joinedLen = - lines.reduce((acc, l) => acc + l.length, 0) + Math.max(0, lines.length - 1); - - if (joinedLen >= maxChars) { - tooLong = true; - break; - } - } - } - - return { - text: lines.join("\n"), - lineCount: lines.length, - truncated, - tooLong, - }; -} diff --git a/data/backup-20260117-145403/src-backup/services/transcript/defaults.ts b/data/backup-20260117-145403/src-backup/services/transcript/defaults.ts deleted file mode 100644 index f85911e9..00000000 --- a/data/backup-20260117-145403/src-backup/services/transcript/defaults.ts +++ /dev/null @@ -1,35 +0,0 @@ -// src/services/transcript/defaults.ts - -import type { TranscriptOptions } from "./buildTranscript.js"; - -/** - * Discord hard limit is 2000 characters. - * Leave headroom so we never accidentally overflow. - */ -export const DISCORD_SAFE_TEXT_LIMIT = 1900; - -/** - * Defaults for /history - * History is meant to be readable playback. - */ -export const HISTORY_DEFAULTS: TranscriptOptions = { - includeTimestamp: true, - includeAuthor: true, - maxLines: 50, - maxChars: DISCORD_SAFE_TEXT_LIMIT, - timeZone: "UTC", - locale: "en-GB", -}; - -/** - * Defaults for /summary - * Summary prefers signal over noise. - */ -export const SUMMARY_DEFAULTS: TranscriptOptions = { - includeTimestamp: false, - includeAuthor: true, - maxLines: 50, - maxChars: DISCORD_SAFE_TEXT_LIMIT, - timeZone: "UTC", - locale: "en-GB", -}; diff --git a/data/backup-20260117-145403/src-backup/services/weather/forecast.ts b/data/backup-20260117-145403/src-backup/services/weather/forecast.ts deleted file mode 100644 index 6e2109b5..00000000 --- a/data/backup-20260117-145403/src-backup/services/weather/forecast.ts +++ /dev/null @@ -1,274 +0,0 @@ -// src/services/weather/forecast.ts - -import { logger } from "../../utils/logger.js"; -import type { TempUnit } from "../../services/weather/types.js"; - -/* -------------------------------------------------------------------------- */ -/* Types */ -/* -------------------------------------------------------------------------- */ - -type WeatherApiResponse = { - location?: { - name?: string; - region?: string; - country?: string; - tz_id?: string; - localtime?: string; // "2026-01-16 10:18" - }; - current?: { - last_updated?: string; - temp_f?: number; - temp_c?: number; - feelslike_f?: number; - feelslike_c?: number; - condition?: { text?: string }; - wind_mph?: number; - wind_kph?: number; - wind_dir?: string; - humidity?: number; - }; - forecast?: { - forecastday?: Array<{ - date?: string; - day?: { - maxtemp_f?: number; - maxtemp_c?: number; - mintemp_f?: number; - mintemp_c?: number; - daily_chance_of_rain?: number; - daily_chance_of_snow?: number; - condition?: { text?: string }; - }; - astro?: { - sunrise?: string; - sunset?: string; - }; - }>; - }; -}; - -// IMPORTANT: forecastday is optional, so strip undefined before indexing -type ForecastDay = NonNullable< - NonNullable["forecastday"] ->[number]; - -export type WeatherNow = { - temp: string; - feelsLike?: string; - condition: string; - asOf?: string; - humidity?: string; - wind?: string; -}; - -export type WeatherDay = { - label: string; - temp: string; - pop?: string; - condition: string; - sunrise?: string; - sunset?: string; -}; - -export type WeatherBundle = { - placeLabel: string; - tzId?: string; - localTime?: string; - now?: WeatherNow; - days: WeatherDay[]; -}; - -/* -------------------------------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------------------------------- */ - -function requireWeatherApiKey(): string { - const key = process.env.WEATHERAPI_KEY?.trim(); - if (!key) { - throw new Error("Missing WEATHERAPI_KEY. Set it in .env (WEATHERAPI_KEY=...)."); - } - return key; -} - -function isRecord(v: unknown): v is Record { - return typeof v === "object" && v !== null; -} - -function readApiErrorMessage(data: unknown): string | null { - const root = isRecord(data) ? data : null; - const err = root && isRecord(root.error) ? root.error : null; - const msg = err && typeof err.message === "string" ? err.message : null; - return msg ?? null; -} - -function pickTemp(unit: TempUnit, f?: number, c?: number): string | null { - if (unit === "c") { - return typeof c === "number" && Number.isFinite(c) ? `${Math.round(c)}°C` : null; - } - return typeof f === "number" && Number.isFinite(f) ? `${Math.round(f)}°F` : null; -} - -function formatWind( - unit: TempUnit, - dir?: string, - mph?: number, - kph?: number, -): string | null { - const d = dir?.trim(); - - if (unit === "c") { - if (typeof kph === "number" && Number.isFinite(kph)) { - return d ? `${d} ${Math.round(kph)} kph` : `${Math.round(kph)} kph`; - } - return null; - } - - if (typeof mph === "number" && Number.isFinite(mph)) { - return d ? `${d} ${Math.round(mph)} mph` : `${Math.round(mph)} mph`; - } - return null; -} - -function buildPlaceLabel(loc: WeatherApiResponse["location"] | undefined): string { - const name = loc?.name?.trim(); - const region = loc?.region?.trim(); - const country = loc?.country?.trim(); - - const bits = [name, region].filter(Boolean); - if (bits.length) return bits.join(", "); - - return country ?? "Unknown location"; -} - -function dailyPopString(item: ForecastDay | undefined): string | null { - if (!item?.day) return null; - - const rain = item.day.daily_chance_of_rain; - const snow = item.day.daily_chance_of_snow; - - const r = typeof rain === "number" && Number.isFinite(rain) ? rain : null; - const s = typeof snow === "number" && Number.isFinite(snow) ? snow : null; - - const best = [r, s] - .filter((x): x is number => typeof x === "number") - .sort((a, b) => b - a)[0]; - - return typeof best === "number" ? `${Math.round(best)}%` : null; -} - -/* -------------------------------------------------------------------------- */ -/* Main fetch */ -/* -------------------------------------------------------------------------- */ - -export async function fetchWeatherBundle(args: { - location: string; - unit: TempUnit; - days: number; -}): Promise { - const key = requireWeatherApiKey(); - - const q = args.location.trim(); - if (!q) throw new Error("Location cannot be empty."); - - const safeDays = Math.max(1, Math.min(args.days, 10)); - - const url = - `https://api.weatherapi.com/v1/forecast.json` + - `?key=${encodeURIComponent(key)}` + - `&q=${encodeURIComponent(q)}` + - `&days=${encodeURIComponent(String(safeDays))}` + - `&aqi=no&alerts=no`; - - const res = await fetch(url, { - headers: { - Accept: "application/json", - "User-Agent": "OmegaBot", - }, - }); - - const raw = await res.text(); - let parsed: WeatherApiResponse | null = null; - - try { - parsed = JSON.parse(raw) as WeatherApiResponse; - } catch { - parsed = null; - } - - if (!res.ok) { - const msg = readApiErrorMessage(parsed) ?? res.statusText ?? "Unknown error"; - - if (res.status === 401 || res.status === 403) { - throw new Error( - `WeatherAPI auth error (${res.status}). Check WEATHERAPI_KEY. ${msg}`, - ); - } - if (res.status === 400) { - throw new Error(`WeatherAPI rejected the location. ${msg}`); - } - if (res.status === 429) { - throw new Error("WeatherAPI rate limit hit. Try again in a bit."); - } - - throw new Error(`WeatherAPI error (${res.status}): ${msg}`); - } - - const placeLabel = buildPlaceLabel(parsed?.location); - const tzId = parsed?.location?.tz_id; - const localTime = parsed?.location?.localtime; - - const nowTemp = pickTemp(args.unit, parsed?.current?.temp_f, parsed?.current?.temp_c); - const feels = pickTemp( - args.unit, - parsed?.current?.feelslike_f, - parsed?.current?.feelslike_c, - ); - - const now: WeatherNow | undefined = nowTemp - ? { - temp: nowTemp, - feelsLike: feels ?? undefined, - condition: parsed?.current?.condition?.text?.trim() ?? "Unknown", - asOf: parsed?.current?.last_updated, - humidity: - typeof parsed?.current?.humidity === "number" - ? `${parsed.current.humidity}%` - : undefined, - wind: - formatWind( - args.unit, - parsed?.current?.wind_dir, - parsed?.current?.wind_mph, - parsed?.current?.wind_kph, - ) ?? undefined, - } - : undefined; - - const days: WeatherDay[] = []; - const fd: ForecastDay[] = parsed?.forecast?.forecastday ?? []; - - for (let i = 0; i < fd.length; i += 1) { - const item = fd[i]; - const label = i === 0 ? "Today" : (item.date ?? `Day ${i + 1}`); - - const max = pickTemp(args.unit, item.day?.maxtemp_f, item.day?.maxtemp_c); - const min = pickTemp(args.unit, item.day?.mintemp_f, item.day?.mintemp_c); - const temp = max && min ? `${min} to ${max}` : (max ?? min ?? "N/A"); - - days.push({ - label, - temp, - condition: item.day?.condition?.text?.trim() ?? "Forecast unavailable", - pop: dailyPopString(item) ?? undefined, - sunrise: item.astro?.sunrise, - sunset: item.astro?.sunset, - }); - } - - logger.debug( - { q, placeLabel, tzId, localTime, days: days.length }, - "[weather] weather bundle fetched", - ); - - return { placeLabel, tzId, localTime, now, days }; -} diff --git a/data/backup-20260117-145403/src-backup/services/weather/types.ts b/data/backup-20260117-145403/src-backup/services/weather/types.ts deleted file mode 100644 index aebba69d..00000000 --- a/data/backup-20260117-145403/src-backup/services/weather/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -// src/services/weather/types.ts - -/** - * Temperature unit preference. - */ -export type TempUnit = "f" | "c"; - -/** - * Execution modes for the weather command. - */ -export type WeatherMode = - | { kind: "daily"; location: string; unit: TempUnit } - | { kind: "7day"; location: string; unit: TempUnit }; - -/* -------------------------------------------------------------------------- */ -/* Legacy (NWS) */ -/* Kept intentionally so old code references do not break during transition. */ -/* Not used by the WeatherAPI.com path. */ -/* -------------------------------------------------------------------------- */ - -export type WeatherPoint = { - lat: number; - lon: number; - label: string; - - /** - * Fully qualified NWS forecast endpoint URL. - * Example: - * https://api.weather.gov/gridpoints/BOX/65,72/forecast - */ - forecastUrl: string; -}; - -export type NwsForecastResponse = { - properties?: { - periods?: Array<{ - name?: string; - startTime?: string; - isDaytime?: boolean; - temperature?: number; - temperatureUnit?: string; // usually "F" or "C" - shortForecast?: string; - detailedForecast?: string; - probabilityOfPrecipitation?: { value: number | null } | null; - }>; - }; -}; diff --git a/data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts b/data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts deleted file mode 100644 index 03464a7e..00000000 --- a/data/backup-20260117-145403/src-backup/services/welcome/welcomeHandler.ts +++ /dev/null @@ -1,86 +0,0 @@ -// src/services/welcome/welcomeHandler.ts - -import type { Guild, GuildMember, TextBasedChannel } from "discord.js"; -import { logger } from "../../utils/logger.js"; -import { buildWelcomeMessage } from "./welcomeMessage.js"; -import { getGuildConfig } from "../config/index.js"; - -/** - * Type guard to ensure a text-based channel supports `.send()`. - * - * This avoids `any` and satisfies ESLint while keeping runtime safety. - */ -function isSendableChannel( - channel: TextBasedChannel, -): channel is TextBasedChannel & { send: (content: unknown) => Promise } { - return typeof (channel as { send?: unknown }).send === "function"; -} - -/** - * Resolve the best channel to post onboarding messages. - * - * Order: - * 1) Guild config (welcomeEnabled / welcomeChannelId) - * 2) Guild system channel - * 3) First available text-based channel - */ -async function resolveWelcomeChannel(guild: Guild): Promise { - const cfg = getGuildConfig(guild.id); - - // Allow per-guild disabling of welcome messages. - if (!cfg.welcomeEnabled) return null; - - // Prefer configured welcome channel. - if (cfg.welcomeChannelId) { - const ch = await guild.channels.fetch(cfg.welcomeChannelId).catch(() => null); - if (ch?.isTextBased()) return ch; - } - - // Fallback to system channel. - if (guild.systemChannel?.isTextBased()) { - return guild.systemChannel; - } - - // Final fallback: first text-based channel found. - const channels = await guild.channels.fetch().catch(() => null); - if (!channels) return null; - - for (const [, ch] of channels) { - if (ch?.isTextBased()) return ch; - } - - return null; -} - -/** - * Event handler for new members joining a guild. - * Never throws — background event safety. - */ -export async function onGuildMemberAdd(member: GuildMember): Promise { - try { - const channel = await resolveWelcomeChannel(member.guild); - - if (!channel) { - logger.info( - { guildId: member.guild.id }, - "Welcome handler skipped (no channel resolved or disabled)", - ); - return; - } - - if (!isSendableChannel(channel)) { - logger.warn( - { guildId: member.guild.id }, - "Resolved welcome channel is not sendable", - ); - return; - } - - await channel.send(buildWelcomeMessage(member)); - } catch (err) { - logger.error( - { err, guildId: member.guild.id, userId: member.user.id }, - "Welcome handler failed", - ); - } -} diff --git a/data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts b/data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts deleted file mode 100644 index a4a9b97d..00000000 --- a/data/backup-20260117-145403/src-backup/services/welcome/welcomeMessage.ts +++ /dev/null @@ -1,25 +0,0 @@ -// src/services/welcome/welcomeMessage.ts - -import type { GuildMember } from "discord.js"; - -/** - * Build a welcome message for a new member. - * - * Keep this PURE: - * - no Discord calls - * - no env lookups - * - easy to tweak copy later - */ -export function buildWelcomeMessage(member: GuildMember): string { - const username = member.user.username; - - return [ - `👋 Welcome, ${username}!`, - ``, - `Here’s how to get started:`, - `📌 Check the server rules`, - `📖 Read the pinned messages`, - ``, - `❓ If you're not sure where to go, just ask — someone will help you out.`, - ].join("\n"); -} diff --git a/data/backup-20260117-145403/src-backup/utils/logger.ts b/data/backup-20260117-145403/src-backup/utils/logger.ts deleted file mode 100644 index 24d109df..00000000 --- a/data/backup-20260117-145403/src-backup/utils/logger.ts +++ /dev/null @@ -1,49 +0,0 @@ -// src/utils/logger.ts - -import pino, { type Logger } from "pino"; - -/** - * Central logger for OmegaBot. - * - * Why a single logger module? - * - Consistent log format across the whole app - * - One place to change log level / destinations later - * - Easy to swap to JSON logs in prod and pretty logs in dev - * - * Notes: - * - We use `pino-pretty` only for local development readability. - * - If `pino-pretty` is not installed, we gracefully fall back to JSON logs. - */ - -const isProd = process.env.NODE_ENV === "production"; -const prettyEnabled = - !isProd && (process.env.LOG_PRETTY ?? "true").toLowerCase() === "true"; - -function createLogger(): Logger { - const level = process.env.LOG_LEVEL ?? (isProd ? "info" : "debug"); - - // Safe default everywhere - const base = pino({ level }); - - if (!prettyEnabled) { - return base; - } - - try { - const transport = pino.transport({ - target: "pino-pretty", - options: { - colorize: true, - translateTime: "SYS:standard", - ignore: "pid,hostname", - }, - }); - - return pino({ level }, transport); - } catch { - // Dev-only dependency missing, fall back cleanly - return base; - } -} - -export const logger = createLogger(); From 2a585111d4b0e3d834902e35adba61df279993c9 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sat, 17 Jan 2026 15:11:30 -0500 Subject: [PATCH 4/5] Fixing this up --- scripts/migrate-all-data.ts | 161 ------------------------------------ 1 file changed, 161 deletions(-) delete mode 100644 scripts/migrate-all-data.ts diff --git a/scripts/migrate-all-data.ts b/scripts/migrate-all-data.ts deleted file mode 100644 index 137276da..00000000 --- a/scripts/migrate-all-data.ts +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env node -import { readFileSync, existsSync, writeFileSync } from "fs"; -import { initDatabase, getDb } from "../services/database/db.js"; -import { logger } from "../utils/logger.js"; - -console.log("🔄 Migrating ALL data to SQLite...\n"); - -initDatabase(); -const db = getDb(); - -let totalMigrated = 0; - -// ============================================================ -// Migrate FAQs -// ============================================================ -if (existsSync("data/faqs.json")) { - console.log("📝 Migrating FAQs..."); - const faqData = JSON.parse(readFileSync("data/faqs.json", "utf-8")); - const faqs = Object.values(faqData.entries || {}) as any[]; - - db.transaction(() => { - const stmt = db.prepare(` - INSERT OR REPLACE INTO faqs - (key, title, body, tags, answer, created_at, updated_at, usage_count, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - for (const faq of faqs) { - const answer = faq.title ? `**${faq.title}**\n\n${faq.body}` : faq.body; - stmt.run( - faq.key, - faq.title || "", - faq.body || "", - JSON.stringify(faq.tags || []), - answer, - new Date(faq.createdAt).getTime(), - new Date(faq.updatedAt).getTime(), - faq.usageCount || 0, - faq.createdBy || "unknown", - faq.updatedBy || "unknown", - ); - } - })(); - - writeFileSync("data/faqs.json.migrated", readFileSync("data/faqs.json")); - console.log(` ✓ Migrated ${faqs.length} FAQs`); - totalMigrated += faqs.length; -} - -// ============================================================ -// Migrate Timezones -// ============================================================ -if (existsSync("data/timezones.json")) { - console.log("🌍 Migrating timezones..."); - const tzData = JSON.parse(readFileSync("data/timezones.json", "utf-8")); - - db.transaction(() => { - const stmt = db.prepare(` - INSERT OR REPLACE INTO user_timezones (user_id, timezone, updated_at) - VALUES (?, ?, ?) - `); - - for (const [userId, tz] of Object.entries(tzData)) { - stmt.run(userId, tz as string, Date.now()); - } - })(); - - const count = Object.keys(tzData).length; - writeFileSync("data/timezones.json.migrated", readFileSync("data/timezones.json")); - console.log(` ✓ Migrated ${count} timezones`); - totalMigrated += count; -} - -// ============================================================ -// Migrate Guild Config -// ============================================================ -if (existsSync("data/guild-config.json")) { - console.log("⚙️ Migrating guild configurations..."); - const configData = JSON.parse(readFileSync("data/guild-config.json", "utf-8")); - - db.transaction(() => { - const stmt = db.prepare(` - INSERT OR REPLACE INTO guild_config (guild_id, config, updated_at) - VALUES (?, ?, ?) - `); - - for (const [guildId, config] of Object.entries(configData)) { - stmt.run(guildId, JSON.stringify(config), Date.now()); - } - })(); - - const count = Object.keys(configData).length; - writeFileSync( - "data/guild-config.json.migrated", - readFileSync("data/guild-config.json"), - ); - console.log(` ✓ Migrated ${count} guild configs`); - totalMigrated += count; -} - -// ============================================================ -// Migrate Fun Usage -// ============================================================ -if (existsSync("data/fun-usage.json")) { - console.log("🎮 Migrating fun command usage..."); - const funData = JSON.parse(readFileSync("data/fun-usage.json", "utf-8")); - - db.transaction(() => { - const stmt = db.prepare(` - INSERT INTO fun_usage (user_id, command, timestamp) - VALUES (?, ?, ?) - `); - - let count = 0; - for (const [userId, commands] of Object.entries(funData)) { - if (typeof commands === "object" && commands !== null) { - for (const [command, usageCount] of Object.entries(commands)) { - // Add entries for each usage (approximated with current timestamp) - for (let i = 0; i < (usageCount as number); i++) { - stmt.run(userId, command, Date.now() - i * 1000); - count++; - } - } - } - } - })(); - - writeFileSync("data/fun-usage.json.migrated", readFileSync("data/fun-usage.json")); - console.log(` ✓ Migrated fun usage data`); -} - -// ============================================================ -// Migrate GitHub Last Seen -// ============================================================ -if (existsSync("data/github-assignees.json")) { - console.log("📦 Migrating GitHub state..."); - const ghData = JSON.parse(readFileSync("data/github-assignees.json", "utf-8")); - - if (ghData.itemsByNumber) { - db.transaction(() => { - const stmt = db.prepare(` - INSERT OR REPLACE INTO github_last_seen (repo_key, last_seen_timestamp, entity_type) - VALUES (?, ?, ?) - `); - - for (const [number, item] of Object.entries(ghData.itemsByNumber)) { - const typedItem = item as any; - stmt.run(`item_${number}`, Date.now(), typedItem.kind || "Issue"); - } - })(); - } - - writeFileSync( - "data/github-assignees.json.migrated", - readFileSync("data/github-assignees.json"), - ); - console.log(` ✓ Migrated GitHub state`); -} - -console.log(`\n✅ Migration complete! Migrated ${totalMigrated} total items`); -console.log("\nOriginal files renamed to *.migrated for safety"); From 6836d1270241acdcf19ca3e6f0301cc3c96c5bee Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Sat, 17 Jan 2026 15:12:18 -0500 Subject: [PATCH 5/5] Updating README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a78a6c72..66cd141a 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ OmegaBot uses discord.js v14 which includes: ├── .env ├── .env.example ├── eslint.config.ts +├── fix-pr-noise-simple.sh ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug.yml @@ -283,7 +284,6 @@ OmegaBot uses discord.js v14 which includes: │ │ │ ├── guildConfigStore.ts │ │ │ ├── index.ts │ │ │ └── types.ts -│ │ ├── database │ │ ├── discord │ │ │ ├── commandLoader.ts │ │ │ ├── commandMeta.ts @@ -310,6 +310,7 @@ OmegaBot uses discord.js v14 which includes: │ │ │ ├── githubClient.ts │ │ │ ├── githubErrorMessage.ts │ │ │ ├── issueAssigneePoller.ts +│ │ │ ├── issueAssigneePoller.ts.backup │ │ │ ├── lastSeenStore.ts │ │ │ ├── prFormatter.ts │ │ │ ├── prPoller.ts