From 4003a89e49341cba6ae9fc4805badf6b72cae2bd Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 15 Dec 2025 15:02:56 -0500 Subject: [PATCH 1/5] Adding in files --- README.md | 21 ++- docs/commands.md | 37 ++++ docs/dev-notes.md | 149 ++++++++++++++++ docs/transcripts.md | 147 ++++++++++++++++ src/commands/history/history.ts | 134 +++++++-------- src/commands/pagination/pagination.ts | 167 ++++++++++++++++++ src/commands/playback/playback.ts | 149 ++++++++++++++++ src/commands/summary/summary.ts | 172 +++++-------------- src/services/discord/fetchChannelMessages.ts | 42 +++++ src/services/summary/llmSummary.ts | 51 +++--- src/services/time/formatTimestamp.ts | 19 +- src/services/transcript/buildTranscript.ts | 21 ++- src/services/transcript/defaults.ts | 31 ++++ 13 files changed, 884 insertions(+), 256 deletions(-) create mode 100644 docs/commands.md create mode 100644 docs/dev-notes.md create mode 100644 docs/transcripts.md create mode 100644 src/commands/pagination/pagination.ts create mode 100644 src/commands/playback/playback.ts create mode 100644 src/services/discord/fetchChannelMessages.ts create mode 100644 src/services/transcript/defaults.ts diff --git a/README.md b/README.md index e475818a..40751a9f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ OmegaBot is a modular Discord bot designed to support development projects with quick summaries, FAQs, GitHub lookups, and automated notifications. The structure is clean and fully service based which makes it easy to extend. +--- + ## Features Current features: @@ -31,6 +33,16 @@ Planned features: - Better summary analysis - Optional LLM powered summaries +--- + +## Documentation + +- πŸ“˜ [Command Reference](docs/commands.md) +- 🧠 [Transcript & Summary Design](docs/transcripts.md) +- πŸ› οΈ [Development Notes](docs/dev-notes.md) + +--- + ## Getting Started ### Requirements @@ -81,6 +93,8 @@ You should see: OmegaBot is online ``` +--- + ## Project Structure
@@ -108,6 +122,8 @@ OmegaBot is online β”‚ β”œβ”€β”€ banner.png β”‚ └── omegabot.png β”œβ”€β”€ CONTRIBUTORS.md +β”œβ”€β”€ docs +β”‚ └── commands.md β”œβ”€β”€ eslint.config.ts β”œβ”€β”€ LICENSE β”œβ”€β”€ package-lock.json @@ -122,6 +138,8 @@ OmegaBot is online β”‚ β”‚ β”‚ └── ping.ts β”‚ β”‚ β”œβ”€β”€ history β”‚ β”‚ β”‚ └── history.ts +β”‚ β”‚ β”œβ”€β”€ pagination +β”‚ β”‚ β”‚ └── pagination.ts β”‚ β”‚ └── summary β”‚ β”‚ └── summary.ts β”‚ β”œβ”€β”€ config @@ -138,7 +156,8 @@ OmegaBot is online β”‚ β”œβ”€β”€ time β”‚ β”‚ └── formatTimestamp.ts β”‚ └── transcript -β”‚ └── buildTranscript.ts +β”‚ β”œβ”€β”€ buildTranscript.ts +β”‚ └── defaults.ts └── tsconfig.json ``` diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 00000000..29c0a314 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,37 @@ +# OmegaBot Commands + +## /history + +Purpose: DM the recent chat history from the current channel. + +Defaults: + +- count default: 50 +- count max: 50 +- transcript format: [YYYY-MM-DD HH:mm] user: message (24-hour) +- delivery: DM only +- overflow: text file attachment + +Notes: + +- If DMs are closed, the bot replies with an ephemeral error message. + +## /summary + +Purpose: Summarize recent messages and DM the result. + +Defaults: + +- count default: 50 +- count max: 100 +- transcript input: includes author, excludes timestamps (to reduce noise) +- delivery: DM only +- overflow: text file attachment + +## Transcript defaults + +Shared in `src/services/transcript/defaults.ts`: + +- locale: en-GB +- timezone: UTC +- 24-hour time (hour12: false) \ No newline at end of file diff --git a/docs/dev-notes.md b/docs/dev-notes.md new file mode 100644 index 00000000..fc3e03f3 --- /dev/null +++ b/docs/dev-notes.md @@ -0,0 +1,149 @@ +# πŸ› οΈ Development Notes + +This document captures practical knowledge learned while building OmegaBot. +It exists to save future-you time. + +--- + +## Interaction Lifecycle (Important) + +Every slash command must do **one** of the following within 3 seconds: + +- `reply()` +- `deferReply()` + +Failing to do this causes: +- Interaction timeout +- Silent failures +- β€œThis interaction failed” messages + +### Best Practice + +- Defer early +- Use ephemeral replies for status +- Deliver results via DM when appropriate + +--- + +## Ephemeral vs DM + +### Ephemeral +Use when: +- Acknowledging success/failure +- Showing short status messages +- Avoiding channel noise + +### DM +Use when: +- Output is long +- Content is private +- Transcript or summary is generated + +Always handle **DM failures** gracefully. + +--- + +## Message Fetching Pipeline + +Standard pattern: + +``` +fetch +β†’ filter bots +β†’ sort oldest β†’ newest +β†’ map to minimal shape +β†’ buildTranscript +``` + +Important notes: +- Discord returns a `Collection`, not an array +- Sorting must be explicit +- Missing permissions can fail silently + +--- + +## Required Permissions + +Your bot must have: + +- View Channels +- Read Message History +- Send Messages +- Attach Files +- Use Slash Commands + +Without **Read Message History**, fetch can return empty results. + +--- + +## Gateway Intents + +Ensure these are enabled: +- `Guilds` +- `GuildMessages` +- `MessageContent` (if needed) + +Mismatch between code and portal settings causes confusing bugs. + +--- + +## Environment Variables + +Never commit `.env`. + +Required: + +``` +DISCORD_TOKEN +DISCORD_APP_ID +DISCORD_GUILD_ID +SUMMARY_MODE +``` + +Always provide `.env.example`. + +--- + +## Command Registration + +During development: +- Prefer **guild commands** +- Faster propagation (seconds) + +Production: +- Global commands +- Can take up to 1 hour to update + +--- + +## Logging Strategy (Recommended) + +At minimum: +- Log errors server-side +- Never expose stack traces to users +- Prefix logs by feature + +Examples: + +- `[history] DM send failed` +- `[summary] LLM request error` + +--- + +## Common Pitfalls + +- Forgetting to defer replies +- Assuming collections are arrays +- Ignoring Discord message limits +- Not handling closed DMs +- Hardcoding timestamps without timezone support + +--- + +## Dev Philosophy + +Small helpers +Clear boundaries +No magic + +If logic feels duplicated, it probably belongs in `services/`. diff --git a/docs/transcripts.md b/docs/transcripts.md new file mode 100644 index 00000000..2cdf633b --- /dev/null +++ b/docs/transcripts.md @@ -0,0 +1,147 @@ +# 🧠 Transcript & Summary Design + +This document explains how OmegaBot fetches, formats, and delivers message transcripts and summaries. + +It is intentionally framework-aware (Discord.js) but logic-focused, so behavior is predictable and reusable. + +--- + +## Goals + +- Produce readable, consistent transcripts +- Avoid duplicating formatting logic across commands +- Handle Discord limits gracefully +- Support future enhancements (pagination, timezones, LLM summaries) + +--- + +## Core Concepts + +### Transcript + +A **transcript** is a formatted, chronological representation of chat messages. + +Format: + +``` +[YYYY-MM-DD HH:mm] username: message content +``` + +Rules: +- Oldest β†’ newest +- Bot messages excluded +- Empty messages ignored +- 24-hour time +- Locale: `en-GB` +- Default timezone: `UTC` + +--- + +## Transcript Builder (`buildTranscript`) + +All transcript formatting lives in a shared helper: + +`src/services/transcript/buildTranscript.ts` + +### Why this exists + +- Prevents duplicated logic between `/history` and `/summary` +- Makes pagination and LLM analysis easier later +- Central place to enforce limits and formatting rules + +### Input + +```ts +buildTranscript( + messages: TranscriptMessage[], + options: TranscriptOptions +) +``` + +#### `TranscriptMessage` (minimal shape) + +```ts +{ + createdTimestamp: number + content: string + author: { username: string } +} +``` + +#### `TranscriptOptions` + +```ts +{ + includeTimestamp: boolean + includeAuthor: boolean + maxLines?: number + maxChars?: number + timeZone?: string + locale?: string +} +``` + +--- + +## Output + +```ts +{ + text: string + lineCount: number + truncated: boolean + tooLong: boolean +} +``` + +This allows calling commands to decide: +- DM vs file attachment +- Pagination vs truncation +- User feedback messaging + +--- + +## Discord Limits + +Known limits enforced by design: + +- **2000 characters** per message +- File upload used as fallback +- DM preferred for privacy + +Commands do **not** silently fail when limits are exceeded. + +--- + +## Commands Using Transcripts + +| Command | Purpose | +|---|---| +| `/history` | Raw message playback via DM | +| `/summary` | Transcript β†’ summary pipeline | + +Future commands (planned): +- `/playback` +- `/export` +- `/timeline` + +--- + +## Future Enhancements + +Planned improvements that this design supports: + +- Pagination with buttons +- User-specific timezone support +- Markdown transcript export +- LLM-based highlights and insights + +--- + +## Design Philosophy + +> Fetch logic belongs in commands +> Formatting belongs in services +> Decisions belong at the edge + +This separation keeps features easy to extend without refactoring. diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 70e3dc49..5e36b624 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -1,90 +1,72 @@ -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; - }; -}; +import { + SlashCommandBuilder, + AttachmentBuilder, + MessageFlags, + type ChatInputCommandInteraction, +} from "discord.js"; +import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; +import { buildTranscript } from "../../services/transcript/buildTranscript.js"; +import { HISTORY_DEFAULTS } from "../../services/transcript/defaults.js"; -export type TranscriptOptions = { - includeTimestamp: boolean; - includeAuthor: boolean; - maxLines?: number; - maxChars?: number; - timeZone?: string; - locale?: string; -}; +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), + ); -export type TranscriptResult = { - text: string; - lineCount: number; - truncated: boolean; - tooLong: boolean; -}; +export async function execute( + interaction: ChatInputCommandInteraction, +): Promise { + const count = interaction.options.getInteger("count") ?? HISTORY_DEFAULTS.maxLines ?? 50; -/** - * Builds a readable transcript from a list of messages. - * Responsible ONLY for formatting + truncation rules. - */ -export function buildTranscript( - messages: TranscriptMessage[], - options: TranscriptOptions, -): TranscriptResult { - const { - includeTimestamp, - includeAuthor, - maxLines, - maxChars, - timeZone = "UTC", - locale = "en-GB", - } = options; + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const lines: string[] = []; - let truncated = false; - let tooLong = false; - - for (const message of messages) { - if (!message?.content) continue; - - const parts: string[] = []; + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support message history."); + return; + } - if (includeTimestamp) { - const ts = formatTimestamp(message.createdTimestamp, timeZone, locale); - parts.push(`[${ts}]`); - } + const messages = await fetchChannelMessages(interaction.channel, { count }); - if (includeAuthor) { - parts.push(`${message.author.username}:`); - } + if (messages.length === 0) { + await interaction.editReply("No history found."); + return; + } - parts.push(message.content.trim()); + const transcript = buildTranscript(messages, HISTORY_DEFAULTS); - const line = parts.join(" "); - lines.push(line); + const text = transcript.text; - // Enforce maxLines - if (maxLines && lines.length >= maxLines) { - truncated = true; - break; - } + if (text.length > 2000) { + const file = new AttachmentBuilder(Buffer.from(text, "utf8"), { + name: "history.txt", + }); - // Enforce maxChars - if (maxChars && lines.join("\n").length >= maxChars) { - tooLong = true; - break; + 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, - }; -} + 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/pagination/pagination.ts b/src/commands/pagination/pagination.ts new file mode 100644 index 00000000..e5a163ed --- /dev/null +++ b/src/commands/pagination/pagination.ts @@ -0,0 +1,167 @@ +// src/commands/pagination/pagination.ts +import { + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + type ChatInputCommandInteraction, +} from "discord.js"; + +/** + * /pagination command + * MVP: fetch recent user messages, format into a transcript, split into pages, + * then let the requester page through it with buttons. + */ +export const data = new SlashCommandBuilder() + .setName("pagination") + .setDescription("Page through recent messages in this channel") + .addIntegerOption((opt) => + opt + .setName("count") + .setDescription("How many messages to fetch") + .setMinValue(10) + .setMaxValue(100), + ); + +const MAX_PAGE_CHARS = 1800; // leave room for header + safety +const COLLECTOR_MS = 90_000; + +function chunkByChars(text: string, maxChars: number): string[] { + const lines = text.split("\n"); + const pages: string[] = []; + let current = ""; + + for (const line of lines) { + // +1 for newline we may add + const nextLen = current.length + (current ? 1 : 0) + line.length; + + // If a single line is huge, hard-split it + if (!current && line.length > maxChars) { + for (let i = 0; i < line.length; i += maxChars) { + pages.push(line.slice(i, i + maxChars)); + } + continue; + } + + if (nextLen > maxChars) { + if (current) pages.push(current); + current = line; + continue; + } + + current = current ? `${current}\n${line}` : line; + } + + if (current) pages.push(current); + return pages.length ? pages : ["(no content)"]; +} + +function buildRow(pageIndex: number, pageCount: number, disabledAll = false) { + const prev = new ButtonBuilder() + .setCustomId("pagination_prev") + .setLabel("Prev") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabledAll || pageIndex <= 0); + + const next = new ButtonBuilder() + .setCustomId("pagination_next") + .setLabel("Next") + .setStyle(ButtonStyle.Primary) + .setDisabled(disabledAll || pageIndex >= pageCount - 1); + + const stop = new ButtonBuilder() + .setCustomId("pagination_stop") + .setLabel("Stop") + .setStyle(ButtonStyle.Danger) + .setDisabled(disabledAll); + + return new ActionRowBuilder().addComponents(prev, next, stop); +} + +function renderPage(pages: string[], pageIndex: number) { + const header = `**Playback** (page ${pageIndex + 1}/${pages.length})`; + return `${header}\n\n${pages[pageIndex]}`; +} + +export async function execute( + interaction: ChatInputCommandInteraction, +): Promise { + const count = interaction.options.getInteger("count") ?? 50; + + // Not ephemeral: buttons + paging is easier as a normal message. + await interaction.deferReply(); + + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support pagination."); + return; + } + + const messages = await interaction.channel.messages.fetch({ limit: count }); + + const userMessages = messages + .filter((m) => !m.author.bot && m.content) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + if (userMessages.size === 0) { + await interaction.editReply("No usable messages found."); + return; + } + + const transcript = userMessages + .map((m) => `${m.author.username}: ${m.content}`) + .join("\n"); + + const pages = chunkByChars(transcript, MAX_PAGE_CHARS); + let pageIndex = 0; + + const replyMessage = await interaction.editReply({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length)], + }); + + const collector = replyMessage.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: COLLECTOR_MS, + filter: (i) => i.user.id === interaction.user.id, + }); + + collector.on("collect", async (i) => { + try { + if (i.customId === "pagination_stop") { + collector.stop("stopped"); + await i.update({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length, true)], + }); + return; + } + + if (i.customId === "pagination_prev") { + pageIndex = Math.max(0, pageIndex - 1); + } + + if (i.customId === "pagination_next") { + pageIndex = Math.min(pages.length - 1, pageIndex + 1); + } + + await i.update({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length)], + }); + } catch (err) { + console.error("[pagination] button update failed", err); + } + }); + + collector.on("end", async () => { + try { + await replyMessage.edit({ + content: renderPage(pages, pageIndex), + components: [buildRow(pageIndex, pages.length, true)], + }); + } catch (err) { + console.error("[pagination] failed to disable buttons", err); + } + }); +} \ 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..75604c80 --- /dev/null +++ b/src/commands/playback/playback.ts @@ -0,0 +1,149 @@ +// 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 + } + }); +} \ No newline at end of file diff --git a/src/commands/summary/summary.ts b/src/commands/summary/summary.ts index c1490a2d..09edbc32 100644 --- a/src/commands/summary/summary.ts +++ b/src/commands/summary/summary.ts @@ -1,3 +1,5 @@ +// src/commands/summary/summary.ts + import { SlashCommandBuilder, AttachmentBuilder, @@ -5,11 +7,10 @@ import { type ChatInputCommandInteraction, } from "discord.js"; import { summarize } from "../../services/summary/summarizer.js"; +import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; +import { buildTranscript } from "../../services/transcript/buildTranscript.js"; +import { SUMMARY_DEFAULTS } from "../../services/transcript/defaults.js"; -/** - * Defines the /summary command. - * Summarizes recent messages from the current channel and sends the result via DM. - */ export const data = new SlashCommandBuilder() .setName("summary") .setDescription("Summarize recent messages and DM it to you") @@ -21,146 +22,59 @@ export const data = new SlashCommandBuilder() .setMaxValue(100), ); -/** - * Handler for the /summary command. - * - * Flow: - * 1. Defer an ephemeral reply so the user sees β€œworking…” without cluttering the channel. - * 2. Validate the channel can provide messages. - * 3. Fetch the last N messages, filter out bots and empty content. - * 4. Build a plain-text transcript. - * 5. Run the transcript through the summarizer. - * 6. Try to DM the summary to the user (text or file). - * 7. Handle and report errors gracefully. - */ export async function execute( interaction: ChatInputCommandInteraction, ): Promise { - // Use the requested count or default to 50 messages. - const count = interaction.options.getInteger("count") ?? 50; - - try { - /** - * Use flags instead of the deprecated `ephemeral: true`. - * This keeps the response visible only to the user while work happens. - */ - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - /** - * Guard: make sure the interaction channel supports text messages. - * Some interaction contexts (like certain system channels) cannot be summarized. - */ - if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply( - "This channel does not support summarizing messages.", - ); - return; - } - - // Fetch the most recent messages up to the requested limit. - const messages = await interaction.channel.messages.fetch({ limit: count }); + const count = interaction.options.getInteger("count") ?? SUMMARY_DEFAULTS.maxLines ?? 50; - // Nothing in the channel at all. - if (messages.size === 0) { - await interaction.editReply("No messages found to summarize."); - return; - } - - /** - * Filter out bot messages and anything without content, - * then sort oldest β†’ newest so the transcript reads in conversation order. - */ - const userMessages = messages - .filter((m) => !m.author.bot && m.content) - .sort((a, b) => a.createdTimestamp - b.createdTimestamp); - - // If everything filtered out (all bots or empty content), there is nothing useful to summarize. - if (userMessages.size === 0) { - await interaction.editReply("No usable messages found to summarize."); - return; - } + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - /** - * Build a simple β€œusername: content” transcript that the summarizer can consume. - */ - const text = userMessages - .map((m) => `${m.author.username}: ${m.content}`) - .join("\n"); + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support summarizing messages."); + return; + } - // Generate the summary using either local or LLM mode, depending on configuration. - const output = await summarize(text); + const messages = await fetchChannelMessages(interaction.channel, { count }); - // Defensive guard: handle a blank or missing summary result. - if (!output || output.trim().length === 0) { - await interaction.editReply("Summary came back empty."); - return; - } + if (messages.length === 0) { + await interaction.editReply("No usable messages found to summarize."); + return; + } - /** - * DM-only delivery mode: - * If the summary is too long for a normal Discord message, send it as a file attachment instead. - */ - if (output.length > 2000) { - const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { - name: "summary.txt", - }); + // Build a transcript intended for summarization (usually no timestamps). + const transcript = buildTranscript(messages, SUMMARY_DEFAULTS); - try { - // Attempt to DM the file to the user. - await interaction.user.send({ - content: "Here is your summary (too long to send as a message):", - files: [file], - }); + const output = await summarize(transcript.text); - // Confirm in the ephemeral reply that the summary was sent. - await interaction.editReply("Summary sent to your DMs."); - } catch (err) { - console.error("[summary] DM file send failed", err); - await interaction.editReply( - "I generated the summary, but your DMs appear to be closed.", - ); - } + if (!output || output.trim().length === 0) { + await interaction.editReply("Summary came back empty."); + return; + } - return; - } + // DM delivery with file fallback + if (output.length > 2000) { + const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { + name: "summary.txt", + }); - /** - * Normal-sized summary: - * Send it as a plain DM message, then confirm in the ephemeral reply. - */ try { - await interaction.user.send(output); + await interaction.user.send({ + content: "Here is your summary (too long to send as a message):", + files: [file], + }); await interaction.editReply("Summary sent to your DMs."); } catch (err) { - console.error("[summary] DM text send failed", err); - await interaction.editReply( - "I generated the summary, but could not DM you. Your DMs may be closed.", - ); + console.error("[summary] DM file send failed", err); + await interaction.editReply("I generated the summary, but your DMs appear to be closed."); } - } catch (err) { - /** - * Top-level catch: if anything in the summarization pipeline fails, - * log the error and try to send a generic failure message back to the user. - */ - console.error("[summary] Summary generation failed", err); + return; + } - try { - if (interaction.replied || interaction.deferred) { - await interaction.editReply( - "Something went wrong while generating the summary.", - ); - } else { - await interaction.reply({ - content: "Something went wrong while generating the summary.", - flags: MessageFlags.Ephemeral, - }); - } - } catch (replyErr) { - // Final fallback if even the error reply fails. - console.error( - "[summary] Failed to send fallback error message", - replyErr, - ); - } + try { + await interaction.user.send(output); + await interaction.editReply("Summary sent to your DMs."); + } catch (err) { + console.error("[summary] DM text send failed", err); + await interaction.editReply("I generated the summary, but could not DM you. Your DMs may be closed."); } -} +} \ No newline at end of file diff --git a/src/services/discord/fetchChannelMessages.ts b/src/services/discord/fetchChannelMessages.ts new file mode 100644 index 00000000..f6f9ea0a --- /dev/null +++ b/src/services/discord/fetchChannelMessages.ts @@ -0,0 +1,42 @@ +// src/services/discord/fetchChannelMessages.ts + +import type { Collection, Message, TextBasedChannel } from "discord.js"; +import type { TranscriptMessage } from "../transcript/buildTranscript.js"; + +export type FetchPlaybackOptions = { + count: number; // 1..100 for Discord fetch + before?: string; // message id + after?: string; // message id +}; + +/** + * Fetch messages and return user messages as a simple array, oldest -> newest. + * + * Notes: + * - Discord fetch supports `before` easily. + * - `after` is supported too, but returns messages AFTER that id, still newest-first. + * - We sort oldest->newest at the end for stable ordering. + */ +export async function fetchChannelMessages( + channel: TextBasedChannel, + options: FetchPlaybackOptions, +): Promise { + const { count, before, after } = options; + + const fetched: Collection = await channel.messages.fetch({ + limit: count, + before, + after, + }); + + const arr = Array.from(fetched.values()) + .filter((m) => !m.author.bot && Boolean(m.content)) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp) + .map((m) => ({ + createdTimestamp: m.createdTimestamp, + content: m.content, + author: { username: m.author.username }, + })); + + return arr; +} \ No newline at end of file diff --git a/src/services/summary/llmSummary.ts b/src/services/summary/llmSummary.ts index 43f42aba..f08ab924 100644 --- a/src/services/summary/llmSummary.ts +++ b/src/services/summary/llmSummary.ts @@ -1,54 +1,47 @@ +// src/services/summary/llmSummary.ts + import OpenAI from "openai"; import { env } from "../../config/env.js"; -/** - * Set client - */ let client: OpenAI | null = null; -/** - * if OpenAPIKey is set use it for client - */ if (env.openAIKey) { client = new OpenAI({ apiKey: env.openAIKey }); } /** - * Generates an LLM summary of the provided text. - * Falls back to a safe message if no API key is configured. + * Generate a structured summary (Markdown) from the transcript. + * Safe fallback if LLM mode is requested without a key. */ export async function llmSummary(text: string): Promise { if (!client) { return "LLM mode requested but no API key is configured."; } - /** - * Build a prompt instructing the LLM to summarize messages in a clean, neutral format. - */ - const prompt = ` -You are a Discord channel summarizer. -Summarize the following messages into a short readable summary. -Do not include usernames or timestamps. -Text: -${text} - `; - - /** - * Send the summary prompt to the gpt-4o-mini model. - */ + const prompt = [ + "You are a Discord channel summarizer.", + "Given the transcript below, produce a structured Markdown output with these sections:", + "## Summary (2 to 5 bullets)", + "## Key points (3 to 8 bullets)", + "## Action items (if any, otherwise say 'None')", + "## Open questions (if any, otherwise say 'None')", + "", + "Rules:", + "- Do not include timestamps.", + "- Avoid quoting usernames; paraphrase instead unless absolutely necessary.", + "- Keep it concise and readable.", + "", + "Transcript:", + text, + ].join("\n"); + const response = await client.chat.completions.create({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }], }); - /** - * Extract the model output, defaulting to a fallback message if no content is returned. - */ const content = response.choices?.[0]?.message?.content ?? "LLM returned no content."; - /** - * Return the cleaned summary text. - */ return content.trim(); -} +} \ No newline at end of file diff --git a/src/services/time/formatTimestamp.ts b/src/services/time/formatTimestamp.ts index 2f65974e..ee0b983d 100644 --- a/src/services/time/formatTimestamp.ts +++ b/src/services/time/formatTimestamp.ts @@ -1,19 +1,14 @@ /** - * Format a Unix timestamp into a readable date/time string. + * Formats a timestamp using a specific locale and timezone. * - * - Uses 24-hour (military) time - * - Allows caller to control locale and 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} + * - locale controls formatting style (en-GB, en-US, etc) + * - timeZone must be an IANA timezone (UTC, America/New_York, Europe/London) */ export function formatTimestamp( ts: number, - locale = "en-GB", - timeZone = "UTC", -) { + timeZone: string = "UTC", + locale: string = "en-GB", +): string { return new Date(ts).toLocaleString(locale, { timeZone, year: "numeric", @@ -23,4 +18,4 @@ export function formatTimestamp( minute: "2-digit", hour12: false, }); -} +} \ No newline at end of file diff --git a/src/services/transcript/buildTranscript.ts b/src/services/transcript/buildTranscript.ts index 1f1c035b..4b67853c 100644 --- a/src/services/transcript/buildTranscript.ts +++ b/src/services/transcript/buildTranscript.ts @@ -1,3 +1,5 @@ +// src/services/transcript/buildTranscript.ts + import { formatTimestamp } from "../time/formatTimestamp.js"; /** @@ -7,9 +9,7 @@ import { formatTimestamp } from "../time/formatTimestamp.js"; export type TranscriptMessage = { createdTimestamp: number; content: string; - author: { - username: string; - }; + author: { username: string }; }; export type TranscriptOptions = { @@ -68,16 +68,19 @@ export function buildTranscript( const line = parts.join(" "); lines.push(line); - // Enforce maxLines if (maxLines && lines.length >= maxLines) { truncated = true; break; } - // Enforce maxChars - if (maxChars && lines.join("\n").length >= maxChars) { - tooLong = true; - break; + // Cheaper than joining every iteration: track length incrementally. + if (maxChars) { + const joinedLen = + lines.reduce((acc, l) => acc + l.length, 0) + Math.max(0, lines.length - 1); + if (joinedLen >= maxChars) { + tooLong = true; + break; + } } } @@ -87,4 +90,4 @@ export function buildTranscript( truncated, tooLong, }; -} +} \ 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..8180daa1 --- /dev/null +++ b/src/services/transcript/defaults.ts @@ -0,0 +1,31 @@ +// src/services/transcript/defaults.ts + +import type { TranscriptOptions } from "./buildTranscript.js"; + +/** + * Central place for transcript defaults so commands stay consistent. + * Commands can override these if they intentionally differ. + */ + +// Discord hard limit is 2000 chars. Leave headroom for labels and safety. +export const DISCORD_SAFE_TEXT_LIMIT = 1900; + +export const HISTORY_DEFAULTS: TranscriptOptions = { + includeTimestamp: true, + includeAuthor: true, + maxLines: 50, + maxChars: DISCORD_SAFE_TEXT_LIMIT, + timeZone: "UTC", + locale: "en-GB", +}; + +export const SUMMARY_DEFAULTS: TranscriptOptions = { + // Summary transcript should be stable for summarizers: + // author helps a lot, timestamps usually add noise. + includeTimestamp: false, + includeAuthor: true, + maxLines: 50, + maxChars: DISCORD_SAFE_TEXT_LIMIT, + timeZone: "UTC", + locale: "en-GB", +}; \ No newline at end of file From 257b50d3cc60e052d6061f9fd3a1b07e5b672362 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Mon, 15 Dec 2025 15:05:44 -0500 Subject: [PATCH 2/5] Fixing formating --- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- docs/commands.md | 2 +- docs/dev-notes.md | 12 ++++++++- docs/transcripts.md | 27 ++++++++++++-------- src/commands/history/history.ts | 17 ++++++++---- src/commands/pagination/pagination.ts | 2 +- src/commands/playback/playback.ts | 16 +++++++++--- src/commands/summary/summary.ts | 17 ++++++++---- src/services/discord/fetchChannelMessages.ts | 2 +- src/services/summary/llmSummary.ts | 2 +- src/services/time/formatTimestamp.ts | 2 +- src/services/transcript/buildTranscript.ts | 5 ++-- src/services/transcript/defaults.ts | 2 +- 13 files changed, 73 insertions(+), 35 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index fb252b8f..0c03aeb7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -48,4 +48,4 @@ body: options: - label: I searched existing issues for duplicates required: true - - label: This issue includes enough detail to act on \ No newline at end of file + - label: This issue includes enough detail to act on diff --git a/docs/commands.md b/docs/commands.md index 29c0a314..8f553378 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -34,4 +34,4 @@ Shared in `src/services/transcript/defaults.ts`: - locale: en-GB - timezone: UTC -- 24-hour time (hour12: false) \ No newline at end of file +- 24-hour time (hour12: false) diff --git a/docs/dev-notes.md b/docs/dev-notes.md index fc3e03f3..0a6b450f 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -13,6 +13,7 @@ Every slash command must do **one** of the following within 3 seconds: - `deferReply()` Failing to do this causes: + - Interaction timeout - Silent failures - β€œThis interaction failed” messages @@ -28,13 +29,17 @@ Failing to do this causes: ## Ephemeral vs DM ### Ephemeral + Use when: + - Acknowledging success/failure - Showing short status messages - Avoiding channel noise ### DM + Use when: + - Output is long - Content is private - Transcript or summary is generated @@ -56,6 +61,7 @@ fetch ``` Important notes: + - Discord returns a `Collection`, not an array - Sorting must be explicit - Missing permissions can fail silently @@ -79,6 +85,7 @@ Without **Read Message History**, fetch can return empty results. ## Gateway Intents Ensure these are enabled: + - `Guilds` - `GuildMessages` - `MessageContent` (if needed) @@ -107,10 +114,12 @@ Always provide `.env.example`. ## Command Registration During development: + - Prefer **guild commands** - Faster propagation (seconds) Production: + - Global commands - Can take up to 1 hour to update @@ -119,6 +128,7 @@ Production: ## Logging Strategy (Recommended) At minimum: + - Log errors server-side - Never expose stack traces to users - Prefix logs by feature @@ -144,6 +154,6 @@ Examples: Small helpers Clear boundaries -No magic +No magic If logic feels duplicated, it probably belongs in `services/`. diff --git a/docs/transcripts.md b/docs/transcripts.md index 2cdf633b..1bedf4e9 100644 --- a/docs/transcripts.md +++ b/docs/transcripts.md @@ -28,6 +28,7 @@ Format: ``` Rules: + - Oldest β†’ newest - Bot messages excluded - Empty messages ignored @@ -62,9 +63,11 @@ buildTranscript( ```ts { - createdTimestamp: number - content: string - author: { username: string } + createdTimestamp: number; + content: string; + author: { + username: string; + } } ``` @@ -87,14 +90,15 @@ buildTranscript( ```ts { - text: string - lineCount: number - truncated: boolean - tooLong: boolean + text: string; + lineCount: number; + truncated: boolean; + tooLong: boolean; } ``` This allows calling commands to decide: + - DM vs file attachment - Pagination vs truncation - User feedback messaging @@ -115,12 +119,13 @@ Commands do **not** silently fail when limits are exceeded. ## Commands Using Transcripts -| Command | Purpose | -|---|---| -| `/history` | Raw message playback via DM | +| Command | Purpose | +| ---------- | ----------------------------- | +| `/history` | Raw message playback via DM | | `/summary` | Transcript β†’ summary pipeline | Future commands (planned): + - `/playback` - `/export` - `/timeline` @@ -142,6 +147,6 @@ Planned improvements that this design supports: > Fetch logic belongs in commands > Formatting belongs in services -> Decisions belong at the edge +> Decisions belong at the edge This separation keeps features easy to extend without refactoring. diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 5e36b624..5268ad18 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -24,12 +24,15 @@ export const data = new SlashCommandBuilder() export async function execute( interaction: ChatInputCommandInteraction, ): Promise { - const count = interaction.options.getInteger("count") ?? HISTORY_DEFAULTS.maxLines ?? 50; + const count = + interaction.options.getInteger("count") ?? HISTORY_DEFAULTS.maxLines ?? 50; await interaction.deferReply({ flags: MessageFlags.Ephemeral }); if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply("This channel does not support message history."); + await interaction.editReply( + "This channel does not support message history.", + ); return; } @@ -57,7 +60,9 @@ export async function execute( 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."); + await interaction.editReply( + "I generated the history, but your DMs appear to be closed.", + ); } return; } @@ -67,6 +72,8 @@ export async function execute( 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."); + 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/pagination/pagination.ts b/src/commands/pagination/pagination.ts index e5a163ed..9a127cb5 100644 --- a/src/commands/pagination/pagination.ts +++ b/src/commands/pagination/pagination.ts @@ -164,4 +164,4 @@ export async function execute( console.error("[pagination] failed to disable buttons", err); } }); -} \ No newline at end of file +} diff --git a/src/commands/playback/playback.ts b/src/commands/playback/playback.ts index 75604c80..21c49dde 100644 --- a/src/commands/playback/playback.ts +++ b/src/commands/playback/playback.ts @@ -10,7 +10,10 @@ import { } from "discord.js"; import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; import { buildTranscript } from "../../services/transcript/buildTranscript.js"; -import { HISTORY_DEFAULTS, DISCORD_SAFE_TEXT_LIMIT } from "../../services/transcript/defaults.js"; +import { + HISTORY_DEFAULTS, + DISCORD_SAFE_TEXT_LIMIT, +} from "../../services/transcript/defaults.js"; function chunkText(text: string, maxChars: number): string[] { if (text.length <= maxChars) return [text]; @@ -70,7 +73,11 @@ export async function execute( return; } - const msgs = await fetchChannelMessages(interaction.channel, { count, before, after }); + const msgs = await fetchChannelMessages(interaction.channel, { + count, + before, + after, + }); if (msgs.length === 0) { await interaction.editReply("No usable messages found for playback."); @@ -127,7 +134,8 @@ 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]}`, @@ -146,4 +154,4 @@ export async function execute( // ignore } }); -} \ No newline at end of file +} diff --git a/src/commands/summary/summary.ts b/src/commands/summary/summary.ts index 09edbc32..57862a8e 100644 --- a/src/commands/summary/summary.ts +++ b/src/commands/summary/summary.ts @@ -25,12 +25,15 @@ export const data = new SlashCommandBuilder() export async function execute( interaction: ChatInputCommandInteraction, ): Promise { - const count = interaction.options.getInteger("count") ?? SUMMARY_DEFAULTS.maxLines ?? 50; + const count = + interaction.options.getInteger("count") ?? SUMMARY_DEFAULTS.maxLines ?? 50; await interaction.deferReply({ flags: MessageFlags.Ephemeral }); if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply("This channel does not support summarizing messages."); + await interaction.editReply( + "This channel does not support summarizing messages.", + ); return; } @@ -65,7 +68,9 @@ export async function execute( await interaction.editReply("Summary sent to your DMs."); } catch (err) { console.error("[summary] DM file send failed", err); - await interaction.editReply("I generated the summary, but your DMs appear to be closed."); + await interaction.editReply( + "I generated the summary, but your DMs appear to be closed.", + ); } return; } @@ -75,6 +80,8 @@ export async function execute( await interaction.editReply("Summary sent to your DMs."); } catch (err) { console.error("[summary] DM text send failed", err); - await interaction.editReply("I generated the summary, but could not DM you. Your DMs may be closed."); + await interaction.editReply( + "I generated the summary, but could not DM you. Your DMs may be closed.", + ); } -} \ No newline at end of file +} diff --git a/src/services/discord/fetchChannelMessages.ts b/src/services/discord/fetchChannelMessages.ts index f6f9ea0a..2ec5b6ec 100644 --- a/src/services/discord/fetchChannelMessages.ts +++ b/src/services/discord/fetchChannelMessages.ts @@ -39,4 +39,4 @@ export async function fetchChannelMessages( })); return arr; -} \ No newline at end of file +} diff --git a/src/services/summary/llmSummary.ts b/src/services/summary/llmSummary.ts index f08ab924..d48b68f3 100644 --- a/src/services/summary/llmSummary.ts +++ b/src/services/summary/llmSummary.ts @@ -44,4 +44,4 @@ export async function llmSummary(text: string): Promise { response.choices?.[0]?.message?.content ?? "LLM returned no content."; return content.trim(); -} \ No newline at end of file +} diff --git a/src/services/time/formatTimestamp.ts b/src/services/time/formatTimestamp.ts index ee0b983d..10b8819d 100644 --- a/src/services/time/formatTimestamp.ts +++ b/src/services/time/formatTimestamp.ts @@ -18,4 +18,4 @@ export function formatTimestamp( minute: "2-digit", hour12: false, }); -} \ No newline at end of file +} diff --git a/src/services/transcript/buildTranscript.ts b/src/services/transcript/buildTranscript.ts index 4b67853c..4019f210 100644 --- a/src/services/transcript/buildTranscript.ts +++ b/src/services/transcript/buildTranscript.ts @@ -76,7 +76,8 @@ export function buildTranscript( // Cheaper than joining every iteration: track length incrementally. if (maxChars) { const joinedLen = - lines.reduce((acc, l) => acc + l.length, 0) + Math.max(0, lines.length - 1); + lines.reduce((acc, l) => acc + l.length, 0) + + Math.max(0, lines.length - 1); if (joinedLen >= maxChars) { tooLong = true; break; @@ -90,4 +91,4 @@ export function buildTranscript( truncated, tooLong, }; -} \ No newline at end of file +} diff --git a/src/services/transcript/defaults.ts b/src/services/transcript/defaults.ts index 8180daa1..dde980f4 100644 --- a/src/services/transcript/defaults.ts +++ b/src/services/transcript/defaults.ts @@ -28,4 +28,4 @@ export const SUMMARY_DEFAULTS: TranscriptOptions = { maxChars: DISCORD_SAFE_TEXT_LIMIT, timeZone: "UTC", locale: "en-GB", -}; \ No newline at end of file +}; From 449f34f16202dd9610a106792468e61a9edc6fdc Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 16 Dec 2025 13:42:05 -0500 Subject: [PATCH 3/5] Fixing up this file --- data/timezones.json | 1 + src/commands/summary/summary.ts | 168 ++++++++++++++++++++++---------- 2 files changed, 119 insertions(+), 50 deletions(-) create mode 100644 data/timezones.json diff --git a/data/timezones.json b/data/timezones.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/data/timezones.json @@ -0,0 +1 @@ +{} diff --git a/src/commands/summary/summary.ts b/src/commands/summary/summary.ts index 57862a8e..e8971571 100644 --- a/src/commands/summary/summary.ts +++ b/src/commands/summary/summary.ts @@ -1,5 +1,3 @@ -// src/commands/summary/summary.ts - import { SlashCommandBuilder, AttachmentBuilder, @@ -7,10 +5,11 @@ import { type ChatInputCommandInteraction, } from "discord.js"; import { summarize } from "../../services/summary/summarizer.js"; -import { fetchChannelMessages } from "../../services/discord/fetchChannelMessages.js"; -import { buildTranscript } from "../../services/transcript/buildTranscript.js"; -import { SUMMARY_DEFAULTS } from "../../services/transcript/defaults.js"; +/** + * Defines the /summary command. + * Summarizes recent messages from the current channel and sends the result via DM. + */ export const data = new SlashCommandBuilder() .setName("summary") .setDescription("Summarize recent messages and DM it to you") @@ -22,66 +21,135 @@ export const data = new SlashCommandBuilder() .setMaxValue(100), ); -export async function execute( - interaction: ChatInputCommandInteraction, -): Promise { - const count = - interaction.options.getInteger("count") ?? SUMMARY_DEFAULTS.maxLines ?? 50; +/** + * Handler for the /summary command. + * + * Flow: + * 1. Defer an ephemeral reply so the user sees β€œworking…” without cluttering the channel. + * 2. Validate the channel can provide messages. + * 3. Fetch the last N messages, filter out bots and empty content. + * 4. Build a plain-text transcript. + * 5. Run the transcript through the summarizer. + * 6. Try to DM the summary to the user (text or file). + * 7. Handle and report errors gracefully. + */ +export async function execute(interaction: ChatInputCommandInteraction): Promise { + // Use the requested count or default to 50 messages. + const count = interaction.options.getInteger("count") ?? 50; - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + try { + /** + * Use flags instead of the deprecated `ephemeral: true`. + * This keeps the response visible only to the user while work happens. + */ + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply( - "This channel does not support summarizing messages.", - ); - return; - } + /** + * Guard: make sure the interaction channel supports text messages. + * Some interaction contexts (like certain system channels) cannot be summarized. + */ + if (!interaction.channel || !interaction.channel.isTextBased()) { + await interaction.editReply("This channel does not support summarizing messages."); + return; + } - const messages = await fetchChannelMessages(interaction.channel, { count }); + // Fetch the most recent messages up to the requested limit. + const messages = await interaction.channel.messages.fetch({ limit: count }); - if (messages.length === 0) { - await interaction.editReply("No usable messages found to summarize."); - return; - } + // Nothing in the channel at all. + if (messages.size === 0) { + await interaction.editReply("No messages found to summarize."); + return; + } - // Build a transcript intended for summarization (usually no timestamps). - const transcript = buildTranscript(messages, SUMMARY_DEFAULTS); + /** + * Filter out bot messages and anything without content, + * then sort oldest β†’ newest so the transcript reads in conversation order. + */ + const userMessages = messages + .filter((m) => !m.author.bot && m.content) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp); - const output = await summarize(transcript.text); + // If everything filtered out (all bots or empty content), there is nothing useful to summarize. + if (userMessages.size === 0) { + await interaction.editReply("No usable messages found to summarize."); + return; + } - if (!output || output.trim().length === 0) { - await interaction.editReply("Summary came back empty."); - return; - } + /** + * Build a simple β€œusername: content” transcript that the summarizer can consume. + */ + const text = userMessages.map((m) => `${m.author.username}: ${m.content}`).join("\n"); - // DM delivery with file fallback - if (output.length > 2000) { - const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { - name: "summary.txt", - }); + // Generate the summary using either local or LLM mode, depending on configuration. + const output = await summarize(text); - try { - await interaction.user.send({ - content: "Here is your summary (too long to send as a message):", - files: [file], + // Defensive guard: handle a blank or missing summary result. + if (!output || output.trim().length === 0) { + await interaction.editReply("Summary came back empty."); + return; + } + + /** + * DM-only delivery mode: + * If the summary is too long for a normal Discord message, send it as a file attachment instead. + */ + if (output.length > 2000) { + const file = new AttachmentBuilder(Buffer.from(output, "utf8"), { + name: "summary.txt", }); + + try { + // Attempt to DM the file to the user. + await interaction.user.send({ + content: "Here is your summary (too long to send as a message):", + files: [file], + }); + + // Confirm in the ephemeral reply that the summary was sent. + await interaction.editReply("Summary sent to your DMs."); + } catch (err) { + console.error("[summary] DM file send failed", err); + await interaction.editReply( + "I generated the summary, but your DMs appear to be closed.", + ); + } + + return; + } + + /** + * Normal-sized summary: + * Send it as a plain DM message, then confirm in the ephemeral reply. + */ + try { + await interaction.user.send(output); await interaction.editReply("Summary sent to your DMs."); } catch (err) { - console.error("[summary] DM file send failed", err); + console.error("[summary] DM text send failed", err); await interaction.editReply( - "I generated the summary, but your DMs appear to be closed.", + "I generated the summary, but could not DM you. Your DMs may be closed.", ); } - return; - } - - try { - await interaction.user.send(output); - await interaction.editReply("Summary sent to your DMs."); } catch (err) { - console.error("[summary] DM text send failed", err); - await interaction.editReply( - "I generated the summary, but could not DM you. Your DMs may be closed.", - ); + /** + * Top-level catch: if anything in the summarization pipeline fails, + * log the error and try to send a generic failure message back to the user. + */ + console.error("[summary] Summary generation failed", err); + + try { + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong while generating the summary."); + } else { + await interaction.reply({ + content: "Something went wrong while generating the summary.", + flags: MessageFlags.Ephemeral, + }); + } + } catch (replyErr) { + // Final fallback if even the error reply fails. + console.error("[summary] Failed to send fallback error message", replyErr); + } } } From 555fe913de52547af25faa0d39215dcf871554ba Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 16 Dec 2025 13:42:31 -0500 Subject: [PATCH 4/5] Adding in this change --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2882b7ca..04b72c16 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "format": "prettier . --write", "format:check": "prettier . --check", "lint": "eslint . --ext .ts --max-warnings=0", + "prepare": "husky", + "precheck": "bash ./scripts/precheck.sh", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -27,4 +29,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.0.0" } -} +} \ No newline at end of file From fd5298cd64d6bc9083399650d029984c196b976c Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Tue, 16 Dec 2025 13:43:47 -0500 Subject: [PATCH 5/5] Adding in this change --- .prettierignore | 12 +++++++++ .prettierrc.yml | 9 +++++++ README.md | 31 +++++++++++++++------- package.json | 2 +- src/bot.ts | 5 +--- src/commands/general/ping.ts | 9 ++----- src/commands/history/history.ts | 8 ++---- src/commands/pagination/pagination.ts | 4 +-- src/commands/playback/playback.ts | 7 ++--- src/services/summary/llmSummary.ts | 3 +-- src/services/transcript/buildTranscript.ts | 3 +-- 11 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.yml diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..2ea8230d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +# secrets / env +.env +.env.* +!.env.example + +# OS junk +.DS_Store + +# deps + build +node_modules +dist +coverage \ No newline at end of file diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 00000000..354f23b6 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,9 @@ +printWidth: 90 +tabWidth: 2 +useTabs: false +semi: true +singleQuote: false +trailingComma: all +bracketSpacing: true +arrowParens: always +endOfLine: lf diff --git a/README.md b/README.md index 40751a9f..0ffd11df 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) --- @@ -118,12 +120,18 @@ OmegaBot is online β”œβ”€β”€ .husky β”‚ β”œβ”€β”€ pre-commit β”‚ └── pre-push +β”œβ”€β”€ .prettierignore +β”œβ”€β”€ .prettierrc.yml β”œβ”€β”€ assets β”‚ β”œβ”€β”€ banner.png β”‚ └── omegabot.png β”œβ”€β”€ CONTRIBUTORS.md +β”œβ”€β”€ data +β”‚ └── timezones.json β”œβ”€β”€ docs -β”‚ └── commands.md +β”‚ β”œβ”€β”€ commands.md +β”‚ β”œβ”€β”€ dev-notes.md +β”‚ └── transcripts.md β”œβ”€β”€ eslint.config.ts β”œβ”€β”€ LICENSE β”œβ”€β”€ package-lock.json @@ -140,6 +148,8 @@ OmegaBot is online β”‚ β”‚ β”‚ └── history.ts β”‚ β”‚ β”œβ”€β”€ pagination β”‚ β”‚ β”‚ └── pagination.ts +β”‚ β”‚ β”œβ”€β”€ playback +β”‚ β”‚ β”‚ └── playback.ts β”‚ β”‚ └── summary β”‚ β”‚ └── summary.ts β”‚ β”œβ”€β”€ config @@ -148,6 +158,7 @@ OmegaBot is online β”‚ └── services β”‚ β”œβ”€β”€ discord β”‚ β”‚ β”œβ”€β”€ commandLoader.ts +β”‚ β”‚ β”œβ”€β”€ fetchChannelMessages.ts β”‚ β”‚ └── interactionHandler.ts β”‚ β”œβ”€β”€ summary β”‚ β”‚ β”œβ”€β”€ llmSummary.ts diff --git a/package.json b/package.json index 04b72c16..c08066dd 100644 --- a/package.json +++ b/package.json @@ -29,4 +29,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.0.0" } -} \ No newline at end of file +} diff --git a/src/bot.ts b/src/bot.ts index 25c7bf9e..81de3a10 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,8 +1,5 @@ import { Client, GatewayIntentBits } from "discord.js"; -import { - loadCommands, - type CommandClient, -} from "./services/discord/commandLoader.js"; +import { loadCommands, type CommandClient } from "./services/discord/commandLoader.js"; import { handleInteraction } from "./services/discord/interactionHandler.js"; import { env } from "./config/env.js"; /* diff --git a/src/commands/general/ping.ts b/src/commands/general/ping.ts index 023ee158..57f4865c 100644 --- a/src/commands/general/ping.ts +++ b/src/commands/general/ping.ts @@ -1,7 +1,4 @@ -import { - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; +import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; /** * Defines the /ping command. @@ -11,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 { /** * First reply (with fetchReply: true) returns the actual message object. * We use this to calculate round-trip latency between interaction and reply. diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index 5268ad18..05d505a4 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -21,18 +21,14 @@ 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") ?? HISTORY_DEFAULTS.maxLines ?? 50; await interaction.deferReply({ flags: MessageFlags.Ephemeral }); if (!interaction.channel || !interaction.channel.isTextBased()) { - await interaction.editReply( - "This channel does not support message history.", - ); + await interaction.editReply("This channel does not support message history."); return; } diff --git a/src/commands/pagination/pagination.ts b/src/commands/pagination/pagination.ts index 9a127cb5..7fcfbcd5 100644 --- a/src/commands/pagination/pagination.ts +++ b/src/commands/pagination/pagination.ts @@ -84,9 +84,7 @@ function renderPage(pages: string[], pageIndex: number) { return `${header}\n\n${pages[pageIndex]}`; } -export async function execute( - interaction: ChatInputCommandInteraction, -): Promise { +export async function execute(interaction: ChatInputCommandInteraction): Promise { const count = interaction.options.getInteger("count") ?? 50; // Not ephemeral: buttons + paging is easier as a normal message. 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/summary/llmSummary.ts b/src/services/summary/llmSummary.ts index d48b68f3..2232e8a4 100644 --- a/src/services/summary/llmSummary.ts +++ b/src/services/summary/llmSummary.ts @@ -40,8 +40,7 @@ export async function llmSummary(text: string): Promise { messages: [{ role: "user", content: prompt }], }); - const content = - response.choices?.[0]?.message?.content ?? "LLM returned no content."; + const content = response.choices?.[0]?.message?.content ?? "LLM returned no content."; return content.trim(); } diff --git a/src/services/transcript/buildTranscript.ts b/src/services/transcript/buildTranscript.ts index 4019f210..7c332bc3 100644 --- a/src/services/transcript/buildTranscript.ts +++ b/src/services/transcript/buildTranscript.ts @@ -76,8 +76,7 @@ export function buildTranscript( // Cheaper than joining every iteration: track length incrementally. if (maxChars) { const joinedLen = - lines.reduce((acc, l) => acc + l.length, 0) + - Math.max(0, lines.length - 1); + lines.reduce((acc, l) => acc + l.length, 0) + Math.max(0, lines.length - 1); if (joinedLen >= maxChars) { tooLong = true; break;