feat: initialize Discord bot project with basic command structure

- Add package.json with dependencies and scripts for building, testing, and running the bot.
- Implement bot startup logic in src/bot.ts, handling interactions and commands.
- Create command structure in src/commands/index.ts, including a ping command in src/commands/ping.ts.
- Add configuration loading from environment variables in src/config.ts.
- Implement command registration in Discord API in src/deploy-commands.ts.
- Bootstrap the bot in src/index.ts.
- Configure TypeScript settings in tsconfig.json.
This commit is contained in:
2026-05-17 15:52:39 +02:00
parent c348204772
commit a402c7b0bb
13 changed files with 6329 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
# Optional. If set, command registration targets this guild for instant updates.
DISCORD_GUILD_ID=
+4
View File
@@ -18,3 +18,7 @@ node_modules/
# build and test outputs
dist/
coverage/
# doc template
arc42_adoc/
download_docs_template.py
+26
View File
@@ -0,0 +1,26 @@
const tsEslint = require("@typescript-eslint/eslint-plugin");
const tsParser = require("@typescript-eslint/parser");
/** @type {import("eslint").Linter.FlatConfig[]} */
module.exports = [
{
ignores: ["dist/**", "node_modules/**"],
},
{
files: ["**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": tsEslint,
},
rules: {
"no-console": "off",
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
moduleFileExtensions: ["ts", "js", "json"],
clearMocks: true,
};
+6055
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "omo-bot",
"version": "1.0.0",
"description": "A custom Discord bot and web integration layer designed to bridge the [openmicodyssey.com](https://openmicodyssey.com) experience with our community server. This application gamifies community engagement, automates content syndication, and provides a suite of event management tools centered around the themes of stand-up comedy, indie filmmaking, and a cross-country road trip.",
"main": "dist/index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "ts-node src/index.ts",
"start": "node dist/index.js",
"lint": "eslint . --ext .ts",
"register:commands": "ts-node src/deploy-commands.ts",
"test": "jest --passWithNoTests"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"discord.js": "^14.26.4"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^25.8.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.4.0",
"jest": "^30.4.2",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
}
}
+56
View File
@@ -0,0 +1,56 @@
import {
Client,
Events,
GatewayIntentBits,
Interaction,
Partials,
} from "discord.js";
import { commandMap } from "./commands";
import { BotConfig } from "./config";
export async function startBot(config: BotConfig): Promise<Client> {
const client = new Client({
intents: [GatewayIntentBits.Guilds],
partials: [Partials.Channel],
});
client.once(Events.ClientReady, (readyClient) => {
console.log(`Bot ready as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
if (!interaction.isChatInputCommand()) {
return;
}
const command = commandMap.get(interaction.commandName);
if (!command) {
await interaction.reply({
content: `Unknown command: ${interaction.commandName}`,
ephemeral: true,
});
return;
}
try {
await command.execute(interaction);
} catch (error: unknown) {
console.error(`Command failed: ${interaction.commandName}`, error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: "Command failed. Try again.",
ephemeral: true,
});
} else {
await interaction.reply({
content: "Command failed. Try again.",
ephemeral: true,
});
}
}
});
await client.login(config.discordToken);
return client;
}
+13
View File
@@ -0,0 +1,13 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import { pingCommand } from "./ping";
export interface ChatCommand {
data: SlashCommandBuilder;
execute(interaction: ChatInputCommandInteraction): Promise<void>;
}
export const commands: ChatCommand[] = [pingCommand];
export const commandMap = new Map<string, ChatCommand>(
commands.map((command) => [command.data.name, command]),
);
+10
View File
@@ -0,0 +1,10 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
export const pingCommand = {
data: new SlashCommandBuilder()
.setName("ping")
.setDescription("Check whether bot is alive."),
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
await interaction.reply("Pong!");
},
};
+24
View File
@@ -0,0 +1,24 @@
export interface BotConfig {
discordToken: string;
discordClientId: string;
discordGuildId?: string;
}
function readRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export function loadConfig(): BotConfig {
const discordGuildId = process.env.DISCORD_GUILD_ID;
return {
discordToken: readRequiredEnv("DISCORD_TOKEN"),
discordClientId: readRequiredEnv("DISCORD_CLIENT_ID"),
...(discordGuildId ? { discordGuildId } : {}),
};
}
+31
View File
@@ -0,0 +1,31 @@
import { REST, Routes } from "discord.js";
import { commands } from "./commands";
import { loadConfig } from "./config";
async function registerCommands(): Promise<void> {
const config = loadConfig();
const rest = new REST({ version: "10" }).setToken(config.discordToken);
const body = commands.map((command) => command.data.toJSON());
if (config.discordGuildId) {
await rest.put(
Routes.applicationGuildCommands(
config.discordClientId,
config.discordGuildId,
),
{ body },
);
console.log(
`Registered ${body.length} guild command(s) for guild ${config.discordGuildId}`,
);
return;
}
await rest.put(Routes.applicationCommands(config.discordClientId), { body });
console.log(`Registered ${body.length} global command(s)`);
}
void registerCommands().catch((error: unknown) => {
console.error("Failed to register commands:", error);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
import { startBot } from "./bot";
import { loadConfig } from "./config";
export async function bootstrap(): Promise<void> {
const config = loadConfig();
await startBot(config);
}
if (require.main === module) {
void bootstrap().catch((error: unknown) => {
console.error("Failed to start bot:", error);
process.exit(1);
});
}
+48
View File
@@ -0,0 +1,48 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "src",
"outDir": "dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "node16",
"target": "es2022",
"types": ["node", "jest"],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"verbatimModuleSyntax": false,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleResolution": "node16",
"moduleDetection": "auto",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}