2026-06-09 10:16:04 +07:00
|
|
|
import { ConfigError, DatabaseError } from "@bete/shared/errors";
|
2026-06-02 22:20:46 +07:00
|
|
|
import { createChildLogger } from "@bete/shared/logger";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { Client } from "discord.js-selfbot-v13";
|
2026-06-10 20:56:16 +07:00
|
|
|
import { inArray, lt } from "drizzle-orm";
|
|
|
|
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
|
2026-06-02 00:32:38 +07:00
|
|
|
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
2026-06-01 21:44:29 +07:00
|
|
|
import {
|
|
|
|
|
EventBroadcaster,
|
|
|
|
|
RedisEventPublisher,
|
|
|
|
|
} from "../modules/event-broadcaster/index.js";
|
|
|
|
|
import {
|
|
|
|
|
registerMessageCapture,
|
2026-06-08 19:14:34 +07:00
|
|
|
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
2026-06-01 21:44:29 +07:00
|
|
|
} from "../modules/message-capture/messageCapture.js";
|
2026-06-10 20:56:16 +07:00
|
|
|
import { getExpiredMessages } from "../modules/message-capture/messageStore.js";
|
2026-06-08 19:14:34 +07:00
|
|
|
import { setEventBroadcaster as setRecorderEventBroadcaster } from "../modules/voice-recording/recorder.js";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { VoiceController } from "../modules/voice-recording/voiceController.js";
|
|
|
|
|
import { config } from "../shared/config/config.js";
|
|
|
|
|
import {
|
|
|
|
|
closeDatabase,
|
2026-06-10 20:56:16 +07:00
|
|
|
getDatabase,
|
2026-06-01 21:44:29 +07:00
|
|
|
initializeDatabase,
|
|
|
|
|
} from "../shared/database/drizzle.js";
|
|
|
|
|
import { runMigrations } from "../shared/database/migrate.js";
|
2026-06-10 20:56:16 +07:00
|
|
|
import type * as schema from "../shared/database/schema.js";
|
|
|
|
|
import {
|
|
|
|
|
attachmentsTable,
|
|
|
|
|
messagesTable,
|
|
|
|
|
voiceRecordingsTable,
|
|
|
|
|
} from "../shared/database/schema.js";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
|
|
|
|
|
import { createGracefulShutdown } from "./shutdown.js";
|
|
|
|
|
|
|
|
|
|
const logger = createChildLogger("discord-gateway");
|
|
|
|
|
|
2026-06-10 20:56:16 +07:00
|
|
|
// ─── Retention Cleanup ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function startRetentionCleanup(): void {
|
|
|
|
|
const intervalMs = config.RETENTION_CLEANUP_INTERVAL_MS;
|
|
|
|
|
const dryRun = config.RETENTION_DRY_RUN;
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
{
|
|
|
|
|
intervalMs,
|
|
|
|
|
dryRun,
|
|
|
|
|
messagesDays: config.RETENTION_MESSAGES_DAYS,
|
|
|
|
|
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
|
|
|
|
voiceDays: config.RETENTION_VOICE_DAYS,
|
|
|
|
|
},
|
|
|
|
|
"Starting retention cleanup scheduler",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
async function runCleanupTick(): Promise<void> {
|
|
|
|
|
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
|
|
|
|
|
|
|
|
|
|
// ── Expired messages ────────────────────────────────────────────────
|
|
|
|
|
if (config.RETENTION_MESSAGES_DAYS > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const expiredMessages = await getExpiredMessages(
|
|
|
|
|
config.RETENTION_MESSAGES_DAYS,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (expiredMessages.length > 0) {
|
|
|
|
|
const ids = expiredMessages.map((m: { id: string }) => m.id);
|
|
|
|
|
logger.info(
|
|
|
|
|
{ count: ids.length, dryRun },
|
|
|
|
|
"Expired messages found for cleanup",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!dryRun) {
|
|
|
|
|
await db
|
|
|
|
|
.delete(messagesTable)
|
|
|
|
|
.where(inArray(messagesTable.id, ids));
|
|
|
|
|
logger.info({ count: ids.length }, "Expired messages deleted");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to clean up expired messages",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Expired attachments ─────────────────────────────────────────────
|
|
|
|
|
if (config.RETENTION_ATTACHMENTS_DAYS > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const cutoff =
|
|
|
|
|
Date.now() - config.RETENTION_ATTACHMENTS_DAYS * 24 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
const expiredAttachments = await db
|
|
|
|
|
.select({ id: attachmentsTable.id })
|
|
|
|
|
.from(attachmentsTable)
|
|
|
|
|
.where(lt(attachmentsTable.created_at, cutoff))
|
|
|
|
|
.limit(1000);
|
|
|
|
|
|
|
|
|
|
if (expiredAttachments.length > 0) {
|
|
|
|
|
const ids = expiredAttachments.map((a: { id: string }) => a.id);
|
|
|
|
|
logger.info(
|
|
|
|
|
{ count: ids.length, dryRun },
|
|
|
|
|
"Expired attachments found for cleanup",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!dryRun) {
|
|
|
|
|
await db
|
|
|
|
|
.delete(attachmentsTable)
|
|
|
|
|
.where(inArray(attachmentsTable.id, ids));
|
|
|
|
|
logger.info({ count: ids.length }, "Expired attachments deleted");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to clean up expired attachments",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Expired voice recordings ────────────────────────────────────────
|
|
|
|
|
if (config.RETENTION_VOICE_DAYS > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const cutoff =
|
|
|
|
|
Date.now() - config.RETENTION_VOICE_DAYS * 24 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
const expiredRecordings = await db
|
|
|
|
|
.select({ id: voiceRecordingsTable.id })
|
|
|
|
|
.from(voiceRecordingsTable)
|
|
|
|
|
.where(lt(voiceRecordingsTable.created_at, cutoff))
|
|
|
|
|
.limit(1000);
|
|
|
|
|
|
|
|
|
|
if (expiredRecordings.length > 0) {
|
|
|
|
|
const ids = expiredRecordings.map((r: { id: string }) => r.id);
|
|
|
|
|
logger.info(
|
|
|
|
|
{ count: ids.length, dryRun },
|
|
|
|
|
"Expired voice recordings found for cleanup",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!dryRun) {
|
|
|
|
|
await db
|
|
|
|
|
.delete(voiceRecordingsTable)
|
|
|
|
|
.where(inArray(voiceRecordingsTable.id, ids));
|
|
|
|
|
logger.info(
|
|
|
|
|
{ count: ids.length },
|
|
|
|
|
"Expired voice recordings deleted",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to clean up expired voice recordings",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Run immediately on start, then schedule
|
|
|
|
|
runCleanupTick().catch((error) => {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
|
|
|
"Initial retention cleanup tick failed",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
runCleanupTick().catch((error) => {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
|
|
|
"Retention cleanup tick failed",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}, intervalMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Bootstrap ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
export async function initializeDiscordGateway() {
|
2026-06-02 21:44:42 +07:00
|
|
|
if (config.AI_ANALYSIS_ENABLED && !config.AI_LLM_API_KEY) {
|
2026-06-08 19:14:34 +07:00
|
|
|
throw new ConfigError(
|
|
|
|
|
"AI_ANALYSIS_ENABLED=true but AI_LLM_API_KEY is missing from environment. AI analysis cannot run without credentials.",
|
2026-06-01 21:44:29 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const token = config.DISCORD_TOKEN;
|
|
|
|
|
logger.info(
|
|
|
|
|
{ hasToken: token.length > 0, tokenLength: token.length },
|
|
|
|
|
"Config loaded",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logger.info("Creating Discord client");
|
|
|
|
|
const client = new Client(createDiscordClientOptions());
|
|
|
|
|
const voiceController = new VoiceController(client);
|
|
|
|
|
|
|
|
|
|
// Initialize Redis event broadcaster
|
|
|
|
|
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
|
2026-06-08 19:14:34 +07:00
|
|
|
const eventBroadcaster = new EventBroadcaster(redisPublisher);
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
// Initialize Redis command handler for backend→gateway commands
|
|
|
|
|
const commandHandler = new CommandHandler();
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
const gracefulShutdown = createGracefulShutdown({
|
|
|
|
|
logger,
|
|
|
|
|
closeDatabase,
|
|
|
|
|
voiceController,
|
|
|
|
|
client,
|
|
|
|
|
eventBroadcaster,
|
2026-06-02 00:32:38 +07:00
|
|
|
commandHandler,
|
2026-06-01 21:44:29 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (config.AUTO_MIGRATE_ON_STARTUP) {
|
|
|
|
|
logger.info(
|
|
|
|
|
"AUTO_MIGRATE_ON_STARTUP enabled; running database migrations",
|
|
|
|
|
);
|
|
|
|
|
await runMigrations();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("Initializing database");
|
|
|
|
|
await initializeDatabase();
|
|
|
|
|
logger.info("PostgreSQL database initialized");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error({ error: err }, "Failed to initialize database");
|
2026-06-08 19:14:34 +07:00
|
|
|
throw new DatabaseError(
|
|
|
|
|
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
client.on("debug", (msg) => {
|
|
|
|
|
if (
|
|
|
|
|
msg.includes("[VOICE") ||
|
|
|
|
|
msg.includes("[ffmpeg") ||
|
|
|
|
|
msg.toLowerCase().includes("error") ||
|
|
|
|
|
msg.toLowerCase().includes("stream")
|
|
|
|
|
) {
|
|
|
|
|
logger.info({ debugMsg: msg }, "Discord Client Debug");
|
|
|
|
|
} else if (config.VERBOSE) {
|
|
|
|
|
logger.debug({ debugMsg: msg }, "Discord Client Debug");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
client.on("ready", async () => {
|
|
|
|
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
2026-06-08 19:14:34 +07:00
|
|
|
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
|
|
|
|
setRecorderEventBroadcaster(eventBroadcaster);
|
2026-06-01 21:44:29 +07:00
|
|
|
registerMessageCapture(client);
|
2026-06-02 12:17:51 +07:00
|
|
|
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
// Start command handler after Discord is ready
|
|
|
|
|
commandHandler.start(client, voiceController);
|
|
|
|
|
logger.info("Command handler started");
|
2026-06-10 20:56:16 +07:00
|
|
|
|
|
|
|
|
// Start retention cleanup scheduler
|
|
|
|
|
startRetentionCleanup();
|
2026-06-01 21:44:29 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
client.on("error", (err) => {
|
|
|
|
|
logger.error({ error: err }, "Client error");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
process.on("SIGINT", () => {
|
|
|
|
|
gracefulShutdown("SIGINT");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
process.on("SIGTERM", () => {
|
|
|
|
|
gracefulShutdown("SIGTERM");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
process.on("uncaughtException", (err) => {
|
|
|
|
|
logger.error({ error: err }, "Uncaught exception");
|
|
|
|
|
gracefulShutdown("uncaughtException");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
process.on("unhandledRejection", (reason, promise) => {
|
|
|
|
|
logger.error({ reason, promise }, "Unhandled rejection");
|
|
|
|
|
gracefulShutdown("unhandledRejection");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info("Calling Discord client.login");
|
|
|
|
|
client
|
|
|
|
|
.login(token)
|
|
|
|
|
.then(() => {
|
|
|
|
|
logger.info("Discord client.login resolved");
|
|
|
|
|
})
|
|
|
|
|
.catch((error: unknown) => {
|
|
|
|
|
logger.error({ error }, "Discord client.login failed");
|
|
|
|
|
});
|
|
|
|
|
}
|