# MeoCord Framework

**MeoCord** is a decorator-based Discord bot framework built on top of discord.js. It brings a NestJS-style architecture — controllers, services, guards, and dependency injection — to bot development, with a full CLI, TypeScript-first design, and testing utilities included out of the box.

---

## Table of Contents

- [Features](#features)
- [Getting Started](#getting-started)
  - [Prerequisites](#prerequisites)
  - [Create a New App](#create-a-new-app)
  - [Quick Example](#quick-example)
- [Project Structure](#project-structure)
- [Configuration](#configuration)
  - [meocord.config.ts](#meocordconfigts)
  - [ESLint](#eslint)
- [CLI Reference](#cli-reference)
- [Guards](#guards)
- [Custom Decorators](#custom-decorators)
- [Testing](#testing)
- [Deployment](#deployment)
- [Contributing](#contributing)
- [License](#license)

---

## Features

- **Decorator-based controllers** — Handle slash commands, buttons, modals, select menus, context menus, messages, and reactions with `@Command`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
- **Dependency injection** — Built on Inversify. Services are wired into controllers automatically; no manual instantiation or service locators.
- **Guard system** — Pre-execution hooks for auth, rate limiting, metrics, and anything else. Apply per-method or per-class with `@UseGuard`. Guards receive the full interaction context.
- **Full CLI** — `meocord create`, `build`, `start`, `generate`. Scaffolds controllers, services, and guards; handles Webpack builds for both development and production.
- **Testing utilities** — `MeoCordTestingModule`, `createMockInteraction`, `createMockMessage`, `createMockUser`, `createMockClient`, `createMockGuild`, `createMockChannel`, `createChatInputOptions`, and `overrideGuard` let you test controllers against real guard logic without a Discord connection. Type guards and reply state machines work out of the box.
- **TypeScript-first** — Strict types throughout. Decorator metadata, `DeepMocked<T>` for test mocks, and typed config interfaces included.
- **Extensible build** — Expose a Webpack config hook in `meocord.config.ts` to add rules, plugins, or loaders without ejecting.

---

## Getting Started

### Prerequisites

- **Runtime**: Node.js (latest LTS) or Bun 1.x+
- **TypeScript**: 5.0+
- **Package manager**: npm, yarn, pnpm, or bun

MeoCord ships dual ESM/CJS builds. New projects generated by the CLI are preconfigured for ESM.

### Create a New App

```shell
npx meocord create <your-app-name>
```

The CLI detects installed package managers and prompts you to choose, or you can pass a flag directly:

```shell
npx meocord create <your-app-name> --use-bun
npx meocord create <your-app-name> --use-npm
npx meocord create <your-app-name> --use-pnpm
npx meocord create <your-app-name> --use-yarn
```

Set your Discord bot token in `meocord.config.ts`, then start the bot:

```shell
npx meocord start --dev   # development with live-reload
npx meocord start --build --prod  # production build + start
```

### Quick Example

A minimal slash command controller:

```typescript
import { Controller, Command, UseGuard } from 'meocord/decorator'
import { CommandType } from 'meocord/enum'
import { type ChatInputCommandInteraction } from 'discord.js'
import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js'
import { GreetingService } from '@src/services/greeting.service.js'

@Controller()
export class GreetingSlashController {
  constructor(private readonly greetingService: GreetingService) {}

  @Command('greet', CommandType.SLASH)
  @UseGuard({ provide: RateLimiterGuard, params: { limit: 3, window: 10_000 } })
  async greet(interaction: ChatInputCommandInteraction) {
    const name = interaction.options.getString('name', true)
    const message = await this.greetingService.buildGreeting(name)
    await interaction.reply({ content: message })
  }
}
```

Register it in `src/app.ts`:

```typescript
import { MeoCord } from 'meocord/decorator'
import { GatewayIntentBits, Partials } from 'discord.js'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js'
import { GreetingService } from '@src/services/greeting.service.js'

@MeoCord({
  controllers: [GreetingSlashController],
  // `services` is for specialized, event-driven services (e.g. RabbitMQ consumers,
  // schedulers). Regular business-logic services are injected via controller
  // constructors — they don't belong here.
  services: [RabbitMQService],
  clientOptions: {
    intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
    partials: [Partials.Message, Partials.Channel],
  },
})
export class App {}
```

---

## Project Structure

```
.
├── meocord.config.ts
├── eslint.config.ts
├── jest.config.ts
├── tsconfig.json
├── tsconfig.eslint.json
├── tsconfig.test.json
├── package.json
└── src
    ├── main.ts                         # Entry point — bootstraps the app
    ├── app.ts                          # Root module — registers controllers and services
    ├── controllers
    │   ├── slash
    │   │   ├── builders/               # Slash command option/subcommand builders
    │   │   ├── sample.slash.controller.ts
    │   │   └── sample.slash.controller.spec.ts
    │   ├── button
    │   │   ├── sample.button.controller.ts
    │   │   └── sample.button.controller.spec.ts
    │   ├── select-menu
    │   │   ├── sample.select-menu.controller.ts
    │   │   └── sample.select-menu.controller.spec.ts
    │   ├── modal-submit
    │   │   ├── sample.modal-submit.controller.ts
    │   │   └── sample.modal-submit.controller.spec.ts
    │   ├── context-menu
    │   │   ├── builders/               # Context menu command builders
    │   │   ├── sample.context-menu.controller.ts
    │   │   └── sample.context-menu.controller.spec.ts
    │   ├── message
    │   │   ├── sample.message.controller.ts
    │   │   └── sample.message.controller.spec.ts
    │   └── reaction
    │       ├── sample.reaction.controller.ts
    │       └── sample.reaction.controller.spec.ts
    ├── guards
    │   ├── rate-limit.guard.ts
    │   └── rate-limit.guard.spec.ts
    └── services
        ├── sample.service.ts
        └── sample.service.spec.ts
```

---

## Configuration

### `meocord.config.ts`

The top-level config file. At minimum it needs `discordToken`. The `webpack` hook lets you extend the build without ejecting.

```typescript
import { type MeoCordConfig } from 'meocord/interface'

export default {
  appName: 'MyBot',
  discordToken: process.env.TOKEN!,
  webpack: config => {
    config.module.rules?.push({
      // add custom rules here
    })
    return config
  },
} satisfies MeoCordConfig
```

### ESLint

MeoCord exports a base ESLint config from `meocord/eslint`. Extend it as needed:

```javascript
import meocordEslint, { typescriptConfig } from 'meocord/eslint'
import unusedImports from 'eslint-plugin-unused-imports'

export default [
  ...meocordEslint,
  {
    ...typescriptConfig,
    plugins: {
      ...typescriptConfig.plugins,
      'unused-imports': unusedImports,
    },
    rules: {
      ...typescriptConfig.rules,
      'unused-imports/no-unused-imports': 'error',
    },
  },
]
```

---

## CLI Reference

```shell
npx meocord --help
```

| Command    | Alias | Description                          |
|------------|-------|--------------------------------------|
| `create`   | —     | Scaffold a new MeoCord application   |
| `build`    | —     | Compile the application via Webpack  |
| `start`    | —     | Start the application                |
| `generate` | `g`   | Scaffold controllers, services, guards |
| `show`     | —     | Display framework info               |

**Common flags:**

```shell
npx meocord build --prod          # production build
npx meocord build --dev           # development build
npx meocord start --dev           # dev mode with live-reload
npx meocord start --build --prod  # production build + start
npx meocord g co slash "profile"  # generate a slash controller
npx meocord g --help              # list all generator sub-commands
```

---

## Guards

Guards run before the handler method. Each guard implements `canActivate` — return `true` to allow, `false` to block.

```typescript
import { Guard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
import { type ChatInputCommandInteraction } from 'discord.js'
import { RedisService } from '@src/services/redis.service.js'

@Guard()
export class RateLimiterGuard implements GuardInterface {
  constructor(private readonly redis: RedisService) {}

  // limit and window are injected via @UseGuard params
  limit = 5
  window = 60_000

  async canActivate(interaction: ChatInputCommandInteraction): Promise<boolean> {
    const key = `ratelimit:${interaction.user.id}`
    const count = await this.redis.increment(key, this.window)
    return count <= this.limit
  }
}
```

Apply to a single method or an entire controller:

```typescript
// Per-method, with params
@Command('search', CommandType.SLASH)
@UseGuard({ provide: RateLimiterGuard, params: { limit: 5, window: 60_000 } })
async search(interaction: ChatInputCommandInteraction) { ... }

// Per-class (applies to every command in the controller)
@Controller()
@UseGuard(MetricsGuard, DefaultGuard)
export class ProfileController { ... }
```

---

## Custom Decorators

MeoCord exports `applyDecorators` and `SetMetadata` from `meocord/common` for composing reusable decorators.

### Composing guards into a reusable decorator

```typescript
import { applyDecorators } from 'meocord/common'
import { UseGuard } from 'meocord/decorator'
import { DefaultGuard, RateLimiterGuard } from '@src/guards/index.js'

export const Protected = (limit = 5) =>
  applyDecorators(
    UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }),
  )
```

```typescript
@Command('profile', CommandType.SLASH)
@Protected(3)
async profile(interaction: ChatInputCommandInteraction) { ... }
```

### Attaching metadata for guards to read

```typescript
// Define the metadata decorator
import { SetMetadata } from 'meocord/common'
export const Roles = (...roles: string[]) => SetMetadata('roles', roles)

// Read it inside a guard
@Guard()
export class RolesGuard implements GuardInterface {
  async canActivate(interaction: ChatInputCommandInteraction): Promise<boolean> {
    const required: string[] = Reflect.getMetadata('roles', interaction.constructor) ?? []
    if (!required.length) return true
    // ... validate member roles
    return true
  }
}

// Compose into a single decorator
export const RequireRoles = (...roles: string[]) =>
  applyDecorators(Roles(...roles), UseGuard(RolesGuard))

// Apply
@Command('ban', CommandType.SLASH)
@RequireRoles('admin', 'moderator')
async ban(interaction: ChatInputCommandInteraction) { ... }
```

---

## Testing

MeoCord ships a `meocord/testing` entry point with utilities for testing controllers in isolation — no real Discord connection required.

### `MeoCordTestingModule`

Builds an isolated DI container from your controllers and providers.

```typescript
import { MeoCordTestingModule } from 'meocord/testing'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js'
import { GreetingService } from '@src/services/greeting.service.js'

const module = MeoCordTestingModule.create({
  controllers: [GreetingSlashController],
  providers: [{ provide: GreetingService, useValue: mockGreetingService }],
}).compile()

const controller = module.get(GreetingSlashController)
```

### `createMockInteraction`

Creates a smart mock instance of any discord.js class. The full prototype chain is preserved so `instanceof` checks pass at every level.

**Type guards run real logic** — `isButton()`, `isRepliable()`, `isChatInputCommand()`, etc. are backed by the actual discord.js prototype methods. The right fields (`type`, `componentType`, `commandType`) are set based on the class you pass in, so no manual `.mockReturnValue(true)` setup is needed. All type guard methods are still `jest.fn()` and can be overridden per test.

**Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral`. All reply methods are still `jest.fn()` so call assertions work normally.

```typescript
import { createMockInteraction } from 'meocord/testing'
import { ChatInputCommandInteraction, ButtonInteraction, BaseInteraction } from 'discord.js'

const interaction = createMockInteraction(ChatInputCommandInteraction)

// instanceof works at every level
expect(interaction).toBeInstanceOf(ChatInputCommandInteraction) // true
expect(interaction).toBeInstanceOf(BaseInteraction)            // true

// type guards work — no manual setup needed
interaction.isChatInputCommand() // → true
interaction.isRepliable()        // → true
interaction.isButton()           // → false

// reply state machine
interaction.replied   // → false
await interaction.reply({ content: 'hi' })
interaction.replied   // → true
await interaction.reply({ content: 'again' }) // → throws (already replied)

// still jest.fn() — call assertions work normally
expect(interaction.reply).toHaveBeenCalledWith({ content: 'hi' })

// direct property writes work normally
interaction.guildId = 'guild-123'
```

Works for any discord.js class — interactions, `Message`, `MessageReaction`, and anything else. No per-type maintenance.

### `createChatInputOptions`

Builds a typed options resolver from a plain record. Type routing mirrors the real `CommandInteractionOptionResolver`: wrong-type access returns `null`, `required=true` throws if the option is absent.

```typescript
import { createMockInteraction, createChatInputOptions } from 'meocord/testing'
import { ChatInputCommandInteraction } from 'discord.js'

const interaction = createMockInteraction(ChatInputCommandInteraction)
interaction.options = createChatInputOptions({
  subcommandGroup: 'admin',
  subcommand: 'ban',
  user: { id: '123456789' },
  reason: 'spam',
  duration: 7,
})

interaction.options.getSubcommandGroup()       // → 'admin'
interaction.options.getSubcommand(true)        // → 'ban'
interaction.options.getUser('user')            // → { id: '123456789' }
interaction.options.getString('reason')        // → 'spam'
interaction.options.getNumber('duration')      // → 7
interaction.options.getString('duration')      // → null (wrong type)
interaction.options.getNumber('x', true)       // → throws (absent + required)
```

All methods are `jest.fn()` — override any per test with `.mockReturnValue()`.

### `createMockUser` / `createMockClient` / `createMockGuild` / `createMockChannel`

Convenience wrappers for common discord.js classes. All methods are auto-stubbed as `jest.fn()`. Nested managers (`client.users`, `guild.members`, etc.) are independent nested stubs.

```typescript
import { createMockUser, createMockClient, createMockGuild, createMockChannel } from 'meocord/testing'
import { TextChannel } from 'discord.js'

const user    = createMockUser()
const client  = createMockClient()
const guild   = createMockGuild()
const channel = createMockChannel(TextChannel)

// override nested manager methods per test
;(client.users as any).fetch = jest.fn(() => Promise.resolve(user))
await (client.users as any).fetch('user-123')
expect((client.users as any).fetch).toHaveBeenCalledWith('user-123')
```

### `createMockMessage`

Creates a smart mock `Message`. Tracks a `deleted` boolean — `delete()`, `edit()`, `reply()`, `react()`, `pin()`, and `unpin()` throw if the message has already been deleted. `edit()` and `reply()` resolve to a new mock `Message` instance. All methods are `jest.fn()`.

```typescript
import { createMockMessage } from 'meocord/testing'

const msg = createMockMessage()

msg.deleted  // → false
await msg.delete()
msg.deleted  // → true
await msg.delete()          // → throws (already deleted)
await msg.edit({ content: 'x' }) // → throws (already deleted)

// edit() and reply() resolve to a new Message mock
const edited = await createMockMessage().edit({ content: 'updated' })
edited.delete  // → jest.fn()

// still jest.fn() — assertions work
expect(msg.delete).toHaveBeenCalledTimes(1)
```

### `overrideGuard`

Replaces a guard class in the DI container with a stub. No guard dependencies need to be provided.

```typescript
const module = MeoCordTestingModule.create({
  controllers: [GreetingSlashController],
  providers: [{ provide: GreetingService, useValue: mockGreetingService }],
})
  .overrideGuard(MetricsGuard).useValue({ canActivate: () => true })
  .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true })
  .compile()
```

`canActivate: () => true` allows the method to run. `() => false` blocks it. Multiple guards chain fluently.

### Full example

```typescript
import { jest } from '@jest/globals'
import { MeoCordTestingModule, createMockInteraction, createChatInputOptions } from 'meocord/testing'
import { ChatInputCommandInteraction } from 'discord.js'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js'
import { GreetingService } from '@src/services/greeting.service.js'
import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js'

describe('GreetingSlashController', () => {
  let controller: GreetingSlashController
  let greetingService: { buildGreeting: jest.MockedFunction<GreetingService['buildGreeting']> }

  beforeEach(() => {
    greetingService = { buildGreeting: jest.fn() }

    const module = MeoCordTestingModule.create({
      controllers: [GreetingSlashController],
      providers: [{ provide: GreetingService, useValue: greetingService }],
    })
      .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true })
      .compile()

    controller = module.get(GreetingSlashController)
  })

  it('replies with a greeting for the provided name', async () => {
    jest.mocked(greetingService.buildGreeting).mockResolvedValue('Hello, Alice!')

    const interaction = createMockInteraction(ChatInputCommandInteraction)
    interaction.options = createChatInputOptions({ name: 'Alice' })

    await controller.greet(interaction)

    expect(greetingService.buildGreeting).toHaveBeenCalledWith('Alice')
    expect(interaction.reply).toHaveBeenCalledWith({ content: 'Hello, Alice!' })
  })
})
```

---

## Deployment

Install all dependencies and build for production:

```shell
npm ci && npx meocord build --prod
```

Strip dev dependencies:

```shell
npm ci --omit=dev      # npm
yarn install --production  # yarn
pnpm install --prod    # pnpm
bun install --production   # bun
```

Required files on the server:

```
dist/
node_modules/   (production only)
package.json
.env            (if used)
<lockfile>
```

Start in production:

```shell
npx meocord start --prod
```

---

## Contributing

1. Fork the repository
2. Create a feature branch: `git checkout -b feat/your-feature`
3. Commit with conventional commits: `git commit -m "feat: add X"`
4. Push and open a pull request against `main`

Include a description of what changed and why, and add tests for any new behaviour.

---

## Release Notes

Full changelog is available on the [GitHub Releases](https://github.com/l7aromeo/meocord/releases) page.

---

## License

**MeoCord Framework** is licensed under the [MIT License](./LICENSE).
