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