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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ OmegaBot is a modular Discord bot designed to support development projects with
- Centralized interaction routing with safe error handling
- Structured logging (pino)
- Welcome / onboarding system triggered on member join (`guildMemberAdd`)
- Per-guild configuration system backed by persistent storage
- Per-guild configuration system backed by persistent storage and admin slash commands

### Core commands

- /help – command discovery and getting started guide
- /ping – health check
- /summary – conversation summaries (local + LLM mode)
- /history – conversation history (DM + file fallback)
Expand Down Expand Up @@ -65,7 +66,6 @@ OmegaBot is a modular Discord bot designed to support development projects with

## Planned features

- /help command for command discovery
- /docs command for documentation lookups
- GitHub issues and pull request lookups
- Pull request announcements
Expand Down Expand Up @@ -152,6 +152,7 @@ OmegaBot is online
│ │ └── OmegaBot.yml
│ └── pull_request_template.md
├── .husky
│ ├── pre-commit
│ └── pre-push
├── assets
│ ├── banner.png
Expand Down Expand Up @@ -196,6 +197,9 @@ OmegaBot is online
│ │ ├── github
│ │ │ ├── gh.ts
│ │ │ └── pr.ts
│ │ ├── help
│ │ │ ├── help.ts
│ │ │ └── helpText.ts
│ │ ├── history
│ │ │ └── history.ts
│ │ ├── pagination
Expand All @@ -213,6 +217,7 @@ OmegaBot is online
│ │ │ └── types.ts
│ │ ├── discord
│ │ │ ├── commandLoader.ts
│ │ │ ├── commandMeta.ts
│ │ │ ├── fetchChannelMessages.ts
│ │ │ └── interactionHandler.ts
│ │ ├── faq
Expand Down
47 changes: 47 additions & 0 deletions src/commands/help/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// src/commands/help/help.ts

import {
SlashCommandBuilder,
PermissionFlagsBits,
type ChatInputCommandInteraction,
} from "discord.js";
import { buildHelpText } from "./helpText.js";
import {
extractCommandList,
type CommandListItem,
} from "../../services/discord/commandMeta.js";

/**
* /help
*
* MVP goal:
* - Give users a quick “what can this bot do?”
* - Point admins to config commands
* - Keep output short and readable
*
* Enhancement:
* - If commands are loaded on the client, we also list them (grouped).
*/
export const data = new SlashCommandBuilder()
.setName("help")
.setDescription("Show what OmegaBot can do and how to get started");

export async function execute(interaction: ChatInputCommandInteraction) {
const isAdmin =
interaction.inGuild() &&
Boolean(interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild));

// Try to extract a command list from the runtime-loaded command map.
// This is optional: if your loader uses a different shape, help still works.
const commands: CommandListItem[] = extractCommandList(interaction.client);

const text = buildHelpText({
isAdmin,
commands,
});

await interaction.reply({
content: text,
ephemeral: true,
});
}
97 changes: 97 additions & 0 deletions src/commands/help/helpText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// src/commands/help/helpText.ts

import type { CommandListItem } from "../../services/discord/commandMeta.js";

/**
* Help text builder for /help.
*
* Keep this file focused on presentation so the command handler stays tiny.
*/
export function buildHelpText(args: {
isAdmin: boolean;
commands: CommandListItem[];
}): string {
const { isAdmin, commands } = args;

const lines: string[] = [];

lines.push("**OmegaBot Help**");
lines.push("");
lines.push("**Start here**");
lines.push("- Try `/help` any time you forget what I can do");
lines.push("");

lines.push("**Onboarding**");
lines.push("- Welcome messages are posted when someone joins");
lines.push("");

if (isAdmin) {
lines.push("**Admin config** (Manage Server)");
lines.push("- `/config welcome-channel set channel:#your-channel`");
lines.push("- `/config welcome-channel clear`");
lines.push("");
} else {
lines.push("**Admin config**");
lines.push("- Ask a server admin to run `/config welcome-channel set` if needed");
lines.push("");
}

// Optional dynamic section: list known commands if we can extract them.
const pretty = formatCommandList(commands, { isAdmin });

if (pretty.length) {
lines.push("**Commands**");
lines.push(...pretty);
lines.push("");
}

lines.push(
"_Tip: If new commands don’t show up, admins may need to run the register script._",
);

return lines.join("\n");
}

