meocord 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +138 -15
  2. package/dist/cjs/decorator/index.cjs +1 -1
  3. package/dist/esm/bin/app-template/README.md.template +61 -0
  4. package/dist/esm/bin/app-template/_env.example.template +2 -0
  5. package/dist/esm/bin/app-template/_gitignore.template +22 -0
  6. package/dist/esm/bin/app-template/_prettierrc.mjs.template +10 -0
  7. package/dist/esm/bin/app-template/eslint.config.ts.template +25 -0
  8. package/dist/esm/bin/app-template/meocord.config.ts.template +25 -0
  9. package/dist/esm/bin/app-template/package.json.template +38 -0
  10. package/dist/esm/bin/app-template/src/app.ts.template +44 -0
  11. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +17 -0
  12. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +19 -0
  13. package/dist/esm/bin/app-template/src/controllers/context-menu/builders/sample.builder.ts.template +10 -0
  14. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.spec.ts.template +17 -0
  15. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.ts.template +13 -0
  16. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.spec.ts.template +17 -0
  17. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +28 -0
  18. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.spec.ts.template +17 -0
  19. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.ts.template +13 -0
  20. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.spec.ts.template +17 -0
  21. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.ts.template +29 -0
  22. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.spec.ts.template +17 -0
  23. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.ts.template +11 -0
  24. package/dist/esm/bin/app-template/src/controllers/slash/builders/sample.builder.ts.template +10 -0
  25. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.spec.ts.template +17 -0
  26. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.ts.template +19 -0
  27. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.spec.ts.template +13 -0
  28. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.ts.template +52 -0
  29. package/dist/esm/bin/app-template/src/main.ts.template +14 -0
  30. package/dist/esm/bin/app-template/src/services/sample.service.spec.ts.template +17 -0
  31. package/dist/esm/bin/app-template/src/services/sample.service.ts.template +9 -0
  32. package/dist/esm/bin/app-template/tsconfig.eslint.json.template +5 -0
  33. package/dist/esm/bin/app-template/tsconfig.json.template +30 -0
  34. package/dist/esm/bin/app-template/tsconfig.test.json.template +8 -0
  35. package/dist/esm/bin/app-template/vitest.config.ts.template +35 -0
  36. package/dist/esm/bin/builder-template/controller/button.controller.template +1 -1
  37. package/dist/esm/bin/helper/app-generator.helper.js +78 -0
  38. package/dist/esm/bin/meocord.js +156 -75
  39. package/dist/esm/decorator/guard.decorator.js +1 -1
  40. package/dist/esm/util/common.util.js +11 -3
  41. package/dist/esm/util/generator-cli.util.js +21 -4
  42. package/dist/esm/util/package-manager.util.js +9 -2
  43. package/dist/esm/util/package-version.util.js +32 -0
  44. package/dist/esm/util/runtime.util.js +72 -0
  45. package/dist/types/core/index.d.ts +2 -2
  46. package/dist/types/decorator/index.d.ts +1 -1
  47. package/dist/types/interface/index.d.ts +5 -3
  48. package/package.json +11 -12
