From 3d22c6f66663c08094cb19359638735952ff6c44 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Fri, 19 Dec 2025 14:32:05 -0500 Subject: [PATCH 1/8] Adding in fun stuff --- README.md | 63 ++++----- src/commands/fun/fun.ts | 136 ++++++++++++++++++ src/commands/fun/subcommands/chucknorris.ts | 145 ++++++++++++++++++++ src/commands/fun/subcommands/coinflip.ts | 5 + src/commands/fun/subcommands/dadjoke.ts | 5 + src/commands/fun/subcommands/dice.ts | 5 + src/commands/fun/subcommands/weather.ts | 5 + 7 files changed, 329 insertions(+), 35 deletions(-) create mode 100644 src/commands/fun/fun.ts create mode 100644 src/commands/fun/subcommands/chucknorris.ts create mode 100644 src/commands/fun/subcommands/coinflip.ts create mode 100644 src/commands/fun/subcommands/dadjoke.ts create mode 100644 src/commands/fun/subcommands/dice.ts create mode 100644 src/commands/fun/subcommands/weather.ts diff --git a/README.md b/README.md index d4a00b06..4eff44a6 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Current features - /pagination command (inline paging) - /timezone command (per-user IANA timezone) - /changelog command (ephemeral preview) +- /fun commands: chucknorris, dadjoke, coinflip, dice, weather - Centralized structured logging Planned features @@ -127,38 +128,15 @@ OmegaBot is online │ │ ├── enhancement_refactor.yml │ │ ├── feature_request.yml │ │ └── question_discussion.yml -│ ├── pull_request_template.md -│ └── workflows -│ └── OmegaBot.yml -├── .gitignore +│ ├── workflows +│ │ └── OmegaBot.yml +│ └── pull_request_template.md ├── .husky -│ ├── _ -│ │ ├── .gitignore -│ │ ├── applypatch-msg -│ │ ├── commit-msg -│ │ ├── h -│ │ ├── husky.sh -│ │ ├── post-applypatch -│ │ ├── post-checkout -│ │ ├── post-commit -│ │ ├── post-merge -│ │ ├── post-rewrite -│ │ ├── pre-applypatch -│ │ ├── pre-auto-gc -│ │ ├── pre-commit -│ │ ├── pre-merge-commit -│ │ ├── pre-push -│ │ ├── pre-rebase -│ │ └── prepare-commit-msg │ ├── pre-commit │ └── pre-push -├── .prettierignore -├── .prettierrc.yml ├── assets │ ├── banner.png │ └── omegabot.png -├── CHANGELOG.md -├── CONTRIBUTORS.md ├── data │ ├── last-seen.json │ └── timezones.json @@ -168,18 +146,20 @@ OmegaBot is online │ ├── setup-discord.md │ ├── setup-env.md │ └── transcripts.md -├── eslint.config.ts -├── LICENSE -├── package-lock.json -├── package.json -├── README.md ├── scripts │ └── precheck.sh ├── src -│ ├── bot.ts │ ├── commands │ │ ├── changelog │ │ │ └── changelog.ts +│ │ ├── fun +│ │ │ ├── subcommands +│ │ │ │ ├── chucknorris.ts +│ │ │ │ ├── coinflip.ts +│ │ │ │ ├── dadjoke.ts +│ │ │ │ ├── dice.ts +│ │ │ │ └── weather.ts +│ │ │ └── fun.ts │ │ ├── general │ │ │ └── ping.ts │ │ ├── github @@ -195,7 +175,6 @@ OmegaBot is online │ │ └── summary.ts │ ├── config │ │ └── env.ts -│ ├── registerCommands.ts │ ├── services │ │ ├── discord │ │ │ ├── commandLoader.ts @@ -221,8 +200,22 @@ OmegaBot is online │ │ └── transcript │ │ ├── buildTranscript.ts │ │ └── defaults.ts -│ └── utils -│ └── logger.ts +│ ├── utils +│ │ └── logger.ts +│ ├── bot.ts +│ └── registerCommands.ts +├── .env.example +├── .gitignore +├── .prettierignore +├── .prettierrc.yml +├── CHANGELOG.md +├── CONTRIBUTORS.md +├── eslint.config.ts +├── LICENSE +├── package-lock.json +├── package.json +├── README.md +└── tsconfig.json ``` diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts new file mode 100644 index 00000000..0a0a9e6c --- /dev/null +++ b/src/commands/fun/fun.ts @@ -0,0 +1,136 @@ +// src/commands/fun/fun.ts + +import { SlashCommandBuilder } from "discord.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +import { + run as runChuckNorris, + type ChuckNorrisMode, +} from "./subcommands/chucknorris.js"; +import { run as runCoinflip } from "./subcommands/coinflip.js"; +import { run as runDadjoke } from "./subcommands/dadjoke.js"; +import { run as runDice } from "./subcommands/dice.js"; +import { run as runWeather } from "./subcommands/weather.js"; + +export const data = new SlashCommandBuilder() + .setName("fun") + .setDescription("Fun commands") + .addSubcommand((s) => + s + .setName("chucknorris") + .setDescription("Chuck Norris facts") + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ) + .addStringOption((o) => + o + .setName("mode") + .setDescription("How to pick a fact") + .setRequired(false) + .addChoices( + { name: "random", value: "random" }, + { name: "category", value: "category" }, + { name: "search", value: "search" }, + ), + ) + .addStringOption((o) => + o + .setName("value") + .setDescription("Category name (for category) or search text (for search)") + .setRequired(false), + ), + ) + .addSubcommand((s) => + s + .setName("dadjoke") + .setDescription("Get a dad joke") + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + .addSubcommand((s) => + s + .setName("coinflip") + .setDescription("Flip a coin") + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + .addSubcommand((s) => + s + .setName("dice") + .setDescription("Roll a dice") + .addIntegerOption((o) => + o + .setName("sides") + .setDescription("Number of sides (default: 6)") + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + .addSubcommand((s) => + s + .setName("weather") + .setDescription("Get the weather for a location") + .addStringOption((o) => + o + .setName("location") + .setDescription("City, State or City, Country") + .setRequired(true), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction) { + const sub = interaction.options.getSubcommand(); + + // Defer exactly once. Subcommands should only call editReply(). + const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral }); + } + + if (sub === "chucknorris") { + const modeOpt = interaction.options.getString("mode") ?? "random"; + const value = (interaction.options.getString("value") ?? "").trim(); + + const mode: ChuckNorrisMode = + modeOpt === "category" + ? { kind: "category", category: value || "dev" } + : modeOpt === "search" + ? { kind: "search", query: value || "code" } + : { kind: "random" }; + + return await runChuckNorris(interaction, mode); + } + + if (sub === "dadjoke") return await runDadjoke(interaction); + if (sub === "coinflip") return await runCoinflip(interaction); + + // Let subcommand read its own options (sides) from interaction + if (sub === "dice") return await runDice(interaction); + + // Let subcommand read its own options (location) from interaction + if (sub === "weather") return await runWeather(interaction); + + return await interaction.editReply("Unknown fun subcommand."); +} \ No newline at end of file diff --git a/src/commands/fun/subcommands/chucknorris.ts b/src/commands/fun/subcommands/chucknorris.ts new file mode 100644 index 00000000..a0f6af30 --- /dev/null +++ b/src/commands/fun/subcommands/chucknorris.ts @@ -0,0 +1,145 @@ +// src/commands/fun/fun.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +/** + * Supported execution modes for Chuck Norris jokes. + * + * The parent command (fun.ts) decides which mode to use + * based on slash command options. + */ +export type ChuckNorrisMode = + | { kind: "random" } + | { kind: "category"; category: string } + | { kind: "search"; query: string }; + +type ChuckNorrisApiResponse = { + value: 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 { + let joke: string; + + switch (mode.kind) { + case "category": + joke = await fetchCategoryJoke(mode.category); + break; + + case "search": + joke = await fetchSearchJoke(mode.query); + break; + + case "random": + default: + joke = await fetchRandomJoke(); + break; + } + + await interaction.editReply(joke); + + logger.debug( + { + userId: interaction.user.id, + mode: mode.kind, + }, + "[fun/chucknorris] joke sent", + ); + } catch (err) { + logger.error({ err, mode }, "[fun/chucknorris] fetch failed"); + + await interaction.editReply( + "Chuck Norris is currently roundhouse kicking the API. Try again later.", + ); + } +} + +/* -------------------------------------------------------------------------- */ +/* API FETCHERS */ +/* -------------------------------------------------------------------------- */ + +/** + * Fetch a random Chuck Norris joke. + */ +async function fetchRandomJoke(): Promise { + return fetchJoke("https://api.chucknorris.io/jokes/random"); +} + +/** + * Fetch a random Chuck Norris joke from a specific category. + */ +async function fetchCategoryJoke(category: string): Promise { + const url = `https://api.chucknorris.io/jokes/random?category=${encodeURIComponent( + category, + )}`; + + return fetchJoke(url); +} + +/** + * Fetch a Chuck Norris joke matching a search query. + * + * NOTE: + * The API returns an array for search results. + * We pick the first result for simplicity. + */ +async function fetchSearchJoke(query: string): Promise { + 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) { + throw new Error(`Chuck Norris API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as { result?: ChuckNorrisApiResponse[] }; + + if (!data.result || data.result.length === 0) { + throw new Error("No Chuck Norris jokes found for that search"); + } + + return data.result[0].value.trim(); +} + +/** + * Shared helper for endpoints that return a single joke object. + */ +async function fetchJoke(url: string): Promise { + const res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + throw new Error(`Chuck Norris API error: ${res.status} ${res.statusText}`); + } + + 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(); +} diff --git a/src/commands/fun/subcommands/coinflip.ts b/src/commands/fun/subcommands/coinflip.ts new file mode 100644 index 00000000..0f7fa075 --- /dev/null +++ b/src/commands/fun/subcommands/coinflip.ts @@ -0,0 +1,5 @@ +import type { ChatInputCommandInteraction } from "discord.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + await interaction.editReply("ok"); +} diff --git a/src/commands/fun/subcommands/dadjoke.ts b/src/commands/fun/subcommands/dadjoke.ts new file mode 100644 index 00000000..0f7fa075 --- /dev/null +++ b/src/commands/fun/subcommands/dadjoke.ts @@ -0,0 +1,5 @@ +import type { ChatInputCommandInteraction } from "discord.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + await interaction.editReply("ok"); +} diff --git a/src/commands/fun/subcommands/dice.ts b/src/commands/fun/subcommands/dice.ts new file mode 100644 index 00000000..0f7fa075 --- /dev/null +++ b/src/commands/fun/subcommands/dice.ts @@ -0,0 +1,5 @@ +import type { ChatInputCommandInteraction } from "discord.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + await interaction.editReply("ok"); +} diff --git a/src/commands/fun/subcommands/weather.ts b/src/commands/fun/subcommands/weather.ts new file mode 100644 index 00000000..0f7fa075 --- /dev/null +++ b/src/commands/fun/subcommands/weather.ts @@ -0,0 +1,5 @@ +import type { ChatInputCommandInteraction } from "discord.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + await interaction.editReply("ok"); +} From 2018741a19bad1c3210d32d84e4f4810e97df02a Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Fri, 19 Dec 2025 14:32:09 -0500 Subject: [PATCH 2/8] style: auto-format with Prettier [skip-precheck] --- src/commands/fun/fun.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index 0a0a9e6c..b0138323 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -133,4 +133,4 @@ export async function execute(interaction: ChatInputCommandInteraction) { if (sub === "weather") return await runWeather(interaction); return await interaction.editReply("Unknown fun subcommand."); -} \ No newline at end of file +} From 6d121573d40a5e8c0e9240914fc796cac13eb279 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Fri, 19 Dec 2025 14:45:41 -0500 Subject: [PATCH 3/8] Adding in files --- src/commands/fun/subcommands/coinflip.ts | 35 ++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/commands/fun/subcommands/coinflip.ts b/src/commands/fun/subcommands/coinflip.ts index 0f7fa075..04d925b2 100644 --- a/src/commands/fun/subcommands/coinflip.ts +++ b/src/commands/fun/subcommands/coinflip.ts @@ -1,5 +1,36 @@ +// src/commands/fun/subcommands/coinflip.ts + import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; -export async function run(interaction: ChatInputCommandInteraction): Promise { - await interaction.editReply("ok"); +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); } + +export async function run(interaction: ChatInputCommandInteraction): Promise { + // Parent (fun.ts) owns deferReply(). We only editReply() here. + + const frames = ["|", "/", "-", "\\", "|", "/", "-", "\\"]; + for (const f of frames) { + await interaction.editReply(`🪙 Flipping ${f}`); + await sleep(140); + } + + // A little “in the air” moment helps the brain read this as motion + await interaction.editReply("🪙 Tossed…"); + await sleep(260); + + const isHeads = Math.random() < 0.5; + + // Make the two results visually distinct + const result = isHeads + ? "🟡 **HEADS** 👑" + : "⚪ **TAILS** 🌀"; + + await interaction.editReply(`${result}`); + + logger.debug( + { userId: interaction.user.id, result: isHeads ? "heads" : "tails" }, + "[fun/coinflip] result sent", + ); +} \ No newline at end of file From 944d665c49fce5323d1dbf001d693970d0843894 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Fri, 19 Dec 2025 14:45:45 -0500 Subject: [PATCH 4/8] style: auto-format with Prettier [skip-precheck] --- src/commands/fun/subcommands/coinflip.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/commands/fun/subcommands/coinflip.ts b/src/commands/fun/subcommands/coinflip.ts index 04d925b2..50582e61 100644 --- a/src/commands/fun/subcommands/coinflip.ts +++ b/src/commands/fun/subcommands/coinflip.ts @@ -23,9 +23,7 @@ export async function run(interaction: ChatInputCommandInteraction): Promise Date: Fri, 19 Dec 2025 17:08:24 -0500 Subject: [PATCH 5/8] Adding in more changes --- README.md | 10 +- src/commands/fun/fun.ts | 144 ++++++++------- src/commands/fun/subcommands/chucknorris.ts | 95 +++------- src/commands/fun/subcommands/weather.ts | 96 +++++++++- src/services/weather/forecast.ts | 184 ++++++++++++++++++++ src/services/weather/geocode.ts | 101 +++++++++++ src/services/weather/types.ts | 30 ++++ 7 files changed, 523 insertions(+), 137 deletions(-) create mode 100644 src/services/weather/forecast.ts create mode 100644 src/services/weather/geocode.ts create mode 100644 src/services/weather/types.ts diff --git a/README.md b/README.md index 4eff44a6..82958753 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,13 @@ OmegaBot is online │ │ ├── timezone │ │ │ ├── timezone.ts │ │ │ └── timezoneStore.ts -│ │ └── transcript -│ │ ├── buildTranscript.ts -│ │ └── defaults.ts +│ │ ├── transcript +│ │ │ ├── buildTranscript.ts +│ │ │ └── defaults.ts +│ │ └── weather +│ │ ├── forecast.ts +│ │ ├── geocode.ts +│ │ └── types.ts │ ├── utils │ │ └── logger.ts │ ├── bot.ts diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index b0138323..4cfd42cd 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -1,52 +1,47 @@ // src/commands/fun/fun.ts -import { SlashCommandBuilder } from "discord.js"; -import type { ChatInputCommandInteraction } from "discord.js"; - import { - run as runChuckNorris, - type ChuckNorrisMode, -} from "./subcommands/chucknorris.js"; -import { run as runCoinflip } from "./subcommands/coinflip.js"; -import { run as runDadjoke } from "./subcommands/dadjoke.js"; + 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 { run as runDice } from "./subcommands/dice.js"; + import { run as runWeather } from "./subcommands/weather.js"; +import type { TempUnit, WeatherMode } from "../../services/weather/types.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") + .setDescription("Random Chuck Norris fact") .addBooleanOption((o) => o .setName("ephemeral") .setDescription("Only show the result to you") .setRequired(false), - ) - .addStringOption((o) => - o - .setName("mode") - .setDescription("How to pick a fact") - .setRequired(false) - .addChoices( - { name: "random", value: "random" }, - { name: "category", value: "category" }, - { name: "search", value: "search" }, - ), - ) - .addStringOption((o) => - o - .setName("value") - .setDescription("Category name (for category) or search text (for search)") - .setRequired(false), ), ) + + // /fun dadjoke .addSubcommand((s) => s .setName("dadjoke") - .setDescription("Get a dad joke") + .setDescription("Random dad joke") .addBooleanOption((o) => o .setName("ephemeral") @@ -54,10 +49,12 @@ export const data = new SlashCommandBuilder() .setRequired(false), ), ) + + // /fun dice .addSubcommand((s) => s - .setName("coinflip") - .setDescription("Flip a coin") + .setName("dice") + .setDescription("Roll some dice") .addBooleanOption((o) => o .setName("ephemeral") @@ -65,14 +62,23 @@ export const data = new SlashCommandBuilder() .setRequired(false), ), ) + + // /fun weather (daily) .addSubcommand((s) => s - .setName("dice") - .setDescription("Roll a dice") - .addIntegerOption((o) => + .setName("weather") + .setDescription("Today’s weather for a location") + .addStringOption((o) => o - .setName("sides") - .setDescription("Number of sides (default: 6)") + .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) => @@ -82,16 +88,25 @@ export const data = new SlashCommandBuilder() .setRequired(false), ), ) + + // /fun weather7 (7-day) .addSubcommand((s) => s - .setName("weather") - .setDescription("Get the weather for a location") + .setName("weather7") + .setDescription("7-day forecast for a location") .addStringOption((o) => o .setName("location") - .setDescription("City, State or City, Country") + .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") @@ -100,37 +115,42 @@ export const data = new SlashCommandBuilder() ), ); -export async function execute(interaction: ChatInputCommandInteraction) { - const sub = interaction.options.getSubcommand(); - - // Defer exactly once. Subcommands should only call editReply(). +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; - if (!interaction.deferred && !interaction.replied) { - await interaction.deferReply({ ephemeral }); - } - if (sub === "chucknorris") { - const modeOpt = interaction.options.getString("mode") ?? "random"; - const value = (interaction.options.getString("value") ?? "").trim(); + // Parent command owns the interaction lifecycle + await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); - const mode: ChuckNorrisMode = - modeOpt === "category" - ? { kind: "category", category: value || "dev" } - : modeOpt === "search" - ? { kind: "search", query: value || "code" } - : { kind: "random" }; + try { + if (sub === "chucknorris") { + const mode: ChuckNorrisMode = { kind: "random" }; + return await runChuckNorris(interaction, mode); + } - return await runChuckNorris(interaction, mode); - } + if (sub === "dadjoke") { + return await runDadJoke(interaction); + } - if (sub === "dadjoke") return await runDadjoke(interaction); - if (sub === "coinflip") return await runCoinflip(interaction); + if (sub === "dice") { + return await runDice(interaction); + } - // Let subcommand read its own options (sides) from interaction - if (sub === "dice") return await runDice(interaction); + if (sub === "weather" || sub === "weather7") { + const location = interaction.options.getString("location", true).trim(); + const unit = parseTempUnit(interaction.options.getString("unit")); - // Let subcommand read its own options (location) from interaction - if (sub === "weather") return await runWeather(interaction); + const mode: WeatherMode = + sub === "weather" + ? { kind: "daily", location, unit } + : { kind: "7day", location, unit }; - return await interaction.editReply("Unknown fun subcommand."); + return await runWeather(interaction, mode); + } + + 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/src/commands/fun/subcommands/chucknorris.ts b/src/commands/fun/subcommands/chucknorris.ts index a0f6af30..c75503e8 100644 --- a/src/commands/fun/subcommands/chucknorris.ts +++ b/src/commands/fun/subcommands/chucknorris.ts @@ -1,23 +1,17 @@ -// src/commands/fun/fun.ts +// src/commands/fun/subcommands/chucknorris.ts import type { ChatInputCommandInteraction } from "discord.js"; import { logger } from "../../../utils/logger.js"; -/** - * Supported execution modes for Chuck Norris jokes. - * - * The parent command (fun.ts) decides which mode to use - * based on slash command options. - */ +type ChuckNorrisApiResponse = { + value: string; +}; + export type ChuckNorrisMode = | { kind: "random" } | { kind: "category"; category: string } | { kind: "search"; query: string }; -type ChuckNorrisApiResponse = { - value: string; -}; - /** * Run handler for /fun chucknorris * @@ -31,73 +25,36 @@ export async function run( mode: ChuckNorrisMode, ): Promise { try { - let joke: string; - - switch (mode.kind) { - case "category": - joke = await fetchCategoryJoke(mode.category); - break; - - case "search": - joke = await fetchSearchJoke(mode.query); - break; - - case "random": - default: - joke = await fetchRandomJoke(); - break; - } + const joke = await fetchChuckNorris(mode); await interaction.editReply(joke); logger.debug( - { - userId: interaction.user.id, - mode: mode.kind, - }, - "[fun/chucknorris] joke sent", + { userId: interaction.user.id, mode: mode.kind }, + "[fun/chucknorris] sent", ); } catch (err) { - logger.error({ err, mode }, "[fun/chucknorris] fetch failed"); - + logger.error({ err, mode }, "[fun/chucknorris] failed"); await interaction.editReply( "Chuck Norris is currently roundhouse kicking the API. Try again later.", ); } } -/* -------------------------------------------------------------------------- */ -/* API FETCHERS */ -/* -------------------------------------------------------------------------- */ +async function fetchChuckNorris(mode: ChuckNorrisMode): Promise { + if (mode.kind === "random") + return fetchSingle("https://api.chucknorris.io/jokes/random"); -/** - * Fetch a random Chuck Norris joke. - */ -async function fetchRandomJoke(): Promise { - return fetchJoke("https://api.chucknorris.io/jokes/random"); -} - -/** - * Fetch a random Chuck Norris joke from a specific category. - */ -async function fetchCategoryJoke(category: string): Promise { - const url = `https://api.chucknorris.io/jokes/random?category=${encodeURIComponent( - category, - )}`; - - return fetchJoke(url); -} + if (mode.kind === "category") { + const url = `https://api.chucknorris.io/jokes/random?category=${encodeURIComponent( + mode.category, + )}`; + return fetchSingle(url); + } -/** - * Fetch a Chuck Norris joke matching a search query. - * - * NOTE: - * The API returns an array for search results. - * We pick the first result for simplicity. - */ -async function fetchSearchJoke(query: string): Promise { + // mode.kind === "search" const url = `https://api.chucknorris.io/jokes/search?query=${encodeURIComponent( - query, + mode.query, )}`; const res = await fetch(url, { @@ -112,18 +69,16 @@ async function fetchSearchJoke(query: string): Promise { } const data = (await res.json()) as { result?: ChuckNorrisApiResponse[] }; + const first = data.result?.[0]?.value; - if (!data.result || data.result.length === 0) { - throw new Error("No Chuck Norris jokes found for that search"); + if (!first || typeof first !== "string") { + throw new Error("No results for that search"); } - return data.result[0].value.trim(); + return first.trim(); } -/** - * Shared helper for endpoints that return a single joke object. - */ -async function fetchJoke(url: string): Promise { +async function fetchSingle(url: string): Promise { const res = await fetch(url, { headers: { Accept: "application/json", diff --git a/src/commands/fun/subcommands/weather.ts b/src/commands/fun/subcommands/weather.ts index 0f7fa075..9eb1f57d 100644 --- a/src/commands/fun/subcommands/weather.ts +++ b/src/commands/fun/subcommands/weather.ts @@ -1,5 +1,97 @@ +// src/commands/fun/subcommands/weather.ts + import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; +import type { WeatherMode, TempUnit } from "../../../services/weather/types.js"; +import { geocodeLocation } from "../../../services/weather/geocode.js"; +import { fetchForecast } from "../../../services/weather/forecast.js"; + +/** + * Run handler for /fun weather + * + * IMPORTANT: + * - NOT a slash command by itself + * - Must NOT call reply() or deferReply() + * - Parent command owns the interaction lifecycle + */ +export async function run( + interaction: ChatInputCommandInteraction, + mode: WeatherMode, +): Promise { + try { + const point = await geocodeLocation(mode.location); + const forecast = await fetchForecast(point); + + const text = + mode.kind === "daily" + ? formatDaily(point.label, forecast, mode.unit) + : format7Day(point.label, forecast, mode.unit); + + await interaction.editReply(text); + + 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("🌩️ Weather API is being dramatic. Try again later."); + } +} + +/* -------------------------------------------------------------------------- */ +/* FORMATTERS */ +/* -------------------------------------------------------------------------- */ + +function formatTemp(celsius: number, unit: TempUnit): string { + return unit === "f" + ? `${Math.round((celsius * 9) / 5 + 32)}°F` + : `${Math.round(celsius)}°C`; +} + +function weatherEmoji(code: number): string { + if (code === 0) return "☀️"; + if (code <= 2) return "🌤️"; + if (code <= 45) return "☁️"; + if (code <= 65) return "🌧️"; + if (code <= 75) return "❄️"; + return "🌩️"; +} + +function formatDaily(label: string, forecast: any, unit: TempUnit): string { + const day = forecast.daily; + + const emoji = weatherEmoji(day.weathercode[0]); + const min = formatTemp(day.temperature_2m_min[0], unit); + const max = formatTemp(day.temperature_2m_max[0], unit); + + const rain = day.precipitation_probability_max?.[0] ?? 0; + + return [ + `📍 **${label}**`, + `${emoji} **Today**`, + `🌡️ ${min} → ${max}`, + `☔ ${rain}%`, + ].join("\n"); +} + +function format7Day(label: string, forecast: any, unit: TempUnit): string { + const lines: string[] = [`📍 **${label}**`, "📆 **7-Day Forecast**"]; + + for (let i = 0; i < 7; i++) { + const emoji = weatherEmoji(forecast.daily.weathercode[i]); + const min = formatTemp(forecast.daily.temperature_2m_min[i], unit); + const max = formatTemp(forecast.daily.temperature_2m_max[i], unit); + const rain = forecast.daily.precipitation_probability_max?.[i] ?? 0; + const date = forecast.daily.time[i]; + + lines.push(`${emoji} ${date}: ${min} → ${max} ☔ ${rain}%`); + } -export async function run(interaction: ChatInputCommandInteraction): Promise { - await interaction.editReply("ok"); + return lines.join("\n"); } diff --git a/src/services/weather/forecast.ts b/src/services/weather/forecast.ts new file mode 100644 index 00000000..ef6ae2ba --- /dev/null +++ b/src/services/weather/forecast.ts @@ -0,0 +1,184 @@ +// src/services/weather/forecast.ts + +import { logger } from "../../utils/logger.js"; +import type { TempUnit, WeatherPoint, WeatherMode } from "./types.js"; + +/** + * NWS forecast response shape (trimmed to what we use). + * Docs: https://www.weather.gov/documentation/services-web-api + */ +export type NwsForecastResponse = { + properties?: { + periods?: NwsPeriod[]; + }; +}; + +export type NwsPeriod = { + name?: string; // "Tonight", "Friday", etc. + startTime?: string; + endTime?: string; + isDaytime?: boolean; + temperature?: number; + temperatureUnit?: "F" | "C"; + windSpeed?: string; // "5 to 10 mph" + windDirection?: string; // "NW" + shortForecast?: string; // "Partly Cloudy" + detailedForecast?: string; + probabilityOfPrecipitation?: { + unitCode?: string; + value?: number | null; // percent as number, can be null + }; + relativeHumidity?: { + value?: number | null; + }; +}; + +/** + * Fetch forecast for a resolved weather point. + * Expects point.forecastUrl to be a valid NWS forecast endpoint. + */ +export async function fetchForecast(point: WeatherPoint): Promise { + const res = await fetch(point.forecastUrl, { + headers: { + Accept: "application/geo+json, application/json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + throw new Error(`NWS forecast error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as unknown; + + // Light validation so we fail with a useful message (not "cannot read 0") + const periods = (data as any)?.properties?.periods; + if (!Array.isArray(periods)) { + logger.warn( + { label: point.label, keys: Object.keys((data as any) ?? {}) }, + "[weather] forecast missing properties.periods", + ); + } + + return data as NwsForecastResponse; +} + +/** + * Format a single “daily” style forecast (first available period). + * This is used for /fun weather daily. + */ +export function formatDaily( + label: string, + forecast: NwsForecastResponse, + unit: TempUnit, +): string { + const periods = forecast?.properties?.periods; + + if (!Array.isArray(periods) || periods.length === 0) { + return `🌦️ ${label}\nNo forecast periods returned. Try another location or try again later.`; + } + + const p = periods[0]; + + const name = p.name ?? "Forecast"; + const short = p.shortForecast ?? "Weather data available"; + const temp = formatTemp(p.temperature, p.temperatureUnit, unit); + const wind = formatWind(p.windDirection, p.windSpeed); + const pop = formatPop(p.probabilityOfPrecipitation?.value); + + return [ + `🌦️ **${label}**`, + `🗓️ ${name}`, + `📋 ${short}`, + temp ? `🌡️ ${temp}` : null, + wind ? `💨 ${wind}` : null, + pop ? `☔ ${pop}` : null, + ] + .filter(Boolean) + .join("\n"); +} + +/** + * Format a simple 7-day style forecast (first 7-ish periods). + * This is used for /fun weather 7day. + */ +export function format7Day( + label: string, + forecast: NwsForecastResponse, + unit: TempUnit, +): string { + const periods = forecast?.properties?.periods; + + if (!Array.isArray(periods) || periods.length === 0) { + return `🌦️ ${label}\nNo forecast periods returned. Try another location or try again later.`; + } + + const take = periods.slice(0, 7); + + const lines = take.map((p) => { + const name = p.name ?? "Forecast"; + const short = p.shortForecast ?? "Weather"; + const temp = formatTemp(p.temperature, p.temperatureUnit, unit); + const pop = formatPop(p.probabilityOfPrecipitation?.value); + + const bits = [ + `• **${name}**: ${short}`, + temp ? `(${temp})` : null, + pop ? `☔ ${pop}` : null, + ].filter(Boolean); + + return bits.join(" "); + }); + + return [`🌦️ **${label}**`, ...lines].join("\n"); +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +function formatTemp( + temp?: number, + tempUnit?: "F" | "C", + desired?: TempUnit, +): string | null { + if (typeof temp !== "number" || !Number.isFinite(temp)) return null; + + // If API tells us the unit, convert if needed. + if (tempUnit === "F" && desired === "c") { + return `${fToC(temp)}°C`; + } + if (tempUnit === "C" && desired === "f") { + return `${cToF(temp)}°F`; + } + + // Otherwise, display as-is with best guess. + const shownUnit = tempUnit ?? (desired === "c" ? "C" : "F"); + return `${Math.round(temp)}°${shownUnit}`; +} + +function formatWind(dir?: string, speed?: string): string | null { + const d = dir?.trim(); + const s = speed?.trim(); + if (!d && !s) return null; + if (d && s) return `${d} ${s}`; + return d ?? s ?? null; +} + +/** + * Probability of precipitation. + * That "4%" you saw is usually "probabilityOfPrecipitation.value". + */ +function formatPop(value?: number | null): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return `${Math.round(value)}% chance`; +} + +function fToC(f: number): number { + return Math.round(((f - 32) * 5) / 9); +} + +function cToF(c: number): number { + return Math.round((c * 9) / 5 + 32); +} diff --git a/src/services/weather/geocode.ts b/src/services/weather/geocode.ts new file mode 100644 index 00000000..07ad9006 --- /dev/null +++ b/src/services/weather/geocode.ts @@ -0,0 +1,101 @@ +// src/services/weather/geocode.ts + +import type { WeatherPoint } from "./types.js"; +import { logger } from "../../utils/logger.js"; + +type ZippopotamPlace = { + latitude: string; + longitude: string; + "place name": string; + "state abbreviation": string; +}; + +type ZippopotamResponse = { + "post code": string; + places: ZippopotamPlace[]; +}; + +/** + * Resolve a user-provided location into a WeatherPoint. + * + * Current behavior: + * - If the input looks like a US ZIP, use Zippopotam.us to get lat/lon + * - Then use NWS points endpoint to get the forecast URL + */ +export async function geocodeLocation(location: string): Promise { + const trimmed = location.trim(); + + // Simple US ZIP detection (5 digits, optionally ZIP+4) + const zipMatch = trimmed.match(/^\d{5}(-\d{4})?$/); + if (!zipMatch) { + throw new Error('Only US ZIP codes are supported right now (example: "02067").'); + } + + const zip = zipMatch[0]; + + const geo = await geocodeZipUS(zip); + const forecastUrl = await getNwsForecastUrl(geo.lat, geo.lon); + + return { + lat: geo.lat, + lon: geo.lon, + label: geo.label, + forecastUrl, + }; +} + +type ZipGeo = { lat: number; lon: number; label: string }; + +async function geocodeZipUS(zip: string): Promise { + const url = `https://api.zippopotam.us/us/${encodeURIComponent(zip)}`; + const res = await fetch(url, { headers: { "User-Agent": "OmegaBot" } }); + + if (!res.ok) { + throw new Error(`ZIP not found: ${zip}`); + } + + const data = (await res.json()) as ZippopotamResponse; + const place = data.places?.[0]; + if (!place) { + throw new Error(`No places for ZIP: ${zip}`); + } + + const lat = Number(place.latitude); + const lon = Number(place.longitude); + + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + throw new Error(`Invalid lat/lon returned for ZIP: ${zip}`); + } + + return { + lat, + lon, + label: `${place["place name"]}, ${place["state abbreviation"]} ${data["post code"]}`, + }; +} + +async function getNwsForecastUrl(lat: number, lon: number): Promise { + const url = `https://api.weather.gov/points/${lat},${lon}`; + const res = await fetch(url, { + headers: { + Accept: "application/geo+json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + throw new Error(`NWS points error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as { + properties?: { forecast?: string }; + }; + + const forecastUrl = data.properties?.forecast; + if (!forecastUrl) { + throw new Error("NWS points response missing forecast URL"); + } + + logger.debug({ lat, lon, forecastUrl }, "[weather] resolved forecast url"); + return forecastUrl; +} diff --git a/src/services/weather/types.ts b/src/services/weather/types.ts new file mode 100644 index 00000000..51885436 --- /dev/null +++ b/src/services/weather/types.ts @@ -0,0 +1,30 @@ +// 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 }; + +/** + * A resolved geographic point returned from geocoding. + * This is what forecast fetchers consume. + */ +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; +}; From 7c7d700f1c54d0fe0f9eedc3ea40dd609f59253b Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 22 Dec 2025 13:53:08 -0500 Subject: [PATCH 6/8] Adding in files for this --- .eslintcache | 1 + README.md | 3 +- src/commands/fun/fun.ts | 49 +++++++- src/commands/fun/subcommands/dadjoke.ts | 120 ++++++++++++++++++- src/commands/fun/subcommands/dice.ts | 70 ++++++++++- src/commands/fun/subcommands/weather.ts | 151 +++++++++++++++++++----- src/services/weather/types.ts | 19 +++ 7 files changed, 372 insertions(+), 41 deletions(-) create mode 100644 .eslintcache diff --git a/.eslintcache b/.eslintcache new file mode 100644 index 00000000..da45b755 --- /dev/null +++ b/.eslintcache @@ -0,0 +1 @@ +[{"/Users/nicholas.clark/git/git-personal/OmegaBot/src/bot.ts":"1","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/changelog/changelog.ts":"2","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/fun.ts":"3","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/chucknorris.ts":"4","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/coinflip.ts":"5","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dadjoke.ts":"6","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dice.ts":"7","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/weather.ts":"8","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/general/ping.ts":"9","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/gh.ts":"10","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/pr.ts":"11","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/history/history.ts":"12","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/pagination/pagination.ts":"13","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/playback/playback.ts":"14","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/summary/summary.ts":"15","/Users/nicholas.clark/git/git-personal/OmegaBot/src/config/env.ts":"16","/Users/nicholas.clark/git/git-personal/OmegaBot/src/registerCommands.ts":"17","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/commandLoader.ts":"18","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/fetchChannelMessages.ts":"19","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/interactionHandler.ts":"20","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubApi.ts":"21","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubClient.ts":"22","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/lastSeenStore.ts":"23","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prFormatter.ts":"24","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prPoller.ts":"25","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/types.ts":"26","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/llmSummary.ts":"27","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/localSummary.ts":"28","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/summarizer.ts":"29","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/formatTimestamp.ts":"30","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/validateTimezone.ts":"31","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezone.ts":"32","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezoneStore.ts":"33","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/buildTranscript.ts":"34","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/defaults.ts":"35","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/forecast.ts":"36","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/geocode.ts":"37","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/types.ts":"38","/Users/nicholas.clark/git/git-personal/OmegaBot/src/utils/logger.ts":"39"},{"size":2107,"mtime":1766014869965,"results":"40","hashOfConfig":"41"},{"size":1408,"mtime":1766015892779,"results":"42","hashOfConfig":"41"},{"size":4389,"mtime":1766182097698,"results":"43","hashOfConfig":"41"},{"size":2590,"mtime":1766181050230,"results":"44","hashOfConfig":"41"},{"size":1061,"mtime":1766173545609,"results":"45","hashOfConfig":"41"},{"size":188,"mtime":1766170724449,"results":"46","hashOfConfig":"41"},{"size":188,"mtime":1766170724451,"results":"47","hashOfConfig":"41"},{"size":3098,"mtime":1766181050243,"results":"48","hashOfConfig":"41"},{"size":2038,"mtime":1766014869966,"results":"49","hashOfConfig":"41"},{"size":4498,"mtime":1766014869966,"results":"50","hashOfConfig":"41"},{"size":2040,"mtime":1766014869967,"results":"51","hashOfConfig":"41"},{"size":4128,"mtime":1766014869967,"results":"52","hashOfConfig":"41"},{"size":5768,"mtime":1766014869968,"results":"53","hashOfConfig":"41"},{"size":5189,"mtime":1766014869968,"results":"54","hashOfConfig":"41"},{"size":4468,"mtime":1766014869969,"results":"55","hashOfConfig":"41"},{"size":4412,"mtime":1766014869969,"results":"56","hashOfConfig":"41"},{"size":2435,"mtime":1766014869970,"results":"57","hashOfConfig":"41"},{"size":2794,"mtime":1766014869970,"results":"58","hashOfConfig":"41"},{"size":1622,"mtime":1766014869971,"results":"59","hashOfConfig":"41"},{"size":2105,"mtime":1766014869971,"results":"60","hashOfConfig":"41"},{"size":6705,"mtime":1766014869972,"results":"61","hashOfConfig":"41"},{"size":2264,"mtime":1766014869972,"results":"62","hashOfConfig":"41"},{"size":3310,"mtime":1766014869973,"results":"63","hashOfConfig":"41"},{"size":630,"mtime":1766013607778,"results":"64","hashOfConfig":"41"},{"size":2392,"mtime":1766014869973,"results":"65","hashOfConfig":"41"},{"size":2918,"mtime":1766013607779,"results":"66","hashOfConfig":"41"},{"size":1996,"mtime":1766014869974,"results":"67","hashOfConfig":"41"},{"size":1862,"mtime":1765572646550,"results":"68","hashOfConfig":"41"},{"size":474,"mtime":1765591002966,"results":"69","hashOfConfig":"41"},{"size":598,"mtime":1765910726381,"results":"70","hashOfConfig":"41"},{"size":729,"mtime":1766014869974,"results":"71","hashOfConfig":"41"},{"size":2470,"mtime":1766014869975,"results":"72","hashOfConfig":"41"},{"size":2639,"mtime":1766014869975,"results":"73","hashOfConfig":"41"},{"size":2160,"mtime":1766014869975,"results":"74","hashOfConfig":"41"},{"size":796,"mtime":1765910726383,"results":"75","hashOfConfig":"41"},{"size":5217,"mtime":1766182097843,"results":"76","hashOfConfig":"41"},{"size":2654,"mtime":1766182097848,"results":"77","hashOfConfig":"41"},{"size":643,"mtime":1766181887879,"results":"78","hashOfConfig":"41"},{"size":1202,"mtime":1766014869976,"results":"79","hashOfConfig":"41"},{"filePath":"80","messages":"81","suppressedMessages":"82","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"4r3etk",{"filePath":"83","messages":"84","suppressedMessages":"85","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"86","messages":"87","suppressedMessages":"88","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"89","messages":"90","suppressedMessages":"91","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"92","messages":"93","suppressedMessages":"94","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"95","messages":"96","suppressedMessages":"97","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"98","messages":"99","suppressedMessages":"100","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"101","messages":"102","suppressedMessages":"103","errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"104","messages":"105","suppressedMessages":"106","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"107","messages":"108","suppressedMessages":"109","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"110","messages":"111","suppressedMessages":"112","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"113","messages":"114","suppressedMessages":"115","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"116","messages":"117","suppressedMessages":"118","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"119","messages":"120","suppressedMessages":"121","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"122","messages":"123","suppressedMessages":"124","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"125","messages":"126","suppressedMessages":"127","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"128","messages":"129","suppressedMessages":"130","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"131","messages":"132","suppressedMessages":"133","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"134","messages":"135","suppressedMessages":"136","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"137","messages":"138","suppressedMessages":"139","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"140","messages":"141","suppressedMessages":"142","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"143","messages":"144","suppressedMessages":"145","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"146","messages":"147","suppressedMessages":"148","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"149","messages":"150","suppressedMessages":"151","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"152","messages":"153","suppressedMessages":"154","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"155","messages":"156","suppressedMessages":"157","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"158","messages":"159","suppressedMessages":"160","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"161","messages":"162","suppressedMessages":"163","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"164","messages":"165","suppressedMessages":"166","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"167","messages":"168","suppressedMessages":"169","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"170","messages":"171","suppressedMessages":"172","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"173","messages":"174","suppressedMessages":"175","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","suppressedMessages":"178","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"179","messages":"180","suppressedMessages":"181","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","suppressedMessages":"184","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"185","messages":"186","suppressedMessages":"187","errorCount":2,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"188","messages":"189","suppressedMessages":"190","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"191","messages":"192","suppressedMessages":"193","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","suppressedMessages":"196","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/nicholas.clark/git/git-personal/OmegaBot/src/bot.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/changelog/changelog.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/fun.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/chucknorris.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/coinflip.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dadjoke.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dice.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/weather.ts",["197","198"],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/general/ping.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/gh.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/pr.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/history/history.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/pagination/pagination.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/playback/playback.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/summary/summary.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/config/env.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/registerCommands.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/commandLoader.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/fetchChannelMessages.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/interactionHandler.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubApi.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubClient.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/lastSeenStore.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prFormatter.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prPoller.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/types.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/llmSummary.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/localSummary.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/summarizer.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/formatTimestamp.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/validateTimezone.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezone.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezoneStore.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/buildTranscript.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/defaults.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/forecast.ts",["199","200","201"],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/geocode.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/types.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/utils/logger.ts",[],[],{"ruleId":"202","severity":2,"message":"203","line":66,"column":47,"nodeType":"204","messageId":"205","endLine":66,"endColumn":50,"suggestions":"206"},{"ruleId":"202","severity":2,"message":"203","line":83,"column":46,"nodeType":"204","messageId":"205","endLine":83,"endColumn":49,"suggestions":"207"},{"ruleId":"208","severity":1,"message":"209","line":4,"column":39,"nodeType":null,"messageId":"210","endLine":4,"endColumn":50},{"ruleId":"202","severity":2,"message":"203","line":55,"column":28,"nodeType":"204","messageId":"205","endLine":55,"endColumn":31,"suggestions":"211"},{"ruleId":"202","severity":2,"message":"203","line":58,"column":56,"nodeType":"204","messageId":"205","endLine":58,"endColumn":59,"suggestions":"212"},"@typescript-eslint/no-explicit-any","Unexpected any. Specify a different type.","TSAnyKeyword","unexpectedAny",["213","214"],["215","216"],"@typescript-eslint/no-unused-vars","'WeatherMode' is defined but never used. Allowed unused vars must match /^_/u.","unusedVar",["217","218"],["219","220"],{"messageId":"221","fix":"222","desc":"223"},{"messageId":"224","fix":"225","desc":"226"},{"messageId":"221","fix":"227","desc":"223"},{"messageId":"224","fix":"228","desc":"226"},{"messageId":"221","fix":"229","desc":"223"},{"messageId":"224","fix":"230","desc":"226"},{"messageId":"221","fix":"231","desc":"223"},{"messageId":"224","fix":"232","desc":"226"},"suggestUnknown",{"range":"233","text":"234"},"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct.","suggestNever",{"range":"235","text":"236"},"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of.",{"range":"237","text":"234"},{"range":"238","text":"236"},{"range":"239","text":"234"},{"range":"240","text":"236"},{"range":"241","text":"234"},{"range":"242","text":"236"},[2033,2036],"unknown",[2033,2036],"never",[2494,2497],[2494,2497],[1519,1522],[1519,1522],[1651,1654],[1651,1654]] \ No newline at end of file diff --git a/README.md b/README.md index 82958753..b085c70e 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ OmegaBot is online ``` . -├── .env.example +├── .env-example ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug.yml @@ -209,6 +209,7 @@ OmegaBot is online │ ├── bot.ts │ └── registerCommands.ts ├── .env.example +├── .eslintcache ├── .gitignore ├── .prettierignore ├── .prettierrc.yml diff --git a/src/commands/fun/fun.ts b/src/commands/fun/fun.ts index 4cfd42cd..9cfa976d 100644 --- a/src/commands/fun/fun.ts +++ b/src/commands/fun/fun.ts @@ -11,6 +11,8 @@ 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"; @@ -28,7 +30,13 @@ export const data = new SlashCommandBuilder() .addSubcommand((s) => s .setName("chucknorris") - .setDescription("Random Chuck Norris fact") + .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") @@ -41,7 +49,10 @@ export const data = new SlashCommandBuilder() .addSubcommand((s) => s .setName("dadjoke") - .setDescription("Random dad joke") + .setDescription("Random dad joke (or search)") + .addStringOption((o) => + o.setName("query").setDescription("Search term (optional)").setRequired(false), + ) .addBooleanOption((o) => o .setName("ephemeral") @@ -55,6 +66,22 @@ export const data = new SlashCommandBuilder() 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") @@ -124,12 +151,24 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise try { if (sub === "chucknorris") { - const mode: ChuckNorrisMode = { kind: "random" }; + 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" }; + return await runChuckNorris(interaction, mode); } if (sub === "dadjoke") { - return await runDadJoke(interaction); + const query = interaction.options.getString("search")?.trim() ?? ""; + + const mode: DadJokeMode = query ? { kind: "search", query } : { kind: "random" }; + + return await runDadJoke(interaction, mode); } if (sub === "dice") { @@ -138,7 +177,7 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise if (sub === "weather" || sub === "weather7") { const location = interaction.options.getString("location", true).trim(); - const unit = parseTempUnit(interaction.options.getString("unit")); + const unit = parseTempUnit(interaction.options.getString("unit") ?? "f"); const mode: WeatherMode = sub === "weather" diff --git a/src/commands/fun/subcommands/dadjoke.ts b/src/commands/fun/subcommands/dadjoke.ts index 0f7fa075..a84022ac 100644 --- a/src/commands/fun/subcommands/dadjoke.ts +++ b/src/commands/fun/subcommands/dadjoke.ts @@ -1,5 +1,121 @@ +// 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 }; + +/** + * 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"); + 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(); + } + + return fetchSearch(q); +} + +/** + * Random joke + * Docs: https://icanhazdadjoke.com/api + */ +async function fetchRandom(): Promise { + const res = await fetch("https://icanhazdadjoke.com/", { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + throw new Error(`Dad Joke API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as DadJokeRandomResponse; + + if (!data?.joke || typeof data.joke !== "string") { + throw new Error("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 = `https://icanhazdadjoke.com/search?term=${encodeURIComponent(query)}`; + + const res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmegaBot", + }, + }); + + if (!res.ok) { + throw new Error(`Dad Joke API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as DadJokeSearchResponse; + + const results = data?.results; + if (!Array.isArray(results) || results.length === 0) { + throw new Error("No results for that search"); + } + + const pick = results[Math.floor(Math.random() * results.length)]; + if (!pick?.joke || typeof pick.joke !== "string") { + throw new Error("Dad Joke API returned an unexpected response shape"); + } -export async function run(interaction: ChatInputCommandInteraction): Promise { - await interaction.editReply("ok"); + return pick.joke.trim(); } diff --git a/src/commands/fun/subcommands/dice.ts b/src/commands/fun/subcommands/dice.ts index 0f7fa075..da7cfc69 100644 --- a/src/commands/fun/subcommands/dice.ts +++ b/src/commands/fun/subcommands/dice.ts @@ -1,5 +1,73 @@ 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 = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"]; + +/** + * Run handler for /fun dice + * + * IMPORTANT: + * - NOT a slash command by itself + * - Must NOT call reply() or deferReply() + * - Parent command owns the interaction lifecycle + */ export async function run(interaction: ChatInputCommandInteraction): Promise { - await interaction.editReply("ok"); + const sides = interaction.options.getInteger("sides") ?? 6; + + try { + // Clamp just in case Discord validation ever changes + const safeSides = Math.min(Math.max(sides, 2), 100); + + await rollAnimation(interaction, safeSides); + + const roll = Math.floor(Math.random() * safeSides) + 1; + + const result = + safeSides === 6 + ? `🎲 **You rolled:** ${D6_FACES[roll - 1]} (${roll})` + : `🎲 **You rolled:** ${roll} (d${safeSides})`; + + await interaction.editReply(result); + + logger.debug( + { + userId: interaction.user.id, + sides: safeSides, + roll, + }, + "[fun/dice] roll complete", + ); + } catch (err) { + logger.error({ err }, "[fun/dice] failed"); + await interaction.editReply("🎲 The dice fell off the table. Try again."); + } +} + +/* -------------------------------------------------------------------------- */ +/* ANIMATION */ +/* -------------------------------------------------------------------------- */ + +async function rollAnimation( + interaction: ChatInputCommandInteraction, + sides: number, +): Promise { + const frames = 8; + + for (let i = 0; i < frames; i++) { + const frame = + sides === 6 + ? D6_FACES[Math.floor(Math.random() * D6_FACES.length)] + : Math.floor(Math.random() * sides) + 1; + + await interaction.editReply(`🎲 Rolling… ${frame}`); + await sleep(120); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/commands/fun/subcommands/weather.ts b/src/commands/fun/subcommands/weather.ts index 9eb1f57d..3d7c7a8c 100644 --- a/src/commands/fun/subcommands/weather.ts +++ b/src/commands/fun/subcommands/weather.ts @@ -2,12 +2,16 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { logger } from "../../../utils/logger.js"; -import type { WeatherMode, TempUnit } from "../../../services/weather/types.js"; +import type { + WeatherMode, + TempUnit, + NwsForecastResponse, +} from "../../../services/weather/types.js"; import { geocodeLocation } from "../../../services/weather/geocode.js"; import { fetchForecast } from "../../../services/weather/forecast.js"; /** - * Run handler for /fun weather + * Run handler for /fun weather and /fun weather7 * * IMPORTANT: * - NOT a slash command by itself @@ -20,7 +24,7 @@ export async function run( ): Promise { try { const point = await geocodeLocation(mode.location); - const forecast = await fetchForecast(point); + const forecast = (await fetchForecast(point)) as NwsForecastResponse; const text = mode.kind === "daily" @@ -45,52 +49,135 @@ export async function run( } /* -------------------------------------------------------------------------- */ -/* FORMATTERS */ +/* TYPES */ /* -------------------------------------------------------------------------- */ -function formatTemp(celsius: number, unit: TempUnit): string { - return unit === "f" - ? `${Math.round((celsius * 9) / 5 + 32)}°F` - : `${Math.round(celsius)}°C`; +type NwsPeriod = { + name?: string; + startTime?: string; + isDaytime?: boolean; + temperature?: number; + temperatureUnit?: string; // "F" or "C" typically + shortForecast?: string; + detailedForecast?: string; + probabilityOfPrecipitation?: { value: number | null } | null; +}; + +function getPeriods(forecast: NwsForecastResponse): NwsPeriod[] { + const periods = forecast?.properties?.periods; + return Array.isArray(periods) ? (periods as NwsPeriod[]) : []; } -function weatherEmoji(code: number): string { - if (code === 0) return "☀️"; - if (code <= 2) return "🌤️"; - if (code <= 45) return "☁️"; - if (code <= 65) return "🌧️"; - if (code <= 75) return "❄️"; - return "🌩️"; +/* -------------------------------------------------------------------------- */ +/* FORMATTERS */ +/* -------------------------------------------------------------------------- */ + +function toC(f: number): number { + return (f - 32) * (5 / 9); +} + +function toF(c: number): number { + return c * (9 / 5) + 32; +} + +function formatTemp( + value: number, + fromUnit: string | undefined, + target: TempUnit, +): string { + const from = (fromUnit ?? "F").toUpperCase(); + + // target is "f" | "c" + if (target === "f") { + const f = from === "C" ? toF(value) : value; + return `${Math.round(f)}°F`; + } + + const c = from === "F" ? toC(value) : value; + return `${Math.round(c)}°C`; +} + +function forecastEmoji(text: string): string { + const t = text.toLowerCase(); + if (t.includes("thunder")) return "🌩️"; + if (t.includes("snow") || t.includes("flurr")) return "❄️"; + if (t.includes("rain") || t.includes("shower") || t.includes("drizzle")) return "🌧️"; + if (t.includes("cloud")) return "☁️"; + if (t.includes("sun") || t.includes("clear")) return "☀️"; + if (t.includes("fog") || t.includes("haze")) return "🌫️"; + return "🌤️"; +} + +function safePop(period: NwsPeriod): number { + const v = period.probabilityOfPrecipitation?.value; + return typeof v === "number" ? v : 0; } -function formatDaily(label: string, forecast: any, unit: TempUnit): string { - const day = forecast.daily; +function formatDaily( + label: string, + forecast: NwsForecastResponse, + unit: TempUnit, +): string { + const periods = getPeriods(forecast); + + if (periods.length === 0) { + return `📍 **${label}**\nNo forecast data available right now.`; + } + + const p = periods[0]; + + const name = p.name ?? "Today"; + const short = p.shortForecast ?? "Forecast unavailable"; + const emoji = forecastEmoji(short); + + const temp = + typeof p.temperature === "number" + ? formatTemp(p.temperature, p.temperatureUnit, unit) + : "N/A"; - const emoji = weatherEmoji(day.weathercode[0]); - const min = formatTemp(day.temperature_2m_min[0], unit); - const max = formatTemp(day.temperature_2m_max[0], unit); + const pop = safePop(p); - const rain = day.precipitation_probability_max?.[0] ?? 0; + const details = p.detailedForecast ?? short; return [ `📍 **${label}**`, - `${emoji} **Today**`, - `🌡️ ${min} → ${max}`, - `☔ ${rain}%`, + `${emoji} **${name}**`, + `🌡️ ${temp}`, + `☔ ${pop}%`, + details, ].join("\n"); } -function format7Day(label: string, forecast: any, unit: TempUnit): string { +function format7Day( + label: string, + forecast: NwsForecastResponse, + unit: TempUnit, +): string { + const periods = getPeriods(forecast); + + if (periods.length === 0) { + return `📍 **${label}**\nNo forecast data available right now.`; + } + + // Prefer daytime periods for a cleaner "7 day" list. + const dayPeriods = periods.filter((p) => p.isDaytime === true); + const list = (dayPeriods.length ? dayPeriods : periods).slice(0, 7); + const lines: string[] = [`📍 **${label}**`, "📆 **7-Day Forecast**"]; - for (let i = 0; i < 7; i++) { - const emoji = weatherEmoji(forecast.daily.weathercode[i]); - const min = formatTemp(forecast.daily.temperature_2m_min[i], unit); - const max = formatTemp(forecast.daily.temperature_2m_max[i], unit); - const rain = forecast.daily.precipitation_probability_max?.[i] ?? 0; - const date = forecast.daily.time[i]; + for (const p of list) { + const name = p.name ?? "Day"; + const short = p.shortForecast ?? "Forecast unavailable"; + const emoji = forecastEmoji(short); + + const temp = + typeof p.temperature === "number" + ? formatTemp(p.temperature, p.temperatureUnit, unit) + : "N/A"; + + const pop = safePop(p); - lines.push(`${emoji} ${date}: ${min} → ${max} ☔ ${rain}%`); + lines.push(`${emoji} **${name}**: ${temp} ☔ ${pop}%`); } return lines.join("\n"); diff --git a/src/services/weather/types.ts b/src/services/weather/types.ts index 51885436..a9925e4d 100644 --- a/src/services/weather/types.ts +++ b/src/services/weather/types.ts @@ -28,3 +28,22 @@ export type WeatherPoint = { */ forecastUrl: string; }; + +/** + * Minimal NWS forecast response shape we actually use. + * https://api.weather.gov/gridpoints/{office}/{gridX},{gridY}/forecast + */ +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; + }>; + }; +}; From c531b4858d3f917c9cbae05bee6797596eb57b86 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 22 Dec 2025 13:59:30 -0500 Subject: [PATCH 7/8] Adding in more changes --- .eslintcache | 1 - src/services/weather/forecast.ts | 15 +++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) delete mode 100644 .eslintcache diff --git a/.eslintcache b/.eslintcache deleted file mode 100644 index da45b755..00000000 --- a/.eslintcache +++ /dev/null @@ -1 +0,0 @@ -[{"/Users/nicholas.clark/git/git-personal/OmegaBot/src/bot.ts":"1","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/changelog/changelog.ts":"2","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/fun.ts":"3","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/chucknorris.ts":"4","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/coinflip.ts":"5","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dadjoke.ts":"6","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dice.ts":"7","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/weather.ts":"8","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/general/ping.ts":"9","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/gh.ts":"10","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/pr.ts":"11","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/history/history.ts":"12","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/pagination/pagination.ts":"13","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/playback/playback.ts":"14","/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/summary/summary.ts":"15","/Users/nicholas.clark/git/git-personal/OmegaBot/src/config/env.ts":"16","/Users/nicholas.clark/git/git-personal/OmegaBot/src/registerCommands.ts":"17","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/commandLoader.ts":"18","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/fetchChannelMessages.ts":"19","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/interactionHandler.ts":"20","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubApi.ts":"21","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubClient.ts":"22","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/lastSeenStore.ts":"23","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prFormatter.ts":"24","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prPoller.ts":"25","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/types.ts":"26","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/llmSummary.ts":"27","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/localSummary.ts":"28","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/summarizer.ts":"29","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/formatTimestamp.ts":"30","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/validateTimezone.ts":"31","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezone.ts":"32","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezoneStore.ts":"33","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/buildTranscript.ts":"34","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/defaults.ts":"35","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/forecast.ts":"36","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/geocode.ts":"37","/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/types.ts":"38","/Users/nicholas.clark/git/git-personal/OmegaBot/src/utils/logger.ts":"39"},{"size":2107,"mtime":1766014869965,"results":"40","hashOfConfig":"41"},{"size":1408,"mtime":1766015892779,"results":"42","hashOfConfig":"41"},{"size":4389,"mtime":1766182097698,"results":"43","hashOfConfig":"41"},{"size":2590,"mtime":1766181050230,"results":"44","hashOfConfig":"41"},{"size":1061,"mtime":1766173545609,"results":"45","hashOfConfig":"41"},{"size":188,"mtime":1766170724449,"results":"46","hashOfConfig":"41"},{"size":188,"mtime":1766170724451,"results":"47","hashOfConfig":"41"},{"size":3098,"mtime":1766181050243,"results":"48","hashOfConfig":"41"},{"size":2038,"mtime":1766014869966,"results":"49","hashOfConfig":"41"},{"size":4498,"mtime":1766014869966,"results":"50","hashOfConfig":"41"},{"size":2040,"mtime":1766014869967,"results":"51","hashOfConfig":"41"},{"size":4128,"mtime":1766014869967,"results":"52","hashOfConfig":"41"},{"size":5768,"mtime":1766014869968,"results":"53","hashOfConfig":"41"},{"size":5189,"mtime":1766014869968,"results":"54","hashOfConfig":"41"},{"size":4468,"mtime":1766014869969,"results":"55","hashOfConfig":"41"},{"size":4412,"mtime":1766014869969,"results":"56","hashOfConfig":"41"},{"size":2435,"mtime":1766014869970,"results":"57","hashOfConfig":"41"},{"size":2794,"mtime":1766014869970,"results":"58","hashOfConfig":"41"},{"size":1622,"mtime":1766014869971,"results":"59","hashOfConfig":"41"},{"size":2105,"mtime":1766014869971,"results":"60","hashOfConfig":"41"},{"size":6705,"mtime":1766014869972,"results":"61","hashOfConfig":"41"},{"size":2264,"mtime":1766014869972,"results":"62","hashOfConfig":"41"},{"size":3310,"mtime":1766014869973,"results":"63","hashOfConfig":"41"},{"size":630,"mtime":1766013607778,"results":"64","hashOfConfig":"41"},{"size":2392,"mtime":1766014869973,"results":"65","hashOfConfig":"41"},{"size":2918,"mtime":1766013607779,"results":"66","hashOfConfig":"41"},{"size":1996,"mtime":1766014869974,"results":"67","hashOfConfig":"41"},{"size":1862,"mtime":1765572646550,"results":"68","hashOfConfig":"41"},{"size":474,"mtime":1765591002966,"results":"69","hashOfConfig":"41"},{"size":598,"mtime":1765910726381,"results":"70","hashOfConfig":"41"},{"size":729,"mtime":1766014869974,"results":"71","hashOfConfig":"41"},{"size":2470,"mtime":1766014869975,"results":"72","hashOfConfig":"41"},{"size":2639,"mtime":1766014869975,"results":"73","hashOfConfig":"41"},{"size":2160,"mtime":1766014869975,"results":"74","hashOfConfig":"41"},{"size":796,"mtime":1765910726383,"results":"75","hashOfConfig":"41"},{"size":5217,"mtime":1766182097843,"results":"76","hashOfConfig":"41"},{"size":2654,"mtime":1766182097848,"results":"77","hashOfConfig":"41"},{"size":643,"mtime":1766181887879,"results":"78","hashOfConfig":"41"},{"size":1202,"mtime":1766014869976,"results":"79","hashOfConfig":"41"},{"filePath":"80","messages":"81","suppressedMessages":"82","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"4r3etk",{"filePath":"83","messages":"84","suppressedMessages":"85","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"86","messages":"87","suppressedMessages":"88","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"89","messages":"90","suppressedMessages":"91","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"92","messages":"93","suppressedMessages":"94","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"95","messages":"96","suppressedMessages":"97","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"98","messages":"99","suppressedMessages":"100","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"101","messages":"102","suppressedMessages":"103","errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"104","messages":"105","suppressedMessages":"106","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"107","messages":"108","suppressedMessages":"109","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"110","messages":"111","suppressedMessages":"112","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"113","messages":"114","suppressedMessages":"115","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"116","messages":"117","suppressedMessages":"118","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"119","messages":"120","suppressedMessages":"121","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"122","messages":"123","suppressedMessages":"124","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"125","messages":"126","suppressedMessages":"127","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"128","messages":"129","suppressedMessages":"130","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"131","messages":"132","suppressedMessages":"133","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"134","messages":"135","suppressedMessages":"136","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"137","messages":"138","suppressedMessages":"139","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"140","messages":"141","suppressedMessages":"142","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"143","messages":"144","suppressedMessages":"145","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"146","messages":"147","suppressedMessages":"148","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"149","messages":"150","suppressedMessages":"151","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"152","messages":"153","suppressedMessages":"154","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"155","messages":"156","suppressedMessages":"157","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"158","messages":"159","suppressedMessages":"160","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"161","messages":"162","suppressedMessages":"163","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"164","messages":"165","suppressedMessages":"166","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"167","messages":"168","suppressedMessages":"169","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"170","messages":"171","suppressedMessages":"172","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"173","messages":"174","suppressedMessages":"175","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","suppressedMessages":"178","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"179","messages":"180","suppressedMessages":"181","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","suppressedMessages":"184","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"185","messages":"186","suppressedMessages":"187","errorCount":2,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"188","messages":"189","suppressedMessages":"190","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"191","messages":"192","suppressedMessages":"193","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","suppressedMessages":"196","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/nicholas.clark/git/git-personal/OmegaBot/src/bot.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/changelog/changelog.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/fun.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/chucknorris.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/coinflip.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dadjoke.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/dice.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/fun/subcommands/weather.ts",["197","198"],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/general/ping.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/gh.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/github/pr.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/history/history.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/pagination/pagination.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/playback/playback.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/commands/summary/summary.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/config/env.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/registerCommands.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/commandLoader.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/fetchChannelMessages.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/discord/interactionHandler.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubApi.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/githubClient.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/lastSeenStore.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prFormatter.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/prPoller.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/github/types.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/llmSummary.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/localSummary.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/summary/summarizer.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/formatTimestamp.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/time/validateTimezone.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezone.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/timezone/timezoneStore.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/buildTranscript.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/transcript/defaults.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/forecast.ts",["199","200","201"],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/geocode.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/services/weather/types.ts",[],[],"/Users/nicholas.clark/git/git-personal/OmegaBot/src/utils/logger.ts",[],[],{"ruleId":"202","severity":2,"message":"203","line":66,"column":47,"nodeType":"204","messageId":"205","endLine":66,"endColumn":50,"suggestions":"206"},{"ruleId":"202","severity":2,"message":"203","line":83,"column":46,"nodeType":"204","messageId":"205","endLine":83,"endColumn":49,"suggestions":"207"},{"ruleId":"208","severity":1,"message":"209","line":4,"column":39,"nodeType":null,"messageId":"210","endLine":4,"endColumn":50},{"ruleId":"202","severity":2,"message":"203","line":55,"column":28,"nodeType":"204","messageId":"205","endLine":55,"endColumn":31,"suggestions":"211"},{"ruleId":"202","severity":2,"message":"203","line":58,"column":56,"nodeType":"204","messageId":"205","endLine":58,"endColumn":59,"suggestions":"212"},"@typescript-eslint/no-explicit-any","Unexpected any. Specify a different type.","TSAnyKeyword","unexpectedAny",["213","214"],["215","216"],"@typescript-eslint/no-unused-vars","'WeatherMode' is defined but never used. Allowed unused vars must match /^_/u.","unusedVar",["217","218"],["219","220"],{"messageId":"221","fix":"222","desc":"223"},{"messageId":"224","fix":"225","desc":"226"},{"messageId":"221","fix":"227","desc":"223"},{"messageId":"224","fix":"228","desc":"226"},{"messageId":"221","fix":"229","desc":"223"},{"messageId":"224","fix":"230","desc":"226"},{"messageId":"221","fix":"231","desc":"223"},{"messageId":"224","fix":"232","desc":"226"},"suggestUnknown",{"range":"233","text":"234"},"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct.","suggestNever",{"range":"235","text":"236"},"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of.",{"range":"237","text":"234"},{"range":"238","text":"236"},{"range":"239","text":"234"},{"range":"240","text":"236"},{"range":"241","text":"234"},{"range":"242","text":"236"},[2033,2036],"unknown",[2033,2036],"never",[2494,2497],[2494,2497],[1519,1522],[1519,1522],[1651,1654],[1651,1654]] \ No newline at end of file diff --git a/src/services/weather/forecast.ts b/src/services/weather/forecast.ts index ef6ae2ba..60d27b92 100644 --- a/src/services/weather/forecast.ts +++ b/src/services/weather/forecast.ts @@ -1,7 +1,7 @@ // src/services/weather/forecast.ts import { logger } from "../../utils/logger.js"; -import type { TempUnit, WeatherPoint, WeatherMode } from "./types.js"; +import type { TempUnit, WeatherPoint } from "./types.js"; /** * NWS forecast response shape (trimmed to what we use). @@ -33,6 +33,10 @@ export type NwsPeriod = { }; }; +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + /** * Fetch forecast for a resolved weather point. * Expects point.forecastUrl to be a valid NWS forecast endpoint. @@ -52,10 +56,13 @@ export async function fetchForecast(point: WeatherPoint): Promise Date: Mon, 22 Dec 2025 13:59:34 -0500 Subject: [PATCH 8/8] style: auto-format with Prettier [skip-precheck] --- src/services/weather/forecast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/weather/forecast.ts b/src/services/weather/forecast.ts index 60d27b92..fcdb4c21 100644 --- a/src/services/weather/forecast.ts +++ b/src/services/weather/forecast.ts @@ -188,4 +188,4 @@ function fToC(f: number): number { function cToF(c: number): number { return Math.round((c * 9) / 5 + 32); -} \ No newline at end of file +}