diff --git a/README.md b/README.md index a3238dc4..79d3c3d4 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ OmegaBot is online │ │ │ └── pr.ts │ │ ├── history │ │ │ └── history.ts +│ │ ├── pagination +│ │ │ └── pagination.ts │ │ ├── playback │ │ │ └── playback.ts │ │ └── summary 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/pagination/pagination.ts b/src/commands/pagination/pagination.ts new file mode 100644 index 00000000..7fcfbcd5 --- /dev/null +++ b/src/commands/pagination/pagination.ts @@ -0,0 +1,165 @@ +// 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); + } + }); +} diff --git a/src/services/summary/llmSummary.ts b/src/services/summary/llmSummary.ts index a053e73d..d362cf3e 100644 --- a/src/services/summary/llmSummary.ts +++ b/src/services/summary/llmSummary.ts @@ -1,41 +1,40 @@ +// 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} - `; + 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"); - /** - * Send the summary prompt to the gpt-4o-mini model. - */ const response = await client.chat.completions.create({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }], @@ -46,8 +45,5 @@ ${text} */ const content = response.choices?.[0]?.message?.content ?? "LLM returned no content."; - /** - * Return the cleaned summary text. - */ return content.trim(); } diff --git a/src/services/transcript/buildTranscript.ts b/src/services/transcript/buildTranscript.ts index 1f1c035b..7c332bc3 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; + } } }