From ad968c6eb1f0850f158bf926f876516482eda83a Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 15 Dec 2025 16:24:29 -0500 Subject: [PATCH 1/3] Adding this in --- .gitignore | 5 +- README.md | 33 ++-- src/commands/general/ping.ts | 21 ++- src/commands/history/history.ts | 173 ++++++++++--------- src/commands/playback/playback.ts | 157 +++++++++++++++++ src/services/discord/fetchChannelMessages.ts | 42 +++++ src/services/time/formatTimestamp.ts | 23 +-- src/services/time/validateTimezone.ts | 0 src/services/timezone/timezone.ts | 80 +++++++++ src/services/timezone/timezoneStore.ts | 75 ++++++++ src/services/transcript/defaults.ts | 35 ++++ 11 files changed, 534 insertions(+), 110 deletions(-) create mode 100644 src/commands/playback/playback.ts create mode 100644 src/services/discord/fetchChannelMessages.ts create mode 100644 src/services/time/validateTimezone.ts create mode 100644 src/services/timezone/timezone.ts create mode 100644 src/services/timezone/timezoneStore.ts create mode 100644 src/services/transcript/defaults.ts diff --git a/.gitignore b/.gitignore index 14e57083..b4dcd2ba 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ Thumbs.db *.tsbuildinfo # Optional: ignore zip files -*.zip \ No newline at end of file +*.zip + +# Runtime data +data/timezones.json \ No newline at end of file diff --git a/README.md b/README.md index b1dfcd05..a9a1bafe 100644 --- a/README.md +++ b/README.md @@ -17,21 +17,23 @@ OmegaBot is a modular Discord bot designed to support development projects with ## Features -Current features: +Current features -- Slash command system -- Ping command for testing -- Summary command with local summary mode -- Automatic command loading -- Simple and readable project structure +- Modular slash-command system (auto-loaded from dist/commands) +- /ping command for testing +- /summary command with local summarizer and optional LLM mode (via SUMMARY_MODE) +- /history command that DMs recent channel history (with file fallback for long output) +- Shared transcript builder + consistent transcript defaults +- Command registration script for fast guild iteration -Planned features: +Planned features - FAQ storage and quick lookup - GitHub issues and pull request lookups - Pull request announcements -- Better summary analysis -- Optional LLM powered summaries +- Pagination for large history/playback (buttons or follow-ups) +- Per-user timezone support (store IANA timezone and apply to transcripts) +- Improved summary output (highlights, action items, structured sections) --- @@ -138,11 +140,12 @@ OmegaBot is online ├── src │ ├── bot.ts │ ├── commands -│ │ ├── .DS_Store │ │ ├── general │ │ │ └── ping.ts │ │ ├── history │ │ │ └── history.ts +│ │ ├── playback +│ │ │ └── playback.ts │ │ └── summary │ │ └── summary.ts │ ├── config @@ -151,15 +154,21 @@ OmegaBot is online │ └── services │ ├── discord │ │ ├── commandLoader.ts +│ │ ├── fetchChannelMessages.ts │ │ └── interactionHandler.ts │ ├── summary │ │ ├── llmSummary.ts │ │ ├── localSummary.ts │ │ └── summarizer.ts │ ├── time -│ │ └── formatTimestamp.ts +│ │ ├── formatTimestamp.ts +│ │ └── validateTimezone.ts +│ ├── timezone +│ │ ├── timezone.ts +│ │ └── timezoneStore.ts │ └── transcript -│ └── buildTranscript.ts +│ ├── buildTranscript.ts +│ └── defaults.ts └── tsconfig.json ``` diff --git a/src/commands/general/ping.ts b/src/commands/general/ping.ts index 57f4865c..0639f50f 100644 --- a/src/commands/general/ping.ts +++ b/src/commands/general/ping.ts @@ -8,15 +8,19 @@ export const data = new SlashCommandBuilder() .setName("ping") .setDescription("Ping test with latency"); -export async function execute(interaction: ChatInputCommandInteraction): Promise { +export async function execute( + interaction: ChatInputCommandInteraction, +): Promise { /** - * First reply (with fetchReply: true) returns the actual message object. - * We use this to calculate round-trip latency between interaction and reply. + * Send the initial reply. + * We avoid deprecated fetchReply option and instead fetch the reply after. */ - const sent = await interaction.reply({ - content: "Pinging...", - fetchReply: true, - }); + await interaction.reply("Pinging..."); + + /** + * Fetch the bot's reply message so we can compute latency. + */ + const sent = await interaction.fetchReply(); /** * Measure latency: @@ -33,9 +37,8 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise /** * Edit the original message to show actual latency numbers. - * This keeps the interaction tidy instead of sending another message. */ await interaction.editReply( `Pong. Round trip latency is ${latency}ms. WebSocket heartbeat is ${wsPing}ms.`, ); -} +} \ No newline at end of file diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 70e3dc49..84f25c60 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -1,90 +1,107 @@ -import { formatTimestamp } from "../../services/time/formatTimestamp.js"; +// src/commands/history/history.ts -/** - * Minimal shape required from a Discord message - * so this helper stays framework-agnostic. - */ -export type TranscriptMessage = { - createdTimestamp: number; - content: string; - author: { - username: string; - }; -}; - -export type TranscriptOptions = { - includeTimestamp: boolean; - includeAuthor: boolean; - maxLines?: number; - maxChars?: number; - timeZone?: string; - locale?: string; -}; - -export type TranscriptResult = { - text: string; - lineCount: number; - truncated: boolean; - tooLong: boolean; -}; +import { + SlashCommandBuilder, + AttachmentBuilder, + MessageFlags, + type ChatInputCommandInteraction, +} from "discord.js"; +import { + buildTranscript, + type TranscriptMessage, +} from "../../services/transcript/buildTranscript.js"; +import { HISTORY_DEFAULTS } from "../../services/transcript/defaults.js"; +import { getUserTimezone } from "../../services/timezone/timezoneStore.js"; /** - * Builds a readable transcript from a list of messages. - * Responsible ONLY for formatting + truncation rules. + * /history command + * Returns the last N raw user messages as a DM to the requester. */ -export function buildTranscript( - messages: TranscriptMessage[], - options: TranscriptOptions, -): TranscriptResult { - const { - includeTimestamp, - includeAuthor, - maxLines, - maxChars, - timeZone = "UTC", - locale = "en-GB", - } = options; - - const lines: string[] = []; - let truncated = false; - let tooLong = false; - - for (const message of messages) { - if (!message?.content) continue; - - const parts: string[] = []; - - if (includeTimestamp) { - const ts = formatTimestamp(message.createdTimestamp, timeZone, locale); - parts.push(`[${ts}]`); - } +export const data = new SlashCommandBuilder() + .setName("history") + .setDescription("DMs you the most recent messages in this channel") + .addIntegerOption((opt) => + opt + .setName("count") + .setDescription("How many messages to fetch") + .setMinValue(5) + .setMaxValue(50), + ); - if (includeAuthor) { - parts.push(`${message.author.username}:`); - } +export async function execute( + interaction: ChatInputCommandInteraction, +): Promise { + const count = interaction.options.getInteger("count") ?? 50; - parts.push(message.content.trim()); + // Ephemeral ack so only the caller sees status. + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const line = parts.join(" "); - lines.push(line); + // Guard: must be text-based to fetch messages. + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support message history."); + return; + } - // Enforce maxLines - if (maxLines && lines.length >= maxLines) { - truncated = true; - break; - } + // Fetch recent messages + const messages = await interaction.channel.messages.fetch({ limit: count }); + + // Filter non-bot + non-empty content, then sort oldest -> newest + const userMessages = messages + .filter((m) => !m.author.bot && m.content) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + if (userMessages.size === 0) { + await interaction.editReply("No history found."); + return; + } + + // Convert Discord Collection -> array in the minimal shape buildTranscript needs + const transcriptMessages: TranscriptMessage[] = userMessages.map((m) => ({ + createdTimestamp: m.createdTimestamp, + content: m.content, + author: { username: m.author.username }, + })); + + // Optional per-user timezone override (fallback to defaults) + const userTz = getUserTimezone(interaction.user.id); + + const result = buildTranscript(transcriptMessages, { + ...HISTORY_DEFAULTS, + timeZone: userTz ?? HISTORY_DEFAULTS.timeZone, + }); - // Enforce maxChars - if (maxChars && lines.join("\n").length >= maxChars) { - tooLong = true; - break; + const text = result.text; + + // If too large for a normal DM, send as a file + if (result.tooLong || text.length > 2000) { + const file = new AttachmentBuilder(Buffer.from(text, "utf8"), { + name: "history.txt", + }); + + try { + await interaction.user.send({ + content: "Here is your recent chat history:", + files: [file], + }); + await interaction.editReply("History sent to your DMs."); + } catch (err) { + console.error("[history] DM file send failed", err); + await interaction.editReply( + "I generated the history, but your DMs appear to be closed.", + ); } + + return; } - return { - text: lines.join("\n"), - lineCount: lines.length, - truncated, - tooLong, - }; -} + // Normal-length transcript -> DM as text + try { + await interaction.user.send(text); + await interaction.editReply("History sent to your DMs."); + } catch (err) { + console.error("[history] DM text send failed", err); + await interaction.editReply( + "I generated the history, but could not DM you. Your DMs may be closed.", + ); + } +} \ No newline at end of file diff --git a/src/commands/playback/playback.ts b/src/commands/playback/playback.ts new file mode 100644 index 00000000..21c49dde --- /dev/null +++ b/src/commands/playback/playback.ts @@ -0,0 +1,157 @@ +// src/commands/playback/playback.ts + +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; +import { buildTranscript } from "../../services/transcript/buildTranscript.js"; +import { + HISTORY_DEFAULTS, + DISCORD_SAFE_TEXT_LIMIT, +} from "../../services/transcript/defaults.js"; + +function chunkText(text: string, maxChars: number): string[] { + if (text.length <= maxChars) return [text]; + + const lines = text.split("\n"); + const chunks: string[] = []; + + let buf = ""; + for (const line of lines) { + const next = buf.length === 0 ? line : `${buf}\n${line}`; + if (next.length > maxChars) { + chunks.push(buf); + buf = line; + } else { + buf = next; + } + } + if (buf) chunks.push(buf); + + return chunks; +} + +export const data = new SlashCommandBuilder() + .setName("playback") + .setDescription("Page through recent messages with buttons") + .addIntegerOption((opt) => + opt + .setName("count") + .setDescription("How many messages to fetch") + .setMinValue(10) + .setMaxValue(100), + ) + .addStringOption((opt) => + opt + .setName("before") + .setDescription("Message ID: show messages before this ID") + .setRequired(false), + ) + .addStringOption((opt) => + opt + .setName("after") + .setDescription("Message ID: show messages after this ID") + .setRequired(false), + ); + +export async function execute( + interaction: ChatInputCommandInteraction, +): Promise { + const count = interaction.options.getInteger("count") ?? 50; + const before = interaction.options.getString("before") ?? undefined; + const after = interaction.options.getString("after") ?? undefined; + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support playback."); + return; + } + + const msgs = await fetchChannelMessages(interaction.channel, { + count, + before, + after, + }); + + if (msgs.length === 0) { + await interaction.editReply("No usable messages found for playback."); + return; + } + + const transcript = buildTranscript(msgs, { + ...HISTORY_DEFAULTS, + // For pagination, do not truncate by maxChars here, we chunk instead. + maxChars: undefined, + maxLines: undefined, + }); + + const pages = chunkText(transcript.text, DISCORD_SAFE_TEXT_LIMIT); + + let index = 0; + + const makeRow = (i: number) => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("pb_prev") + .setStyle(ButtonStyle.Secondary) + .setLabel("Prev") + .setDisabled(i <= 0), + new ButtonBuilder() + .setCustomId("pb_next") + .setStyle(ButtonStyle.Secondary) + .setLabel("Next") + .setDisabled(i >= pages.length - 1), + new ButtonBuilder() + .setCustomId("pb_close") + .setStyle(ButtonStyle.Danger) + .setLabel("Close"), + ); + + await interaction.editReply({ + content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, + components: [makeRow(index)], + }); + + const msg = await interaction.fetchReply(); + + const collector = msg.createMessageComponentCollector({ + time: 5 * 60_000, + filter: (i) => i.user.id === interaction.user.id, + }); + + collector.on("collect", async (btn) => { + try { + if (btn.customId === "pb_close") { + collector.stop("closed"); + await btn.update({ content: "Playback closed.", components: [] }); + return; + } + + if (btn.customId === "pb_prev") index = Math.max(0, index - 1); + if (btn.customId === "pb_next") + index = Math.min(pages.length - 1, index + 1); + + await btn.update({ + content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, + components: [makeRow(index)], + }); + } catch (err) { + console.error("[playback] button update failed", err); + } + }); + + collector.on("end", async () => { + try { + // disable buttons after timeout + await interaction.editReply({ components: [] }); + } catch { + // ignore + } + }); +} diff --git a/src/services/discord/fetchChannelMessages.ts b/src/services/discord/fetchChannelMessages.ts new file mode 100644 index 00000000..54c7dc42 --- /dev/null +++ b/src/services/discord/fetchChannelMessages.ts @@ -0,0 +1,42 @@ +import type { TextBasedChannel, Message } from "discord.js"; + +export type FetchMessagesOptions = { + count?: number; + before?: string; + after?: string; +}; + +/** + * Fetch messages from a text-based channel with optional + * count / before / after filters. + * + * Returns messages sorted oldest → newest. + */ +export async function fetchChannelMessages( + channel: TextBasedChannel, + options: FetchMessagesOptions = {}, +): Promise { + const { + count = 50, + before, + after, + } = options; + + const fetchOptions: { + limit: number; + before?: string; + after?: string; + } = { + limit: count, + }; + + if (before) fetchOptions.before = before; + if (after) fetchOptions.after = after; + + const collection = await channel.messages.fetch(fetchOptions); + + // Convert Collection → Array and sort chronologically + return Array.from(collection.values()).sort( + (a, b) => a.createdTimestamp - b.createdTimestamp, + ); +} \ No newline at end of file diff --git a/src/services/time/formatTimestamp.ts b/src/services/time/formatTimestamp.ts index 825d4f03..7776e746 100644 --- a/src/services/time/formatTimestamp.ts +++ b/src/services/time/formatTimestamp.ts @@ -1,15 +1,18 @@ /** - * Format a Unix timestamp into a readable date/time string. + * Format a Unix timestamp (ms) into a readable date/time string. * - * - Uses 24-hour (military) time - * - Allows caller to control locale and timezone + * - Uses 24-hour time (hour12: false) + * - Caller controls locale + timezone * - * @param {number} ts - Unix timestamp (milliseconds) - * @param {string} locale - Locale string (ex: "en-GB", "en-US") - * @param {string} timeZone - IANA timezone (ex: "UTC", "America/New_York") - * @returns {string} + * @param ts - Timestamp in milliseconds + * @param timeZone - IANA timezone (ex: "America/New_York") + * @param locale - Locale string (ex: "en-GB", "en-US") */ -export function formatTimestamp(ts: number, locale = "en-GB", timeZone = "UTC") { +export function formatTimestamp( + ts: number, + timeZone: string, + locale: string +): string { return new Date(ts).toLocaleString(locale, { timeZone, year: "numeric", @@ -17,6 +20,6 @@ export function formatTimestamp(ts: number, locale = "en-GB", timeZone = "UTC") day: "2-digit", hour: "2-digit", minute: "2-digit", - hour12: false, + hour12: false }); -} +} \ No newline at end of file diff --git a/src/services/time/validateTimezone.ts b/src/services/time/validateTimezone.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/services/timezone/timezone.ts b/src/services/timezone/timezone.ts new file mode 100644 index 00000000..61b33193 --- /dev/null +++ b/src/services/timezone/timezone.ts @@ -0,0 +1,80 @@ +import { + SlashCommandBuilder, + MessageFlags, + type ChatInputCommandInteraction +} from "discord.js"; +import { + assertValidTimeZone, + clearUserTimezone, + getUserTimezone, + setUserTimezone +} from "../../services/timezone/timezoneStore.js"; + +/** + * /timezone command + * + * Stores a per-user IANA timezone like "America/New_York". + * Does not guess timezones. Validates via Intl. + */ +export const data = new SlashCommandBuilder() + .setName("timezone") + .setDescription("Set or clear your timezone for /history timestamps") + .addSubcommand((sub) => + sub + .setName("set") + .setDescription("Set your timezone (IANA format)") + .addStringOption((opt) => + opt + .setName("tz") + .setDescription('IANA timezone like "America/New_York"') + .setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("clear") + .setDescription("Clear your saved timezone") + ) + .addSubcommand((sub) => + sub + .setName("show") + .setDescription("Show your saved timezone") + ); + +export async function execute( + interaction: ChatInputCommandInteraction +): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const sub = interaction.options.getSubcommand(); + const userId = interaction.user.id; + + if (sub === "show") { + const current = getUserTimezone(userId); + await interaction.editReply( + current ? `Your timezone is set to: ${current}` : "You have no timezone set." + ); + return; + } + + if (sub === "clear") { + clearUserTimezone(userId); + await interaction.editReply("Timezone cleared."); + return; + } + + // sub === "set" + const tz = interaction.options.getString("tz", true).trim(); + + try { + assertValidTimeZone(tz); + } catch { + await interaction.editReply( + `That timezone is not valid. Use an IANA value like "America/New_York" or "Europe/London".` + ); + return; + } + + setUserTimezone(userId, tz); + await interaction.editReply(`Timezone saved: ${tz}`); +} \ No newline at end of file diff --git a/src/services/timezone/timezoneStore.ts b/src/services/timezone/timezoneStore.ts new file mode 100644 index 00000000..d0506f61 --- /dev/null +++ b/src/services/timezone/timezoneStore.ts @@ -0,0 +1,75 @@ +import fs from "fs"; +import path from "path"; + +type TimezoneStore = Record; + +/** + * Store file lives inside the repo folder when running locally. + * If you deploy later, you will likely want to switch this to a real DB or KV store. + */ +const DATA_DIR = path.join(process.cwd(), "data"); +const STORE_PATH = path.join(DATA_DIR, "timezones.json"); + +function ensureStoreFile(): void { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + if (!fs.existsSync(STORE_PATH)) { + fs.writeFileSync(STORE_PATH, JSON.stringify({}, null, 2), "utf8"); + } +} + +function loadStore(): TimezoneStore { + ensureStoreFile(); + try { + const raw = fs.readFileSync(STORE_PATH, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object") return parsed as TimezoneStore; + return {}; + } catch { + return {}; + } +} + +function saveStore(store: TimezoneStore): void { + ensureStoreFile(); + fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +/** + * Validate an IANA timezone string (ex: "America/New_York"). + * We do not guess. If it is invalid, throw. + */ +export function assertValidTimeZone(tz: string): void { + // Intl throws RangeError for invalid timeZone + new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date()); +} + +/** + * Return the saved IANA timezone for a user, or null if not set. + */ +export function getUserTimezone(userId: string): string | null { + const store = loadStore(); + return store[userId] ?? null; +} + +/** + * Save the user's IANA timezone. + */ +export function setUserTimezone(userId: string, tz: string): void { + assertValidTimeZone(tz); + const store = loadStore(); + store[userId] = tz; + saveStore(store); +} + +/** + * Remove any saved timezone for the user. + */ +export function clearUserTimezone(userId: string): void { + const store = loadStore(); + if (store[userId] !== undefined) { + delete store[userId]; + saveStore(store); + } +} \ No newline at end of file diff --git a/src/services/transcript/defaults.ts b/src/services/transcript/defaults.ts new file mode 100644 index 00000000..eccea4de --- /dev/null +++ b/src/services/transcript/defaults.ts @@ -0,0 +1,35 @@ +// src/services/transcript/defaults.ts + +import type { TranscriptOptions } from "./buildTranscript.js"; + +/** + * Discord hard limit is 2000 characters. + * Leave headroom so we never accidentally overflow. + */ +export const DISCORD_SAFE_TEXT_LIMIT = 1900; + +/** + * Defaults for /history + * History is meant to be readable playback. + */ +export const HISTORY_DEFAULTS: TranscriptOptions = { + includeTimestamp: true, + includeAuthor: true, + maxLines: 50, + maxChars: DISCORD_SAFE_TEXT_LIMIT, + timeZone: "UTC", + locale: "en-GB", +}; + +/** + * Defaults for /summary + * Summary prefers signal over noise. + */ +export const SUMMARY_DEFAULTS: TranscriptOptions = { + includeTimestamp: false, + includeAuthor: true, + maxLines: 50, + maxChars: DISCORD_SAFE_TEXT_LIMIT, + timeZone: "UTC", + locale: "en-GB", +}; \ No newline at end of file From 6c320c6b2e97c81d0dc4f30d297704799b0a6d9d Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 15 Dec 2025 16:24:45 -0500 Subject: [PATCH 2/3] Trying this again --- src/commands/general/ping.ts | 6 ++--- src/commands/history/history.ts | 6 ++--- src/commands/playback/playback.ts | 7 ++--- src/services/discord/fetchChannelMessages.ts | 8 ++---- src/services/time/formatTimestamp.ts | 10 +++---- src/services/timezone/timezone.ts | 28 +++++++------------- src/services/timezone/timezoneStore.ts | 2 +- src/services/transcript/defaults.ts | 2 +- 8 files changed, 23 insertions(+), 46 deletions(-) diff --git a/src/commands/general/ping.ts b/src/commands/general/ping.ts index 0639f50f..f262d455 100644 --- a/src/commands/general/ping.ts +++ b/src/commands/general/ping.ts @@ -8,9 +8,7 @@ export const data = new SlashCommandBuilder() .setName("ping") .setDescription("Ping test with latency"); -export async function execute( - interaction: ChatInputCommandInteraction, -): Promise { +export async function execute(interaction: ChatInputCommandInteraction): Promise { /** * Send the initial reply. * We avoid deprecated fetchReply option and instead fetch the reply after. @@ -41,4 +39,4 @@ export async function execute( await interaction.editReply( `Pong. Round trip latency is ${latency}ms. WebSocket heartbeat is ${wsPing}ms.`, ); -} \ No newline at end of file +} diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 84f25c60..0431d551 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -28,9 +28,7 @@ export const data = new SlashCommandBuilder() .setMaxValue(50), ); -export async function execute( - interaction: ChatInputCommandInteraction, -): Promise { +export async function execute(interaction: ChatInputCommandInteraction): Promise { const count = interaction.options.getInteger("count") ?? 50; // Ephemeral ack so only the caller sees status. @@ -104,4 +102,4 @@ export async function execute( "I generated the history, but could not DM you. Your DMs may be closed.", ); } -} \ No newline at end of file +} diff --git a/src/commands/playback/playback.ts b/src/commands/playback/playback.ts index 21c49dde..986a9208 100644 --- a/src/commands/playback/playback.ts +++ b/src/commands/playback/playback.ts @@ -59,9 +59,7 @@ export const data = new SlashCommandBuilder() .setRequired(false), ); -export async function execute( - interaction: ChatInputCommandInteraction, -): Promise { +export async function execute(interaction: ChatInputCommandInteraction): Promise { const count = interaction.options.getInteger("count") ?? 50; const before = interaction.options.getString("before") ?? undefined; const after = interaction.options.getString("after") ?? undefined; @@ -134,8 +132,7 @@ export async function execute( } if (btn.customId === "pb_prev") index = Math.max(0, index - 1); - if (btn.customId === "pb_next") - index = Math.min(pages.length - 1, index + 1); + if (btn.customId === "pb_next") index = Math.min(pages.length - 1, index + 1); await btn.update({ content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`, diff --git a/src/services/discord/fetchChannelMessages.ts b/src/services/discord/fetchChannelMessages.ts index 54c7dc42..182b9a50 100644 --- a/src/services/discord/fetchChannelMessages.ts +++ b/src/services/discord/fetchChannelMessages.ts @@ -16,11 +16,7 @@ export async function fetchChannelMessages( channel: TextBasedChannel, options: FetchMessagesOptions = {}, ): Promise { - const { - count = 50, - before, - after, - } = options; + const { count = 50, before, after } = options; const fetchOptions: { limit: number; @@ -39,4 +35,4 @@ export async function fetchChannelMessages( return Array.from(collection.values()).sort( (a, b) => a.createdTimestamp - b.createdTimestamp, ); -} \ No newline at end of file +} diff --git a/src/services/time/formatTimestamp.ts b/src/services/time/formatTimestamp.ts index 7776e746..3bd9acdf 100644 --- a/src/services/time/formatTimestamp.ts +++ b/src/services/time/formatTimestamp.ts @@ -8,11 +8,7 @@ * @param timeZone - IANA timezone (ex: "America/New_York") * @param locale - Locale string (ex: "en-GB", "en-US") */ -export function formatTimestamp( - ts: number, - timeZone: string, - locale: string -): string { +export function formatTimestamp(ts: number, timeZone: string, locale: string): string { return new Date(ts).toLocaleString(locale, { timeZone, year: "numeric", @@ -20,6 +16,6 @@ export function formatTimestamp( day: "2-digit", hour: "2-digit", minute: "2-digit", - hour12: false + hour12: false, }); -} \ No newline at end of file +} diff --git a/src/services/timezone/timezone.ts b/src/services/timezone/timezone.ts index 61b33193..ced30211 100644 --- a/src/services/timezone/timezone.ts +++ b/src/services/timezone/timezone.ts @@ -1,13 +1,13 @@ import { SlashCommandBuilder, MessageFlags, - type ChatInputCommandInteraction + type ChatInputCommandInteraction, } from "discord.js"; import { assertValidTimeZone, clearUserTimezone, getUserTimezone, - setUserTimezone + setUserTimezone, } from "../../services/timezone/timezoneStore.js"; /** @@ -27,23 +27,15 @@ export const data = new SlashCommandBuilder() opt .setName("tz") .setDescription('IANA timezone like "America/New_York"') - .setRequired(true) - ) + .setRequired(true), + ), ) .addSubcommand((sub) => - sub - .setName("clear") - .setDescription("Clear your saved timezone") + sub.setName("clear").setDescription("Clear your saved timezone"), ) - .addSubcommand((sub) => - sub - .setName("show") - .setDescription("Show your saved timezone") - ); + .addSubcommand((sub) => sub.setName("show").setDescription("Show your saved timezone")); -export async function execute( - interaction: ChatInputCommandInteraction -): Promise { +export async function execute(interaction: ChatInputCommandInteraction): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const sub = interaction.options.getSubcommand(); @@ -52,7 +44,7 @@ export async function execute( if (sub === "show") { const current = getUserTimezone(userId); await interaction.editReply( - current ? `Your timezone is set to: ${current}` : "You have no timezone set." + current ? `Your timezone is set to: ${current}` : "You have no timezone set.", ); return; } @@ -70,11 +62,11 @@ export async function execute( assertValidTimeZone(tz); } catch { await interaction.editReply( - `That timezone is not valid. Use an IANA value like "America/New_York" or "Europe/London".` + `That timezone is not valid. Use an IANA value like "America/New_York" or "Europe/London".`, ); return; } setUserTimezone(userId, tz); await interaction.editReply(`Timezone saved: ${tz}`); -} \ No newline at end of file +} diff --git a/src/services/timezone/timezoneStore.ts b/src/services/timezone/timezoneStore.ts index d0506f61..f85e7f37 100644 --- a/src/services/timezone/timezoneStore.ts +++ b/src/services/timezone/timezoneStore.ts @@ -72,4 +72,4 @@ export function clearUserTimezone(userId: string): void { delete store[userId]; saveStore(store); } -} \ No newline at end of file +} diff --git a/src/services/transcript/defaults.ts b/src/services/transcript/defaults.ts index eccea4de..f85911e9 100644 --- a/src/services/transcript/defaults.ts +++ b/src/services/transcript/defaults.ts @@ -32,4 +32,4 @@ export const SUMMARY_DEFAULTS: TranscriptOptions = { maxChars: DISCORD_SAFE_TEXT_LIMIT, timeZone: "UTC", locale: "en-GB", -}; \ No newline at end of file +}; From a3d503eb305c0dbdacb5f8f4a3537e0983bf876e Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 15 Dec 2025 16:26:42 -0500 Subject: [PATCH 3/3] Adding in more files --- src/services/time/validateTimezone.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/services/time/validateTimezone.ts b/src/services/time/validateTimezone.ts index e69de29b..6e9d02ce 100644 --- a/src/services/time/validateTimezone.ts +++ b/src/services/time/validateTimezone.ts @@ -0,0 +1,22 @@ +/** + * Validate whether a string is a valid IANA timezone. + * + * Examples of valid values: + * - "UTC" + * - "America/New_York" + * - "Europe/London" + * + * This does NOT guess timezones. + * It only validates exact IANA identifiers. + */ +export function validateTimeZone(tz: string): boolean { + if (!tz || typeof tz !== "string") return false; + + try { + // Intl.DateTimeFormat will throw if the timezone is invalid + Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +}