mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
291 lines
8.0 KiB
TypeScript
291 lines
8.0 KiB
TypeScript
import { Client, GatewayIntentBits } from "discord.js";
|
|
import { Redis } from "ioredis";
|
|
import { Pool } from "pg";
|
|
|
|
import type { AppFeatureServices } from "./container.js";
|
|
import { createCommandList } from "../commands/index.js";
|
|
import { env } from "../config/env.js";
|
|
import { CommandRegistry } from "../core/commands/registry.js";
|
|
import { CommandExecutor } from "../core/execution/CommandExecutor.js";
|
|
import {
|
|
type CommandDispatchMode,
|
|
LocalCommandDispatchPort,
|
|
} from "../core/execution/dispatch.js";
|
|
import {
|
|
MemoryCooldownStore,
|
|
RedisCooldownStore,
|
|
} from "../core/execution/cooldownStore.js";
|
|
import {
|
|
MemoryGlobalRateLimitStore,
|
|
RedisGlobalRateLimitStore,
|
|
} from "../core/execution/globalRateLimitStore.js";
|
|
import { createScopedLogger } from "../core/logging/logger.js";
|
|
import {
|
|
LocalLeaderCoordinator,
|
|
PostgresLeaderCoordinator,
|
|
} from "../core/runtime/leaderCoordinator.js";
|
|
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
|
|
import { PostgresLogEventStore } from "../database/stores/logEventStore.js";
|
|
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
|
|
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
|
|
import { registerEvents } from "../events/index.js";
|
|
import { createPrefixHandler } from "../handlers/prefixHandler.js";
|
|
import { createSlashHandler } from "../handlers/slashHandler.js";
|
|
import { I18nService } from "../i18n/index.js";
|
|
import { LogEventService } from "../modules/logs/index.js";
|
|
import { MemberMessageService } from "../modules/memberMessages/index.js";
|
|
import { PresenceService } from "../modules/presence/index.js";
|
|
|
|
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
const log = createScopedLogger("bootstrap");
|
|
|
|
const bindGracefulShutdown = (
|
|
shutdown: (signal: string) => Promise<void>,
|
|
): void => {
|
|
process.once("SIGINT", () => {
|
|
void shutdown("SIGINT");
|
|
});
|
|
|
|
process.once("SIGTERM", () => {
|
|
void shutdown("SIGTERM");
|
|
});
|
|
};
|
|
|
|
const bindFatalProcessHandlers = (
|
|
shutdown: (
|
|
signal: string,
|
|
exitCode?: number,
|
|
error?: unknown,
|
|
) => Promise<void>,
|
|
): void => {
|
|
process.once("uncaughtException", (error) => {
|
|
log.error({ err: error }, "process uncaught exception");
|
|
void shutdown("UNCAUGHT_EXCEPTION", 1, error);
|
|
});
|
|
|
|
process.once("unhandledRejection", (reason) => {
|
|
log.error({ reason }, "process unhandled rejection");
|
|
void shutdown("UNHANDLED_REJECTION", 1, reason);
|
|
});
|
|
};
|
|
|
|
export const bootstrap = async (): Promise<void> => {
|
|
const dispatchMode: CommandDispatchMode = env.COMMAND_DISPATCH_MODE;
|
|
if (dispatchMode === "worker") {
|
|
throw new Error(
|
|
"Worker mode is not implemented and must not be used in production",
|
|
);
|
|
}
|
|
|
|
const pool = new Pool({
|
|
connectionString: env.DATABASE_URL,
|
|
ssl: env.DATABASE_SSL
|
|
? {
|
|
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
|
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
const requiresRedis = env.STATE_BACKEND === "redis";
|
|
let redis: Redis | null = null;
|
|
|
|
if (requiresRedis) {
|
|
const redisUrl = env.REDIS_URL;
|
|
if (!redisUrl) {
|
|
throw new Error("REDIS_URL is required when STATE_BACKEND=redis");
|
|
}
|
|
|
|
try {
|
|
redis = new Redis(redisUrl, {
|
|
maxRetriesPerRequest: null,
|
|
enableReadyCheck: true,
|
|
});
|
|
|
|
await redis.ping();
|
|
} catch (error) {
|
|
log.error(
|
|
{
|
|
err: error,
|
|
stateBackend: env.STATE_BACKEND,
|
|
},
|
|
"redis unavailable at startup",
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const presenceStore = new PostgresPresenceStore(pool);
|
|
const memberMessageStore = new PostgresMemberMessageStore(pool);
|
|
const logEventStore = new PostgresLogEventStore(pool);
|
|
|
|
const dbLifecycle = new DatabaseLifecycle(
|
|
[
|
|
{ name: "presenceStore", init: () => presenceStore.init() },
|
|
{ name: "memberMessageStore", init: () => memberMessageStore.init() },
|
|
{ name: "logEventStore", init: () => logEventStore.init() },
|
|
],
|
|
async () => {
|
|
await pool.end();
|
|
},
|
|
);
|
|
|
|
const services: AppFeatureServices = {
|
|
presenceService: new PresenceService(
|
|
presenceStore,
|
|
env.PRESENCE_STREAM_URL,
|
|
),
|
|
memberMessageService: new MemberMessageService(memberMessageStore),
|
|
logEventService: new LogEventService(logEventStore),
|
|
};
|
|
|
|
const cooldownStore =
|
|
env.STATE_BACKEND === "redis" && redis
|
|
? new RedisCooldownStore(redis)
|
|
: new MemoryCooldownStore();
|
|
|
|
const globalRateLimitStore =
|
|
env.STATE_BACKEND === "redis" && redis
|
|
? new RedisGlobalRateLimitStore(redis)
|
|
: new MemoryGlobalRateLimitStore();
|
|
|
|
const executor = new CommandExecutor({
|
|
cooldownStore,
|
|
globalRateLimitStore,
|
|
globalRateLimitPolicy: {
|
|
limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
|
|
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
|
|
},
|
|
rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN,
|
|
logger: createScopedLogger("command-executor"),
|
|
});
|
|
|
|
const dispatcher = new LocalCommandDispatchPort(executor);
|
|
|
|
const leaderCoordinator = env.ENABLE_LEADER_ELECTION
|
|
? new PostgresLeaderCoordinator(pool, "discordjs-framework-template")
|
|
: new LocalLeaderCoordinator();
|
|
|
|
let shuttingDown = false;
|
|
let client: Client | null = null;
|
|
|
|
const shutdown = async (
|
|
signal: string,
|
|
exitCode = 0,
|
|
error?: unknown,
|
|
): Promise<void> => {
|
|
if (shuttingDown) {
|
|
return;
|
|
}
|
|
|
|
shuttingDown = true;
|
|
log.info({ signal }, "shutdown signal received");
|
|
|
|
if (error !== undefined) {
|
|
log.error({ signal, err: error }, "shutdown reason");
|
|
}
|
|
|
|
const forcedExitCode = exitCode === 0 ? 1 : exitCode;
|
|
const forceExitTimer = setTimeout(() => {
|
|
log.error(
|
|
{ signal, forcedExitCode, timeoutMs: SHUTDOWN_TIMEOUT_MS },
|
|
"forcing process exit",
|
|
);
|
|
process.exit(forcedExitCode);
|
|
}, SHUTDOWN_TIMEOUT_MS);
|
|
|
|
forceExitTimer.unref?.();
|
|
|
|
if (client) {
|
|
client.destroy();
|
|
}
|
|
|
|
await services.logEventService.shutdown().catch((error) => {
|
|
log.error({ err: error }, "logs service shutdown failed");
|
|
});
|
|
|
|
await services.presenceService.shutdown().catch((error) => {
|
|
log.error({ err: error }, "presence service shutdown failed");
|
|
});
|
|
|
|
await dbLifecycle.shutdown().catch((error) => {
|
|
log.error({ err: error }, "database shutdown failed");
|
|
});
|
|
|
|
if (redis) {
|
|
await redis.quit().catch((error: unknown) => {
|
|
log.error({ err: error }, "redis shutdown failed");
|
|
});
|
|
}
|
|
|
|
clearTimeout(forceExitTimer);
|
|
process.exit(exitCode);
|
|
};
|
|
|
|
bindFatalProcessHandlers(shutdown);
|
|
|
|
try {
|
|
await dbLifecycle.init();
|
|
bindGracefulShutdown((signal) => shutdown(signal));
|
|
|
|
client = new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMembers,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.MessageContent,
|
|
],
|
|
});
|
|
|
|
const i18n = new I18nService(env.DEFAULT_LANG);
|
|
const registry = new CommandRegistry(
|
|
createCommandList(services, i18n),
|
|
i18n,
|
|
);
|
|
|
|
const onPrefixMessage = createPrefixHandler({
|
|
registry,
|
|
i18n,
|
|
dispatcher,
|
|
prefix: env.PREFIX,
|
|
defaultLang: env.DEFAULT_LANG,
|
|
});
|
|
|
|
const onSlashInteraction = createSlashHandler({
|
|
registry,
|
|
i18n,
|
|
dispatcher,
|
|
prefix: env.PREFIX,
|
|
defaultLang: env.DEFAULT_LANG,
|
|
});
|
|
|
|
registerEvents(
|
|
client,
|
|
i18n,
|
|
{ onPrefixMessage, onSlashInteraction },
|
|
registry,
|
|
services,
|
|
leaderCoordinator,
|
|
);
|
|
|
|
log.info(
|
|
{
|
|
stateBackend: env.STATE_BACKEND,
|
|
commandDispatchMode: dispatchMode,
|
|
configuredCommandDispatchMode: env.COMMAND_DISPATCH_MODE,
|
|
rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN,
|
|
rateLimit: {
|
|
maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
|
|
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
|
|
},
|
|
},
|
|
"runtime initialized",
|
|
);
|
|
|
|
await client.login(env.DISCORD_TOKEN);
|
|
} catch (error) {
|
|
await shutdown("BOOTSTRAP_FAILURE", 1, error);
|
|
throw error;
|
|
}
|
|
};
|