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..f262d455 100644 --- a/src/commands/general/ping.ts +++ b/src/commands/general/ping.ts @@ -10,13 +10,15 @@ export const data = new SlashCommandBuilder() 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,7 +35,6 @@ 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.`, diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 70e3dc49..0431d551 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -1,90 +1,105 @@ -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.", + ); + } } diff --git a/src/commands/playback/playback.ts b/src/commands/playback/playback.ts new file mode 100644 index 00000000..986a9208 --- /dev/null +++ b/src/commands/playback/playback.ts @@ -0,0 +1,154 @@ +// 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..182b9a50 --- /dev/null +++ b/src/services/discord/fetchChannelMessages.ts @@ -0,0 +1,38 @@ +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, + ); +} diff --git a/src/services/time/formatTimestamp.ts b/src/services/time/formatTimestamp.ts index 825d4f03..3bd9acdf 100644 --- a/src/services/time/formatTimestamp.ts +++ b/src/services/time/formatTimestamp.ts @@ -1,15 +1,14 @@ /** - * 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", diff --git a/src/services/time/validateTimezone.ts b/src/services/time/validateTimezone.ts new file mode 100644 index 00000000..6e9d02ce --- /dev/null +++ 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; + } +} diff --git a/src/services/timezone/timezone.ts b/src/services/timezone/timezone.ts new file mode 100644 index 00000000..ced30211 --- /dev/null +++ b/src/services/timezone/timezone.ts @@ -0,0 +1,72 @@ +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}`); +} diff --git a/src/services/timezone/timezoneStore.ts b/src/services/timezone/timezoneStore.ts new file mode 100644 index 00000000..f85e7f37 --- /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); + } +} diff --git a/src/services/transcript/defaults.ts b/src/services/transcript/defaults.ts new file mode 100644 index 00000000..f85911e9 --- /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", +};