@@ -0,0 +1,29 @@
1
+ import { MessageReaction } from 'discord.js'
2
+ import { Controller, ReactionHandler } from 'meocord/decorator'
3
+ import { Logger } from 'meocord/common'
4
+ import { ReactionHandlerAction } from 'meocord/enum'
5
+ import { type ReactionHandlerOptions } from 'meocord/interface'
6
+
7
+ @Controller()
8
+ export class SampleReactionController {
9
+ private readonly logger = new Logger(SampleReactionController.name)
10
+
11
+ @ReactionHandler('😋')
12
+ async handleReaction(reaction: MessageReaction, { user, action }: ReactionHandlerOptions) {
13
+ this.logger.log(`Reaction 😋 ${action === ReactionHandlerAction.ADD ? 'added' : 'removed'}.`)
14
+
15
+ if (!reaction.message) return
16
+
17
+ if (action === ReactionHandlerAction.ADD) {
18
+ await reaction.message.reply(`${user.username} reacted with 😋!`)
19
+ }
20
+ }
21
+
22
+ @ReactionHandler()
23
+ async handleAnyReaction(reaction: MessageReaction, { user, action }: ReactionHandlerOptions) {
24
+ this.logger.log('Reaction detected!')
25
+ if (reaction.message && action === ReactionHandlerAction.ADD) {
26
+ await reaction.message.reply(`${user.username} reacted with ${reaction.emoji.name}!`)
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,17 @@
1
+ import { MeoCordTestingModule } from 'meocord/testing'
2
+ import { SampleSelectMenuController } from '@src/controllers/select-menu/sample.select-menu.controller.js'
3
+
4
+ describe('SampleSelectMenuController', () => {
5
+ let controller: SampleSelectMenuController
6
+
7
+ beforeEach(() => {
8
+ const module = MeoCordTestingModule.create({
9
+ controllers: [SampleSelectMenuController],
10
+ }).compile()
11
+ controller = module.get(SampleSelectMenuController)
12
+ })
13
+
14
+ it('should be defined', () => {
15
+ expect(controller).toBeDefined()
16
+ })
17
+ })
@@ -0,0 +1,11 @@
1
+ import { StringSelectMenuInteraction } from 'discord.js'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { CommandType } from 'meocord/enum'
4
+
5
+ @Controller()
6
+ export class SampleSelectMenuController {
7
+ @Command('select-menu', CommandType.SELECT_MENU)
8
+ async handleSelectMenu(interaction: StringSelectMenuInteraction) {
9
+ await interaction.reply('Select menu used!')
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ import { SlashCommandBuilder } from 'discord.js'
2
+ import { CommandBuilder } from 'meocord/decorator'
3
+ import { CommandType } from 'meocord/enum'
4
+
5
+ @CommandBuilder(CommandType.SLASH)
6
+ export class SampleCommandBuilder {
7
+ build(commandName: string) {
8
+ return new SlashCommandBuilder().setName(commandName).setDescription('This is a sample slash command')
9
+ }
10
+ }
@@ -0,0 +1,17 @@
1
+ import { MeoCordTestingModule } from 'meocord/testing'
2
+ import { SampleSlashController } from '@src/controllers/slash/sample.slash.controller.js'
3
+
4
+ describe('SampleSlashController', () => {
5
+ let controller: SampleSlashController
6
+
7
+ beforeEach(() => {
8
+ const module = MeoCordTestingModule.create({
9
+ controllers: [SampleSlashController],
10
+ }).compile()
11
+ controller = module.get(SampleSlashController)
12
+ })
13
+
14
+ it('should be defined', () => {
15
+ expect(controller).toBeDefined()
16
+ })
17
+ })
@@ -0,0 +1,19 @@
1
+ import { ChatInputCommandInteraction } from 'discord.js'
2
+ import { Controller, Command, UseGuard } from 'meocord/decorator'
3
+ import { SampleCommandBuilder } from '@src/controllers/slash/builders/sample.builder'
4
+ import { RateLimitGuard } from '@src/guards/rate-limit.guard'
5
+
6
+ @Controller()
7
+ export class SampleSlashController {
8
+ @Command('sample-slash', SampleCommandBuilder)
9
+ @UseGuard(RateLimitGuard)
10
+ async handleSampleSlash(interaction: ChatInputCommandInteraction) {
11
+ await interaction.reply('This is sample reply of slash command.')
12
+ }
13
+
14
+ @Command('sample-slash-2', SampleCommandBuilder)
15
+ @UseGuard({ provide: RateLimitGuard, params: { limit: 2, windowInSeconds: 60 } })
16
+ async handleSampleSlashTwo(interaction: ChatInputCommandInteraction) {
17
+ await interaction.reply('This is sample reply of slash command.')
18
+ }
19
+ }
@@ -0,0 +1,13 @@
1
+ import { RateLimitGuard } from '@src/guards/rate-limit.guard.js'
2
+
3
+ describe('RateLimitGuard', () => {
4
+ let guard: RateLimitGuard
5
+
6
+ beforeEach(() => {
7
+ guard = new RateLimitGuard({ limit: 3, windowInSeconds: 60 })
8
+ })
9
+
10
+ it('should be defined', () => {
11
+ expect(guard).toBeDefined()
12
+ })
13
+ })
@@ -0,0 +1,52 @@
1
+ import { Guard } from 'meocord/decorator'
2
+ import { type GuardInterface } from 'meocord/interface'
3
+ import { BaseInteraction } from 'discord.js'
4
+ import { Logger } from 'meocord/common'
5
+
6
+ class RateLimiterGuardOptions {
7
+ limit?: number
8
+ windowInSeconds?: number
9
+ }
10
+
11
+ @Guard()
12
+ export class RateLimitGuard implements GuardInterface {
13
+ // ==============================
14
+ // NOTE: Consider using Redis or another distributed store for rate limits in production
15
+ // ==============================
16
+ private logger = new Logger(RateLimitGuard.name)
17
+
18
+ private rateLimits = new Map<string, number[]>()
19
+ private readonly limit: number
20
+ private readonly windowInSeconds: number
21
+
22
+ constructor(options: RateLimiterGuardOptions = {}) {
23
+ this.limit = options.limit || 5
24
+ this.windowInSeconds = options.windowInSeconds || 60
25
+ }
26
+
27
+ async canActivate(interaction: BaseInteraction): Promise<boolean> {
28
+ const userId = interaction.user.id
29
+
30
+ let key: string
31
+ if (interaction.isChatInputCommand()) {
32
+ const commandId = interaction.commandName
33
+ const subCommand = interaction.options.getSubcommand(false) || ''
34
+ key = subCommand ? `${commandId}:${subCommand}:${userId}` : `${commandId}:${userId}`
35
+ } else if (interaction.isMessageComponent()) {
36
+ key = `${interaction.customId}:${userId}`
37
+ } else {
38
+ key = userId
39
+ }
40
+
41
+ const currentTime = Math.floor(Date.now() / 1000)
42
+ const timestamps = this.rateLimits.get(key) || []
43
+
44
+ const filteredTimestamps = timestamps.filter(time => currentTime - time < this.windowInSeconds)
45
+ filteredTimestamps.push(currentTime)
46
+ this.rateLimits.set(key, filteredTimestamps)
47
+
48
+ this.logger.debug(key, filteredTimestamps.length, this.limit, this.windowInSeconds)
49
+
50
+ return filteredTimestamps.length <= this.limit
51
+ }
52
+ }
@@ -0,0 +1,14 @@
1
+ import App from '@src/app'
2
+ import { Logger } from 'meocord/common'
3
+ import { MeoCordFactory } from 'meocord/core'
4
+
5
+ const logger = new Logger()
6
+ const app = MeoCordFactory.create(App)
7
+
8
+ async function bootstrap() {
9
+ logger.log('Starting application')
10
+ await app.start()
11
+ logger.log('Application started')
12
+ }
13
+
14
+ bootstrap().catch(error => logger.error('Error during startup:', error))
@@ -0,0 +1,17 @@
1
+ import { MeoCordTestingModule } from 'meocord/testing'
2
+ import { SampleService } from '@src/services/sample.service.js'
3
+
4
+ describe('SampleService', () => {
5
+ let service: SampleService
6
+
7
+ beforeEach(() => {
8
+ const module = MeoCordTestingModule.create({
9
+ providers: [{ provide: SampleService, useClass: SampleService }],
10
+ }).compile()
11
+ service = module.get(SampleService)
12
+ })
13
+
14
+ it('should be defined', () => {
15
+ expect(service).toBeDefined()
16
+ })
17
+ })
@@ -0,0 +1,9 @@
1
+ import { Service } from 'meocord/decorator'
2
+
3
+ @Service()
4
+ export class SampleService {
5
+ async handleInteraction() {
6
+ // TODO: Implement service logic
7
+ return 'SampleService is working!'
8
+ }
9
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["meocord.eslint.ts", "meocord.config.ts", "vitest.config.ts", "eslint.config.ts"],
4
+ "exclude": ["node_modules"]
5
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "ESNext",
4
+ "target": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "strictNullChecks": true,
8
+ "strictBindCallApply": true,
9
+ "strictPropertyInitialization": false,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "emitDecoratorMetadata": true,
13
+ "experimentalDecorators": true,
14
+ "resolveJsonModule": true,
15
+ "verbatimModuleSyntax": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "skipLibCheck": true,
19
+ "noImplicitAny": false,
20
+ "noEmit": true,
21
+ "outDir": "./dist",
22
+ "rootDir": ".",
23
+ "paths": {
24
+ "@src/*": ["./src/*"]
25
+ },
26
+ "types": ["node"]
27
+ },
28
+ "include": ["src/**/*.ts"],
29
+ "exclude": ["meocord.config.ts", "dist", "vitest.config.ts", "node_modules", "src/**/*.spec.ts"]
30
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["node", "vitest/globals"]
5
+ },
6
+ "include": ["src/**/*.ts"],
7
+ "exclude": ["node_modules"]
8
+ }
@@ -0,0 +1,35 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import swc from 'unplugin-swc'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ export default defineConfig({
6
+ // SWC rather than vite's esbuild: esbuild cannot emit decorator metadata, which the
7
+ // dependency injection behind @Controller and @Service reads at runtime.
8
+ plugins: [
9
+ swc.vite({
10
+ jsc: {
11
+ parser: { syntax: 'typescript', decorators: true },
12
+ transform: { legacyDecorator: true, decoratorMetadata: true },
13
+ target: 'es2022',
14
+ },
15
+ module: { type: 'es6' },
16
+ }),
17
+ ],
18
+ resolve: {
19
+ alias: { '@src': fileURLToPath(new URL('./src', import.meta.url)) },
20
+ },
21
+ test: {
22
+ globals: true,
23
+ setupFiles: ['reflect-metadata'],
24
+ clearMocks: true,
25
+ include: ['src/**/*.spec.ts'],
26
+ coverage: {
27
+ // istanbul rather than v8: v8 coverage needs Node's inspector API, which bun does
28
+ // not implement, and this template is expected to run under either runtime.
29
+ provider: 'istanbul',
30
+ all: false,
31
+ include: ['src/**/*.ts'],
32
+ exclude: ['src/**/*.spec.ts'],
33
+ },
34
+ },
35
+ })
@@ -10,7 +10,7 @@ export class {{className}}ButtonController {
10
10
  await interaction.reply('Button clicked!')
11
11
  }
12
12
 
13
- @Command('button-with-{id}', CommandType.BUTTON)
13
+ @Command('button-with/{id}', CommandType.BUTTON)
14
14
  async handleButtonWithId(interaction: ButtonInteraction, { id }) {
15
15
  await interaction.reply(`Button with id: ${id} clicked!`)
16
16
  }
@@ -0,0 +1,78 @@
1
+ import fs__default from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { populateTemplate } from '../../util/generator-cli.util.js';
5
+
6
+ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
7
+ /** Marks a packaged template file, and is dropped from the name that gets written. */ const TEMPLATE_SUFFIX = '.template';
8
+ /**
9
+ * Stands in for a leading dot while the file is packaged.
10
+ *
11
+ * npm omits a `.gitignore` from a published tarball, and this repository's own tooling
12
+ * would treat a packaged `.ts` as one of its sources, so nothing here is stored under
13
+ * the name it is written as.
14
+ */ const DOT_PREFIX = '_';
15
+ /**
16
+ * The prefix each package manager needs for the framework to run on its runtime.
17
+ *
18
+ * The separating space belongs to the value, because the template writes
19
+ * `{{runtimePrefix}}meocord` with nothing between them — a package manager that needs
20
+ * no prefix has to render as `meocord …` rather than ` meocord …`. Trimming an entry
21
+ * here would join it to the command that follows.
22
+ */ const RUNTIME_PREFIXES = {
23
+ // Without `--bun`, bun honours the CLI's `#!/usr/bin/env node` line and hands it to
24
+ // node, which an image built on bun alone does not have.
25
+ bun: 'bun --bun '
26
+ };
27
+ /**
28
+ * The script prefix for a package manager.
29
+ *
30
+ * @param packageManager - Package manager the application was created with.
31
+ */ function runtimePrefixFor(packageManager) {
32
+ return RUNTIME_PREFIXES[packageManager] ?? '';
33
+ }
34
+ /** The name a packaged template is written under. */ function outputName(templateName) {
35
+ const name = templateName.endsWith(TEMPLATE_SUFFIX) ? templateName.slice(0, -TEMPLATE_SUFFIX.length) : templateName;
36
+ return name.startsWith(DOT_PREFIX) ? `.${name.slice(DOT_PREFIX.length)}` : name;
37
+ }
38
+ /**
39
+ * Writes a new application from the template packaged with this framework.
40
+ *
41
+ * The template ships inside the package rather than being fetched, so the application a
42
+ * given release scaffolds is always one that release can run. A template resolved at
43
+ * generation time drifts from the CLI asking for it, in whichever direction happens to
44
+ * be newer.
45
+ */ class AppGeneratorHelper {
46
+ /**
47
+ * Renders every packaged template file into the target directory.
48
+ *
49
+ * @param targetDir - Directory to write the application into.
50
+ * @param variables - Values substituted into the template.
51
+ * @returns The written paths, relative to the target directory.
52
+ */ generateApp(targetDir, variables) {
53
+ return this.templateFiles(this.templateDir).map((templatePath)=>{
54
+ const relative = path.relative(this.templateDir, templatePath).split(path.sep).map((segment)=>outputName(segment)).join(path.sep);
55
+ const destination = path.join(targetDir, relative);
56
+ fs__default.mkdirSync(path.dirname(destination), {
57
+ recursive: true
58
+ });
59
+ fs__default.writeFileSync(destination, populateTemplate(templatePath, variables));
60
+ return relative;
61
+ });
62
+ }
63
+ templateFiles(dir) {
64
+ return fs__default.readdirSync(dir, {
65
+ withFileTypes: true
66
+ }).flatMap((entry)=>{
67
+ const full = path.join(dir, entry.name);
68
+ return entry.isDirectory() ? this.templateFiles(full) : [
69
+ full
70
+ ];
71
+ });
72
+ }
73
+ constructor(){
74
+ this.templateDir = path.resolve(__dirname$1, '..', 'app-template');
75
+ }
76
+ }
77
+
78
+ export { AppGeneratorHelper, runtimePrefixFor };