diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2280958 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.env +.envrc +node_modules +data +build +.DS_Store +README.md +flake.nix +flake.lock diff --git a/.env.example b/.env.example index 8d9d4b8..9c5ee97 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,10 @@ OPENAI_API_KEY="" OPENROUTER_API_KEY="" AI_CLASSIFICATION_MODEL="google/gemma-3-12b-it:free" AI_CLASSIFICATION_FALLBACK_MODELS="meta-llama/llama-3.3-70b-instruct:free,mistralai/mistral-small-3.1-24b-instruct:free" -REPLICATE_API_TOKEN="" +REPLICATE_API_KEY="" ELEVENLABS_API_KEY="" ELEVENLABS_VOICE_ID="" ELEVENLABS_MODEL="eleven_multilingual_v2" WEB_PORT="3000" WEB_BASE_URL="http://localhost:3000" -SESSION_SECRET="" \ No newline at end of file +SESSION_SECRET="" diff --git a/Dockerfile b/Dockerfile index 0cd3bfc..46f68fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,8 @@ RUN bun install --frozen-lockfile COPY src ./src COPY tsconfig.json drizzle.config.ts ./ +RUN bun run css:build + # Production stage FROM oven/bun:1-slim @@ -25,7 +27,7 @@ WORKDIR /app # Install runtime dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg \ + && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ && rm -rf /var/lib/apt/lists/* # Copy from builder @@ -33,14 +35,18 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/src ./src COPY --from=builder /app/package.json ./ COPY --from=builder /app/tsconfig.json ./ +COPY --from=builder /app/drizzle.config.ts ./ # Set environment variables ENV NODE_ENV=production ENV LOG_LEVEL=info ENV DATABASE_PATH=/data/db.sqlite3 +ENV WEB_PORT=3000 # Persist runtime data (SQLite) VOLUME ["/data"] +EXPOSE 3000 + # Run the bot CMD ["bun", "run", "src/index.ts"] diff --git a/README.md b/README.md index 9fe7bce..6c8723c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,56 @@ docker run -d \ your-image:latest ``` +## Docker + +Build the image: + +```bash +docker build -t ghcr.io/your-org/joel-bot:latest . +``` + +Run it locally: + +```bash +docker run -d \ + --name joel-bot \ + -p 3000:3000 \ + -v joel_data:/data \ + --env-file .env \ + ghcr.io/your-org/joel-bot:latest +``` + +The container now starts in production mode and does not enable the CSS file watcher. + +## k3s + +The repo includes starter manifests in [`k8s/deployment.yaml`](/Users/eric/Projects/joel-discord/k8s/deployment.yaml), [`k8s/secret.example.yaml`](/Users/eric/Projects/joel-discord/k8s/secret.example.yaml), and [`k8s/ingress.example.yaml`](/Users/eric/Projects/joel-discord/k8s/ingress.example.yaml). + +Typical flow: + +```bash +# Build and push the image +docker build -t ghcr.io/your-org/joel-bot:latest . +docker push ghcr.io/your-org/joel-bot:latest + +# Create your secret manifest from the example and fill in real values +cp k8s/secret.example.yaml k8s/secret.yaml + +# Apply namespace, PVC, deployment, and service +kubectl apply -f k8s/deployment.yaml +kubectl apply -f k8s/secret.yaml + +# Optional if you want the web UI exposed through Traefik +kubectl apply -f k8s/ingress.example.yaml +``` + +Notes: + +- Update the image in `k8s/deployment.yaml` to your registry path. +- Set `WEB_BASE_URL` in the secret to the public HTTPS URL you expose through ingress. +- The deployment mounts `/data` on a PVC and stores SQLite at `/data/db.sqlite3`. +- k3s usually provisions the PVC automatically via the default `local-path` storage class. + ## Scripts | Script | Description | diff --git a/flake.lock b/flake.lock index fb8f8c1..33a3889 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,22 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, "git-hooks": { "inputs": { "flake-compat": "flake-compat", @@ -36,6 +52,26 @@ "type": "github" } }, + "git-hooks_2": { + "inputs": { + "flake-compat": "flake-compat_2", + "gitignore": "gitignore_2", + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1772024342, + "narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ @@ -57,6 +93,28 @@ "type": "github" } }, + "gitignore_2": { + "inputs": { + "nixpkgs": [ + "repo-lib", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1764947035, @@ -89,10 +147,98 @@ "type": "github" } }, + "nixpkgs_3": { + "locked": { + "lastModified": 1770073757, + "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "47472570b1e607482890801aeaf29bfb749884f6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1772542754, + "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "8c809a146a140c5c8806f13399592dbcb1bb5dc4", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { + "locked": { + "lastModified": 1770107345, + "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "repo-lib": { + "inputs": { + "git-hooks": "git-hooks_2", + "nixpkgs": "nixpkgs_4", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1772866275, + "narHash": "sha256-lsJrFIbq6OO5wUC648VnvOmJm3qgJrlEugbdjeZsP34=", + "ref": "refs/tags/v3.0.0", + "rev": "96d2d190466dddcb9e652c38b70152f09b9fcb05", + "revCount": 50, + "type": "git", + "url": "https://git.dgren.dev/eric/nix-flake-lib" + }, + "original": { + "ref": "refs/tags/v3.0.0", + "type": "git", + "url": "https://git.dgren.dev/eric/nix-flake-lib" + } + }, "root": { "inputs": { "git-hooks": "git-hooks", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_2", + "repo-lib": "repo-lib" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_5" + }, + "locked": { + "lastModified": 1770228511, + "narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "337a4fe074be1042a35086f15481d763b8ddc0e7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 6fc92a6..ba20c17 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,7 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; git-hooks.url = "github:cachix/git-hooks.nix"; + repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.0.0"; }; outputs = diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..378919c --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,91 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: joel-bot +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: joel-bot-data + namespace: joel-bot +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: joel-bot + namespace: joel-bot +spec: + replicas: 1 + revisionHistoryLimit: 2 + selector: + matchLabels: + app: joel-bot + template: + metadata: + labels: + app: joel-bot + spec: + containers: + - name: joel-bot + image: ghcr.io/your-org/joel-bot:latest + imagePullPolicy: IfNotPresent + env: + - name: NODE_ENV + value: "production" + - name: LOG_LEVEL + value: "info" + - name: DATABASE_PATH + value: /data/db.sqlite3 + - name: WEB_PORT + value: "3000" + envFrom: + - secretRef: + name: joel-bot-env + ports: + - name: http + containerPort: 3000 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + volumes: + - name: data + persistentVolumeClaim: + claimName: joel-bot-data +--- +apiVersion: v1 +kind: Service +metadata: + name: joel-bot + namespace: joel-bot +spec: + selector: + app: joel-bot + ports: + - name: http + port: 3000 + targetPort: http diff --git a/k8s/ingress.example.yaml b/k8s/ingress.example.yaml new file mode 100644 index 0000000..622525d --- /dev/null +++ b/k8s/ingress.example.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: joel-bot + namespace: joel-bot + annotations: + kubernetes.io/ingress.class: traefik +spec: + rules: + - host: joel.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: joel-bot + port: + number: 3000 + tls: + - hosts: + - joel.example.com + secretName: joel-bot-tls diff --git a/k8s/secret.example.yaml b/k8s/secret.example.yaml new file mode 100644 index 0000000..fbdbfdd --- /dev/null +++ b/k8s/secret.example.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +metadata: + name: joel-bot-env + namespace: joel-bot +type: Opaque +stringData: + DISCORD_TOKEN: "" + DISCORD_CLIENT_ID: "" + DISCORD_CLIENT_SECRET: "" + OPENROUTER_API_KEY: "" + OPENAI_API_KEY: "" + HF_TOKEN: "" + REPLICATE_API_KEY: "" + FAL_KEY: "" + KLIPY_API_KEY: "" + ELEVENLABS_API_KEY: "" + ELEVENLABS_VOICE_ID: "" + ELEVENLABS_MODEL: "eleven_multilingual_v2" + AI_CLASSIFICATION_MODEL: "google/gemma-3-12b-it:free" + AI_CLASSIFICATION_FALLBACK_MODELS: "meta-llama/llama-3.3-70b-instruct:free,mistralai/mistral-small-3.1-24b-instruct:free" + WEB_BASE_URL: "https://joel.example.com" + SESSION_SECRET: "replace-with-a-long-random-secret" diff --git a/src/core/config.ts b/src/core/config.ts index d2f286d..1af40a5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -66,6 +66,17 @@ function getEnvOrDefault(key: string, defaultValue: string): string { return Bun.env[key] ?? defaultValue; } +function getFirstEnvOrDefault(keys: string[], defaultValue: string): string { + for (const key of keys) { + const value = Bun.env[key]; + if (value !== undefined) { + return value; + } + } + + return defaultValue; +} + function getBooleanEnvOrDefault(key: string, defaultValue: boolean): boolean { const raw = Bun.env[key]; if (raw === undefined) { @@ -112,7 +123,7 @@ export const config: BotConfig = { temperature: parseFloat(getEnvOrDefault("AI_TEMPERATURE", "1.2")), }, replicate: { - apiKey: getEnvOrDefault("REPLICATE_API_KEY", ""), + apiKey: getFirstEnvOrDefault(["REPLICATE_API_KEY", "REPLICATE_API_TOKEN"], ""), }, fal: { apiKey: getEnvOrDefault("FAL_KEY", ""), diff --git a/src/index.ts b/src/index.ts index a41adf8..c21e777 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import type { FSWatcher } from "fs"; const logger = createLogger("Main"); let webCssWatcher: FSWatcher | null = null; +const isProduction = Bun.env.NODE_ENV === "production"; // Create the Discord client with required intents const client = new BotClient({ @@ -53,7 +54,9 @@ async function main(): Promise { // Start web server after bot is logged in await buildWebCss(); - webCssWatcher = startWebCssWatcher(); + if (!isProduction) { + webCssWatcher = startWebCssWatcher(); + } await startWebServer(client); } catch (error) { logger.error("Failed to start bot", error);