function formatCommandList(
commands: CommandListItem[],
opts: { isAdmin: boolean },
): string[] {
if (!commands.length) return [];

// Group by category and hide admin-only commands from non-admin users.
const visible = commands.filter((c) => (opts.isAdmin ? true : !c.adminOnly));

// Keep stable ordering: group then name.
visible.sort((a, b) => {
const g = a.group.localeCompare(b.group);
if (g !== 0) return g;
return a.name.localeCompare(b.name);
});

const out: string[] = [];
let currentGroup: string | null = null;

for (const cmd of visible) {
const groupLabel = titleCase(cmd.group);

if (currentGroup !== groupLabel) {
currentGroup = groupLabel;
out.push(`- **${currentGroup}**`);
}

// Indent commands under group header
const desc = cmd.description ? `: ${cmd.description}` : "";
out.push(` - \`/${cmd.name}\`${desc}`);
}

return out;
}

function titleCase(s: string): string {
if (!s) return "Other";
return s
.split(/[-_\s]+/g)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
94 changes: 94 additions & 0 deletions src/services/discord/commandMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// src/services/discord/commandMeta.ts

/**
* Minimal metadata shape used by /help.
*
* This is intentionally defensive: it tries to extract command names and
* descriptions from whatever your loader stores in `client.commands`.
*/

export type CommandListItem = {
name: string;
description: string;
group: string;
adminOnly: boolean;
};

type MaybeCommandModule = {
data?: {
name?: string;
description?: string;
toJSON?: () => { name?: string; description?: string };
default_member_permissions?: string | null;
};
meta?: Partial<Pick<CommandListItem, "group" | "adminOnly">>;
};

type MaybeHasCommandsMap = {
commands?: Map<string, unknown>;
};

/**
* Extract a list of commands from a Discord client object.
*
* Supports common shapes:
* - client.commands = Map<string, { data: SlashCommandBuilder, execute: fn }>
* - client.commands = Map<string, { data: { name, description } }>
*
* If nothing matches, returns an empty array and /help remains static.
*/
export function extractCommandList(client: unknown): CommandListItem[] {
const c = client as MaybeHasCommandsMap;

if (!c?.commands || !(c.commands instanceof Map)) return [];

const items: CommandListItem[] = [];

for (const [fallbackName, modUnknown] of c.commands.entries()) {
const mod = modUnknown as MaybeCommandModule;

const fromJson = safeToJson(mod?.data);
const name = (mod?.data?.name ?? fromJson?.name ?? fallbackName)?.trim();
if (!name) continue;

const description = (mod?.data?.description ?? fromJson?.description ?? "").trim();

// Best-effort group: use explicit meta.group if provided, else infer.
// (You can improve this later by having the loader attach folder name.)
const group = (mod?.meta?.group ?? inferGroupFromName(name)).trim();

// Best-effort adminOnly:
// - If command module sets meta.adminOnly, trust it
// - Else if default_member_permissions is set on command data, consider it adminOnly
const adminOnly =
Boolean(mod?.meta?.adminOnly) || Boolean(mod?.data?.default_member_permissions);

items.push({
name,
description,
group,
adminOnly,
});
}

return items;
}

function safeToJson(data: unknown): { name?: string; description?: string } | null {
try {
const d = data as { toJSON?: () => unknown };
if (typeof d?.toJSON !== "function") return null;
const j = d.toJSON() as { name?: string; description?: string };
return j ?? null;
} catch {
return null;
}
}

function inferGroupFromName(name: string): string {
// Minimal inference, keeps output readable until the loader tags groups.
if (name === "help") return "general";
if (name === "config") return "admin";
if (name.includes("github")) return "github";
return "other";
}