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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ Thumbs.db
*.tsbuildinfo

# Optional: ignore zip files
*.zip
*.zip

# Runtime data
data/timezones.json
33 changes: 21 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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
Expand All @@ -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
```

Expand Down
15 changes: 8 additions & 7 deletions src/commands/general/ping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export const data = new SlashCommandBuilder()

export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
/**
* 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:
Expand All @@ -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.`,
Expand Down
169 changes: 92 additions & 77 deletions src/commands/history/history.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.",
);
}
}
Loading