diff --git a/README.md b/README.md index 1a840d58..e475818a 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,6 @@ OmegaBot is online ├── README.md ├── scripts │ └── precheck.sh -├── scripts copy -│ └── precheck.sh ├── src │ ├── bot.ts │ ├── commands @@ -133,10 +131,14 @@ OmegaBot is online │ ├── discord │ │ ├── commandLoader.ts │ │ └── interactionHandler.ts -│ └── summary -│ ├── llmSummary.ts -│ ├── localSummary.ts -│ └── summarizer.ts +│ ├── summary +│ │ ├── llmSummary.ts +│ │ ├── localSummary.ts +│ │ └── summarizer.ts +│ ├── time +│ │ └── formatTimestamp.ts +│ └── transcript +│ └── buildTranscript.ts └── tsconfig.json ``` diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts index d2e69ff5..70e3dc49 100644 --- a/src/commands/history/history.ts +++ b/src/commands/history/history.ts @@ -1,108 +1,90 @@ -import { - SlashCommandBuilder, - AttachmentBuilder, - MessageFlags, - type ChatInputCommandInteraction, -} from "discord.js"; +import { formatTimestamp } from "../../services/time/formatTimestamp.js"; /** - * /history command - * Returns the last N raw user messages as a DM to the requester. - * MVP version for chat playback feature (F1). + * Minimal shape required from a Discord message + * so this helper stays framework-agnostic. */ -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 TranscriptMessage = { + createdTimestamp: number; + content: string; + author: { + username: string; + }; +}; -export async function execute( - interaction: ChatInputCommandInteraction, -): Promise { - // Default to 50 messages if no count is provided. - const count = interaction.options.getInteger("count") ?? 50; +export type TranscriptOptions = { + includeTimestamp: boolean; + includeAuthor: boolean; + maxLines?: number; + maxChars?: number; + timeZone?: string; + locale?: string; +}; - // Ephemeral: only the caller sees the acknowledgment message. - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); +export type TranscriptResult = { + text: string; + lineCount: number; + truncated: boolean; + tooLong: boolean; +}; - // Fetch messages from the current channel. - const messages = await interaction.channel?.messages.fetch({ limit: count }); - if (!messages) { - await interaction.editReply("Could not fetch messages for this channel."); - return; - } +/** + * 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; - /** - * Filter user messages only (exclude bots), - * oldest first so playback reads correctly. - */ - const userMessages = messages - .filter((m) => !m.author.bot && m.content) - .sort((a, b) => a.createdTimestamp - b.createdTimestamp); + const lines: string[] = []; + let truncated = false; + let tooLong = false; - if (userMessages.size === 0) { - await interaction.editReply("No history found."); - return; - } + for (const message of messages) { + if (!message?.content) continue; - /** - * Build a readable transcript. - */ - const text = userMessages - .map((m) => { - const dt = new Date(m.createdTimestamp).toLocaleString([], { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + const parts: string[] = []; - return `[${dt}] ${m.author.username}: ${m.content}`; - }) - .join("\n"); + if (includeTimestamp) { + const ts = formatTimestamp(message.createdTimestamp, timeZone, locale); + parts.push(`[${ts}]`); + } - /** - * If too large for a normal DM, send as a file. - */ - if (text.length > 2000) { - const file = new AttachmentBuilder(Buffer.from(text, "utf8"), { - name: "history.txt", - }); + if (includeAuthor) { + parts.push(`${message.author.username}:`); + } + + parts.push(message.content.trim()); - try { - await interaction.user.send({ - content: "Here is your recent chat history:", - files: [file], - }); + const line = parts.join(" "); + lines.push(line); - 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.", - ); + // Enforce maxLines + if (maxLines && lines.length >= maxLines) { + truncated = true; + break; } - return; + // Enforce maxChars + if (maxChars && lines.join("\n").length >= maxChars) { + tooLong = true; + break; + } } - /** - * Normal-length transcript → DM it 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.", - ); - } + return { + text: lines.join("\n"), + lineCount: lines.length, + truncated, + tooLong, + }; } diff --git a/src/services/time/formatTimestamp.ts b/src/services/time/formatTimestamp.ts new file mode 100644 index 00000000..2f65974e --- /dev/null +++ b/src/services/time/formatTimestamp.ts @@ -0,0 +1,26 @@ +/** + * Format a Unix timestamp into a readable date/time string. + * + * - 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} + */ +export function formatTimestamp( + ts: number, + locale = "en-GB", + timeZone = "UTC", +) { + return new Date(ts).toLocaleString(locale, { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} diff --git a/src/services/transcript/buildTranscript.ts b/src/services/transcript/buildTranscript.ts new file mode 100644 index 00000000..1f1c035b --- /dev/null +++ b/src/services/transcript/buildTranscript.ts @@ -0,0 +1,90 @@ +import { formatTimestamp } from "../time/formatTimestamp.js"; + +/** + * 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; +}; + +/** + * 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; + + 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}]`); + } + + if (includeAuthor) { + parts.push(`${message.author.username}:`); + } + + parts.push(message.content.trim()); + + 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; + } + } + + return { + text: lines.join("\n"), + lineCount: lines.length, + truncated, + tooLong, + }; +}