Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ OmegaBot is online
│ │ │ └── pr.ts
│ │ ├── history
│ │ │ └── history.ts
│ │ ├── pagination
│ │ │ └── pagination.ts
│ │ ├── playback
│ │ │ └── playback.ts
│ │ └── summary
Expand Down
1 change: 1 addition & 0 deletions data/timezones.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
165 changes: 165 additions & 0 deletions src/commands/pagination/pagination.ts
Original file line number Diff line number Diff line change
@@ -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<ButtonBuilder>().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<void> {
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);
}
});
}
44 changes: 20 additions & 24 deletions src/services/summary/llmSummary.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 }],
Expand All @@ -46,8 +45,5 @@ ${text}
*/
const content = response.choices?.[0]?.message?.content ?? "LLM returned no content.";

/**
* Return the cleaned summary text.
*/
return content.trim();
}
19 changes: 11 additions & 8 deletions src/services/transcript/buildTranscript.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// src/services/transcript/buildTranscript.ts

import { formatTimestamp } from "../time/formatTimestamp.js";

/**
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
}
}
}

Expand Down