diff --git a/README.md b/README.md index d4a00b06..b085c70e 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 @@ -118,7 +119,7 @@ OmegaBot is online ``` . -├── .env.example +├── .env-example ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug.yml @@ -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 @@ -218,11 +197,30 @@ OmegaBot is online │ │ ├── timezone │ │ │ ├── timezone.ts │ │ │ └── timezoneStore.ts -│ │ └── transcript -│ │ ├── buildTranscript.ts -│ │ └── defaults.ts -│ └── utils -│ └── logger.ts +│ │ ├── transcript +│ │ │ ├── buildTranscript.ts +│ │ │ └── defaults.ts +│ │ └── weather +│ │ ├── forecast.ts +│ │ ├── geocode.ts +│ │ └── types.ts +│ ├── utils +│ │ └── logger.ts +│ ├── bot.ts +│ └── registerCommands.ts +├── .env.example +├── .eslintcache +├── .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..9cfa976d --- /dev/null +++ b/src/commands/fun/fun.ts @@ -0,0 +1,195 @@ +// src/commands/fun/fun.ts + +import { + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { logger } from "../../utils/logger.js"; + +import { run as runChuckNorris } from "./subcommands/chucknorris.js"; +import type { ChuckNorrisMode } from "./subcommands/chucknorris.js"; + +import { run as runDadJoke } from "./subcommands/dadjoke.js"; +import type { DadJokeMode } from "./subcommands/dadjoke.js"; + +import { run as runDice } from "./subcommands/dice.js"; + +import { run as runWeather } from "./subcommands/weather.js"; +import type { TempUnit, WeatherMode } from "../../services/weather/types.js"; + +function parseTempUnit(raw: string | null): TempUnit { + return raw?.toLowerCase() === "c" ? "c" : "f"; +} + +export const data = new SlashCommandBuilder() + .setName("fun") + .setDescription("Fun commands") + + // /fun chucknorris + .addSubcommand((s) => + s + .setName("chucknorris") + .setDescription("Chuck Norris facts (random, category, or search)") + .addStringOption((o) => + o.setName("category").setDescription("Category (optional)").setRequired(false), + ) + .addStringOption((o) => + o.setName("query").setDescription("Search term (optional)").setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun dadjoke + .addSubcommand((s) => + s + .setName("dadjoke") + .setDescription("Random dad joke (or search)") + .addStringOption((o) => + o.setName("query").setDescription("Search term (optional)").setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun dice + .addSubcommand((s) => + s + .setName("dice") + .setDescription("Roll some dice") + .addIntegerOption((o) => + o + .setName("sides") + .setDescription("Number of sides on each die") + .setMinValue(2) + .setMaxValue(100) + .setRequired(false), + ) + .addIntegerOption((o) => + o + .setName("count") + .setDescription("How many dice to roll") + .setMinValue(1) + .setMaxValue(10) + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun weather (daily) + .addSubcommand((s) => + s + .setName("weather") + .setDescription("Today’s weather for a location") + .addStringOption((o) => + o + .setName("location") + .setDescription('City, "City, ST", ZIP, etc.') + .setRequired(true), + ) + .addStringOption((o) => + o + .setName("unit") + .setDescription("Temperature unit") + .addChoices({ name: "F", value: "f" }, { name: "C", value: "c" }) + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ) + + // /fun weather7 (7-day) + .addSubcommand((s) => + s + .setName("weather7") + .setDescription("7-day forecast for a location") + .addStringOption((o) => + o + .setName("location") + .setDescription('City, "City, ST", ZIP, etc.') + .setRequired(true), + ) + .addStringOption((o) => + o + .setName("unit") + .setDescription("Temperature unit") + .addChoices({ name: "F", value: "f" }, { name: "C", value: "c" }) + .setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("ephemeral") + .setDescription("Only show the result to you") + .setRequired(false), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(true); + const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; + + // Parent command owns the interaction lifecycle + await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); + + try { + if (sub === "chucknorris") { + const category = interaction.options.getString("category")?.trim(); + const query = interaction.options.getString("query")?.trim(); + + const mode: ChuckNorrisMode = query + ? { kind: "search", query } + : category + ? { kind: "category", category } + : { kind: "random" }; + + return await runChuckNorris(interaction, mode); + } + + if (sub === "dadjoke") { + const query = interaction.options.getString("search")?.trim() ?? ""; + + const mode: DadJokeMode = query ? { kind: "search", query } : { kind: "random" }; + + return await runDadJoke(interaction, mode); + } + + 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") ?? "f"); + + const mode: WeatherMode = + sub === "weather" + ? { kind: "daily", location, unit } + : { kind: "7day", location, unit }; + + 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 new file mode 100644 index 00000000..c75503e8 --- /dev/null +++ b/src/commands/fun/subcommands/chucknorris.ts @@ -0,0 +1,100 @@ +// src/commands/fun/subcommands/chucknorris.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +type ChuckNorrisApiResponse = { + value: string; +}; + +export type ChuckNorrisMode = + | { kind: "random" } + | { kind: "category"; category: string } + | { kind: "search"; query: string }; + +/** + * Run handler for /fun chucknorris + * + * IMPORTANT: + * - This file is NOT a slash command by itself + * - It must NOT call reply() or deferReply() + * - The parent command (fun.ts) owns the interaction lifecycle + */ +export async function run( + interaction: ChatInputCommandInteraction, + mode: ChuckNorrisMode, +): Promise { + try { + const joke = await fetchChuckNorris(mode); + + await interaction.editReply(joke); + + logger.debug( + { userId: interaction.user.id, mode: mode.kind }, + "[fun/chucknorris] sent", + ); + } catch (err) { + logger.error({ err, mode }, "[fun/chucknorris] failed"); + await interaction.editReply( + "Chuck Norris is currently roundhouse kicking the API. Try again later.", + ); + } +} + +async function fetchChuckNorris(mode: ChuckNorrisMode): Promise { + if (mode.kind === "random") + return fetchSingle("https://api.chucknorris.io/jokes/random"); + + if (mode.kind === "category") { + const url = `https://api.chucknorris.io/jokes/random?category=${encodeURIComponent( + mode.category, + )}`; + return fetchSingle(url); + } + + // mode.kind === "search" + const url = `https://api.chucknorris.io/jokes/search?query=${encodeURIComponent( + mode.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[] }; + const first = data.result?.[0]?.value; + + if (!first || typeof first !== "string") { + throw new Error("No results for that search"); + } + + return first.trim(); +} + +async function fetchSingle(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..50582e61 --- /dev/null +++ b/src/commands/fun/subcommands/coinflip.ts @@ -0,0 +1,34 @@ +// src/commands/fun/subcommands/coinflip.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +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", + ); +} diff --git a/src/commands/fun/subcommands/dadjoke.ts b/src/commands/fun/subcommands/dadjoke.ts new file mode 100644 index 00000000..a84022ac --- /dev/null +++ b/src/commands/fun/subcommands/dadjoke.ts @@ -0,0 +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"); + } + + return pick.joke.trim(); +} diff --git a/src/commands/fun/subcommands/dice.ts b/src/commands/fun/subcommands/dice.ts new file mode 100644 index 00000000..da7cfc69 --- /dev/null +++ b/src/commands/fun/subcommands/dice.ts @@ -0,0 +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 { + 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 new file mode 100644 index 00000000..3d7c7a8c --- /dev/null +++ b/src/commands/fun/subcommands/weather.ts @@ -0,0 +1,184 @@ +// src/commands/fun/subcommands/weather.ts + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.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 and /fun weather7 + * + * 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)) as NwsForecastResponse; + + 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."); + } +} + +/* -------------------------------------------------------------------------- */ +/* TYPES */ +/* -------------------------------------------------------------------------- */ + +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[]) : []; +} + +/* -------------------------------------------------------------------------- */ +/* 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: 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 pop = safePop(p); + + const details = p.detailedForecast ?? short; + + return [ + `📍 **${label}**`, + `${emoji} **${name}**`, + `🌡️ ${temp}`, + `☔ ${pop}%`, + details, + ].join("\n"); +} + +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 (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} **${name}**: ${temp} ☔ ${pop}%`); + } + + return lines.join("\n"); +} diff --git a/src/services/weather/forecast.ts b/src/services/weather/forecast.ts new file mode 100644 index 00000000..fcdb4c21 --- /dev/null +++ b/src/services/weather/forecast.ts @@ -0,0 +1,191 @@ +// src/services/weather/forecast.ts + +import { logger } from "../../utils/logger.js"; +import type { TempUnit, WeatherPoint } 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; + }; +}; + +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. + */ +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 root = isRecord(data) ? data : null; + const props = root && isRecord(root.properties) ? root.properties : null; + const periods = props?.periods; + + if (!Array.isArray(periods)) { + logger.warn( + { label: point.label, keys: root ? Object.keys(root) : [] }, + "[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..a9925e4d --- /dev/null +++ b/src/services/weather/types.ts @@ -0,0 +1,49 @@ +// 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; +}; + +/** + * 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; + }>; + }; +};