Files
flint/apps/bot/src/legacy/app/bootstrap.ts
T
2026-04-23 23:39:18 +02:00

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;
}
};