diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index cb7687ee..b071e80a 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -55,8 +55,6 @@ jobs:
environment: ${{ github.ref == 'refs/heads/main' && 'Main branch' || 'CI' }}
outputs:
pr_number: ${{ steps.get-pr.outputs.result }}
- preview_url: ${{ steps.set-outputs.outputs.preview_url }}
- is_production: ${{ steps.set-outputs.outputs.is_production }}
steps:
- name: Checkout
@@ -158,13 +156,13 @@ jobs:
-H 'Content-Type: application/json' \
-d '{"version": "${{github.sha}}"}'
- # --- Preview deployment (PR branches only) ---
- - name: Deploy preview
- if: false
- # if: github.ref != 'refs/heads/main' && steps.get-pr.outputs.result != ''
+ # --- Smoke test (PR branches only) ---
+ - name: Smoke test
+ id: smoke-test
+ if: github.ref != 'refs/heads/main' && steps.get-pr.outputs.result != ''
run: |
PR_NUMBER=${{ steps.get-pr.outputs.result }}
- echo "Deploying preview for PR #${PR_NUMBER}"
+ echo "Running smoke test for PR #${PR_NUMBER}"
kubectl config set-context --current --namespace=staging
@@ -198,32 +196,46 @@ jobs:
envsubst < cluster/preview/deployment.yaml | kubectl apply -f -
+ # Measure startup time
+ SECONDS=0
kubectl rollout restart statefulset/mod-bot-pr-${PR_NUMBER}
-
- echo "Preview deployed at https://${PR_NUMBER}.euno-staging.reactiflux.com"
-
- - name: Set deployment outputs
- id: set-outputs
- run: |
- if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
- echo "is_production=true" >> $GITHUB_OUTPUT
- echo "preview_url=" >> $GITHUB_OUTPUT
- elif [[ -n "${{ steps.get-pr.outputs.result }}" ]]; then
- echo "is_production=false" >> $GITHUB_OUTPUT
- echo "preview_url=https://${{ steps.get-pr.outputs.result }}.euno-staging.reactiflux.com" >> $GITHUB_OUTPUT
+ kubectl rollout status statefulset/mod-bot-pr-${PR_NUMBER} --timeout=5m
+ STARTUP_TIME=${SECONDS}
+ echo "startup_time=${STARTUP_TIME}s" >> $GITHUB_OUTPUT
+
+ # Get image size from GHCR
+ IMAGE_TAG="sha-${{ github.sha }}"
+ IMAGE_SIZE=$(kubectl get pod -l app=mod-bot-pr-${PR_NUMBER} -o jsonpath='{.items[0].status.containerStatuses[0].image}' 2>/dev/null || echo "")
+ # Query GHCR API for the image size
+ IMAGE_SIZE_BYTES=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
+ "https://ghcr.io/v2/${{ github.repository }}/manifests/${IMAGE_TAG}" \
+ -H "Accept: application/vnd.oci.image.index.v1+json" \
+ | jq '[.manifests[]?.size // 0] | add // 0' 2>/dev/null || echo "0")
+ if [ "$IMAGE_SIZE_BYTES" -gt 0 ] 2>/dev/null; then
+ IMAGE_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", ${IMAGE_SIZE_BYTES}/1048576}")
+ echo "image_size=${IMAGE_SIZE_MB} MB" >> $GITHUB_OUTPUT
else
- echo "is_production=false" >> $GITHUB_OUTPUT
- echo "preview_url=" >> $GITHUB_OUTPUT
+ # Fallback: get compressed size from the image manifest config
+ IMAGE_SIZE_MB=$(kubectl get pod -l app=mod-bot-pr-${PR_NUMBER} -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' 2>/dev/null | head -c 20 || echo "")
+ echo "image_size=unknown" >> $GITHUB_OUTPUT
fi
- - name: Comment preview URL on PR
+ # Tear down the preview deployment
+ echo "Tearing down smoke test deployment..."
+ envsubst < cluster/preview/deployment.yaml | kubectl delete -f - --ignore-not-found
+ kubectl delete pvc -l app=mod-bot-pr-${PR_NUMBER} --ignore-not-found
+
+ echo "Smoke test complete"
+
+ - name: Comment smoke test results on PR
if: github.ref != 'refs/heads/main' && steps.get-pr.outputs.result != ''
uses: actions/github-script@v7
with:
script: |
const prNumber = parseInt('${{ steps.get-pr.outputs.result }}');
- const previewUrl = `https://${prNumber}.euno-staging.reactiflux.com`;
const commitSha = '${{ github.sha }}';
+ const startupTime = '${{ steps.smoke-test.outputs.startup_time }}';
+ const imageSize = '${{ steps.smoke-test.outputs.image_size }}';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
@@ -232,20 +244,17 @@ jobs:
});
const botComment = comments.data.find(c =>
- c.user.type === 'Bot' && c.body.includes('Preview deployed')
+ c.user.type === 'Bot' && c.body.includes('Smoke Test Results')
);
- const body = `### Preview deployed
-
- It may take a few minutes before the service becomes available.
+ const body = `### Smoke Test Results
- | Environment | URL |
- |-------------|-----|
- | Preview | ${previewUrl} |
+ | Metric | Value |
+ |--------|-------|
+ | Image Size | ${imageSize} |
+ | Startup Time | ${startupTime} |
- Deployed commit: \`${commitSha.substring(0, 7)}\`
-
- This preview will be updated on each push and deleted when the PR is closed.`;
+ Tested commit: \`${commitSha.substring(0, 7)}\``;
if (botComment) {
await github.rest.issues.updateComment({
@@ -262,146 +271,3 @@ jobs:
body
});
}
-
- # --- E2E Tests after deployment ---
- # e2e:
- # needs: deployment
- # if: needs.deployment.outputs.preview_url != '' || needs.deployment.outputs.is_production == 'true'
- # runs-on: ubuntu-latest
- # timeout-minutes: 10
- # env:
- # TARGET_URL: ${{ needs.deployment.outputs.preview_url || 'https://euno.reactiflux.com' }}
- # PR_NUMBER: ${{ needs.deployment.outputs.pr_number }}
- # steps:
- # - name: Checkout repo
- # uses: actions/checkout@v4
-
- # - name: Setup node
- # uses: actions/setup-node@v4
- # with:
- # node-version: 24
-
- # - run: npm ci
-
- # - name: Cache Playwright browsers
- # uses: actions/cache@v4
- # with:
- # path: ~/.cache/ms-playwright
- # key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
-
- # - name: Install Playwright browsers
- # run: npx playwright install chromium
-
- # - name: Wait for service to be ready
- # run: |
- # for i in {1..30}; do
- # if curl -sf "$TARGET_URL" > /dev/null; then
- # echo "Service is ready"
- # exit 0
- # fi
- # echo "Waiting for service... ($i/30)"
- # sleep 10
- # done
- # echo "Service did not become ready in time"
- # exit 1
-
- # - name: Run Playwright tests
- # run: npm run test:e2e
- # env:
- # E2E_PREVIEW_URL: ${{ env.TARGET_URL }}
-
- # - name: Upload test artifacts
- # if: always()
- # uses: actions/upload-artifact@v4
- # with:
- # name: playwright-report-${{ github.run_id }}
- # path: |
- # playwright-report/
- # test-results/
- # retention-days: 30
-
- # - name: Deploy test report to GitHub Pages
- # if: always()
- # uses: peaceiris/actions-gh-pages@v4
- # with:
- # github_token: ${{ secrets.GITHUB_TOKEN }}
- # publish_dir: ./playwright-report
- # destination_dir: reports/${{ github.run_number }}
- # keep_files: true
-
- # - name: Comment PR with test results
- # if: ${{ always() && env.PR_NUMBER != '' }}
- # uses: actions/github-script@v7
- # with:
- # script: |
- # const fs = require('fs');
- # const prNumber = parseInt('${{ env.PR_NUMBER }}');
- # const targetUrl = '${{ env.TARGET_URL }}';
- # const reportUrl = `https://reactiflux.github.io/mod-bot/reports/${{ github.run_number }}`;
- # const runUrl = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}';
-
- # // Parse test results
- # let stats = { passed: 0, failed: 0, flaky: 0, skipped: 0 };
- # try {
- # const results = JSON.parse(fs.readFileSync('test-results/results.json', 'utf8'));
- # const countTests = (suites) => {
- # for (const suite of suites) {
- # for (const spec of suite.specs || []) {
- # for (const test of spec.tests || []) {
- # if (test.status === 'expected') stats.passed++;
- # else if (test.status === 'unexpected') stats.failed++;
- # else if (test.status === 'flaky') stats.flaky++;
- # else if (test.status === 'skipped') stats.skipped++;
- # }
- # }
- # if (suite.suites) countTests(suite.suites);
- # }
- # };
- # countTests(results.suites || []);
- # } catch (e) {
- # console.log('Could not parse test results:', e.message);
- # }
-
- # const emoji = stats.failed > 0 ? '❌' : stats.flaky > 0 ? '⚠️' : '✅';
- # const status = stats.failed > 0 ? 'Failed' : stats.flaky > 0 ? 'Flaky' : 'Passed';
- # const statsParts = [
- # stats.passed > 0 && `**${stats.passed}** passed`,
- # stats.flaky > 0 && `**${stats.flaky}** flaky`,
- # stats.failed > 0 && `**${stats.failed}** failed`,
- # stats.skipped > 0 && `**${stats.skipped}** skipped`,
- # ].filter(Boolean).join(' · ');
-
- # const body = `## ${emoji} E2E Tests ${status}
-
- # ${statsParts}
-
- # [View Report](${reportUrl}) · [View Run](${runUrl})
-
- # Tested against: ${targetUrl}`;
-
- # // Find existing E2E comment to update
- # const { data: comments } = await github.rest.issues.listComments({
- # owner: context.repo.owner,
- # repo: context.repo.repo,
- # issue_number: prNumber
- # });
-
- # const existingComment = comments.find(c =>
- # c.user.type === 'Bot' && c.body.includes('E2E Tests')
- # );
-
- # if (existingComment) {
- # await github.rest.issues.updateComment({
- # owner: context.repo.owner,
- # repo: context.repo.repo,
- # comment_id: existingComment.id,
- # body
- # });
- # } else {
- # await github.rest.issues.createComment({
- # owner: context.repo.owner,
- # repo: context.repo.repo,
- # issue_number: prNumber,
- # body
- # });
- # }
diff --git a/app/AppRuntime.ts b/app/AppRuntime.ts
new file mode 100644
index 00000000..3eaa5df6
--- /dev/null
+++ b/app/AppRuntime.ts
@@ -0,0 +1,45 @@
+import { Effect, Layer, ManagedRuntime } from "effect";
+
+import { DatabaseLayer, DatabaseService, type EffectKysely } from "#~/Database";
+import { NotFoundError } from "#~/effects/errors";
+
+// App layer: database + PostHog + feature flags
+// FeatureFlagServiceLive depends on both DatabaseService and PostHogService
+const AppLayer = Layer.mergeAll(DatabaseLayer);
+
+// ManagedRuntime keeps the AppLayer scope alive for the process lifetime.
+// Unlike Effect.runSync which closes the scope (and thus the SQLite connection)
+// after execution, ManagedRuntime holds the scope open until explicit disposal.
+export const runtime = ManagedRuntime.make(AppLayer);
+
+// The context type provided by the ManagedRuntime. Use this for typing functions
+// that accept effects which need database access.
+export type RuntimeContext = ManagedRuntime.ManagedRuntime.Context<
+ typeof runtime
+>;
+
+// Extract the PostHog client for use by metrics.ts (null when no API key configured).
+export const db: EffectKysely = await runtime.runPromise(DatabaseService);
+
+// --- Bridge functions for legacy async/await code ---
+
+// Convenience helpers for legacy async/await code that needs to run
+// EffectKysely query builders as Promises.
+export const run = (effect: Effect.Effect): Promise =>
+ Effect.runPromise(effect);
+
+export const runTakeFirst = (
+ effect: Effect.Effect,
+): Promise =>
+ Effect.runPromise(Effect.map(effect, (rows) => rows[0]));
+
+export const runTakeFirstOrThrow = (
+ effect: Effect.Effect,
+): Promise =>
+ Effect.runPromise(
+ Effect.flatMap(effect, (rows) =>
+ rows[0] !== undefined
+ ? Effect.succeed(rows[0])
+ : Effect.fail(new NotFoundError({ resource: "db record", id: "" })),
+ ),
+ );
diff --git a/app/Database.ts b/app/Database.ts
index 0f80a44d..da7265af 100644
--- a/app/Database.ts
+++ b/app/Database.ts
@@ -1,15 +1,16 @@
-import { Context, Effect, Layer, ManagedRuntime } from "effect";
+import { Context, Effect, Layer, Schedule } from "effect";
+import * as Reactivity from "@effect/experimental/Reactivity";
import { SqlClient } from "@effect/sql";
import * as Sqlite from "@effect/sql-kysely/Sqlite";
import { SqliteClient } from "@effect/sql-sqlite-node";
import { ResultLengthMismatch, SqlError } from "@effect/sql/SqlError";
import type { DB } from "./db";
-import { DatabaseCorruptionError, NotFoundError } from "./effects/errors";
+import { DatabaseCorruptionError } from "./effects/errors";
+import { logEffect } from "./effects/observability";
import { databaseUrl, emergencyWebhook } from "./helpers/env.server";
import { log } from "./helpers/observability";
-import { scheduleTask } from "./helpers/schedule";
// Re-export SQL errors and DB type for consumers
export { SqlError, ResultLengthMismatch };
@@ -26,9 +27,12 @@ export class DatabaseService extends Context.Tag("DatabaseService")<
// Base SQLite client layer
// Note: WAL mode is enabled by default by @effect/sql-sqlite-node
-const SqliteLive = SqliteClient.layer({
- filename: databaseUrl,
-});
+const SqliteLive = Layer.scoped(
+ SqlClient.SqlClient,
+ SqliteClient.make({
+ filename: databaseUrl,
+ }).pipe(Effect.tap((sql) => sql.unsafe("PRAGMA busy_timeout = 5000"))),
+).pipe(Layer.provide(Reactivity.layer));
// Kysely service layer - provides the effectified Kysely instance
const KyselyLive = Layer.effect(DatabaseService, Sqlite.make()).pipe(
@@ -40,72 +44,13 @@ export const DatabaseLayer = Layer.mergeAll(SqliteLive, KyselyLive);
log("info", "Database", `Database configured at ${databaseUrl}`);
-// --- ManagedRuntime (single connection for the process lifetime) ---
-
-// ManagedRuntime keeps the DatabaseLayer scope alive for the process lifetime.
-// Unlike Effect.runSync which closes the scope (and thus the SQLite connection)
-// after execution, ManagedRuntime holds the scope open until explicit disposal.
-export const runtime = ManagedRuntime.make(DatabaseLayer);
-
-// The context type provided by the ManagedRuntime. Use this for typing functions
-// that accept effects which need database access.
-export type RuntimeContext = ManagedRuntime.ManagedRuntime.Context<
- typeof runtime
->;
-
-// Extract the EffectKysely instance synchronously.
-// The connection stays open because the runtime manages the layer's lifecycle.
-export const db: EffectKysely = runtime.runSync(DatabaseService);
-
-// Set busy_timeout so queries wait for locks instead of failing immediately
-runtime.runSync(
- Effect.gen(function* () {
+export function checkpointWal() {
+ return Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
- yield* sql.unsafe("PRAGMA busy_timeout = 5000");
- }),
-);
-
-/** Checkpoint WAL to main database and dispose the runtime. Call on process shutdown. */
-export function shutdownDatabase() {
- try {
- runtime.runSync(
- Effect.gen(function* () {
- const sql = yield* SqlClient.SqlClient;
- yield* sql.unsafe("PRAGMA wal_checkpoint(TRUNCATE)");
- }),
- );
- } catch (e) {
- console.error("Failed to checkpoint WAL on shutdown", e);
- }
+ yield* sql.unsafe("PRAGMA wal_checkpoint(TRUNCATE)");
+ });
}
-// --- Bridge functions for legacy async/await code ---
-
-// Convenience helpers for legacy async/await code that needs to run
-// EffectKysely query builders as Promises.
-export const run = (effect: Effect.Effect): Promise =>
- Effect.runPromise(effect);
-
-export const runTakeFirst = (
- effect: Effect.Effect,
-): Promise =>
- Effect.runPromise(Effect.map(effect, (rows) => rows[0]));
-
-export const runTakeFirstOrThrow = (
- effect: Effect.Effect,
-): Promise =>
- Effect.runPromise(
- Effect.flatMap(effect, (rows) =>
- rows[0] !== undefined
- ? Effect.succeed(rows[0])
- : Effect.fail(new NotFoundError({ resource: "db record", id: "" })),
- ),
- );
-
-// --- Integrity Check ---
-
-const TWELVE_HOURS = 12 * 60 * 60 * 1000;
-
const sendWebhookAlert = (message: string) =>
Effect.tryPromise({
try: () =>
@@ -149,25 +94,17 @@ export const runIntegrityCheck = Effect.gen(function* () {
return yield* new DatabaseCorruptionError({ errors });
}).pipe(
- Effect.catchTag("SqlError", (e) =>
- Effect.gen(function* () {
- log("error", "IntegrityCheck", "Integrity check failed to run", {
- error: e.message,
- });
- yield* sendWebhookAlert(
- `🚨 **Database Integrity Check Failed**\n\`\`\`\n${e.message}\n\`\`\``,
- );
- return yield* e;
- }),
+ Effect.repeat(Schedule.fixed("6 hours")),
+ Effect.catchTag("SqlError", (error) =>
+ Effect.all([
+ logEffect("error", "IntegrityCheck", "Integrity check failed to run", {
+ error,
+ }),
+ sendWebhookAlert(
+ `🚨 **Database Integrity Check Failed**\n\`\`\`\n${error.message}\n${String(error.cause)}\n${error.stack}\n\`\`\``,
+ ),
+ ]),
),
+ Effect.catchAll(() => Effect.succeed(null)),
Effect.withSpan("runIntegrityCheck"),
);
-
-/** Start the twice-daily integrity check scheduler */
-export function startIntegrityCheck() {
- return scheduleTask("IntegrityCheck", TWELVE_HOURS, () => {
- runtime.runPromise(runIntegrityCheck).catch(() => {
- // Errors already logged and webhook sent
- });
- });
-}
diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts
index 864d8f09..5c82bd81 100644
--- a/app/discord/activityTracker.ts
+++ b/app/discord/activityTracker.ts
@@ -1,7 +1,7 @@
import { ChannelType, Events, type Client } from "discord.js";
import { Effect } from "effect";
-import { db, run } from "#~/Database";
+import { db, run } from "#~/AppRuntime";
import { getMessageStats } from "#~/helpers/discord.js";
import { threadStats } from "#~/helpers/metrics";
import { log, trackPerformance } from "#~/helpers/observability";
diff --git a/app/discord/client.server.ts b/app/discord/client.server.ts
index 63b13978..f721157d 100644
--- a/app/discord/client.server.ts
+++ b/app/discord/client.server.ts
@@ -8,6 +8,7 @@ export const client = new Client({
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildEmojisAndStickers,
+ GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildModeration,
diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts
index 9ae7529a..956a330b 100644
--- a/app/discord/deployCommands.server.ts
+++ b/app/discord/deployCommands.server.ts
@@ -6,6 +6,7 @@ import {
type OAuth2Guild,
type SlashCommandBuilder,
} from "discord.js";
+import { Effect } from "effect";
import { ssrDiscordSdk } from "#~/discord/api";
import {
@@ -178,15 +179,26 @@ export const deployTestCommands = async (
};
const commands = new Map();
-export const registerCommand = (config: AnyCommand | AnyCommand[]) => {
- if (Array.isArray(config)) {
- config.forEach((c) => {
- commands.set(c.command.name, c);
- });
- return;
- }
- commands.set(config.command.name, config);
-};
+export const registerCommand = (
+ config: AnyCommand | AnyCommand[],
+): Effect.Effect =>
+ Effect.sync(() => {
+ if (Array.isArray(config)) {
+ config.forEach((c) => {
+ commands.set(c.command.name, c);
+ });
+ return;
+ }
+ commands.set(config.command.name, config);
+ }).pipe(
+ Effect.withSpan("registerCommand", {
+ attributes: {
+ name: Array.isArray(config)
+ ? config.map((c) => c.command.name)
+ : config.command.name,
+ },
+ }),
+ );
export const matchCommand = (customId: string) => {
const config = commands.get(customId);
if (config) {
diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts
index 7bd4c9fd..bced7960 100644
--- a/app/discord/gateway.ts
+++ b/app/discord/gateway.ts
@@ -1,39 +1,32 @@
-import { Events, InteractionType } from "discord.js";
+import { Events, InteractionType, type Client } from "discord.js";
+import { Effect } from "effect";
-import modActionLogger from "#~/commands/report/modActionLogger";
-import { shutdownDatabase, startIntegrityCheck } from "#~/Database";
-import { startActivityTracking } from "#~/discord/activityTracker";
-import automod from "#~/discord/automod";
import { client, login } from "#~/discord/client.server";
-import { deployCommands, matchCommand } from "#~/discord/deployCommands.server";
-import { startEscalationResolver } from "#~/discord/escalationResolver";
-import onboardGuild from "#~/discord/onboardGuild";
-import { startReactjiChanneler } from "#~/discord/reactjiChanneler";
+import { matchCommand } from "#~/discord/deployCommands.server";
+import { logEffect } from "#~/effects/observability.ts";
import { runEffect } from "#~/effects/runtime";
import { type AnyCommand } from "#~/helpers/discord.ts";
-import { botStats, shutdownMetrics } from "#~/helpers/metrics";
-import { log, trackPerformance } from "#~/helpers/observability";
+import { botStats } from "#~/helpers/metrics";
+import { log } from "#~/helpers/observability";
import Sentry from "#~/helpers/sentry.server";
-import { startHoneypotTracking } from "./honeypotTracker";
-
// Track if gateway is already initialized to prevent duplicate logins during HMR
// Use globalThis so the flag persists across module reloads
declare global {
var __discordGatewayInitialized: boolean | undefined;
}
-export default function init() {
+export const initDiscordBot: Effect.Effect = Effect.gen(function* () {
if (globalThis.__discordGatewayInitialized) {
- log(
+ yield* logEffect(
"info",
"Gateway",
"Gateway already initialized, skipping duplicate init",
);
- return;
+ return client;
}
- log("info", "Gateway", "Initializing Discord gateway");
+ yield* logEffect("info", "Gateway", "Initializing Discord gateway");
globalThis.__discordGatewayInitialized = true;
void login();
@@ -51,48 +44,6 @@ export default function init() {
},
);
- client.on(Events.ClientReady, async () => {
- await trackPerformance(
- "gateway_startup",
- async () => {
- log("info", "Gateway", "Bot ready event triggered", {
- guildCount: client.guilds.cache.size,
- userCount: client.users.cache.size,
- });
-
- await Promise.all([
- onboardGuild(client),
- automod(client),
- modActionLogger(client),
- deployCommands(client),
- startActivityTracking(client),
- startHoneypotTracking(client),
- startReactjiChanneler(client),
- ]);
-
- // Start escalation resolver scheduler (must be after client is ready)
- startEscalationResolver(client);
-
- // Start twice-daily database integrity check
- startIntegrityCheck();
-
- log("info", "Gateway", "Gateway initialization completed", {
- guildCount: client.guilds.cache.size,
- userCount: client.users.cache.size,
- });
-
- // Track bot startup in business analytics
- botStats.botStarted(client.guilds.cache.size, client.users.cache.size);
- },
- {
- guildCount: client.guilds.cache.size,
- userCount: client.users.cache.size,
- },
- );
- });
-
- // client.on(Events.messageReactionAdd, () => {});
-
client.on(Events.ThreadCreate, (thread) => {
log("info", "Gateway", "Thread created", {
threadId: thread.id,
@@ -182,19 +133,14 @@ export default function init() {
botStats.reconnection(client.guilds.cache.size, client.users.cache.size);
});
- // Graceful shutdown handler to flush metrics and close database
- const handleShutdown = async (signal: string) => {
- log("info", "Gateway", `Received ${signal}, shutting down gracefully`, {});
- await shutdownMetrics();
- try {
- shutdownDatabase();
- log("info", "Gateway", "Database closed cleanly", {});
- } catch (e) {
- log("error", "Gateway", "Error closing database", { error: String(e) });
- }
- process.exit(0);
- };
+ // Wait for the client to be ready before continuing
+ const waitForReady = Effect.async((resume) => {
+ client.once(Events.ClientReady, () => {
+ resume(Effect.succeed(client));
+ });
+ });
- process.on("SIGTERM", () => void handleShutdown("SIGTERM"));
- process.on("SIGINT", () => void handleShutdown("SIGINT"));
-}
+ yield* waitForReady;
+
+ return client;
+});
diff --git a/app/discord/honeypotTracker.ts b/app/discord/honeypotTracker.ts
index 3f0e8f6b..239fbc0a 100644
--- a/app/discord/honeypotTracker.ts
+++ b/app/discord/honeypotTracker.ts
@@ -1,7 +1,7 @@
import { ChannelType, Events, type Client } from "discord.js";
+import { db, run } from "#~/AppRuntime";
import { logUserMessageLegacy } from "#~/commands/report/userLog.ts";
-import { db, run } from "#~/Database";
import { featureStats } from "#~/helpers/metrics";
import { log } from "#~/helpers/observability";
import { fetchSettings, SETTINGS } from "#~/models/guilds.server.js";
diff --git a/app/discord/reactjiChanneler.ts b/app/discord/reactjiChanneler.ts
index 2d7e4835..65ffd200 100644
--- a/app/discord/reactjiChanneler.ts
+++ b/app/discord/reactjiChanneler.ts
@@ -1,6 +1,6 @@
import { Events, type Client } from "discord.js";
-import { db, runTakeFirst } from "#~/Database";
+import { db, runTakeFirst } from "#~/AppRuntime";
import { featureStats } from "#~/helpers/metrics";
import { log } from "#~/helpers/observability";
diff --git a/app/discord/utils.ts b/app/discord/utils.ts
index 53a19aff..bc288843 100644
--- a/app/discord/utils.ts
+++ b/app/discord/utils.ts
@@ -1,6 +1,6 @@
import type { Message, TextChannel } from "discord.js";
-import { db, run, runTakeFirst } from "#~/Database";
+import { db, run, runTakeFirst } from "#~/AppRuntime";
import { log } from "#~/helpers/observability";
export async function getOrFetchChannel(msg: Message) {
diff --git a/app/effects/runtime.ts b/app/effects/runtime.ts
index 4f8df09c..4fb5b8f2 100644
--- a/app/effects/runtime.ts
+++ b/app/effects/runtime.ts
@@ -1,6 +1,6 @@
import { Effect, Layer, Logger, LogLevel } from "effect";
-import { runtime, type RuntimeContext } from "#~/Database.js";
+import { runtime, type RuntimeContext } from "#~/AppRuntime.js";
import { isProd } from "#~/helpers/env.server.js";
import { log } from "#~/helpers/observability.js";
diff --git a/app/helpers/cohortAnalysis.ts b/app/helpers/cohortAnalysis.ts
index 09f320bd..eca5973a 100644
--- a/app/helpers/cohortAnalysis.ts
+++ b/app/helpers/cohortAnalysis.ts
@@ -1,7 +1,7 @@
import { sql } from "kysely";
import { partition } from "lodash-es";
-import { run } from "#~/Database";
+import { run } from "#~/AppRuntime";
import type { CodeStats } from "#~/helpers/discord";
import { descriptiveStats, percentile } from "#~/helpers/statistics";
import { createMessageStatsQuery } from "#~/models/activity.server";
diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts
index baf219bd..d4f588f8 100644
--- a/app/helpers/discord.ts
+++ b/app/helpers/discord.ts
@@ -22,7 +22,7 @@ import { Effect } from "effect";
import { partition } from "lodash-es";
import prettyBytes from "pretty-bytes";
-import type { RuntimeContext } from "#~/Database";
+import type { RuntimeContext } from "#~/AppRuntime";
import { resolveMessagePartial } from "#~/effects/discordSdk";
import { NotFoundError, type DiscordApiError } from "#~/effects/errors.ts";
import {
diff --git a/app/helpers/metrics.ts b/app/helpers/metrics.ts
index c26a30d6..49e94652 100644
--- a/app/helpers/metrics.ts
+++ b/app/helpers/metrics.ts
@@ -8,9 +8,10 @@ import type {
} from "discord.js";
import { PostHog } from "posthog-node";
-import { posthogApiKey, posthogHost } from "#~/helpers/env.server";
import { log } from "#~/helpers/observability";
+import { posthogApiKey, posthogHost } from "./env.server";
+
type EventValue = string | number | boolean;
type EmitEventData = Record;
diff --git a/app/models/activity.server.ts b/app/models/activity.server.ts
index d20c91f9..4e397545 100644
--- a/app/models/activity.server.ts
+++ b/app/models/activity.server.ts
@@ -1,9 +1,10 @@
import { sql } from "kysely";
-import { db, run, type DB } from "#~/Database";
+import { db, run } from "#~/AppRuntime";
+import { type DB } from "#~/Database";
import { getUserCohortAnalysis } from "#~/helpers/cohortAnalysis";
import { fillDateGaps } from "#~/helpers/dateUtils";
-import { getOrFetchUser } from "#~/helpers/userInfoCache.js";
+import { getOrFetchUser } from "#~/helpers/userInfoCache";
type MessageStats = DB["message_stats"];
diff --git a/app/models/guilds.server.ts b/app/models/guilds.server.ts
index fa91682d..590d640c 100644
--- a/app/models/guilds.server.ts
+++ b/app/models/guilds.server.ts
@@ -1,13 +1,7 @@
import { Effect } from "effect";
-import {
- db,
- run,
- runTakeFirst,
- runTakeFirstOrThrow,
- type DB,
-} from "#~/Database";
-import { DatabaseService } from "#~/Database.ts";
+import { db, run, runTakeFirst, runTakeFirstOrThrow } from "#~/AppRuntime";
+import { DatabaseService, type DB } from "#~/Database";
import { NotFoundError } from "#~/effects/errors.ts";
import { log, trackPerformance } from "#~/helpers/observability";
diff --git a/app/models/session.server.ts b/app/models/session.server.ts
index 0958336d..18cc0fd9 100644
--- a/app/models/session.server.ts
+++ b/app/models/session.server.ts
@@ -7,13 +7,8 @@ import {
} from "react-router";
import { AuthorizationCode } from "simple-oauth2";
-import {
- db,
- run,
- runTakeFirst,
- runTakeFirstOrThrow,
- type DB,
-} from "#~/Database";
+import { db, run, runTakeFirst, runTakeFirstOrThrow } from "#~/AppRuntime";
+import { type DB } from "#~/Database";
import {
applicationId,
discordSecret,
diff --git a/app/models/subscriptions.server.ts b/app/models/subscriptions.server.ts
index 4ecb6dd2..cdc3f49b 100644
--- a/app/models/subscriptions.server.ts
+++ b/app/models/subscriptions.server.ts
@@ -1,4 +1,4 @@
-import { db, run, runTakeFirst } from "#~/Database";
+import { db, run, runTakeFirst } from "#~/AppRuntime";
import { log, trackPerformance } from "#~/helpers/observability";
import Sentry from "#~/helpers/sentry.server";
diff --git a/app/models/user.server.ts b/app/models/user.server.ts
index b83d53be..60de562a 100644
--- a/app/models/user.server.ts
+++ b/app/models/user.server.ts
@@ -1,12 +1,7 @@
import { randomUUID } from "crypto";
-import {
- db,
- run,
- runTakeFirst,
- runTakeFirstOrThrow,
- type DB,
-} from "#~/Database";
+import { db, run, runTakeFirst, runTakeFirstOrThrow } from "#~/AppRuntime";
+import { type DB } from "#~/Database";
import { log, trackPerformance } from "#~/helpers/observability";
export type User = DB["users"];
diff --git a/app/routes/export-data.tsx b/app/routes/export-data.tsx
index d7aa2531..3dceab88 100644
--- a/app/routes/export-data.tsx
+++ b/app/routes/export-data.tsx
@@ -1,4 +1,4 @@
-import { db, run, runTakeFirst } from "#~/Database";
+import { db, run, runTakeFirst } from "#~/AppRuntime";
import { log, trackPerformance } from "#~/helpers/observability";
import { requireUser } from "#~/models/session.server";
import { SubscriptionService } from "#~/models/subscriptions.server";
diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx
index 5d95aee8..4882dacd 100644
--- a/app/routes/healthcheck.tsx
+++ b/app/routes/healthcheck.tsx
@@ -1,5 +1,5 @@
// learn more: https://fly.io/docs/reference/configuration/#services-http_checks
-import { db, run } from "#~/Database";
+import { db, run } from "#~/AppRuntime";
import type { Route } from "./+types/healthcheck";
diff --git a/app/server.ts b/app/server.ts
index 18e3dcf7..c0051a9f 100644
--- a/app/server.ts
+++ b/app/server.ts
@@ -2,6 +2,7 @@ import "react-router";
import bodyParser from "body-parser";
import { verifyKey } from "discord-interactions";
+import { Effect } from "effect";
import express from "express";
import pinoHttp from "pino-http";
@@ -10,15 +11,31 @@ import { createRequestHandler } from "@react-router/express";
import { EscalationCommands } from "#~/commands/escalationControls";
import { Command as forceBan } from "#~/commands/force-ban";
import { Command as report } from "#~/commands/report";
+import modActionLogger from "#~/commands/report/modActionLogger";
import { Command as setup } from "#~/commands/setup";
import { Command as setupHoneypot } from "#~/commands/setupHoneypot";
import { Command as setupReactjiChannel } from "#~/commands/setupReactjiChannel";
import { Command as setupTicket } from "#~/commands/setupTickets";
import { Command as track } from "#~/commands/track";
-import { registerCommand } from "#~/discord/deployCommands.server";
-import discordBot from "#~/discord/gateway";
+import { startActivityTracking } from "#~/discord/activityTracker";
+import automod from "#~/discord/automod";
+import {
+ deployCommands,
+ registerCommand,
+} from "#~/discord/deployCommands.server";
+import { startEscalationResolver } from "#~/discord/escalationResolver";
+import { initDiscordBot } from "#~/discord/gateway";
+import onboardGuild from "#~/discord/onboardGuild";
+import { startReactjiChanneler } from "#~/discord/reactjiChanneler";
import { applicationKey } from "#~/helpers/env.server";
+import { runtime } from "./AppRuntime";
+import { checkpointWal, runIntegrityCheck } from "./Database";
+import { startHoneypotTracking } from "./discord/honeypotTracker";
+import { DiscordApiError } from "./effects/errors";
+import { logEffect } from "./effects/observability";
+import { botStats, shutdownMetrics } from "./helpers/metrics";
+
export const app = express();
const logger = pinoHttp();
@@ -57,19 +74,81 @@ app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => {
next();
});
-/**
- * Initialize Discord gateway.
- */
-discordBot();
-
-/**
- * Register Discord commands.
- */
-registerCommand(setup);
-registerCommand(report);
-registerCommand(forceBan);
-registerCommand(track);
-registerCommand(setupTicket);
-registerCommand(setupReactjiChannel);
-registerCommand(EscalationCommands);
-registerCommand(setupHoneypot);
+const startup = Effect.gen(function* () {
+ yield* logEffect("debug", "Server", "initializing commands");
+
+ yield* Effect.all([
+ registerCommand(setup),
+ registerCommand(report),
+ registerCommand(forceBan),
+ registerCommand(track),
+ registerCommand(setupTicket),
+ registerCommand(setupReactjiChannel),
+ registerCommand(EscalationCommands),
+ registerCommand(setupHoneypot),
+ ]);
+
+ yield* logEffect("debug", "Server", "initializing Discord bot");
+ const discordClient = yield* initDiscordBot;
+
+ yield* Effect.tryPromise({
+ try: () =>
+ Promise.allSettled([
+ onboardGuild(discordClient),
+ automod(discordClient),
+ modActionLogger(discordClient),
+ deployCommands(discordClient),
+ startActivityTracking(discordClient),
+ startHoneypotTracking(discordClient),
+ startReactjiChanneler(discordClient),
+ ]),
+ catch: (error) => new DiscordApiError({ operation: "init", cause: error }),
+ });
+
+ // Start escalation resolver scheduler (must be after client is ready)
+ startEscalationResolver(discordClient);
+
+ yield* logEffect("info", "Gateway", "Gateway initialization completed", {
+ guildCount: discordClient.guilds.cache.size,
+ userCount: discordClient.users.cache.size,
+ });
+
+ // Track bot startup in business analytics
+ botStats.botStarted(
+ discordClient.guilds.cache.size,
+ discordClient.users.cache.size,
+ );
+
+ yield* logEffect("debug", "Server", "scheduling integrity check");
+ yield* runtime.runFork(runIntegrityCheck);
+
+ // Graceful shutdown handler to checkpoint WAL and dispose the runtime
+ // (tears down PostHog finalizer, feature flag interval, and SQLite connection)
+ const handleShutdown = (signal: string) =>
+ Promise.all([
+ shutdownMetrics(),
+ runtime
+ .runPromise(
+ Effect.gen(function* () {
+ yield* logEffect("info", "Server", `Received ${signal}`);
+ try {
+ yield* checkpointWal();
+ yield* logEffect("info", "Server", "Database WAL checkpointed");
+ } catch (e) {
+ yield* logEffect("error", "Server", "Error checkpointing WAL", {
+ error: String(e),
+ });
+ }
+ process.exit(0);
+ }),
+ )
+ .then(() => runtime.dispose().then(() => console.log("ok"))),
+ ]);
+
+ yield* logEffect("debug", "Server", "setting signal handlers");
+ process.on("SIGTERM", () => void handleShutdown("SIGTERM"));
+ process.on("SIGINT", () => void handleShutdown("SIGINT"));
+});
+
+console.log("running program");
+runtime.runCallback(startup);
diff --git a/index.dev.js b/index.dev.js
index c89e8a2f..9128db85 100644
--- a/index.dev.js
+++ b/index.dev.js
@@ -32,7 +32,7 @@ async function loadServerModule() {
}
// Initial load
-await loadServerModule();
+void loadServerModule();
// Add Vite middleware first
app.use(viteDevServer.middlewares);
diff --git a/vite.config.ts b/vite.config.ts
index 9db41c52..86a0f92b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,6 +4,7 @@ import { reactRouter } from "@react-router/dev/vite";
export default defineConfig(({ isSsrBuild }) => ({
build: {
+ target: isSsrBuild ? "esnext" : undefined,
sourcemap: true,
rollupOptions: isSsrBuild ? { input: "./app/server.ts" } : undefined,
},