Skip to content

Commit b80a3d5

Browse files
authored
feat: make modules isolated using creation function (#81)
1 parent a03ddbe commit b80a3d5

23 files changed

+238
-138
lines changed

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ DISCORD_TOKEN=
55
REDIS_URL=
66

77
# CHANNELS
8-
COOL_LINKS_CHANNEL_ID=
8+
COOL_LINKS_MANAGEMENT_CHANNEL_ID=
9+
COOL_LINKS_MANAGEMENT_PAGE_SUMMARIZER_BASE_URL=
910

10-
# API
11-
PAGE_SUMMARIZER_BASE_URL=
11+
PATTERN_REPLACE_EXCLUDED_CHANNEL_ID=

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
"dependencies": {
1717
"@keyv/redis": "2.7.0",
1818
"cheerio": "1.0.0-rc.12",
19+
"constant-case": "3.0.4",
1920
"cron": "2.4.3",
2021
"discord.js": "14.13.0",
21-
"env-var": "7.4.1",
2222
"keyv": "4.5.3",
2323
"open-graph-scraper": "6.2.2",
24-
"param-case": "3.0.4"
24+
"param-case": "3.0.4",
25+
"zod": "3.22.2"
2526
},
2627
"devDependencies": {
2728
"@types/node": "20.6.2",

pnpm-lock.yaml

Lines changed: 27 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/mocks/config.mock.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/__tests__/summarize-cool-pages.spec.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ type SummarizeCoolPagesFixture = ReturnType<typeof createSummarizeCoolPagesFixtu
3030
describe('Feature: summarize cool pages', () => {
3131
let fixture: SummarizeCoolPagesFixture;
3232
beforeEach(() => {
33-
// config is mocked to avoid to call the third party API and to avoid to handle env-var
34-
vi.mock('../config', async () => ({
35-
config: (await import('./mocks/config.mock')).default,
36-
}));
3733
// useless atm but will be useful when we will have to reset the fixture
3834
fixture = createSummarizeCoolPagesFixture();
3935
});

src/config.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/core/cache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import '@keyv/redis';
22

33
import Keyv from 'keyv';
44

5-
import { config } from '../config';
65
import type { Frequency } from '../modules/recurringMessage/recurringMessage.helpers';
6+
import { env } from './env';
77

88
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99
interface CacheGet<Entries extends Record<string, any>> {
@@ -28,7 +28,7 @@ interface CacheEntries {
2828
}
2929

3030
class CacheImpl implements Cache<CacheEntries> {
31-
private readonly backend = new Keyv(config.redis.url);
31+
private readonly backend = new Keyv(env.redisUrl);
3232

3333
public get<Key extends keyof CacheEntries>(key: Key): Promise<CacheEntries[Key] | undefined>;
3434
public get<Key extends keyof CacheEntries>(

src/core/createEnvForModule.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { constantCase } from 'constant-case';
2+
3+
import type { CreatedModule, ModuleFactory } from './createModule';
4+
5+
const createEnvForModule = (constantName: string) =>
6+
Object.entries(process.env)
7+
.filter(([key]) => key.startsWith(constantName))
8+
.reduce<Record<string, string>>((acc, [key, value]) => {
9+
const envKey = key.replace(`${constantName}_`, '');
10+
11+
if (value === undefined) {
12+
return acc;
13+
}
14+
15+
acc[envKey] = value;
16+
17+
return acc;
18+
}, {});
19+
20+
export const createAllModules = async (
21+
modules: Record<string, ModuleFactory>,
22+
): Promise<CreatedModule[]> => {
23+
const createdModules: CreatedModule[] = [];
24+
25+
for (const [name, factory] of Object.entries(modules)) {
26+
const moduleConstantName = constantCase(name);
27+
const moduleEnv = createEnvForModule(moduleConstantName);
28+
const module = await factory({ env: moduleEnv });
29+
30+
createdModules.push(module);
31+
}
32+
33+
return createdModules;
34+
};

src/core/createModule.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { ClientEvents, ClientOptions } from 'discord.js';
2+
import type { ZodTypeAny } from 'zod';
3+
import { z } from 'zod';
4+
5+
import type { BotCommand, EventHandler } from '../types/bot';
6+
7+
type InferredZodShape<Shape extends Record<string, ZodTypeAny>> = {
8+
[K in keyof Shape]: Shape[K]['_type'];
9+
};
10+
11+
interface Context<Env extends Record<string, ZodTypeAny>> {
12+
env: InferredZodShape<Env>;
13+
}
14+
15+
type ModuleFunction<Env extends Record<string, ZodTypeAny>, ReturnType> = (
16+
context: Context<Env>,
17+
) => ReturnType;
18+
19+
type EventHandlers = {
20+
[K in keyof ClientEvents]?: EventHandler<K>;
21+
};
22+
23+
type BotModule<Env extends Record<string, ZodTypeAny>> = {
24+
env?: Env;
25+
intents?: ClientOptions['intents'];
26+
slashCommands?: ModuleFunction<Env, Array<BotCommand>>;
27+
eventHandlers?: ModuleFunction<Env, EventHandlers>;
28+
};
29+
30+
interface CreatedModuleInput {
31+
env: unknown;
32+
}
33+
34+
export interface CreatedModule {
35+
intents: ClientOptions['intents'];
36+
slashCommands: Array<BotCommand>;
37+
eventHandlers: EventHandlers;
38+
}
39+
40+
export type ModuleFactory = (input: CreatedModuleInput) => Promise<CreatedModule>;
41+
42+
export const createModule = <Env extends Record<string, ZodTypeAny>>(
43+
module: BotModule<Env>,
44+
): ModuleFactory => {
45+
return async (input) => {
46+
const env = await z.object(module.env ?? ({} as Env)).parseAsync(input.env);
47+
48+
const context = {
49+
env,
50+
};
51+
52+
return {
53+
intents: module.intents ?? [],
54+
slashCommands: module.slashCommands?.(context) ?? [],
55+
eventHandlers: module.eventHandlers?.(context) ?? {},
56+
};
57+
};
58+
};

src/core/env.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { z } from 'zod';
2+
3+
const envShape = z
4+
.object({
5+
DISCORD_TOKEN: z.string().nonempty(),
6+
REDIS_URL: z.string().url(),
7+
})
8+
.transform((object) => ({
9+
discordToken: object.DISCORD_TOKEN,
10+
redisUrl: object.REDIS_URL,
11+
}));
12+
13+
export const env = envShape.parse(process.env);

src/core/getIntentsFromModules.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { BotModule } from '../types/bot';
1+
import type { CreatedModule } from './createModule';
22

3-
export const getIntentsFromModules = (modules: Record<string, BotModule>) => {
4-
const intents = Object.values(modules).flatMap((module) => module.intents ?? []);
3+
export const getIntentsFromModules = (modules: CreatedModule[]) => {
4+
const intents = modules.flatMap((module) => module.intents ?? []);
55
return [...new Set(intents)] as const;
66
};

src/core/loadModules.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { type Client } from 'discord.js';
22

3-
import type { BotModule } from '../types/bot';
43
import { checkUniqueSlashCommandNames } from './checkUniqueSlashCommandNames';
4+
import type { CreatedModule } from './createModule';
5+
import { env } from './env';
56
import { pushCommands, routeCommands } from './loaderCommands';
67
import { routeHandlers } from './routeHandlers';
78

89
export const loadModules = async (
910
client: Client<true>,
10-
modulesToLoad: Record<string, BotModule>,
11+
modules: CreatedModule[],
1112
): Promise<void> => {
12-
const botCommands = Object.values(modulesToLoad).flatMap((module) => module.slashCommands ?? []);
13+
await Promise.allSettled(modules.map((module) => module.eventHandlers?.ready?.(client)));
14+
15+
const botCommands = modules.flatMap((module) => module.slashCommands ?? []);
16+
1317
checkUniqueSlashCommandNames(botCommands);
1418
routeCommands(client, botCommands);
1519

@@ -19,11 +23,13 @@ export const loadModules = async (
1923
const { guilds } = client;
2024

2125
for (const guild of guilds.cache.values()) {
22-
await pushCommands(
23-
botCommands.map((command) => command.schema),
24-
clientId,
25-
guild.id,
26-
);
26+
await pushCommands({
27+
commands: botCommands.map((command) => command.schema),
28+
clientId: clientId,
29+
guildId: guild.id,
30+
discordToken: env.discordToken,
31+
});
2732
}
28-
routeHandlers(client, modulesToLoad);
33+
34+
routeHandlers(client, modules);
2935
};

src/core/loaderCommands.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,23 @@ import {
55
Routes,
66
} from 'discord.js';
77

8-
import { config } from '../config';
98
import type { BotCommand } from '../types/bot';
109
import { deleteExistingCommands } from './deleteExistingCommands';
1110

12-
const { discord } = config;
11+
interface PushCommandsOptions {
12+
commands: RESTPostAPIChatInputApplicationCommandsJSONBody[];
13+
clientId: string;
14+
guildId: string;
15+
discordToken: string;
16+
}
1317

14-
export const pushCommands = async (
15-
commands: RESTPostAPIChatInputApplicationCommandsJSONBody[],
16-
clientId: string,
17-
guildId: string,
18-
) => {
19-
const rest = new REST({ version: '10' }).setToken(discord.token);
18+
export const pushCommands = async ({
19+
commands,
20+
clientId,
21+
guildId,
22+
discordToken,
23+
}: PushCommandsOptions) => {
24+
const rest = new REST({ version: '10' }).setToken(discordToken);
2025
await deleteExistingCommands(rest, clientId, guildId);
2126
await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
2227
body: commands,

0 commit comments

Comments
 (0)