diff --git a/AGENTS.md b/AGENTS.md index 90c1379..b21358c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ ### Repository Layout - Monorepo managed by Bun workspaces: `packages/gateway`, `packages/orchestrator`, `packages/worker`, `packages/core`. -- Top-level: `Makefile`, `bin/` (CLI/setup), `docker-compose*.yml`, `charts/peerbot` (Helm), `workspaces/`, `.env*`. +- Top-level: `Makefile`, `bin/` (CLI/setup), `sidecar.yaml`, `charts/peerbot` (Helm), `workspaces/`, `.env*`. - TypeScript sources under `packages/*/src`. Tests in `packages/*/src/__tests__` and `packages/core/tests`. - **ALWAYS prefer `bun` commands over `npm`** - When fixing unused parameter errors, remove the parameter entirely if possible rather than prefixing with underscore @@ -23,7 +23,7 @@ ### Architecture #### Platform -We currently only support Slack as messaging app. +We currently use WhatsApp as the messaging platform (Slack support also available but not configured). There is also a public endpoint in gateway to trigger running the agent. #### Orchestration @@ -64,7 +64,7 @@ TypeScript packages must be compiled from `src/` โ†’ `dist/`. If you modify any - The "is running" thread status indicator (with rotating messages) provides user feedback during processing; visible "Still processing" heartbeat messages are not sent to avoid clutter. - Anytime you make changes in the code, you MUST: -1. Have the bot running via `make dev` running in the background for development. This uses Docker Compose with hot reload enabled when NODE_ENV=development. +1. Have the bot running via sidecar (`/process-management`) for development. 2. Test the bot using the test script: ```bash ./scripts/test-bot.sh "@me test prompt" @@ -73,7 +73,7 @@ The script automatically handles sending the message, waiting for response, and ```bash ./scripts/test-bot.sh "@me first message" "follow up question" "another question" ``` -3. Check logs using `docker compose logs` or `make logs` to verify the bot works properly. +3. Check logs using `get_logs("gateway")` via `/process-management` to verify the bot works properly. - If you create ephemeral files, you MUST delete them when you're done with them. - Use Docker to build and run the Slack bot in development mode, K8S for production. @@ -87,33 +87,88 @@ File attachments are fully supported in all message contexts (DM, app mentions, ## Development Mode -- **Docker Compose**: Run `make dev` to start all services with hot reload enabled (uses docker-compose.dev.yml) -- **Logs**: View logs with `make logs` or `docker compose -f docker-compose.dev.yml logs -f [service]` -- **Hot Reload**: Source code changes are automatically detected when NODE_ENV=development - - **Gateway**: Source files are mounted as volumes and Bun runs with `--watch` flag - - Changes to `packages/gateway/src/`, `packages/core/src/`, or `packages/github/src/` trigger immediate restart - - Built dependencies (`packages/core/dist/`, `packages/github/dist/`) are also mounted - - Just save the file and watch logs for "Restarting..." message - - **Worker**: Worker image is rebuilt automatically in Docker mode (no rebuild needed for code changes) - - If hot reload isn't working, verify you're using `make dev` not `docker compose up` - -### Automatic Build on `make dev` -- `make dev` now automatically runs `make build-packages` before starting services, so you don't have to remember. -- However, if you're testing changes without restarting: -- 1. **Option A(Recommended)**: Use `make watch-packages` in a separate terminal for auto-rebuild -- 2. **Option B**: Manually run `make build-packages` after each change -- 3. **Option C**: Restart with `make dev` to rebuild everything +### Prerequisites +- Redis: `brew install redis` +- Bun: installed +- Docker Desktop: running + +### First-time Setup +```bash +./scripts/setup-dev.sh +``` + +### Starting Development +**Automatic!** Just open this project in Claude Code - sidecar auto-starts: +1. Redis server (port 6379) +2. Package watcher (rebuilds on changes) +3. Gateway with hot reload (port 8080) + +A tmux session opens showing all process output. + +### Managing Processes +Use `/process-management` if needed: +- `list_processes` - See status +- `get_logs("gateway")` - View logs +- `restart_process("gateway")` - Restart + +### Hot Reload +- **Gateway**: Runs with `bun --watch`, auto-restarts on source changes +- **Packages**: The `packages` process watches and rebuilds TypeScript packages +- **Worker**: Run `make clean-workers` after worker code changes + +### Testing +```bash +./scripts/test-bot.sh "@me test prompt" +``` ## Deployment Instructions When making changes to the Slack bot: -1. **Development**: Use `make dev` to start the server if it's not running. Use docker compose (for docker mode) or kubectl (for kubernetes mode) to pull the logs. +1. **Development**: Open project in Claude Code (auto-starts). View logs with `get_logs("gateway")` 2. **Kubernetes deployment**: Use `make deploy` for production deployment +## Environment Configuration + +The `.env` file is the single source of truth for all secrets and configuration. + +### Local Development +- Sidecar automatically reloads gateway when `.env` changes (via `envFile: .env`) +- No manual action needed + +### Kubernetes Deployment +When `.env` changes, sync secrets to K8s using Sealed Secrets: + +```bash +# Seal and apply secrets from .env +./scripts/seal-env.sh --apply + +# Or output to file for review +./scripts/seal-env.sh -o sealed-secrets.yaml +kubectl apply -f sealed-secrets.yaml +``` + +**Prerequisites for Sealed Secrets:** +```bash +# Install controller (once per cluster) +helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets +helm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system + +# Install CLI +brew install kubeseal +``` + +The gateway deployment has a checksum annotation that triggers automatic pod restart when secrets change via `helm upgrade`. + ## Development Configuration - Rate limiting is disabled in local development -- Worker image is built automatically when running `make dev` +- Worker image built with `make build-worker` or `make setup` + +### Docker Compose (Alternative) +For quick demos without sidecar, docker-compose.yml is available: +```bash +docker compose up +``` ## Persistent Storage Worker deployments use persistent volumes for session continuity across scale-to-zero: @@ -208,6 +263,10 @@ export GOOGLE_CLIENT_SECRET=your_google_client_secret Use the `test-bot.sh` script for easy bot testing. No manual curl commands needed. +**Platform self-testing behavior:** +- **Slack**: Cannot trigger its own event handlers (Slack filters bot-to-self messages). The test script uses `/api/messaging/send` endpoint which posts via bot token, then gateway receives as normal Slack events. +- **WhatsApp**: Supports self-chat mode! Set `WHATSAPP_SELF_CHAT=true` and send to the bot's own phone number. The gateway detects self-messages and queues them directly to workers, bypassing event handler filters. + ### Basic Test ```bash ./scripts/test-bot.sh "@me hello" @@ -241,6 +300,7 @@ curl -X POST http://localhost:8080/api/messaging/send \ ``` ### Check Logs -```bash -docker compose -f docker-compose.dev.yml logs gateway --tail 50 +Use `/process-management`: +``` +get_logs("gateway", tail=50) ``` \ No newline at end of file diff --git a/Dockerfile.gateway b/Dockerfile.gateway index 857fed2..69c2087 100644 --- a/Dockerfile.gateway +++ b/Dockerfile.gateway @@ -1,21 +1,21 @@ FROM node:20-alpine -# Install bun and docker-cli for build and runtime +# Install bun for fast dependency installation, docker-cli for runtime RUN apk add --no-cache curl bash docker-cli && \ - curl -fsSL https://bun.sh/install | bash -ENV PATH="/root/.bun/bin:${PATH}" + curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash && \ + chmod +x /usr/local/bin/bun +ENV PATH="/usr/local/bin:${PATH}" WORKDIR /app -# Only set up a minimal workspace for gateway + core + github to avoid cache busts +# Set up workspace for gateway + core + github COPY tsconfig.json ./ RUN echo '{ "name": "@peerbot/gateway-build", "private": true, "workspaces": [ "packages/core", "packages/github", "packages/gateway" ] }' > package.json COPY packages/core/package.json ./packages/core/ COPY packages/github/package.json ./packages/github/ COPY packages/gateway/package.json ./packages/gateway/ -# Install dependencies for gateway + core + github -# Allow some packages to fail (post-install scripts) but keep others +# Install dependencies with Bun (faster than npm) RUN --mount=type=cache,target=/root/.bun/install/cache bun install || true # Copy source code @@ -23,28 +23,25 @@ COPY packages/core/ ./packages/core/ COPY packages/github/ ./packages/github/ COPY packages/gateway/ ./packages/gateway/ -# Build core first +# Build core and github packages (gateway has type errors, run from source) WORKDIR /app/packages/core RUN bun run build -# Build github module WORKDIR /app/packages/github RUN bun run build -# Build gateway - Skip build due to pre-existing type errors, run from source -# Gateway build has type errors that are pre-existing -WORKDIR /app/packages/gateway -# RUN bun run build +# Install tsx for running TypeScript with Node.js +WORKDIR /app +RUN npm install -g tsx -# Runtime +# Runtime configuration ENV NODE_ENV=production EXPOSE 3000 -# Runtime user setup is handled by node:20-alpine - -# Set working directory back to /app for runtime to resolve modules correctly -WORKDIR /app +# Fix permissions for non-root K8s execution (user 1001) +RUN chmod -R a+rX /app -# Use Bun to run from source (better WebSocket compatibility) -# In development, docker-compose overrides this with --watch flag -CMD ["bun", "packages/gateway/src/index.ts"] +# Run with Node.js + tsx for TypeScript support +# This gives us Node.js compatibility while running .ts files directly +ENTRYPOINT [] +CMD ["tsx", "packages/gateway/src/index.ts"] diff --git a/Makefile b/Makefile index 16a104a..c350a61 100644 --- a/Makefile +++ b/Makefile @@ -5,19 +5,18 @@ # Default target help: @echo "Available commands:" - @echo " peerbot setup - Interactive setup for Slack bot development" - @echo " peerbot build-packages - Build all TypeScript packages (core, gateway, worker)" - @echo " peerbot check-build - Check if packages need rebuilding" - @echo " peerbot watch-packages - Watch packages and rebuild on changes (for development)" - @echo " peerbot dev - Start development with Docker Compose (hot reload)" - @echo " peerbot prod - Start production with Docker Compose" - @echo " peerbot down - Stop all services including dynamic workers" - @echo " peerbot logs - View Docker Compose service logs" - @echo " peerbot deploy - Deploy to K8s using values-local.yaml" - @echo " peerbot deploy --target=production - Deploy to K8s using values-production.yaml" - @echo " peerbot deploy --target=path/to/values.yaml - Deploy to K8s using custom values file" - @echo " peerbot test - Run test bot" - @echo " peerbot clean - Stop all services and clean up all resources" + @echo " make setup - Setup development environment (run once)" + @echo " make build-packages - Build all TypeScript packages" + @echo " make build-worker - Build worker Docker image" + @echo " make watch-packages - Watch packages and rebuild on changes" + @echo " make deploy - Deploy to K8s using values-local.yaml" + @echo " make deploy TARGET=production - Deploy to K8s using values-production.yaml" + @echo " make test - Run test bot" + @echo " make clean - Stop all services and clean up" + @echo " make clean-workers - Remove worker containers only" + @echo "" + @echo "Development:" + @echo " Use /process-management to start/stop sidecar processes (redis, packages, gateway)" # Build all TypeScript packages in dependency order build-packages: @@ -40,41 +39,14 @@ check-build: watch-packages: @./scripts/watch-packages.sh -# Start local development with Docker Compose in foreground -dev: - @if [ ! -f .env ]; then \ - echo "โŒ .env file not found!"; \ - echo ""; \ - echo "Please run setup first:"; \ - echo " make setup"; \ - echo ""; \ - exit 1; \ - fi - @echo "โ„น๏ธ Loading environment overrides from .env" - @echo "" - @echo "๐Ÿ“ฆ Step 1/2: Building TypeScript packages..." - @$(MAKE) build-packages - @echo "" - @echo "๐Ÿš€ Step 2/2: Starting local development mode with Docker Compose..." - @echo " This will:" - @echo " - Build all services including worker image" - @echo " - Start services with hot reload" - @echo " - Mount source code for live changes" - @echo "" - @echo "๐Ÿ”จ Building all services..." - @DETACH_FLAG=""; [ "$(DETACH)" = "1" ] && DETACH_FLAG="-d"; \ - if [ -n "$$DETACH_FLAG" ]; then echo "๐Ÿงฉ Running in detached mode"; fi; \ - COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f docker-compose.dev.yml up $$DETACH_FLAG --build gateway redis +# Setup development environment (run once) +setup: + @./scripts/setup-dev.sh -# Build the worker image on demand +# Build the worker image build-worker: @echo "๐Ÿ“ฆ Building worker image..." - @COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f docker-compose.dev.yml build worker - -# Convenience alias for detached dev -.PHONY: dev-d dev-detached -dev-d dev-detached: - @$(MAKE) dev DETACH=1 + @docker build -t peerbot-worker:latest -f Dockerfile.worker --build-arg NODE_ENV=development . # Catch-all target to prevent errors when passing arguments %: @@ -179,22 +151,19 @@ logs: echo ""; \ echo "View logs with:"; \ echo " kubectl logs -f -n peerbot"; \ - elif docker compose -f docker-compose.dev.yml ps --services 2>/dev/null | grep -q .; then \ - docker compose -f docker-compose.dev.yml logs -f; \ else \ - docker compose logs -f; \ + echo "For development, use /process-management to view logs:"; \ + echo " get_logs(\"gateway\")"; \ + echo " get_logs(\"redis\")"; \ fi -# Stop all services without removing volumes +# Stop worker containers down: - @echo "๐Ÿ›‘ Stopping all peerbot services and workers..." - @# Stop and remove all containers with the peerbot project label (includes dynamic workers) - @docker ps -q --filter "label=com.docker.compose.project=peerbot" | xargs -r docker stop 2>/dev/null || true - @docker ps -aq --filter "label=com.docker.compose.project=peerbot" | xargs -r docker rm 2>/dev/null || true - @# Use docker compose to clean up networks and remaining resources - @docker compose -f docker-compose.dev.yml down --remove-orphans 2>/dev/null || true - @docker compose down --remove-orphans 2>/dev/null || true - @echo "โœ… All peerbot services stopped" + @echo "๐Ÿ›‘ Stopping peerbot worker containers..." + @docker ps -q --filter "label=app.kubernetes.io/component=worker" | xargs -r docker stop 2>/dev/null || true + @docker ps -aq --filter "label=app.kubernetes.io/component=worker" | xargs -r docker rm 2>/dev/null || true + @echo "โœ… Worker containers stopped" + @echo "Note: For sidecar processes (redis, gateway), use /process-management" # Clean up everything including volumes clean: @@ -211,13 +180,11 @@ clean: kubectl delete namespace peerbot --wait=false 2>/dev/null || true; \ echo "โœ… Kubernetes resources cleaned up"; \ else \ - echo "๐Ÿณ Cleaning Docker Compose resources..."; \ - docker ps -q --filter "label=com.docker.compose.project=peerbot" | xargs -r docker stop 2>/dev/null || true; \ - docker ps -aq --filter "label=com.docker.compose.project=peerbot" | xargs -r docker rm 2>/dev/null || true; \ + echo "๐Ÿณ Cleaning Docker worker containers..."; \ docker ps -q --filter "label=app.kubernetes.io/component=worker" | xargs -r docker stop 2>/dev/null || true; \ docker ps -aq --filter "label=app.kubernetes.io/component=worker" | xargs -r docker rm 2>/dev/null || true; \ - docker compose -f docker-compose.dev.yml down -v --remove-orphans 2>/dev/null || true; \ - docker compose down -v --remove-orphans 2>/dev/null || true; \ + docker volume ls -q --filter "name=peerbot-workspace-" | xargs -r docker volume rm 2>/dev/null || true; \ + docker network rm peerbot-internal 2>/dev/null || true; \ echo "โœ… Docker containers and volumes cleaned up"; \ fi diff --git a/bun.lock b/bun.lock index 883efc3..e3ef4ba 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@anthropic-ai/claude-code-action", @@ -7,6 +8,7 @@ "@biomejs/biome": "^2.2.2", "@types/bun": "1.2.11", "@types/node": "^20.0.0", + "@types/qrcode-terminal": "^0.12.2", "husky": "^9.1.7", "jscpd": "^4.0.5", "knip": "^5.66.3", @@ -36,7 +38,6 @@ "name": "@peerbot/core", "version": "2.0.0", "dependencies": { - "@sentry/integrations": "^7.114.0", "@sentry/node": "^10.23.0", "ioredis": "^5.4.1", "winston": "^3.17.0", @@ -59,6 +60,7 @@ "@slack/types": "^2.17.0", "@slack/web-api": "^7.11.0", "@types/multer": "^2.0.0", + "@whiskeysockets/baileys": "^7.0.0-rc.9", "bullmq": "^5.31.5", "commander": "^14.0.1", "dockerode": "^4.0.7", @@ -69,6 +71,8 @@ "marked": "^12.0.0", "multer": "^2.0.2", "node-fetch": "^3.3.2", + "pino": "^9.1.0", + "qrcode-terminal": "^0.12.0", "zod": "^4.1.12", }, "devDependencies": { @@ -108,6 +112,7 @@ "zod": "^4.1.12", }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/node": "^20.0.0", "typescript": "^5.8.3", }, @@ -151,13 +156,21 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DAuHhHekGfiGb6lCcsT4UyxQmVwQiBCBUMwVra/dcOSs9q8OhfaZgey51MlekT3p8UwRqtXQfFuEJBhJNdLZwg=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + + "@cacheable/memory": ["@cacheable/memory@2.0.7", "", { "dependencies": { "@cacheable/utils": "^2.3.3", "@keyv/bigmap": "^1.3.0", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A=="], + + "@cacheable/node-cache": ["@cacheable/node-cache@1.7.6", "", { "dependencies": { "cacheable": "^2.3.1", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A=="], + + "@cacheable/utils": ["@cacheable/utils@2.3.3", "", { "dependencies": { "hashery": "^1.3.0", "keyv": "^5.5.5" } }, "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A=="], + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], "@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="], - "@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], @@ -165,26 +178,58 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], + "@hapi/boom": ["@hapi/boom@9.1.4", "", { "dependencies": { "@hapi/hoek": "9.x.x" } }, "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw=="], + + "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="], @@ -205,6 +250,10 @@ "@jscpd/tokenizer": ["@jscpd/tokenizer@4.0.1", "", { "dependencies": { "@jscpd/core": "4.0.1", "reprism": "^0.0.11", "spark-md5": "^3.0.2" } }, "sha512-l/CPeEigadYcQUsUxf1wdCBfNjyAxYcQU04KciFNmSZAMY+ykJ8fZsiuyfjb+oOuDgsIPZZ9YvbvsCr6NBXueg=="], + "@keyv/bigmap": ["@keyv/bigmap@1.3.0", "", { "dependencies": { "hashery": "^1.2.0", "hookified": "^1.13.0" }, "peerDependencies": { "keyv": "^5.5.4" } }, "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@kubernetes/client-node": ["@kubernetes/client-node@0.21.0", "", { "dependencies": { "@types/js-yaml": "^4.0.1", "@types/node": "^20.1.1", "@types/request": "^2.47.1", "@types/ws": "^8.5.3", "byline": "^5.0.0", "isomorphic-ws": "^5.0.0", "js-yaml": "^4.1.0", "jsonpath-plus": "^8.0.0", "request": "^2.88.0", "rfc4648": "^1.3.0", "stream-buffers": "^3.0.2", "tar": "^7.0.0", "tslib": "^2.4.1", "ws": "^8.11.0" }, "optionalDependencies": { "openid-client": "^5.3.0" } }, "sha512-yYRbgMeyQbvZDHt/ZqsW3m4lRefzhbbJEuj8sVXM+bufKrgmzriA2oq7lWPH/k/LQIicAME9ixPUadTrxIF6dQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.4", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw=="], @@ -361,6 +410,8 @@ "@peerbot/worker": ["@peerbot/worker@workspace:packages/worker"], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@prisma/instrumentation": ["@prisma/instrumentation@6.15.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-6TXaH6OmDkMOQvOxwLZ8XS51hU2v4A3vmE2pSijCIiGRJYyNeMcL6nMHQMyYdZRD8wl7LF3Wzc+AMPMV/9Oo7A=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -383,9 +434,7 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], - "@sentry/core": ["@sentry/core@7.114.0", "", { "dependencies": { "@sentry/types": "7.114.0", "@sentry/utils": "7.114.0" } }, "sha512-YnanVlmulkjgZiVZ9BfY9k6I082n+C+LbZo52MTvx3FY6RE5iyiPMpaOh67oXEZRWcYQEGm+bKruRxLVP6RlbA=="], - - "@sentry/integrations": ["@sentry/integrations@7.114.0", "", { "dependencies": { "@sentry/core": "7.114.0", "@sentry/types": "7.114.0", "@sentry/utils": "7.114.0", "localforage": "^1.8.1" } }, "sha512-BJIBWXGKeIH0ifd7goxOS29fBA8BkEgVVCahs6xIOXBjX1IRS6PmX0zYx/GP23nQTfhJiubv2XPzoYOlZZmDxg=="], + "@sentry/core": ["@sentry/core@10.23.0", "", {}, "sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g=="], "@sentry/node": ["@sentry/node@10.23.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-amqplib": "0.51.0", "@opentelemetry/instrumentation-connect": "0.48.0", "@opentelemetry/instrumentation-dataloader": "0.22.0", "@opentelemetry/instrumentation-express": "0.53.0", "@opentelemetry/instrumentation-fs": "0.24.0", "@opentelemetry/instrumentation-generic-pool": "0.48.0", "@opentelemetry/instrumentation-graphql": "0.52.0", "@opentelemetry/instrumentation-hapi": "0.51.0", "@opentelemetry/instrumentation-http": "0.204.0", "@opentelemetry/instrumentation-ioredis": "0.52.0", "@opentelemetry/instrumentation-kafkajs": "0.14.0", "@opentelemetry/instrumentation-knex": "0.49.0", "@opentelemetry/instrumentation-koa": "0.52.0", "@opentelemetry/instrumentation-lru-memoizer": "0.49.0", "@opentelemetry/instrumentation-mongodb": "0.57.0", "@opentelemetry/instrumentation-mongoose": "0.51.0", "@opentelemetry/instrumentation-mysql": "0.50.0", "@opentelemetry/instrumentation-mysql2": "0.51.0", "@opentelemetry/instrumentation-pg": "0.57.0", "@opentelemetry/instrumentation-redis": "0.53.0", "@opentelemetry/instrumentation-tedious": "0.23.0", "@opentelemetry/instrumentation-undici": "0.15.0", "@opentelemetry/resources": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.15.0", "@sentry/core": "10.23.0", "@sentry/node-core": "10.23.0", "@sentry/opentelemetry": "10.23.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-5PwJJ1zZ89tB8hrjTVKNE4fIGtSXlR+Mdg2u1Nm2FJ2Vj1Ac6JArLiRzMqoq/pA7vwgZMoHwviDAA+PfpJ0Agg=="], @@ -393,10 +442,6 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.23.0", "", { "dependencies": { "@sentry/core": "10.23.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0" } }, "sha512-ZbSB5y8K8YXp5+sBp2w7xHsNLv9EglJRTRqWMi2ncovXy4jcvo+pSreiZu68nSGvxX25brYKDw19vl+tnmqZVg=="], - "@sentry/types": ["@sentry/types@7.114.0", "", {}, "sha512-tsqkkyL3eJtptmPtT0m9W/bPLkU7ILY7nvwpi1hahA5jrM7ppoU0IMaQWAgTD+U3rzFH40IdXNBFb8Gnqcva4w=="], - - "@sentry/utils": ["@sentry/utils@7.114.0", "", { "dependencies": { "@sentry/types": "7.114.0" } }, "sha512-319N90McVpupQ6vws4+tfCy/03AdtsU0MurIE4+W5cubHME08HtiEWlfacvAxX+yuKFhvdsO4K4BB/dj54ideg=="], - "@slack/bolt": ["@slack/bolt@4.5.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^3.0.4", "@slack/socket-mode": "^2.0.5", "@slack/types": "^2.17.0", "@slack/web-api": "^7.11.0", "axios": "^1.12.0", "express": "^5.0.0", "path-to-regexp": "^8.1.0", "raw-body": "^3", "tsscmp": "^1.0.6" }, "peerDependencies": { "@types/express": "^5.0.0" } }, "sha512-1YbgO/UDLYa0vOtGsTohpnl/dSKwo7RbUd29IJMfqNDLn+t81MmIL0w2KPNjZJQLsoevTRNCdHDeh4PJyY8DIA=="], "@slack/logger": ["@slack/logger@4.0.0", "", { "dependencies": { "@types/node": ">=18.0.0" } }, "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA=="], @@ -409,6 +454,10 @@ "@slack/web-api": ["@slack/web-api@7.11.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/types": "^2.17.0", "@types/node": ">=18.0.0", "@types/retry": "0.12.0", "axios": "^1.11.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-m+dGluB7OTebNqEt7wRXyvUfjUGLBuqN4ZAjEmQvu7oeKmVNBXO+mQbH9nop0f/GCvkGK52aaoOWz0H1ole2xg=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], @@ -419,6 +468,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], "@types/dockerode": ["@types/dockerode@3.3.43", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-YCi0aKKpKeC9dhKTbuglvsWDnAyuIITd6CCJSTKiAdbDzPH4RWu0P9IK2XkJHdyplH6mzYtDYO+gB06JlzcPxg=="], @@ -435,6 +486,8 @@ "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -449,6 +502,8 @@ "@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="], + "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -477,6 +532,8 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@whiskeysockets/baileys": ["@whiskeysockets/baileys@7.0.0-rc.9", "", { "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", "ws": "^8.13.0" }, "peerDependencies": { "audio-decode": "^2.1.3", "jimp": "^1.6.0", "link-preview-js": "^3.0.0", "sharp": "*" }, "optionalPeers": ["audio-decode", "jimp", "link-preview-js"] }, "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -507,8 +564,12 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "aws-sign2": ["aws-sign2@0.7.0", "", {}, "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA=="], "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], @@ -553,6 +614,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cacheable": ["cacheable@2.3.1", "", { "dependencies": { "@cacheable/memory": "^2.0.6", "@cacheable/utils": "^2.3.2", "hookified": "^1.14.0", "keyv": "^5.5.5", "qified": "^0.5.3" } }, "sha512-yr+FSHWn1ZUou5LkULX/S+jhfgfnLbuKQjE40tyEd4fxGZVMbBL5ifno0J0OauykS8UiCSgHi+DV/YD+rjFxFg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -623,6 +686,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "curve25519-js": ["curve25519-js@0.0.4", "", {}, "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w=="], + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -709,6 +774,8 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="], @@ -767,8 +834,12 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hashery": ["hashery@1.4.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hookified": ["hookified@1.15.0", "", {}, "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw=="], + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], "http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="], @@ -781,8 +852,6 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - "import-in-the-middle": ["import-in-the-middle@1.14.2", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -861,13 +930,13 @@ "jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="], + "keyv": ["keyv@5.5.5", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ=="], + "knip": ["knip@5.66.3", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "js-yaml": "^4.1.0", "minimist": "^1.2.8", "oxc-resolver": "^11.8.3", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.4.1", "strip-json-comments": "5.0.2", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-BEe9ZCI8fm4TJzehnrCt+L/Faqu6qfMH6VrwSfck+lCGotQzf0jh5dVXysPWjWqMpdUSr6+MpMu9JW/G6wiAcQ=="], "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], - "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], - - "localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], + "libsignal": ["@whiskeysockets/libsignal-node@github:whiskeysockets/libsignal-node#1c30d7d", { "dependencies": { "curve25519-js": "^0.0.4", "protobufjs": "6.8.8" } }, "WhiskeySockets-libsignal-node-1c30d7d"], "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], @@ -895,7 +964,7 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], "luxon": ["luxon@3.7.1", "", {}, "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg=="], @@ -905,7 +974,7 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], @@ -949,6 +1018,8 @@ "multer": ["multer@2.0.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw=="], + "music-metadata": ["music-metadata@11.10.5", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.2.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-G0i86zpL7AARmZx8XEkHBVf7rJMQDFfGEFc1C83//rKHGuaK0gwxmNNeo9mjm4g07KUwoT0s0dW7g5QwZhi+qQ=="], + "mute-stream": ["mute-stream@1.0.0", "", {}, "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA=="], "nan": ["nan@2.23.0", "", {}, "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ=="], @@ -977,6 +1048,8 @@ "oidc-token-hash": ["oidc-token-hash@5.1.1", "", {}, "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1019,6 +1092,12 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -1029,6 +1108,8 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], @@ -1067,16 +1148,24 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qified": ["qified@0.5.3", "", { "dependencies": { "hookified": "^1.13.0" } }, "sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ=="], + + "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], + "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], @@ -1123,6 +1212,8 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1143,10 +1234,14 @@ "smol-toml": ["smol-toml@1.4.2", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="], + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], + "spark-md5": ["spark-md5@3.0.2", "", {}, "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="], "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], @@ -1173,6 +1268,8 @@ "strip-json-comments": ["strip-json-comments@5.0.2", "", {}, "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g=="], + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1185,12 +1282,16 @@ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "token-stream": ["token-stream@1.0.0", "", {}, "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="], "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], @@ -1211,6 +1312,8 @@ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -1271,24 +1374,30 @@ "@anthropic-ai/claude-agent-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-linux-arm/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-linux-arm64/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-linux-x64/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], + "@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], "@peerbot/worker/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg=="], - "@sentry/node/@sentry/core": ["@sentry/core@10.23.0", "", {}, "sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g=="], - - "@sentry/node-core/@sentry/core": ["@sentry/core@10.23.0", "", {}, "sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g=="], - - "@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.23.0", "", {}, "sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g=="], - "@slack/bolt/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "@slack/oauth/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], @@ -1303,6 +1412,8 @@ "@types/connect/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], + "@types/cors/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], + "@types/docker-modem/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], "@types/dockerode/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], @@ -1331,6 +1442,8 @@ "@types/ws/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], + "@whiskeysockets/baileys/p-queue": ["p-queue@9.1.0", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1373,14 +1486,16 @@ "jstransformer/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "libsignal/protobufjs": ["protobufjs@6.8.8", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/long": "^4.0.0", "@types/node": "^10.1.0", "long": "^4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw=="], - "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "node-sarif-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "ora/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1405,8 +1520,24 @@ "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + "sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1497,6 +1628,8 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@whiskeysockets/baileys/p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1519,6 +1652,12 @@ "inquirer/ora/log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "libsignal/protobufjs/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], + + "libsignal/protobufjs/long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], + + "openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "ora/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -1531,16 +1670,10 @@ "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@peerbot/worker/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "@peerbot/worker/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@slack/bolt/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "@slack/bolt/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "inquirer/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], diff --git a/charts/peerbot/Chart.yaml b/charts/peerbot/Chart.yaml index 527ee22..a35ad6b 100644 --- a/charts/peerbot/Chart.yaml +++ b/charts/peerbot/Chart.yaml @@ -43,3 +43,8 @@ dependencies: version: "0.4.0" repository: oci://ghcr.io/spegel-org/helm-charts condition: spegel.enabled + # Redis for message queues and state management + - name: redis + version: "~20.x" + repository: oci://registry-1.docker.io/bitnamicharts + condition: redis.enabled diff --git a/charts/peerbot/templates/gateway-deployment.yaml b/charts/peerbot/templates/gateway-deployment.yaml index 4024334..b33f9e4 100644 --- a/charts/peerbot/templates/gateway-deployment.yaml +++ b/charts/peerbot/templates/gateway-deployment.yaml @@ -16,10 +16,12 @@ spec: app.kubernetes.io/component: gateway template: metadata: - {{- with .Values.podAnnotations }} annotations: + # Auto-restart pod when secrets change + checksum/secrets: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} labels: {{- include "peerbot.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: gateway @@ -44,21 +46,42 @@ spec: - name: health containerPort: 8080 protocol: TCP + # HTTP proxy port for worker network isolation + - name: proxy + containerPort: 8118 + protocol: TCP + # Startup probe: Allow up to 60s for gateway to initialize (Redis, K8s client, etc.) + startupProbe: + httpGet: + path: /ready + port: 8080 + failureThreshold: 30 + periodSeconds: 2 + timeoutSeconds: 3 livenessProbe: httpGet: path: /health port: 8080 - initialDelaySeconds: 30 + initialDelaySeconds: 0 # Startup probe handles initial delay periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 readinessProbe: httpGet: path: /ready port: 8080 - initialDelaySeconds: 10 + initialDelaySeconds: 0 # Startup probe handles initial delay periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 2 resources: {{- toYaml .Values.gateway.resources | nindent 12 }} env: + # Port configuration + - name: PORT + value: "{{ .Values.gateway.service.targetPort }}" + + {{- if .Values.slack.enabled }} # Slack configuration - name: SLACK_BOT_TOKEN valueFrom: @@ -79,24 +102,71 @@ spec: optional: true - name: SLACK_HTTP_MODE value: {{ if .Values.slack.socketMode }}"false"{{ else }}"true"{{ end }} - - name: PORT - value: "{{ .Values.gateway.service.targetPort }}" - name: SLACK_ALLOW_DIRECT_MESSAGES value: "{{ .Values.slack.allowDirectMessages }}" - name: SLACK_ALLOW_PRIVATE_CHANNELS value: "{{ .Values.slack.allowPrivateChannels }}" - + {{- end }} + + {{- if .Values.whatsapp.enabled }} + # WhatsApp configuration + - name: WHATSAPP_ENABLED + value: "true" + - name: WHATSAPP_SELF_CHAT + value: "{{ .Values.whatsapp.selfChat }}" + - name: WHATSAPP_ALLOW_GROUPS + value: "{{ .Values.whatsapp.allowGroups }}" + - name: WHATSAPP_REQUIRE_MENTION + value: "{{ .Values.whatsapp.requireMention }}" + # WhatsApp credentials mounted as file (too large for env var) + - name: WHATSAPP_CREDENTIALS + value: "/etc/whatsapp/credentials.txt" + {{- end }} + # Kubernetes configuration + - name: DEPLOYMENT_MODE + value: "kubernetes" - name: KUBERNETES_NAMESPACE value: "{{ .Values.kubernetes.namespace }}" - - name: WORKER_IMAGE - value: "{{ .Values.global.imageRegistry }}{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag }}" + - name: WORKER_IMAGE_REPOSITORY + value: "{{ .Values.global.imageRegistry }}{{ .Values.worker.image.repository }}" + - name: WORKER_IMAGE_TAG + value: "{{ .Values.worker.image.tag }}" - name: WORKER_CPU value: "{{ .Values.worker.resources.requests.cpu }}" - name: WORKER_MEMORY value: "{{ .Values.worker.resources.requests.memory }}" - name: WORKER_TIMEOUT_SECONDS value: "{{ .Values.worker.job.timeoutSeconds }}" + - name: WORKER_RUNTIME_CLASS_NAME + value: "{{ .Values.worker.runtimeClassName }}" + # Worker network access control (via HTTP proxy) + - name: WORKER_ALLOWED_DOMAINS + value: "{{ .Values.worker.allowedDomains }}" + {{- if .Values.worker.disallowedDomains }} + - name: WORKER_DISALLOWED_DOMAINS + value: "{{ .Values.worker.disallowedDomains }}" + {{- end }} + + # Redis queue configuration + {{- if .Values.redis.enabled }} + {{- if .Values.redis.auth.enabled }} + # IMPORTANT: REDIS_PASSWORD must be defined BEFORE QUEUE_URL for $(VAR) substitution to work + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "peerbot.fullname" . }}-redis + key: redis-password + - name: QUEUE_URL + value: "redis://:$(REDIS_PASSWORD)@{{ include "peerbot.fullname" . }}-redis-master:6379" + {{- else }} + - name: QUEUE_URL + value: "redis://{{ include "peerbot.fullname" . }}-redis-master:6379" + {{- end }} + {{- end }} + # Dispatcher service name for workers to connect back + - name: DISPATCHER_SERVICE_NAME + value: "{{ include "peerbot.fullname" . }}-gateway" # Sentry configuration - name: SENTRY_DSN @@ -128,6 +198,9 @@ spec: value: "{{ .Values.gateway.config.nodeEnv }}" - name: LOG_LEVEL value: "{{ .Values.gateway.config.logLevel }}" + # Use simple console logger (Winston doesn't work with Bun on Alpine) + - name: USE_SIMPLE_LOGGER + value: "true" # Encryption key for token at-rest encryption - name: ENCRYPTION_KEY @@ -147,7 +220,27 @@ spec: - name: ANTHROPIC_BASE_URL value: "{{ .Values.gateway.anthropicProxy.baseUrl }}" {{- end }} - + volumeMounts: + # Tmpfs for tsx and other temp files (required for readOnlyRootFilesystem) + - name: tmp + mountPath: /tmp + {{- if .Values.whatsapp.enabled }} + - name: whatsapp-credentials + mountPath: /etc/whatsapp + readOnly: true + {{- end }} + volumes: + # Tmpfs for temporary files (in-memory) + - name: tmp + emptyDir: + medium: Memory + sizeLimit: "256Mi" + {{- if .Values.whatsapp.enabled }} + - name: whatsapp-credentials + secret: + secretName: {{ include "peerbot.fullname" . }}-whatsapp + optional: true + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/peerbot/templates/network-policy.yaml b/charts/peerbot/templates/network-policy.yaml index 321f665..c97f0f1 100644 --- a/charts/peerbot/templates/network-policy.yaml +++ b/charts/peerbot/templates/network-policy.yaml @@ -40,34 +40,67 @@ spec: # Egress rules - restrict outbound traffic egress: - # Allow DNS resolution - - to: [] + # Allow DNS resolution (cluster DNS only) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 - - # Allow HTTPS to external APIs (Slack, GitHub, Claude) - - to: [] + + # Allow communication within the peerbot namespace (Redis, workers) + - to: + - podSelector: {} # All pods in same namespace + ports: + - protocol: TCP + port: 6379 # Redis + - protocol: TCP + port: 8080 # Worker API + - protocol: TCP + port: 8118 # Proxy (if workers call back) + + # Allow HTTPS to external APIs (Slack, Anthropic, GitHub, WhatsApp) + # Note: This is still permissive; for stricter security, use Istio/Cilium with FQDN policies + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 # Block private ranges + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.0.0/16 # Link-local ports: - protocol: TCP port: 443 - - # Allow HTTP for package downloads and redirects - - to: [] + - protocol: TCP + port: 5222 # WhatsApp XMPP (legacy) + - protocol: TCP + port: 5228 # WhatsApp push notifications + - protocol: TCP + port: 5242 # WhatsApp additional port + - protocol: TCP + port: 5223 # WhatsApp additional port + + # Allow HTTP for OAuth redirects (limited use) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.0.0/16 ports: - protocol: TCP port: 80 - - # Allow communication within the namespace - - to: - - namespaceSelector: - matchLabels: - name: {{ .Values.kubernetes.namespace }} - + # Allow communication to Kubernetes API server - - to: [] + - to: + - ipBlock: + cidr: 0.0.0.0/0 # K8s API may be external ports: - protocol: TCP port: 6443 @@ -75,7 +108,8 @@ spec: port: 443 --- -# Separate policy for worker pods with more restrictive rules +# Separate policy for worker pods - workers MUST use gateway HTTP proxy for all external traffic +# This enforces domain-based filtering via WORKER_ALLOWED_DOMAINS/WORKER_DISALLOWED_DOMAINS apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -86,35 +120,46 @@ metadata: spec: podSelector: matchLabels: - app: claude-worker + app.kubernetes.io/component: worker policyTypes: - Ingress - Egress - + # Workers should not accept any ingress traffic ingress: [] - - # Workers can only make specific egress connections + + # Workers can ONLY communicate with gateway (all external traffic via HTTP proxy) egress: - # Allow DNS resolution + # Allow DNS resolution (required for K8s service discovery) - to: [] ports: - protocol: UDP port: 53 - protocol: TCP port: 53 - - # Allow HTTPS to external APIs - - to: [] + + # Allow traffic to gateway only (HTTP proxy on 8118, API on 8080) + # Workers use HTTP_PROXY=http://gateway:8118 for all external requests + - to: + - podSelector: + matchLabels: + app.kubernetes.io/component: gateway ports: - protocol: TCP - port: 443 - - # Allow HTTP for package downloads - - to: [] + port: 8118 # HTTP proxy for external traffic (domain filtering) + - protocol: TCP + port: 8080 # Gateway API (SSE, file uploads, health) + - protocol: TCP + port: 3000 # Slack events (if needed) + + # Allow traffic to Redis for message queues (Bitnami Redis subchart labels) + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: redis ports: - protocol: TCP - port: 80 + port: 6379 --- # Deny-all default policy (optional, uncomment if you want default deny) diff --git a/charts/peerbot/templates/rbac.yaml b/charts/peerbot/templates/rbac.yaml index 0d33af9..b5889ff 100644 --- a/charts/peerbot/templates/rbac.yaml +++ b/charts/peerbot/templates/rbac.yaml @@ -44,6 +44,11 @@ rules: resources: ["configmaps", "secrets"] verbs: ["get", "list"] + # PersistentVolumeClaims for worker storage + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["create", "get", "list", "delete"] + --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/charts/peerbot/templates/secrets.yaml b/charts/peerbot/templates/secrets.yaml index 2c08426..6522d56 100644 --- a/charts/peerbot/templates/secrets.yaml +++ b/charts/peerbot/templates/secrets.yaml @@ -1,3 +1,6 @@ +{{- if not .Values.sealedSecrets.enabled }} +# Regular K8s Secret - for development or when not using Sealed Secrets +# For production, set sealedSecrets.enabled=true and use encrypted secrets apiVersion: v1 kind: Secret metadata: @@ -12,21 +15,21 @@ data: # REQUIRED: Set via Helm values or external secret management # slack-bot-token: "" {{- end }} - + {{- if .Values.secrets.slackSigningSecret }} slack-signing-secret: {{ .Values.secrets.slackSigningSecret | b64enc }} {{- else }} # OPTIONAL: For webhook verification (Socket Mode doesn't require this) # slack-signing-secret: "" {{- end }} - + {{- if .Values.secrets.slackAppToken }} slack-app-token: {{ .Values.secrets.slackAppToken | b64enc }} {{- else }} # REQUIRED for Socket Mode: Set via Helm values or external secret management # slack-app-token: "" {{- end }} - + {{- if .Values.secrets.githubToken }} github-token: {{ .Values.secrets.githubToken | b64enc }} {{- else }} @@ -41,7 +44,7 @@ data: # OPTIONAL: Set if enabling GitHub OAuth login # github-client-secret: "" {{- end }} - + {{- if .Values.secrets.claudeCodeOAuthToken }} claude-code-oauth-token: {{ .Values.secrets.claudeCodeOAuthToken | b64enc }} {{- else }} @@ -63,50 +66,18 @@ data: # REQUIRED if using GitHub OAuth token storage encryption # encryption-key: "" {{- end }} +{{- end }} -{{- if not .Values.secrets.slackBotToken }} - +{{- if and .Values.whatsapp.enabled .Values.secrets.whatsappCredentials }} --- -# Example of using external secret management with External Secrets Operator -# Uncomment and modify as needed for your environment -# -# apiVersion: external-secrets.io/v1beta1 -# kind: SecretStore -# metadata: -# name: {{ include "peerbot.fullname" . }}-secret-store -# labels: -# # Add labels here -# spec: -# provider: -# gcpsm: -# projectId: "your-project-id" -# auth: -# workloadIdentity: -# clusterLocation: "your-cluster-location" -# clusterName: "your-cluster-name" -# serviceAccountRef: -# name: {{ include "peerbot.serviceAccountName" . }} -# -# --- -# apiVersion: external-secrets.io/v1beta1 -# kind: ExternalSecret -# metadata: -# name: {{ include "peerbot.fullname" . }}-external-secrets -# labels: -# # Add labels here -# spec: -# refreshInterval: 5m -# secretStoreRef: -# name: {{ include "peerbot.fullname" . }}-secret-store -# kind: SecretStore -# target: -# name: {{ include "peerbot.fullname" . }}-secrets -# creationPolicy: Owner -# data: -# - secretKey: slack-bot-token -# remoteRef: -# key: peerbot-slack-bot-token -# - secretKey: github-token -# remoteRef: -# key: peerbot-github-token +# WhatsApp credentials secret (mounted as file due to size) +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "peerbot.fullname" . }}-whatsapp + labels: + {{- include "peerbot.labels" . | nindent 4 }} +type: Opaque +stringData: + credentials.txt: {{ .Values.secrets.whatsappCredentials | quote }} {{- end }} diff --git a/charts/peerbot/templates/service.yaml b/charts/peerbot/templates/service.yaml index 674abe6..07cac9e 100644 --- a/charts/peerbot/templates/service.yaml +++ b/charts/peerbot/templates/service.yaml @@ -18,6 +18,11 @@ spec: targetPort: 8080 protocol: TCP name: health-proxy + # HTTP proxy port for worker network isolation (domain filtering) + - port: 8118 + targetPort: 8118 + protocol: TCP + name: http-proxy selector: {{- include "peerbot.selectorLabels" . | nindent 4 }} app.kubernetes.io/component: gateway \ No newline at end of file diff --git a/charts/peerbot/templates/worker-pvc.yaml b/charts/peerbot/templates/worker-pvc.yaml index 7c77bac..950a7b4 100644 --- a/charts/peerbot/templates/worker-pvc.yaml +++ b/charts/peerbot/templates/worker-pvc.yaml @@ -1,18 +1,30 @@ -{{- if .Values.worker.persistence.enabled }} +{{/* +NOTE: This static PVC template is NOT used by the current architecture. +Workers are created dynamically by the K8s deployment manager (k8s-deployment.ts), +which creates per-deployment PVCs with names like "{deploymentName}-pvc". + +This template is kept for reference but disabled. +If you need shared storage across all workers, enable this and mount it manually. + +To enable: + 1. Set worker.staticPvc.enabled: true in values.yaml + 2. Mount the PVC in the worker deployment spec +*/}} +{{- if and .Values.worker.staticPvc .Values.worker.staticPvc.enabled }} apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: {{ include "peerbot.fullname" . }}-worker-pvc + name: {{ include "peerbot.fullname" . }}-worker-shared-pvc labels: {{- include "peerbot.labels" . | nindent 4 }} - app.kubernetes.io/component: worker + app.kubernetes.io/component: worker-shared-storage spec: accessModes: - - ReadWriteOnce + - ReadWriteMany # RWX for shared access across workers resources: requests: - storage: {{ .Values.worker.persistence.size }} - {{- if .Values.worker.persistence.storageClass }} - storageClassName: {{ .Values.worker.persistence.storageClass }} + storage: {{ .Values.worker.staticPvc.size | default "10Gi" }} + {{- if .Values.worker.staticPvc.storageClass }} + storageClassName: {{ .Values.worker.staticPvc.storageClass }} {{- end }} {{- end }} \ No newline at end of file diff --git a/charts/peerbot/templates/worker-rbac.yaml b/charts/peerbot/templates/worker-rbac.yaml index 22aa010..03fe7d2 100644 --- a/charts/peerbot/templates/worker-rbac.yaml +++ b/charts/peerbot/templates/worker-rbac.yaml @@ -8,40 +8,46 @@ metadata: app.kubernetes.io/component: worker --- +# SECURITY: Use namespace-scoped Role instead of ClusterRole +# Workers should ONLY have permissions within the peerbot namespace apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: Role metadata: name: {{ include "peerbot.fullname" . }}-worker + namespace: {{ .Values.kubernetes.namespace }} labels: {{- include "peerbot.labels" . | nindent 4 }} app.kubernetes.io/component: worker rules: -# Minimal permissions for worker pods +# Minimal permissions for worker pods (read-only) - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"] +# Read-only access to configmaps (no secrets access for workers) - apiGroups: [""] - resources: ["configmaps", "secrets"] - verbs: ["get", "list"] -# Additional permissions for worker cleanup cronjob + resources: ["configmaps"] + verbs: ["get"] +# Self-cleanup: workers can delete their own deployment/PVC on shutdown +# Note: This is namespace-scoped, so workers cannot affect other namespaces - apiGroups: ["apps"] resources: ["deployments"] - verbs: ["get", "list", "delete", "patch"] + verbs: ["get", "delete"] - apiGroups: [""] resources: ["persistentvolumeclaims"] - verbs: ["get", "list", "delete"] + verbs: ["get", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: name: {{ include "peerbot.fullname" . }}-worker + namespace: {{ .Values.kubernetes.namespace }} labels: {{- include "peerbot.labels" . | nindent 4 }} app.kubernetes.io/component: worker roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: {{ include "peerbot.fullname" . }}-worker subjects: - kind: ServiceAccount diff --git a/charts/peerbot/values.yaml b/charts/peerbot/values.yaml index b1c366c..27e4c14 100644 --- a/charts/peerbot/values.yaml +++ b/charts/peerbot/values.yaml @@ -4,15 +4,28 @@ # Global configuration global: imagePullSecrets: [] + imageRegistry: "" # Optional: Prefix for all images (e.g., "myregistry.io/") secrets: - slackBotToken: "placeholder" - slackSigningSecret: "placeholder" - slackAppToken: "placeholder" - githubToken: "placeholder" - githubClientSecret: "" # Optional: GitHub OAuth Client Secret - encryptionKey: "" # Optional: 32-char key for AES-GCM at-rest encryption - claudeCodeOAuthToken: "placeholder" + slackBotToken: "" # Optional: Slack bot token (if slack.enabled) + slackSigningSecret: "" # Optional: Slack signing secret + slackAppToken: "" # Optional: Slack app token for Socket Mode + githubToken: "" # Optional: GitHub token + githubClientSecret: "" # Optional: GitHub OAuth Client Secret + encryptionKey: "" # Optional: 32-char key for AES-GCM at-rest encryption + claudeCodeOAuthToken: "" # Required: Claude Code OAuth token + whatsappCredentials: "" # Optional: Base64-encoded WhatsApp credentials JSON + +# Bitnami Sealed Secrets - for production use with Git-stored encrypted secrets +# See templates/sealed-secrets.yaml for setup instructions +sealedSecrets: + enabled: false # Set to true to use SealedSecrets instead of plain secrets + # Encrypted data from kubeseal output (copy the encryptedData section) + encryptedData: {} + # slack-bot-token: "AgBx..." (encrypted value from kubeseal) + # slack-app-token: "AgBy..." + # claude-code-oauth-token: "AgBz..." + # github-token: "AgBw..." config: githubOrganization: "peerbot-community" @@ -44,7 +57,8 @@ securityContext: runAsNonRoot: true runAsUser: 1001 runAsGroup: 1001 - readOnlyRootFilesystem: false # Claude needs write access + readOnlyRootFilesystem: true # SECURITY: Enable for gateway (only workers need write access) + allowPrivilegeEscalation: false # Pod Disruption Budget podDisruptionBudget: @@ -54,11 +68,24 @@ podDisruptionBudget: autoscaling: enabled: false -# Network Policy +# Network Policy - enabled by default for worker network isolation networkPolicy: - enabled: false + enabled: true defaultDeny: false +# OPA Gatekeeper - Policy enforcement at admission time +# Prerequisites: Install Gatekeeper controller first +# kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/deploy/gatekeeper.yaml +gatekeeper: + enabled: false # Set to true after installing Gatekeeper controller + enforceConstraints: false # Set to true to enforce policies (vs audit-only) + # Allowed container registries (used by K8sAllowedRegistries constraint) + allowedRegistries: + - "docker.io" + - "ghcr.io" + - "buremba/" # Your Docker Hub images + - "registry-1.docker.io/bitnamicharts" # Bitnami charts + # Ingress ingress: enabled: false @@ -68,10 +95,19 @@ limitRange: enabled: false slack: + enabled: false # Disabled by default, enable with credentials socketMode: true allowDirectMessages: true allowPrivateChannels: true +# WhatsApp configuration +whatsapp: + enabled: true + selfChat: true # Allow self-chat mode for testing + allowGroups: true + requireMention: true + # Credentials are stored in secrets.whatsappCredentials + claude: model: "claude-sonnet-4-20250514" timeoutMinutes: "5" # Default timeout @@ -99,6 +135,12 @@ worker: timeoutSeconds: 300 backoffLimit: 0 ttlSecondsAfterFinished: 300 + # Network access control for workers (via HTTP proxy) + # - Empty/unset: Complete isolation (no internet) + # - "*": Unrestricted access + # - "domain1,domain2": Allowlist mode + allowedDomains: "api.anthropic.com,statsig.anthropic.com,api.github.com,github.com,registry.npmjs.org" + disallowedDomains: "" # Blocklist (only used with "*" allowlist) env: USE_CLAUDE_RESUME: "true" CLAUDE_CODE_ENABLE_UNIFIED_READ_TOOL: "1" @@ -112,6 +154,7 @@ worker: # Development settings gateway: enabled: true + replicaCount: 1 image: repository: buremba/peerbot-gateway pullPolicy: Always @@ -188,3 +231,42 @@ spegel: requests: cpu: "100m" memory: "128Mi" + +# Prometheus metrics and monitoring +metrics: + enabled: true # Enable ServiceMonitor for Prometheus/Grafana + serviceMonitor: + interval: "30s" + scrapeTimeout: "10s" + labels: {} # Additional labels for ServiceMonitor (e.g., for Prometheus Operator discovery) + honorLabels: false + metricRelabelings: [] + relabelings: [] + prometheusRule: + enabled: false # Enable PrometheusRule for alerting + labels: {} + +# Redis for message queues and state management (Bitnami subchart) +redis: + enabled: true + # Use standalone mode (no replication for simplicity) + architecture: standalone + auth: + enabled: true # SECURITY: Enable auth for production + # Password is auto-generated by Bitnami chart if not specified + # Set redis.auth.password in your values override for production + master: + persistence: + enabled: true + size: 1Gi + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" + # Label for network policy + podLabels: + app.kubernetes.io/component: redis + # Connection URL used by gateway: redis://peerbot-redis-master:6379 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 9e58d2c..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,93 +0,0 @@ -version: '3.8' - -services: - # Build worker image that orchestrator will use - worker: - build: - context: . - dockerfile: Dockerfile.worker - args: - NODE_ENV: development - image: peerbot-worker:latest - # This service only builds the image, doesn't run continuously - command: echo "Worker image built successfully" - profiles: - - build-only - - gateway: - build: - context: . - dockerfile: Dockerfile.gateway - restart: unless-stopped # Auto-restart on failure, but not if manually stopped - command: bun --watch packages/gateway/src/index.ts - environment: - NODE_ENV: development - AGENT_DEFAULT_MODEL: ${AGENT_DEFAULT_MODEL} - QUEUE_URL: redis://redis:6379/0 - SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} - SLACK_APP_TOKEN: ${SLACK_APP_TOKEN} - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} - CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} - DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-docker} - ENCRYPTION_KEY: ${ENCRYPTION_KEY} - PEERBOT_MCP_SERVERS_URL: ${PEERBOT_MCP_SERVERS_URL:-examples/mcp/oauth.json} - PUBLIC_GATEWAY_URL: ${PUBLIC_GATEWAY_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - TEST_USER_ID: ${TEST_USER_ID} - # Development mode: Host project path for mounting into worker containers - PEERBOT_DEV_PROJECT_PATH: ${PWD} - # Optional: Additional volume mounts for worker containers (semicolon-separated) - # Supports placeholders: ${PWD} (project root), ${WORKSPACE_DIR} (thread workspace) - # Example: ${PWD}/examples/startup-builder:/workspace/startup-builder:ro - WORKER_VOLUME_MOUNTS: "${PWD}/examples/startup-builder:/workspace/startup-builder:ro" - # Security: Disable read-only rootfs for development (enabled by default in production) - WORKER_READONLY_ROOTFS: "false" - # Additional allowed domains for worker internet access (comma-separated) - WORKER_ALLOWED_DOMAINS: ${WORKER_ALLOWED_DOMAINS:-} - volumes: - # Mount Docker socket for Docker mode - - /var/run/docker.sock:/var/run/docker.sock - # Persistent env storage - - env_storage:/app/.peerbot/env - # Mount MCP config for live editing - - ./examples:/app/examples:ro - # Hot reload: Mount source code for gateway, core, and github - - ./packages/gateway/src:/app/packages/gateway/src:ro - - ./packages/core/src:/app/packages/core/src:ro - - ./packages/core/dist:/app/packages/core/dist:ro - - ./packages/github/src:/app/packages/github/src:ro - - ./packages/github/dist:/app/packages/github/dist:ro - - ./tsconfig.json:/app/tsconfig.json:ro - ports: - - "3000:3000" - - "8080:8080" # OAuth and health endpoints - - "8118:8118" # HTTP proxy for workers - networks: - - peerbot-public # Can reach internet - - peerbot-internal # Can reach workers and redis - depends_on: - - redis - - redis: - image: redis:7-alpine - command: redis-server --appendonly yes - volumes: - - redis_data:/data - ports: - - "6379:6379" - networks: - - peerbot-internal # Only accessible from internal network - -networks: - # Public network - gateway can reach internet - peerbot-public: - driver: bridge - - # Internal network - workers cannot reach internet directly - peerbot-internal: - internal: true - driver: bridge - -volumes: - redis_data: - env_storage: diff --git a/package.json b/package.json index bd87b6a..f8c0f49 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@biomejs/biome": "^2.2.2", "@types/bun": "1.2.11", "@types/node": "^20.0.0", + "@types/qrcode-terminal": "^0.12.2", "husky": "^9.1.7", "jscpd": "^4.0.5", "knip": "^5.66.3", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index cc39782..65d0e5c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -901,9 +901,8 @@ services: context: . dockerfile: ${dockerfilePath} image: ${workerImage} - command: echo "Worker image built successfully" - profiles: - - build-only + command: echo "Worker image built successfully - this service only builds, does not run" + restart: "no" networks: # Public network with internet access (gateway only) diff --git a/packages/cli/src/templates/README.md.tmpl b/packages/cli/src/templates/README.md.tmpl index 3a534d8..00b9500 100644 --- a/packages/cli/src/templates/README.md.tmpl +++ b/packages/cli/src/templates/README.md.tmpl @@ -7,7 +7,7 @@ Peerbot instance created with `@peerbot/cli` v{{CLI_VERSION}} Make sure you have Docker CLI installed. ```bash -# Start the services +# Start the services (automatically builds worker image on first run) docker compose up -d # View logs @@ -17,6 +17,32 @@ docker compose logs -f docker compose down ``` +**Note**: The first startup will take a few minutes to build the worker image. Subsequent starts will be much faster. + +## Upgrading to Latest Version + +### Update Gateway and Pre-built Images +Pull the latest images and restart services: +```bash +docker compose pull +docker compose up -d +``` + +### Rebuild Worker (when Dockerfile.worker changes) +If you modified your `Dockerfile.worker` or want to use the latest base image: +```bash +docker compose build worker +docker compose restart gateway +``` + +### Update Environment Variables +Check for new environment variables in release notes or the [Peerbot documentation](https://github.com/buremba/peerbot): +1. Compare your `.env` with the latest `.env.example` from the repository +2. Add any new required variables +3. Restart services: `docker compose restart` + +**Tip**: Always use `latest` tags to ensure gateway and worker versions stay in sync. + ## Configuration ### Environment Variables diff --git a/packages/core/package.json b/packages/core/package.json index 69e12c1..d7994ce 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,7 +11,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@sentry/integrations": "^7.114.0", "@sentry/node": "^10.23.0", "ioredis": "^5.4.1", "winston": "^3.17.0" diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 497080c..eedc6ea 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -77,10 +77,26 @@ export class WorkspaceError extends OperationError { } /** - * Error class for Slack-related operations + * Error class for platform-related operations (Slack, WhatsApp, etc.) */ -export class SlackError extends OperationError { - override readonly name = "SlackError"; +export class PlatformError extends OperationError { + override readonly name = "PlatformError"; + + constructor( + public platform: string, + operation: string, + message: string, + cause?: Error + ) { + super(operation, message, cause); + } + + override toJSON(): Record { + return { + ...super.toJSON(), + platform: this.platform, + }; + } } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc6b0ac..6cb6f5f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,7 +16,7 @@ export * from "./modules"; // Redis & worker helpers export * from "./redis/base-store"; // Observability -export { initSentry, getSentry } from "./sentry"; +export { getSentry, initSentry } from "./sentry"; // Core types export type { AgentOptions, diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index f3b0808..eabe1ee 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -1,3 +1,7 @@ +// Detect if we're in an environment where Winston Console transport doesn't work +// Must be declared before any imports to avoid circular dependency issues +const USE_SIMPLE_LOGGER = process.env.USE_SIMPLE_LOGGER === "true"; + import winston from "winston"; import { getSentry } from "./sentry"; @@ -8,6 +12,88 @@ export interface Logger { debug: (message: any, ...args: any[]) => void; } +// Simple console logger fallback for environments where Winston doesn't work (Bun + Alpine) +// Supports both formats: logger.info("message", data) AND pino-style logger.info({ data }, "message") +function createConsoleLogger(serviceName: string): Logger { + const level = process.env.LOG_LEVEL || "info"; + const levels: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, + }; + const currentLevel = levels[level] ?? 2; + + const formatMessage = (lvl: string, message: any, ...args: any[]): string => { + const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19); + let msgStr: string; + let meta: any = null; + + // Handle pino-style format: logger.info({ key: value }, "message") + if ( + typeof message === "object" && + message !== null && + !Array.isArray(message) && + !(message instanceof Error) + ) { + if (args.length > 0 && typeof args[0] === "string") { + // First arg is metadata object, second arg is the actual message + msgStr = args[0]; + meta = message; + args = args.slice(1); + } else { + // Just an object, stringify it + try { + msgStr = JSON.stringify(message); + } catch { + msgStr = "[object]"; + } + } + } else { + msgStr = String(message); + } + + // Append remaining args + if (args.length > 0) { + try { + msgStr += ` ${JSON.stringify(args.length === 1 ? args[0] : args)}`; + } catch { + msgStr += " [unserializable]"; + } + } + + // Append metadata object + if (meta) { + try { + msgStr += ` ${JSON.stringify(meta)}`; + } catch { + msgStr += " [meta unserializable]"; + } + } + + return `[${timestamp}] [${lvl}] [${serviceName}] ${msgStr}`; + }; + + return { + error: (message: any, ...args: any[]) => { + if (currentLevel >= 0) + console.error(formatMessage("error", message, ...args)); + }, + warn: (message: any, ...args: any[]) => { + if (currentLevel >= 1) + console.warn(formatMessage("warn", message, ...args)); + }, + info: (message: any, ...args: any[]) => { + if (currentLevel >= 2) + console.log(formatMessage("info", message, ...args)); + }, + debug: (message: any, ...args: any[]) => { + if (currentLevel >= 3) + console.log(formatMessage("debug", message, ...args)); + }, + }; +} + /** * Custom Winston transport that sends errors to Sentry */ @@ -65,9 +151,14 @@ class SentryTransport extends winston.transports.Stream { * Creates a logger instance for a specific service * Provides consistent logging format across all packages with level and timestamp * @param serviceName The name of the service using the logger - * @returns A winston logger instance + * @returns A winston logger instance (or simple console logger if USE_SIMPLE_LOGGER=true) */ export function createLogger(serviceName: string): Logger { + // Use simple console logger if Winston doesn't work in this environment + if (USE_SIMPLE_LOGGER) { + return createConsoleLogger(serviceName); + } + const isProduction = process.env.NODE_ENV === "production"; const level = process.env.LOG_LEVEL || "info"; @@ -115,11 +206,6 @@ export function createLogger(serviceName: string): Logger { }), ]; - // Add Sentry transport in production or if SENTRY_DSN is set - if (isProduction || process.env.SENTRY_DSN) { - transports.push(new SentryTransport()); - } - const logger = winston.createLogger({ level, format: winston.format.combine( @@ -131,6 +217,20 @@ export function createLogger(serviceName: string): Logger { transports, }); + // Add Sentry transport in production or if SENTRY_DSN is set + // Deferred to avoid circular dependency with sentry.ts + // The check is inside setImmediate to ensure SentryTransport class is fully initialized + setImmediate(() => { + if (isProduction || process.env.SENTRY_DSN) { + try { + const transport = new SentryTransport(); + logger.add(transport); + } catch { + // Ignore errors during Sentry transport setup + } + } + }); + return logger; } diff --git a/packages/core/src/modules.ts b/packages/core/src/modules.ts index 5dde5d0..b82078a 100644 --- a/packages/core/src/modules.ts +++ b/packages/core/src/modules.ts @@ -48,6 +48,7 @@ export interface OrchestratorModule /** Build environment variables for worker container */ buildEnvVars( userId: string, + spaceId: string, baseEnv: Record ): Promise>; @@ -59,7 +60,8 @@ export interface DispatcherContext { userId: string; channelId: string; threadTs: string; - slackClient?: any; + /** Platform-specific client (e.g., Slack WebClient, WhatsApp BaileysClient) */ + platformClient?: unknown; moduleData: TModuleData; } @@ -74,6 +76,7 @@ export interface DispatcherModule handleAction( actionId: string, userId: string, + spaceId: string, context: any ): Promise; @@ -155,6 +158,7 @@ export abstract class BaseModule async buildEnvVars( _userId: string, + _spaceId: string, baseEnv: Record ): Promise> { // Default: pass through unchanged @@ -176,6 +180,7 @@ export abstract class BaseModule async handleAction( _actionId: string, _userId: string, + _spaceId: string, _context: any ): Promise { // Default: not handled diff --git a/packages/core/src/sentry.ts b/packages/core/src/sentry.ts index 7253dc5..a168328 100644 --- a/packages/core/src/sentry.ts +++ b/packages/core/src/sentry.ts @@ -1,6 +1,13 @@ -import { createLogger } from "./logger"; +import { createLogger, type Logger } from "./logger"; -const logger = createLogger("shared"); +// Lazy logger initialization to avoid circular dependency +let _logger: Logger | null = null; +function getLogger(): Logger { + if (!_logger) { + _logger = createLogger("sentry"); + } + return _logger; +} let sentryInstance: typeof import("@sentry/node") | null = null; @@ -29,9 +36,9 @@ export async function initSentry() { ], }); - logger.info("โœ… Sentry monitoring initialized"); + getLogger().info("โœ… Sentry monitoring initialized"); } catch (error) { - logger.warn( + getLogger().warn( "โš ๏ธ Sentry initialization failed (continuing without monitoring):", error ); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ea53d83..fe23c6d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -50,6 +50,7 @@ export type LogLevel = "debug" | "info" | "warn" | "error"; */ export interface InstructionContext { userId: string; + spaceId: string; sessionKey: string; workingDirectory: string; availableProjects?: string[]; diff --git a/packages/core/src/worker/auth.ts b/packages/core/src/worker/auth.ts index 1c29a7f..9ee4970 100644 --- a/packages/core/src/worker/auth.ts +++ b/packages/core/src/worker/auth.ts @@ -13,6 +13,7 @@ export interface WorkerTokenData { threadId: string; channelId: string; teamId?: string; // Optional - not all platforms have teams + spaceId?: string; // Space ID for multi-tenant isolation deploymentName: string; timestamp: number; platform?: string; @@ -29,6 +30,7 @@ export function generateWorkerToken( options: { channelId: string; teamId?: string; + spaceId?: string; platform?: string; sessionKey?: string; } @@ -44,6 +46,7 @@ export function generateWorkerToken( threadId, channelId: options.channelId, teamId: options.teamId, // Can be undefined - that's ok + spaceId: options.spaceId, // Space ID for multi-tenant credential lookup deploymentName, timestamp, platform: options.platform, diff --git a/packages/gateway/package.json b/packages/gateway/package.json index baeefae..b5c48d2 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -18,6 +18,8 @@ "@peerbot/core": "workspace:*", "@sentry/node": "^10.19.0", "@slack/bolt": "^4.5.0", + "@whiskeysockets/baileys": "^7.0.0-rc.9", + "qrcode-terminal": "^0.12.0", "@slack/types": "^2.17.0", "@slack/web-api": "^7.11.0", "@types/multer": "^2.0.0", @@ -31,6 +33,7 @@ "marked": "^12.0.0", "multer": "^2.0.2", "node-fetch": "^3.3.2", + "pino": "^9.1.0", "zod": "^4.1.12" }, "devDependencies": { diff --git a/packages/gateway/src/auth/claude/credential-store.ts b/packages/gateway/src/auth/claude/credential-store.ts index 44cc767..353a2ea 100644 --- a/packages/gateway/src/auth/claude/credential-store.ts +++ b/packages/gateway/src/auth/claude/credential-store.ts @@ -11,7 +11,7 @@ export interface ClaudeCredentials { /** * Store and retrieve Claude OAuth credentials from Redis - * Pattern: claude:credential:{userId} + * Pattern: claude:credential:{spaceId} */ export class ClaudeCredentialStore extends BaseCredentialStore { constructor(redis: Redis) { @@ -23,50 +23,50 @@ export class ClaudeCredentialStore extends BaseCredentialStore { - const key = this.buildKey(userId); + const key = this.buildKey(spaceId); await this.set(key, credentials); - this.logger.info(`Stored Claude credentials for user ${userId}`, { + this.logger.info(`Stored Claude credentials for space ${spaceId}`, { expiresAt: new Date(credentials.expiresAt).toISOString(), scopes: credentials.scopes, }); } /** - * Get Claude credentials for a user + * Get Claude credentials for a space * Returns null if not found or if credentials are missing required fields */ - async getCredentials(userId: string): Promise { - const key = this.buildKey(userId); + async getCredentials(spaceId: string): Promise { + const key = this.buildKey(spaceId); const credentials = await this.get(key); if (!credentials) { - this.logger.debug(`No Claude credentials found for user ${userId}`); + this.logger.debug(`No Claude credentials found for space ${spaceId}`); } return credentials; } /** - * Delete Claude credentials for a user + * Delete Claude credentials for a space */ - async deleteCredentials(userId: string): Promise { - const key = this.buildKey(userId); + async deleteCredentials(spaceId: string): Promise { + const key = this.buildKey(spaceId); await this.delete(key); - this.logger.info(`Deleted Claude credentials for user ${userId}`); + this.logger.info(`Deleted Claude credentials for space ${spaceId}`); } /** - * Check if user has Claude credentials + * Check if space has Claude credentials */ - async hasCredentials(userId: string): Promise { - const key = this.buildKey(userId); + async hasCredentials(spaceId: string): Promise { + const key = this.buildKey(spaceId); return this.exists(key); } } diff --git a/packages/gateway/src/auth/claude/oauth-module.ts b/packages/gateway/src/auth/claude/oauth-module.ts index e711777..64c6855 100644 --- a/packages/gateway/src/auth/claude/oauth-module.ts +++ b/packages/gateway/src/auth/claude/oauth-module.ts @@ -43,25 +43,26 @@ export class ClaudeOAuthModule extends BaseModule { /** * Build environment variables for worker deployment - * Injects user's Claude OAuth token and model preference if available + * Injects space's Claude OAuth token and user's model preference if available */ async buildEnvVars( userId: string, + spaceId: string, envVars: Record ): Promise> { - // Try to get user's credentials - const credentials = await this.credentialStore.getCredentials(userId); + // Try to get space's credentials + const credentials = await this.credentialStore.getCredentials(spaceId); if (credentials) { - // User has OAuth credentials - use their token - logger.info(`Injecting user OAuth token for ${userId}`); + // Space has OAuth credentials - use their token + logger.info(`Injecting OAuth token for space ${spaceId}`); envVars.CLAUDE_CODE_OAUTH_TOKEN = credentials.accessToken; } else { - logger.debug(`No user credentials for ${userId}, using system token`); + logger.debug(`No credentials for space ${spaceId}, using system token`); // System token (if any) will already be in envVars from base deployment } - // Inject user's model preference if set + // Inject user's model preference if set (still user-scoped) const modelPreference = await this.modelPreferenceStore.getModelPreference(userId); if (modelPreference) { @@ -76,27 +77,33 @@ export class ClaudeOAuthModule extends BaseModule { /** * Validate and decode the secure token generated for OAuth init links - * Returns the userId if valid, null otherwise + * Returns the userId and spaceId if valid, null otherwise */ - private validateSecureToken(token: string): string | null { + private validateSecureToken( + token: string + ): { userId: string; spaceId: string } | null { try { const decrypted = decrypt(token); const data = JSON.parse(decrypted) as { userId?: string; + spaceId?: string; expiresAt?: number; }; - if (!data.userId || !data.expiresAt) { + if (!data.userId || !data.spaceId || !data.expiresAt) { logger.warn("Token missing required fields"); return null; } if (Date.now() > data.expiresAt) { - logger.warn("Token expired", { userId: data.userId }); + logger.warn("Token expired", { + userId: data.userId, + spaceId: data.spaceId, + }); return null; } - return data.userId; + return { userId: data.userId, spaceId: data.spaceId }; } catch (error) { logger.error("Failed to validate secure token", { error }); return null; @@ -129,7 +136,10 @@ export class ClaudeOAuthModule extends BaseModule { * Get platform-agnostic authentication status for Claude * Returns abstract provider data that can be rendered by any platform adapter */ - async getAuthStatus(userId: string): Promise< + async getAuthStatus( + userId: string, + spaceId: string + ): Promise< Array<{ id: string; name: string; @@ -140,17 +150,36 @@ export class ClaudeOAuthModule extends BaseModule { }> > { try { - const hasCredentials = await this.credentialStore.hasCredentials(userId); + const hasCredentials = await this.credentialStore.hasCredentials(spaceId); const availableModels = await this.modelPreferenceStore.getAvailableModels(); const currentModel = await this.modelPreferenceStore.getModelPreference(userId); + const isAuthenticated = hasCredentials || this.systemTokenAvailable; + + // Only show login/logout if no system token (users manage their own auth) + let loginUrl: string | undefined; + let logoutUrl: string | undefined; + + if (!this.systemTokenAvailable) { + if (!hasCredentials) { + // Not authenticated - provide login action + // We use action_id pattern for Slack button actions + loginUrl = "action:claude_auth_start"; + } else { + // Authenticated - provide logout action + logoutUrl = "action:claude_logout"; + } + } + return [ { id: "claude", name: "Claude AI", - isAuthenticated: hasCredentials || this.systemTokenAvailable, + isAuthenticated, + loginUrl, + logoutUrl, metadata: { availableModels, currentModel, @@ -170,11 +199,12 @@ export class ClaudeOAuthModule extends BaseModule { async handleAction( actionId: string, userId: string, + spaceId: string, context: any ): Promise { if (actionId === "claude_logout") { - await this.credentialStore.deleteCredentials(userId); - logger.info(`User ${userId} logged out from Claude`); + await this.credentialStore.deleteCredentials(spaceId); + logger.info(`Space ${spaceId} logged out from Claude`); // Update home tab if (context.updateAppHome) { @@ -209,7 +239,7 @@ export class ClaudeOAuthModule extends BaseModule { const codeVerifier = this.oauthClient.generateCodeVerifier(); // Generate OAuth state for CSRF protection and store with code verifier - const state = await this.stateStore.create(userId, codeVerifier); + const state = await this.stateStore.create(userId, spaceId, codeVerifier); // Build Claude OAuth URL that redirects to console.anthropic.com callback const authUrl = this.oauthClient.buildAuthUrl( @@ -387,9 +417,9 @@ export class ClaudeOAuthModule extends BaseModule { state ); - // Store credentials - await this.credentialStore.setCredentials(userId, credentials); - logger.info(`OAuth successful for user ${userId} via modal`); + // Store credentials using spaceId for multi-tenant isolation + await this.credentialStore.setCredentials(stateData.spaceId, credentials); + logger.info(`OAuth successful for space ${stateData.spaceId} via modal`); // Parse login context to determine where to send success message let loginContext: any = { source: "home_tab" }; @@ -456,18 +486,20 @@ export class ClaudeOAuthModule extends BaseModule { } // Validate and decode token - const userId = this.validateSecureToken(token); - if (!userId) { + const tokenData = this.validateSecureToken(token); + if (!tokenData) { res.status(401).json({ error: "Invalid or expired token" }); return; } + const { userId, spaceId } = tokenData; + try { // Generate PKCE code verifier const codeVerifier = this.oauthClient.generateCodeVerifier(); - // Store state with code verifier - const state = await this.stateStore.create(userId, codeVerifier); + // Store state with code verifier and spaceId + const state = await this.stateStore.create(userId, spaceId, codeVerifier); // Build authorization URL const callbackUrl = `${this.publicGatewayUrl}/claude/oauth/callback`; @@ -479,9 +511,9 @@ export class ClaudeOAuthModule extends BaseModule { // Redirect to Claude OAuth res.redirect(authUrl); - logger.info(`Initiated OAuth for user ${userId}`); + logger.info(`Initiated OAuth for space ${spaceId}`); } catch (error) { - logger.error("Failed to init OAuth", { error, userId }); + logger.error("Failed to init OAuth", { error, spaceId }); res.status(500).json({ error: "Failed to initialize OAuth" }); } } @@ -534,10 +566,10 @@ export class ClaudeOAuthModule extends BaseModule { callbackUrl ); - // Store credentials - await this.credentialStore.setCredentials(stateData.userId, credentials); + // Store credentials using spaceId for multi-tenant isolation + await this.credentialStore.setCredentials(stateData.spaceId, credentials); - logger.info(`OAuth successful for user ${stateData.userId}`); + logger.info(`OAuth successful for space ${stateData.spaceId}`); // Show success page res.send(this.renderSuccessPage()); @@ -558,19 +590,19 @@ export class ClaudeOAuthModule extends BaseModule { * Handle logout - delete credentials */ private async handleLogout(req: Request, res: Response): Promise { - const userId = req.body.userId || req.query.userId; + const spaceId = req.body.spaceId || req.query.spaceId; - if (!userId) { - res.status(400).json({ error: "Missing userId" }); + if (!spaceId) { + res.status(400).json({ error: "Missing spaceId" }); return; } try { - await this.credentialStore.deleteCredentials(userId as string); - logger.info(`User ${userId} logged out from Claude`); + await this.credentialStore.deleteCredentials(spaceId as string); + logger.info(`Space ${spaceId} logged out from Claude`); res.json({ success: true }); } catch (error) { - logger.error("Failed to logout", { error, userId }); + logger.error("Failed to logout", { error, spaceId }); res.status(500).json({ error: "Failed to logout" }); } } diff --git a/packages/gateway/src/auth/claude/oauth-state-store.ts b/packages/gateway/src/auth/claude/oauth-state-store.ts index f7749a5..795a538 100644 --- a/packages/gateway/src/auth/claude/oauth-state-store.ts +++ b/packages/gateway/src/auth/claude/oauth-state-store.ts @@ -1,9 +1,20 @@ import type Redis from "ioredis"; import { OAuthStateStore } from "../oauth/state-store"; +/** + * Context for routing auth completion back to the originating platform. + */ +export interface OAuthPlatformContext { + platform: string; + channelId: string; // chatJid for WhatsApp, channel for Slack + threadId?: string; +} + interface ClaudeOAuthStateData { userId: string; + spaceId: string; codeVerifier: string; + context?: OAuthPlatformContext; } interface OAuthState extends ClaudeOAuthStateData { @@ -32,8 +43,13 @@ export class ClaudeOAuthStateStore { * Create a new OAuth state with PKCE code verifier * Returns the state string to use in OAuth flow */ - async create(userId: string, codeVerifier: string): Promise { - return this.store.create({ userId, codeVerifier }); + async create( + userId: string, + spaceId: string, + codeVerifier: string, + context?: OAuthPlatformContext + ): Promise { + return this.store.create({ userId, spaceId, codeVerifier, context }); } /** diff --git a/packages/gateway/src/auth/mcp/config-service.ts b/packages/gateway/src/auth/mcp/config-service.ts index 76b368e..90e2f4e 100644 --- a/packages/gateway/src/auth/mcp/config-service.ts +++ b/packages/gateway/src/auth/mcp/config-service.ts @@ -3,12 +3,12 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createLogger, verifyWorkerToken } from "@peerbot/core"; import { z } from "zod"; -import type { McpCredentialStore } from "./credential-store"; -import type { McpInputStore } from "./input-store"; import type { DiscoveredOAuthMetadata, OAuthDiscoveryService, } from "../oauth/discovery"; +import type { McpCredentialStore } from "./credential-store"; +import type { McpInputStore } from "./input-store"; const logger = createLogger("mcp-config-service"); @@ -157,9 +157,9 @@ export class McpConfigService { } /** - * Get status of all MCPs for a specific user (auth/config state) + * Get status of all MCPs for a specific space (auth/config state) */ - async getMcpStatus(userId: string): Promise { + async getMcpStatus(spaceId: string): Promise { const config = await this.loadConfig(); const statuses: McpStatus[] = []; @@ -180,7 +180,7 @@ export class McpConfigService { let authenticated = false; if (requiresAuth && this.credentialStore) { const credentials = await this.credentialStore.getCredentials( - userId, + spaceId, id ); authenticated = !!credentials?.accessToken; @@ -189,7 +189,7 @@ export class McpConfigService { // Check configuration status let configured = false; if (requiresInput && this.inputStore) { - const inputs = await this.inputStore.getInputs(userId, id); + const inputs = await this.inputStore.getInputs(spaceId, id); configured = !!inputs; } diff --git a/packages/gateway/src/auth/mcp/credential-store.ts b/packages/gateway/src/auth/mcp/credential-store.ts index 1fa1627..0a20bdc 100644 --- a/packages/gateway/src/auth/mcp/credential-store.ts +++ b/packages/gateway/src/auth/mcp/credential-store.ts @@ -10,7 +10,7 @@ export interface McpCredentialRecord { } /** - * MCP credential store with multi-part keys (userId, mcpId) + * MCP credential store with multi-part keys (spaceId, mcpId) * Extends BaseCredentialStore for consistent pattern */ export class McpCredentialStore extends BaseCredentialStore { @@ -23,25 +23,25 @@ export class McpCredentialStore extends BaseCredentialStore } async getCredentials( - userId: string, + spaceId: string, mcpId: string ): Promise { - const key = this.buildKey(userId, mcpId); + const key = this.buildKey(spaceId, mcpId); return this.get(key); } async setCredentials( - userId: string, + spaceId: string, mcpId: string, record: McpCredentialRecord, ttlSeconds?: number ): Promise { - const key = this.buildKey(userId, mcpId); + const key = this.buildKey(spaceId, mcpId); await this.set(key, record, ttlSeconds); } - async deleteCredentials(userId: string, mcpId: string): Promise { - const key = this.buildKey(userId, mcpId); + async deleteCredentials(spaceId: string, mcpId: string): Promise { + const key = this.buildKey(spaceId, mcpId); await this.delete(key); } } diff --git a/packages/gateway/src/auth/mcp/input-store.ts b/packages/gateway/src/auth/mcp/input-store.ts index a1e4364..98248de 100644 --- a/packages/gateway/src/auth/mcp/input-store.ts +++ b/packages/gateway/src/auth/mcp/input-store.ts @@ -19,41 +19,41 @@ export class McpInputStore extends BaseRedisStore { } /** - * Store input values for a user and MCP server + * Store input values for a space and MCP server * No TTL - these are persistent until explicitly deleted */ async setInputs( - userId: string, + spaceId: string, mcpId: string, inputs: InputValues ): Promise { - const key = this.buildKey(userId, mcpId); + const key = this.buildKey(spaceId, mcpId); await this.set(key, inputs); - this.logger.info(`Stored inputs for user ${userId}, MCP ${mcpId}`); + this.logger.info(`Stored inputs for space ${spaceId}, MCP ${mcpId}`); } /** - * Retrieve input values for a user and MCP server + * Retrieve input values for a space and MCP server */ - async getInputs(userId: string, mcpId: string): Promise { - const key = this.buildKey(userId, mcpId); + async getInputs(spaceId: string, mcpId: string): Promise { + const key = this.buildKey(spaceId, mcpId); return this.get(key); } /** - * Delete input values for a user and MCP server + * Delete input values for a space and MCP server */ - async deleteInputs(userId: string, mcpId: string): Promise { - const key = this.buildKey(userId, mcpId); + async deleteInputs(spaceId: string, mcpId: string): Promise { + const key = this.buildKey(spaceId, mcpId); await this.delete(key); - this.logger.info(`Deleted inputs for user ${userId}, MCP ${mcpId}`); + this.logger.info(`Deleted inputs for space ${spaceId}, MCP ${mcpId}`); } /** - * Check if user has inputs stored for an MCP server + * Check if space has inputs stored for an MCP server */ - async has(userId: string, mcpId: string): Promise { - const values = await this.getInputs(userId, mcpId); + async has(spaceId: string, mcpId: string): Promise { + const values = await this.getInputs(spaceId, mcpId); return values !== null; } } diff --git a/packages/gateway/src/auth/mcp/oauth-module.ts b/packages/gateway/src/auth/mcp/oauth-module.ts index 99d75c9..bb8b3ba 100644 --- a/packages/gateway/src/auth/mcp/oauth-module.ts +++ b/packages/gateway/src/auth/mcp/oauth-module.ts @@ -54,33 +54,37 @@ export class McpOAuthModule extends BaseModule { /** * Generate a secure token for OAuth init URL - * Token contains encrypted userId, mcpId, and expiry + * Token contains encrypted userId, spaceId, mcpId, and expiry */ - private generateSecureToken(userId: string, mcpId: string): string { + private generateSecureToken( + userId: string, + spaceId: string, + mcpId: string + ): string { const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes - const payload = JSON.stringify({ userId, mcpId, expiresAt }); + const payload = JSON.stringify({ userId, spaceId, mcpId, expiresAt }); return encrypt(payload); } /** * Validate and decode a secure token - * Returns { userId, mcpId } if valid, null if invalid or expired + * Returns { userId, spaceId, mcpId } if valid, null if invalid or expired */ private validateSecureToken( token: string - ): { userId: string; mcpId: string } | null { + ): { userId: string; spaceId: string; mcpId: string } | null { try { const decrypted = decrypt(token); const data = JSON.parse(decrypted); - const { userId, mcpId, expiresAt } = data; + const { userId, spaceId, mcpId, expiresAt } = data; // Check expiry if (Date.now() > expiresAt) { - logger.warn("Token expired", { userId, mcpId }); + logger.warn("Token expired", { userId, spaceId, mcpId }); return null; } - return { userId, mcpId }; + return { userId, spaceId, mcpId }; } catch (error) { logger.error("Failed to validate token", { error }); return null; @@ -116,7 +120,10 @@ export class McpOAuthModule extends BaseModule { * Get platform-agnostic authentication status for all MCP servers * Returns abstract provider data that can be rendered by any platform adapter */ - async getAuthStatus(userId: string): Promise< + async getAuthStatus( + userId: string, + spaceId: string + ): Promise< Array<{ id: string; name: string; @@ -127,7 +134,7 @@ export class McpOAuthModule extends BaseModule { }> > { try { - const mcpStatuses = await this.getMcpStatuses(userId); + const mcpStatuses = await this.getMcpStatuses(spaceId); return mcpStatuses.map((mcp) => { const provider: { @@ -153,14 +160,14 @@ export class McpOAuthModule extends BaseModule { !mcp.isAuthenticated && (mcp.authType === "oauth" || mcp.authType === "discovered-oauth") ) { - const token = this.generateSecureToken(userId, mcp.id); + const token = this.generateSecureToken(userId, spaceId, mcp.id); provider.loginUrl = `${this.publicGatewayUrl}/mcp/oauth/init/${mcp.id}?token=${encodeURIComponent(token)}`; } return provider; }); } catch (error) { - logger.error("Failed to get MCP auth status", { error, userId }); + logger.error("Failed to get MCP auth status", { error, userId, spaceId }); return []; } } @@ -173,6 +180,12 @@ export class McpOAuthModule extends BaseModule { userId: string, context: any ): Promise { + const spaceId = context.spaceId; + if (!spaceId) { + logger.error("Missing spaceId in action context", { actionId, userId }); + return false; + } + // Handle configure button (inputs) if (actionId.startsWith("mcp_configure_")) { const mcpId = actionId.replace("mcp_configure_", ""); @@ -188,8 +201,8 @@ export class McpOAuthModule extends BaseModule { return false; } - // Build modal with input fields - const modal = this.buildInputModal(mcpId, httpServer.inputs); + // Build modal with input fields (include spaceId in metadata) + const modal = this.buildInputModal(mcpId, spaceId, httpServer.inputs); // Open modal if (context.client && context.body?.trigger_id) { @@ -199,13 +212,16 @@ export class McpOAuthModule extends BaseModule { }); } - logger.info(`Opened input modal for user ${userId}, MCP ${mcpId}`); + logger.info( + `Opened input modal for user ${userId}, space ${spaceId}, MCP ${mcpId}` + ); return true; } catch (error) { logger.error("Failed to handle configure action", { error, mcpId, userId, + spaceId, }); return false; } @@ -216,10 +232,10 @@ export class McpOAuthModule extends BaseModule { const mcpId = actionId.replace("mcp_logout_", ""); // Delete both OAuth credentials and input values - await this.credentialStore.deleteCredentials(userId, mcpId); - await this.inputStore.deleteInputs(userId, mcpId); + await this.credentialStore.deleteCredentials(spaceId, mcpId); + await this.inputStore.deleteInputs(spaceId, mcpId); - logger.info(`User ${userId} logged out/cleared from ${mcpId}`); + logger.info(`Space ${spaceId} logged out/cleared from ${mcpId}`); // Update home tab if (context.updateAppHome) { @@ -242,12 +258,14 @@ export class McpOAuthModule extends BaseModule { privateMetadata: string ): Promise { try { - // Parse metadata to get mcpId + // Parse metadata to get mcpId and spaceId const metadata = JSON.parse(privateMetadata); - const mcpId = metadata.mcpId; + const { mcpId, spaceId } = metadata; - if (!mcpId) { - logger.error("No mcpId in modal metadata"); + if (!mcpId || !spaceId) { + logger.error("Missing mcpId or spaceId in modal metadata", { + metadata, + }); return; } @@ -264,9 +282,9 @@ export class McpOAuthModule extends BaseModule { } } - // Store input values - await this.inputStore.setInputs(userId, mcpId, inputValues); - logger.info(`Stored input values for user ${userId}, MCP ${mcpId}`); + // Store input values using spaceId + await this.inputStore.setInputs(spaceId, mcpId, inputValues); + logger.info(`Stored input values for space ${spaceId}, MCP ${mcpId}`); } catch (error) { logger.error("Failed to handle view submission", { error, userId }); throw error; @@ -276,7 +294,7 @@ export class McpOAuthModule extends BaseModule { /** * Build Slack modal for collecting input values */ - private buildInputModal(mcpId: string, inputs: any[]): any { + private buildInputModal(mcpId: string, spaceId: string, inputs: any[]): any { const blocks: any[] = []; // Add input blocks for each required input @@ -302,7 +320,7 @@ export class McpOAuthModule extends BaseModule { return { type: "modal", callback_id: `mcp_input_modal_${mcpId}`, - private_metadata: JSON.stringify({ mcpId }), + private_metadata: JSON.stringify({ mcpId, spaceId }), title: { type: "plain_text", text: `Configure ${formatMcpName(mcpId)}`, @@ -320,11 +338,13 @@ export class McpOAuthModule extends BaseModule { } /** - * Get status of all configured MCP servers for a user + * Get status of all configured MCP servers for a space */ - private async getMcpStatuses(userId: string): Promise { + private async getMcpStatuses(spaceId: string): Promise { const httpServers = await this.configService.getAllHttpServers(); - logger.info(`getMcpStatuses: Found ${httpServers.size} HTTP servers`); + logger.info( + `getMcpStatuses: Found ${httpServers.size} HTTP servers for space ${spaceId}` + ); const statuses: McpStatus[] = []; @@ -359,7 +379,7 @@ export class McpOAuthModule extends BaseModule { // Check OAuth credentials (works for static and discovered OAuth) authType = hasOAuth ? "oauth" : "discovered-oauth"; const credentials = await this.credentialStore.getCredentials( - userId, + spaceId, id ); // Show as authenticated if credentials exist, even if expired @@ -369,7 +389,7 @@ export class McpOAuthModule extends BaseModule { } else { // Input-based authentication authType = "inputs"; - const inputValues = await this.inputStore.getInputs(userId, id); + const inputValues = await this.inputStore.getInputs(spaceId, id); isAuthenticated = !!inputValues; } @@ -416,7 +436,7 @@ export class McpOAuthModule extends BaseModule { return; } - const userId = tokenData.userId; + const { userId, spaceId } = tokenData; try { // Get MCP config @@ -513,8 +533,8 @@ export class McpOAuthModule extends BaseModule { return; } - // Generate and store state - const state = await this.stateStore.create({ userId, mcpId }); + // Generate and store state (include spaceId for credential storage) + const state = await this.stateStore.create({ userId, spaceId, mcpId }); // Build OAuth URL const loginUrl = this.oauth2Client.buildAuthUrl( @@ -525,7 +545,9 @@ export class McpOAuthModule extends BaseModule { // Redirect to OAuth provider res.redirect(loginUrl); - logger.info(`Initiated OAuth for user ${userId}, MCP ${mcpId}`); + logger.info( + `Initiated OAuth for user ${userId}, space ${spaceId}, MCP ${mcpId}` + ); } catch (error) { logger.error("Failed to init OAuth", { error, mcpId, userId }); res.status(500).json({ error: "Failed to initialize OAuth" }); @@ -655,13 +677,13 @@ export class McpOAuthModule extends BaseModule { // Store credentials without TTL to preserve refresh token // Even if access token expires, we keep credentials so we can refresh await this.credentialStore.setCredentials( - stateData.userId, + stateData.spaceId, stateData.mcpId, credentials ); logger.info( - `OAuth successful for user ${stateData.userId}, MCP ${stateData.mcpId}` + `OAuth successful for space ${stateData.spaceId}, MCP ${stateData.mcpId}` ); // Show success page @@ -688,19 +710,19 @@ export class McpOAuthModule extends BaseModule { */ private async handleLogout(req: Request, res: Response): Promise { const { mcpId } = req.params; - const userId = req.body.userId || req.query.userId; + const spaceId = req.body.spaceId || req.query.spaceId; - if (!userId) { - res.status(400).json({ error: "Missing userId" }); + if (!spaceId) { + res.status(400).json({ error: "Missing spaceId" }); return; } try { - await this.credentialStore.deleteCredentials(userId as string, mcpId!); - logger.info(`User ${userId} logged out from ${mcpId}`); + await this.credentialStore.deleteCredentials(spaceId as string, mcpId!); + logger.info(`Space ${spaceId} logged out from ${mcpId}`); res.json({ success: true }); } catch (error) { - logger.error("Failed to logout", { error, mcpId, userId }); + logger.error("Failed to logout", { error, mcpId, spaceId }); res.status(500).json({ error: "Failed to logout" }); } } diff --git a/packages/gateway/src/auth/mcp/oauth-state-store.ts b/packages/gateway/src/auth/mcp/oauth-state-store.ts index 107046b..d2cb4f6 100644 --- a/packages/gateway/src/auth/mcp/oauth-state-store.ts +++ b/packages/gateway/src/auth/mcp/oauth-state-store.ts @@ -4,6 +4,7 @@ import { OAuthStateStore as BaseOAuthStateStore } from "../oauth/state-store"; interface McpOAuthStateData { userId: string; + spaceId: string; mcpId: string; nonce: string; redirectPath?: string; diff --git a/packages/gateway/src/auth/mcp/proxy.ts b/packages/gateway/src/auth/mcp/proxy.ts index 930d576..721ef27 100644 --- a/packages/gateway/src/auth/mcp/proxy.ts +++ b/packages/gateway/src/auth/mcp/proxy.ts @@ -102,16 +102,16 @@ export class McpProxy { const discoveredOAuth = await this.configService.getDiscoveredOAuth(mcpId!); const hasDiscoveredOAuth = !!discoveredOAuth; + // Get spaceId from token data (fallback to userId for backwards compatibility) + const spaceId = tokenData.spaceId || tokenData.userId; + // Try OAuth credentials first (supports both static and discovered OAuth) if (hasOAuth || hasDiscoveredOAuth) { - credentials = await this.credentialStore.getCredentials( - tokenData.userId, - mcpId! - ); + credentials = await this.credentialStore.getCredentials(spaceId, mcpId!); if (!credentials || !credentials.accessToken) { logger.info("MCP OAuth credentials missing", { - userId: tokenData.userId, + spaceId, mcpId, }); this.sendJsonRpcError( @@ -125,7 +125,7 @@ export class McpProxy { // Check if token is expired and attempt refresh if (credentials.expiresAt && credentials.expiresAt <= Date.now()) { logger.info("MCP access token expired, attempting refresh", { - userId: tokenData.userId, + spaceId, mcpId, hasRefreshToken: !!credentials.refreshToken, }); @@ -177,7 +177,7 @@ export class McpProxy { // Store the new credentials (without TTL) await this.credentialStore.setCredentials( - tokenData.userId, + spaceId, mcpId!, refreshedCredentials ); @@ -186,7 +186,7 @@ export class McpProxy { credentials = refreshedCredentials; logger.info("Successfully refreshed MCP access token", { - userId: tokenData.userId, + spaceId, mcpId, }); } catch (error) { @@ -195,7 +195,7 @@ export class McpProxy { errorMessage: error instanceof Error ? error.message : String(error), errorStack: error instanceof Error ? error.stack : undefined, - userId: tokenData.userId, + spaceId, mcpId, }); this.sendJsonRpcError( @@ -207,7 +207,7 @@ export class McpProxy { } } else { logger.warn("MCP credentials expired with no refresh token", { - userId: tokenData.userId, + spaceId, mcpId, }); this.sendJsonRpcError( @@ -222,11 +222,11 @@ export class McpProxy { // Load input values if MCP uses inputs if (httpServer.inputs && httpServer.inputs.length > 0) { - inputValues = await this.inputStore.getInputs(tokenData.userId, mcpId!); + inputValues = await this.inputStore.getInputs(spaceId, mcpId!); if (!inputValues) { logger.info("MCP input values missing", { - userId: tokenData.userId, + spaceId, mcpId, }); this.sendJsonRpcError( @@ -245,7 +245,7 @@ export class McpProxy { httpServer, credentials, inputValues || {}, - tokenData.userId, + spaceId, mcpId! ); } catch (error) { @@ -305,10 +305,10 @@ export class McpProxy { httpServer: any, credentials: { accessToken: string; tokenType?: string } | null, inputValues: Record, - userId: string, + spaceId: string, mcpId: string ): Promise { - const sessionKey = `mcp:session:${userId}:${mcpId}`; + const sessionKey = `mcp:session:${spaceId}:${mcpId}`; const sessionId = await this.getSession(sessionKey); // Get request body @@ -316,7 +316,7 @@ export class McpProxy { logger.info("Proxying MCP request", { mcpId, - userId, + spaceId, method: req.method, hasSession: !!sessionId, bodyLength: bodyText.length, @@ -355,7 +355,7 @@ export class McpProxy { logger.debug("Applied input substitution to request body", { mcpId, - userId, + spaceId, }); } catch { // If body is not JSON, apply string substitution directly @@ -377,7 +377,7 @@ export class McpProxy { await this.setSession(sessionKey, newSessionId); logger.debug("Stored MCP session ID", { mcpId, - userId, + spaceId, sessionId: newSessionId, }); } diff --git a/packages/gateway/src/auth/oauth/generic-client.ts b/packages/gateway/src/auth/oauth/generic-client.ts index cec92a7..203c80a 100644 --- a/packages/gateway/src/auth/oauth/generic-client.ts +++ b/packages/gateway/src/auth/oauth/generic-client.ts @@ -1,8 +1,19 @@ -import type { - OAuthErrorResponse, - OAuthTokens, -} from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuth2Config } from "../mcp/config-service"; + +// Local type definitions to avoid dependency on MCP SDK internal paths +interface OAuthTokens { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +interface OAuthErrorResponse { + error: string; + error_description?: string; + error_uri?: string; +} import type { McpCredentialRecord } from "../mcp/credential-store"; import { BaseOAuth2Client } from "./base-client"; diff --git a/packages/gateway/src/auth/platform-auth.ts b/packages/gateway/src/auth/platform-auth.ts new file mode 100644 index 0000000..66e465c --- /dev/null +++ b/packages/gateway/src/auth/platform-auth.ts @@ -0,0 +1,67 @@ +/** + * Platform-agnostic authentication adapter interface. + * Each platform (Slack, WhatsApp) implements this to handle auth prompts in their native format. + */ + +export interface AuthProvider { + id: string; + name: string; +} + +export interface PlatformAuthAdapter { + /** + * Send authentication required prompt with provider list. + * Platform implementations render this in their native format: + * - Slack: Blocks with buttons + * - WhatsApp: Numbered text list + */ + sendAuthPrompt( + userId: string, + channelId: string, + threadId: string, + providers: AuthProvider[], + platformMetadata?: Record + ): Promise; + + /** + * Send authentication success message. + */ + sendAuthSuccess( + userId: string, + channelId: string, + provider: AuthProvider + ): Promise; + + /** + * Handle potential auth response (e.g., numbered selection in WhatsApp). + * Returns true if the message was handled as an auth response. + */ + handleAuthResponse?( + channelId: string, + userId: string, + text: string + ): Promise; +} + +/** + * Registry for platform auth adapters. + * Used by orchestration layer to route auth prompts to correct platform. + */ +export class PlatformAuthRegistry { + private adapters = new Map(); + + register(platform: string, adapter: PlatformAuthAdapter): void { + this.adapters.set(platform, adapter); + } + + get(platform: string): PlatformAuthAdapter | undefined { + return this.adapters.get(platform); + } + + has(platform: string): boolean { + return this.adapters.has(platform); + } +} + +// Global registry instance +export const platformAuthRegistry = new PlatformAuthRegistry(); diff --git a/packages/gateway/src/cli/gateway.ts b/packages/gateway/src/cli/gateway.ts index dfcc6b8..95b9833 100644 --- a/packages/gateway/src/cli/gateway.ts +++ b/packages/gateway/src/cli/gateway.ts @@ -5,6 +5,7 @@ import { createLogger } from "@peerbot/core"; import express from "express"; import type { GatewayConfig } from "../config"; import type { SlackConfig } from "../slack"; +import type { WhatsAppConfig } from "../whatsapp/config"; const logger = createLogger("gateway-startup"); @@ -20,7 +21,8 @@ function setupHealthEndpoints( fileHandler?: any, sessionManager?: any, interactionService?: any, - platformRegistry?: any + platformRegistry?: any, + coreServices?: any ) { if (healthServer) return; @@ -44,6 +46,13 @@ function setupHealthEndpoints( res.json({ ready: true }); }); + // Prometheus metrics endpoint for Grafana + proxyApp.get("/metrics", (_req, res) => { + const { getMetricsText } = require("../metrics/prometheus"); + res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); + res.send(getMetricsText()); + }); + // Test endpoint for Sentry integration proxyApp.get("/test/sentry-error", (_req, res) => { logger.error("Test error for Sentry integration", { @@ -55,6 +64,37 @@ function setupHealthEndpoints( res.json({ message: "Test error logged. Check Sentry dashboard." }); }); + // Test endpoint for simulating incoming messages (dev/test only) + proxyApp.post("/test/simulate-message", express.json(), async (req, res) => { + if (process.env.NODE_ENV === "production") { + return res + .status(403) + .json({ error: "Test endpoint disabled in production" }); + } + + const { platform, userId, message } = req.body; + if (!platform || !userId || !message) { + return res + .status(400) + .json({ error: "Missing required fields: platform, userId, message" }); + } + + const msgId = `TEST${Date.now()}`; + const threadId = msgId; + + logger.info( + `[TEST] Simulating incoming ${platform} message from ${userId}: ${message}` + ); + + // This will be set up after coreServices is available + res.json({ + success: true, + messageId: msgId, + threadId, + note: "Message simulation endpoint. Use with coreServices injection.", + }); + }); + // Add Anthropic proxy if provided if (anthropicProxy) { proxyApp.use("/api/anthropic", anthropicProxy.getRouter()); @@ -133,6 +173,22 @@ function setupHealthEndpoints( logger.info("โœ… Messaging routes enabled at :8080/api/messaging/send"); } + // Setup auth callback routes for WhatsApp and other non-modal platforms + if (coreServices) { + const stateStore = coreServices.getClaudeOAuthStateStore(); + const credentialStore = coreServices.getClaudeCredentialStore(); + if (stateStore && credentialStore) { + const { Router } = require("express"); + const authRouter = Router(); + // Add form parsing middleware for auth callback + authRouter.use(express.urlencoded({ extended: true })); + const { registerAuthCallbackRoutes } = require("../routes/auth-callback"); + registerAuthCallbackRoutes(authRouter, { stateStore, credentialStore }); + proxyApp.use(authRouter); + logger.info("โœ… Auth callback routes enabled at :8080/auth/callback"); + } + } + // Create HTTP server with Express app healthServer = http.createServer(proxyApp); @@ -150,7 +206,8 @@ function setupHealthEndpoints( */ export async function startGateway( config: GatewayConfig, - slackConfig: SlackConfig + slackConfig: SlackConfig | null, + whatsappConfig?: WhatsAppConfig | null ): Promise { logger.info("๐Ÿš€ Starting Peerbot Gateway"); @@ -161,23 +218,15 @@ export async function startGateway( // Import dependencies (after config is loaded) const { Orchestrator } = await import("../orchestration"); const { Gateway } = await import("../gateway-main"); - const { SlackPlatform } = await import("../slack"); // Create and start orchestrator const orchestrator = new Orchestrator(config.orchestration); await orchestrator.start(); logger.info("โœ… Orchestrator started"); - // Create Gateway with Slack platform + // Create Gateway const gateway = new Gateway(config); - // Construct Slack platform config - const slackPlatformConfig = { - slack: slackConfig, - logLevel: config.logLevel as any, // Core LogLevel is compatible with Slack LogLevel - health: config.health, - }; - const agentOptions = { allowedTools: config.claude.allowedTools, disallowedTools: config.claude.disallowedTools, @@ -185,12 +234,44 @@ export async function startGateway( timeoutMinutes: config.claude.timeoutMinutes, }; - const slackPlatform = new SlackPlatform( - slackPlatformConfig, - agentOptions, - config.sessionTimeoutMinutes - ); - gateway.registerPlatform(slackPlatform); + // Register Slack platform if configured + let slackPlatform: any = null; + if (slackConfig) { + const { SlackPlatform } = await import("../slack"); + + // Construct Slack platform config + const slackPlatformConfig = { + slack: slackConfig, + logLevel: config.logLevel as any, // Core LogLevel is compatible with Slack LogLevel + health: config.health, + }; + + slackPlatform = new SlackPlatform( + slackPlatformConfig, + agentOptions, + config.sessionTimeoutMinutes + ); + gateway.registerPlatform(slackPlatform); + logger.info("โœ… Slack platform registered"); + } + + // Register WhatsApp platform if enabled + let whatsappPlatform: any = null; + if (whatsappConfig?.enabled) { + const { WhatsAppPlatform } = await import("../whatsapp"); + + const whatsappPlatformConfig = { + whatsapp: whatsappConfig, + }; + + whatsappPlatform = new WhatsAppPlatform( + whatsappPlatformConfig, + agentOptions as any, + config.sessionTimeoutMinutes + ); + gateway.registerPlatform(whatsappPlatform); + logger.info("โœ… WhatsApp platform registered"); + } // Start gateway (initializes core services + platforms) await gateway.start(); @@ -207,7 +288,7 @@ export async function startGateway( logger.info("โœ… Orchestrator configured with core services"); // Get file handler from Slack platform (if available) - const fileHandler = slackPlatform.getFileHandler(); + const fileHandler = slackPlatform?.getFileHandler() ?? null; const sessionManager = coreServices.getSessionManager(); // Setup health endpoints on port 8080 @@ -218,7 +299,8 @@ export async function startGateway( fileHandler, sessionManager, coreServices.getInteractionService(), - gateway.getPlatformRegistry() + gateway.getPlatformRegistry(), + coreServices ); logger.info("โœ… Peerbot Gateway is running!"); diff --git a/packages/gateway/src/cli/index.ts b/packages/gateway/src/cli/index.ts index 273607d..f3055c0 100644 --- a/packages/gateway/src/cli/index.ts +++ b/packages/gateway/src/cli/index.ts @@ -4,10 +4,11 @@ import { ConfigError, createLogger, initSentry } from "@peerbot/core"; import { Command } from "commander"; import { buildGatewayConfig, - buildSlackConfig, - displayConfig, + displayGatewayConfig, loadEnvFile, } from "../config"; +import { buildSlackConfig, displaySlackConfig } from "../slack/config"; +import { buildWhatsAppConfig, displayWhatsAppConfig } from "../whatsapp/config"; import { startGateway } from "./gateway"; const logger = createLogger("cli"); @@ -24,7 +25,30 @@ async function main() { program .name("peerbot-gateway") .description("Peerbot gateway service - connects Slack to Claude workers") - .version("1.0.0") + .version("1.0.0"); + + // WhatsApp setup command + program + .command("whatsapp-setup") + .description( + "One-time WhatsApp QR code setup - outputs WHATSAPP_CREDENTIALS" + ) + .action(async () => { + try { + const { runWhatsAppSetup } = await import("../whatsapp/setup"); + await runWhatsAppSetup(); + process.exit(0); + } catch (error) { + logger.error( + "WhatsApp setup failed:", + error instanceof Error ? error.message : String(error) + ); + process.exit(1); + } + }); + + // Main gateway command (default) + program .option("--env ", "Path to .env file (default: .env)") .option("--validate", "Validate configuration and exit") .option("--show-config", "Display parsed configuration and exit") @@ -36,22 +60,27 @@ async function main() { // Build configuration from environment const config = buildGatewayConfig(); const slackConfig = buildSlackConfig(); + const whatsappConfig = buildWhatsAppConfig(); // Handle --validate flag if (options.validate) { console.log("โœ… Configuration is valid"); - displayConfig(config, slackConfig); + displayGatewayConfig(config); + displaySlackConfig(slackConfig); + displayWhatsAppConfig(whatsappConfig); process.exit(0); } // Handle --show-config flag if (options.showConfig) { - displayConfig(config, slackConfig); + displayGatewayConfig(config); + displaySlackConfig(slackConfig); + displayWhatsAppConfig(whatsappConfig); process.exit(0); } // Start the gateway - await startGateway(config, slackConfig); + await startGateway(config, slackConfig, whatsappConfig); } catch (error) { if (error instanceof ConfigError) { logger.error("โŒ Configuration error:", error.message); diff --git a/packages/gateway/src/config/index.ts b/packages/gateway/src/config/index.ts index db243bc..36c8907 100644 --- a/packages/gateway/src/config/index.ts +++ b/packages/gateway/src/config/index.ts @@ -14,7 +14,6 @@ import { } from "@peerbot/core"; import { config as dotenvConfig } from "dotenv"; import type { OrchestratorConfig } from "../orchestration/base-deployment-manager"; -import type { SlackConfig } from "../slack"; const logger = createLogger("cli-config"); @@ -32,8 +31,6 @@ const logger = createLogger("cli-config"); const GATEWAY_DEFAULTS = { /** Default HTTP server port */ HTTP_PORT: 3000, - /** Default Slack API URL */ - SLACK_API_URL: "https://slack.com/api", /** Default public gateway URL */ PUBLIC_GATEWAY_URL: "http://localhost:8080", /** Default queue names */ @@ -153,25 +150,6 @@ export function loadEnvFile(envPath?: string): void { } } -/** - * Build Slack-specific configuration from environment variables - */ -export function buildSlackConfig(): SlackConfig { - const botToken = getRequiredEnv("SLACK_BOT_TOKEN"); - const socketMode = process.env.SLACK_HTTP_MODE !== "true"; - - return { - token: botToken, - appToken: process.env.SLACK_APP_TOKEN, - signingSecret: process.env.SLACK_SIGNING_SECRET, - socketMode, - port: getOptionalNumber("PORT", DEFAULTS.HTTP_PORT), - botUserId: process.env.SLACK_BOT_USER_ID, - botId: undefined, // Will be set during initialization - apiUrl: getOptionalEnv("SLACK_API_URL", DEFAULTS.SLACK_API_URL), - }; -} - /** * Build complete gateway configuration from environment variables * This is the SINGLE source of truth for all configuration @@ -351,28 +329,14 @@ export function buildGatewayConfig(): GatewayConfig { } /** - * Validate configuration and display it + * Display gateway configuration (platform-agnostic parts only) + * Platform-specific display should be handled by platform modules */ -export function displayConfig( - config: GatewayConfig, - slackConfig: SlackConfig -): void { +export function displayGatewayConfig(config: GatewayConfig): void { const separator = "=".repeat(DISPLAY.SEPARATOR_LENGTH); console.log("Gateway Configuration:"); console.log(separator); - console.log("\nSlack:"); - console.log( - ` Mode: ${slackConfig.socketMode ? "Socket Mode" : "HTTP Mode"}` - ); - console.log(` Port: ${slackConfig.port}`); - console.log( - ` Bot Token: ${slackConfig.token?.substring(0, DISPLAY.TOKEN_PREVIEW_LENGTH)}... (${slackConfig.token.length} chars)` - ); - console.log( - ` App Token: ${slackConfig.appToken ? `${slackConfig.appToken.substring(0, DISPLAY.TOKEN_PREVIEW_LENGTH)}... (${slackConfig.appToken.length} chars)` : "not set"}` - ); - console.log(` API URL: ${slackConfig.apiUrl}`); console.log("\nQueues:"); console.log( diff --git a/packages/gateway/src/gateway-main.ts b/packages/gateway/src/gateway-main.ts index 591a57e..ceab05d 100644 --- a/packages/gateway/src/gateway-main.ts +++ b/packages/gateway/src/gateway-main.ts @@ -3,6 +3,7 @@ import { createLogger } from "@peerbot/core"; import type { GatewayConfig } from "./config"; import { type PlatformAdapter, platformRegistry } from "./platform"; +import { UnifiedThreadResponseConsumer } from "./platform/unified-thread-consumer"; import { CoreServices } from "./services/core-services"; const logger = createLogger("gateway"); @@ -23,6 +24,7 @@ const logger = createLogger("gateway"); export class Gateway { private coreServices: CoreServices; private platforms: Map = new Map(); + private unifiedConsumer?: UnifiedThreadResponseConsumer; private isRunning = false; constructor(private readonly config: GatewayConfig) { @@ -90,6 +92,16 @@ export class Gateway { await platform.start(); } + // 5. Start unified thread response consumer + // Single consumer routes responses to platforms via registry + logger.info("Starting unified thread response consumer"); + this.unifiedConsumer = new UnifiedThreadResponseConsumer( + this.coreServices.getQueue(), + platformRegistry + ); + await this.unifiedConsumer.start(); + logger.info("โœ… Unified thread response consumer started"); + this.isRunning = true; logger.info( `โœ… Gateway started successfully with ${this.platforms.size} platform(s)` @@ -98,12 +110,23 @@ export class Gateway { /** * Stop the gateway gracefully - * 1. Stop all platforms - * 2. Shutdown core services + * 1. Stop unified consumer if running + * 2. Stop all platforms + * 3. Shutdown core services */ async stop(): Promise { logger.info("Stopping gateway..."); + // Stop unified consumer if running + if (this.unifiedConsumer) { + logger.info("Stopping unified thread response consumer"); + try { + await this.unifiedConsumer.stop(); + } catch (error) { + logger.error("Failed to stop unified consumer:", error); + } + } + // Stop all platforms for (const [name, platform] of this.platforms) { logger.info(`Stopping platform: ${name}`); diff --git a/packages/gateway/src/gateway/index.ts b/packages/gateway/src/gateway/index.ts index 48e9284..600cefb 100644 --- a/packages/gateway/src/gateway/index.ts +++ b/packages/gateway/src/gateway/index.ts @@ -183,12 +183,14 @@ export class WorkerGateway { } try { - const { userId, platform, sessionKey, threadId } = auth.tokenData; + const { userId, platform, sessionKey, threadId, spaceId } = + auth.tokenData; const baseUrl = this.getRequestBaseUrl(req); // Build instruction context const instructionContext: InstructionContext = { userId, + spaceId: spaceId || threadId || "", // Fall back to threadId for backwards compatibility sessionKey: sessionKey || "", // Use empty string if sessionKey is undefined workingDirectory: "/workspace", availableProjects: [], diff --git a/packages/gateway/src/infrastructure/model-provider/anthropic-proxy.ts b/packages/gateway/src/infrastructure/model-provider/anthropic-proxy.ts index 3766ae6..9c4ce51 100644 --- a/packages/gateway/src/infrastructure/model-provider/anthropic-proxy.ts +++ b/packages/gateway/src/infrastructure/model-provider/anthropic-proxy.ts @@ -17,7 +17,7 @@ export class AnthropicProxy { private config: AnthropicProxyConfig; private credentialStore?: ClaudeCredentialStore; private oauthClient: ClaudeOAuthClient; - private refreshLocks: Map>; // userId -> refresh promise + private refreshLocks: Map>; // spaceId -> refresh promise constructor( config: AnthropicProxyConfig, @@ -36,35 +36,35 @@ export class AnthropicProxy { } /** - * Refresh an expired OAuth token for a user - * Uses locking to prevent concurrent refresh attempts for the same user + * Refresh an expired OAuth token for a space + * Uses locking to prevent concurrent refresh attempts for the same space * Returns the new access token or null if refresh failed */ - private async refreshUserToken(userId: string): Promise { - // Check if there's already a refresh in progress for this user - const existingRefresh = this.refreshLocks.get(userId); + private async refreshSpaceToken(spaceId: string): Promise { + // Check if there's already a refresh in progress for this space + const existingRefresh = this.refreshLocks.get(spaceId); if (existingRefresh) { - logger.info(`Waiting for existing token refresh for user ${userId}`); + logger.info(`Waiting for existing token refresh for space ${spaceId}`); return existingRefresh; } // Create a new refresh promise and store it - const refreshPromise = this.performTokenRefresh(userId); - this.refreshLocks.set(userId, refreshPromise); + const refreshPromise = this.performTokenRefresh(spaceId); + this.refreshLocks.set(spaceId, refreshPromise); try { const result = await refreshPromise; return result; } finally { // Clean up the lock after refresh completes (success or failure) - this.refreshLocks.delete(userId); + this.refreshLocks.delete(spaceId); } } /** * Perform the actual token refresh */ - private async performTokenRefresh(userId: string): Promise { + private async performTokenRefresh(spaceId: string): Promise { if (!this.credentialStore) { logger.error("Cannot refresh token: credential store not available"); return null; @@ -72,13 +72,13 @@ export class AnthropicProxy { try { // Get current credentials to access refresh token - const credentials = await this.credentialStore.getCredentials(userId); + const credentials = await this.credentialStore.getCredentials(spaceId); if (!credentials || !credentials.refreshToken) { - logger.warn(`No refresh token available for user ${userId}`); + logger.warn(`No refresh token available for space ${spaceId}`); return null; } - logger.info(`Refreshing expired token for user ${userId}`); + logger.info(`Refreshing expired token for space ${spaceId}`); // Use ClaudeOAuthClient to refresh the token const newCredentials = await this.oauthClient.refreshToken( @@ -86,17 +86,17 @@ export class AnthropicProxy { ); // Store the new credentials - await this.credentialStore.setCredentials(userId, newCredentials); + await this.credentialStore.setCredentials(spaceId, newCredentials); - logger.info(`Successfully refreshed token for user ${userId}`); + logger.info(`Successfully refreshed token for space ${spaceId}`); return newCredentials.accessToken; } catch (error) { - logger.error(`Failed to refresh token for user ${userId}`, { error }); + logger.error(`Failed to refresh token for space ${spaceId}`, { error }); // If refresh failed, delete the invalid credentials try { - await this.credentialStore.deleteCredentials(userId); - logger.info(`Deleted invalid credentials for user ${userId}`); + await this.credentialStore.deleteCredentials(spaceId); + logger.info(`Deleted invalid credentials for space ${spaceId}`); } catch (deleteError) { logger.error(`Failed to delete invalid credentials`, { deleteError }); } @@ -143,13 +143,13 @@ export class AnthropicProxy { private async forwardToAnthropic(req: Request, res: Response): Promise { // Authentication flow: // 1. Worker sends encrypted worker token via Claude SDK in x-api-key header - // 2. Validate token and extract userId - // 3. Use userId to get user's OAuth token (if available) or fall back to system API key + // 2. Validate token and extract spaceId + // 3. Use spaceId to get space's OAuth token (if available) or fall back to system API key // 4. Forward request to Anthropic with real credentials const workerToken = req.headers["x-api-key"] as string | undefined; - // Validate worker token and extract userId - let userId: string | undefined; + // Validate worker token and extract spaceId + let spaceId: string | undefined; if (workerToken && !workerToken.startsWith("sk-ant-")) { // This is a worker token, not an Anthropic API key const { verifyWorkerToken } = await import("@peerbot/core"); @@ -166,43 +166,49 @@ export class AnthropicProxy { return; } - userId = tokenData.userId; - logger.info(`Authenticated worker request for user: ${userId}`); + // Use spaceId from token for credential lookup (fall back to userId for backwards compat) + spaceId = tokenData.spaceId || tokenData.userId; + logger.info(`Authenticated worker request for space: ${spaceId}`); } - // Resolve API key/token: user token > system token > error + // Resolve API key/token: space token > system token > error let apiKey: string | undefined; - let tokenSource: "user" | "system" | "none" = "none"; + let tokenSource: "space" | "system" | "none" = "none"; - // Check for user credentials first - if (userId && this.credentialStore) { - const credentials = await this.credentialStore.getCredentials(userId); + // Check for space credentials first + if (spaceId && this.credentialStore) { + const credentials = await this.credentialStore.getCredentials(spaceId); if (credentials) { // Check if token is expired (with 5 minute buffer) const expiryBuffer = 5 * 60 * 1000; // 5 minutes in milliseconds const isExpired = credentials.expiresAt <= Date.now() + expiryBuffer; if (isExpired) { - logger.info(`Token expired for user ${userId}, attempting refresh`, { - expiresAt: new Date(credentials.expiresAt).toISOString(), - now: new Date().toISOString(), - }); + logger.info( + `Token expired for space ${spaceId}, attempting refresh`, + { + expiresAt: new Date(credentials.expiresAt).toISOString(), + now: new Date().toISOString(), + } + ); // Attempt to refresh the token - const refreshedToken = await this.refreshUserToken(userId); + const refreshedToken = await this.refreshSpaceToken(spaceId); if (refreshedToken) { apiKey = refreshedToken; - tokenSource = "user"; - logger.info(`Using refreshed OAuth token for ${userId}`); + tokenSource = "space"; + logger.info(`Using refreshed OAuth token for space ${spaceId}`); } else { // Refresh failed - will fall back to system token or return error - logger.warn(`Token refresh failed for ${userId}, falling back`); + logger.warn( + `Token refresh failed for space ${spaceId}, falling back` + ); } } else { // Token is still valid apiKey = credentials.accessToken; - tokenSource = "user"; - logger.info(`Using user OAuth token for ${userId}`); + tokenSource = "space"; + logger.info(`Using space OAuth token for ${spaceId}`); } } } @@ -216,7 +222,7 @@ export class AnthropicProxy { // No credentials available - return error if (!apiKey) { - logger.warn(`No API key available for request`, { userId }); + logger.warn(`No API key available for request`, { spaceId }); res.status(401).json({ error: { type: "authentication_error", diff --git a/packages/gateway/src/infrastructure/queue/queue-producer.ts b/packages/gateway/src/infrastructure/queue/queue-producer.ts index 8367953..0090f28 100644 --- a/packages/gateway/src/infrastructure/queue/queue-producer.ts +++ b/packages/gateway/src/infrastructure/queue/queue-producer.ts @@ -16,6 +16,7 @@ export interface MessagePayload { messageId: string; // Individual message ID channelId: string; // Platform channel ID teamId: string; // Team/workspace ID (required for all platforms) + spaceId: string; // Space ID for multi-tenant isolation (user-{hash} or group-{hash}) // Bot & platform info (passed through to worker) botId: string; // Bot identifier diff --git a/packages/gateway/src/infrastructure/queue/types.ts b/packages/gateway/src/infrastructure/queue/types.ts index 05caa3c..40a32ca 100644 --- a/packages/gateway/src/infrastructure/queue/types.ts +++ b/packages/gateway/src/infrastructure/queue/types.ts @@ -101,6 +101,7 @@ export interface ThreadResponsePayload { threadId: string; userId: string; teamId: string; + platform?: string; // Platform identifier (slack, whatsapp, etc.) for multi-platform routing content?: string; // Used only for ephemeral messages (OAuth/auth flows) delta?: string; isFullReplacement?: boolean; diff --git a/packages/gateway/src/orchestration/base-deployment-manager.ts b/packages/gateway/src/orchestration/base-deployment-manager.ts index 8a37bcb..3f7b4db 100644 --- a/packages/gateway/src/orchestration/base-deployment-manager.ts +++ b/packages/gateway/src/orchestration/base-deployment-manager.ts @@ -14,19 +14,24 @@ const logger = createLogger("orchestrator"); /** * Generate a consistent deployment name from user ID and thread ID * This ensures all messages in the same thread use the same worker + * K8s names must be lowercase alphanumeric with hyphens only */ export function generateDeploymentName( userId: string, threadId: string ): string { - const shortThreadId = threadId.replace(".", "-").slice(-10); - const shortUserId = userId.toLowerCase().slice(0, 8); + // Sanitize threadId: replace dots with hyphens, lowercase, take last 10 chars + const shortThreadId = threadId.replace(".", "-").toLowerCase().slice(-10); + // Sanitize userId: remove non-alphanumeric, lowercase, take first 8 chars + const sanitizedUserId = userId.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + const shortUserId = sanitizedUserId.slice(0, 8); return `peerbot-worker-${shortUserId}-${shortThreadId}`; } // Type for module environment variable builder function export type ModuleEnvVarsBuilder = ( userId: string, + spaceId: string, envVars: Record ) => Promise>; @@ -44,7 +49,7 @@ export interface OrchestratorConfig { tag: string; pullPolicy: string; }; - runtimeClassName: string; + runtimeClassName?: string; // Optional - if not set or unavailable, uses default container runtime resources: { requests: { cpu: string; memory: string }; limits: { cpu: string; memory: string }; @@ -215,12 +220,19 @@ export abstract class BaseDeploymentManager { } // Generate worker authentication token with platform info + // Check both top-level teamId (WhatsApp) and platformMetadata.teamId (Slack) + const teamId = messageData.teamId || platformMetadata?.teamId; + const spaceId = messageData.spaceId || threadId; // Fall back to threadId for backwards compatibility const workerToken = generateWorkerToken(userId, threadId, deploymentName, { channelId, - teamId: platformMetadata?.teamId, + teamId, platform: messageData.platform, + spaceId, }); + // Get the dispatcher host for proxy configuration + const dispatcherHost = this.getDispatcherHost(); + let envVars: { [key: string]: string } = { USER_ID: userId, USERNAME: username, @@ -231,9 +243,20 @@ export abstract class BaseDeploymentManager { LOG_LEVEL: "info", WORKSPACE_DIR: "/workspace", THREAD_ID: threadId, + SPACE_ID: spaceId, // Worker authentication and communication WORKER_TOKEN: workerToken, DISPATCHER_URL: this.getDispatcherUrl(), + // Node environment - always production for workers (they have read-only filesystem) + NODE_ENV: "production", + // Enable SDK debugging for crash investigation + DEBUG: "1", + // HTTP proxy configuration for network isolation + // Workers must route all external traffic through the gateway proxy + HTTP_PROXY: `http://${dispatcherHost}:8118`, + HTTPS_PROXY: `http://${dispatcherHost}:8118`, + // Don't proxy internal services + NO_PROXY: `${dispatcherHost},redis,localhost,127.0.0.1`, }; // Add optional environment variables only if they exist @@ -245,7 +268,7 @@ export abstract class BaseDeploymentManager { if (includeSecrets && this.moduleEnvVarsBuilder) { // Add module-specific environment variables try { - envVars = await this.moduleEnvVarsBuilder(userId, envVars); + envVars = await this.moduleEnvVarsBuilder(userId, spaceId, envVars); } catch (error) { logger.warn("Failed to build module environment variables:", error); } diff --git a/packages/gateway/src/orchestration/deployment-utils.ts b/packages/gateway/src/orchestration/deployment-utils.ts index 3e93ea2..82881cb 100644 --- a/packages/gateway/src/orchestration/deployment-utils.ts +++ b/packages/gateway/src/orchestration/deployment-utils.ts @@ -2,8 +2,8 @@ import { moduleRegistry } from "@peerbot/core"; import { platformRegistry } from "../platform"; import type { DeploymentInfo, - OrchestratorConfig, MessagePayload, + OrchestratorConfig, } from "./base-deployment-manager"; /** @@ -65,6 +65,7 @@ export class ResourceParser { */ export async function buildModuleEnvVars( userId: string, + spaceId: string, baseEnv: Record ): Promise> { let envVars = { ...baseEnv }; @@ -72,7 +73,7 @@ export async function buildModuleEnvVars( const orchestratorModules = moduleRegistry.getOrchestratorModules(); for (const module of orchestratorModules) { if (module.buildEnvVars) { - envVars = await module.buildEnvVars(userId, envVars); + envVars = await module.buildEnvVars(userId, spaceId, envVars); } } @@ -85,6 +86,18 @@ export const BASE_WORKER_LABELS = { "peerbot/managed-by": "orchestrator", } as const; +/** + * Worker security constants - must match Dockerfile.worker user configuration + * The 'claude' user is created with UID/GID 1001 in the worker image + */ +export const WORKER_SECURITY = { + USER_ID: 1001, + GROUP_ID: 1001, + // Tmpfs volume sizes (in-memory, matches Docker Tmpfs settings) + TMP_SIZE_LIMIT: "100Mi", + BUN_CACHE_SIZE_LIMIT: "200Mi", +} as const; + export const WORKER_SELECTOR_LABELS = { "app.kubernetes.io/name": BASE_WORKER_LABELS["app.kubernetes.io/name"], "app.kubernetes.io/component": @@ -116,7 +129,7 @@ export function resolvePlatformDeploymentMetadata( } export function getVeryOldThresholdDays(config: OrchestratorConfig): number { - return (config.cleanup?.veryOldDays as number | undefined) || 7; + return config.cleanup?.veryOldDays ?? 7; } export function buildDeploymentInfoSummary({ diff --git a/packages/gateway/src/orchestration/impl/docker-deployment.ts b/packages/gateway/src/orchestration/impl/docker-deployment.ts index da88d44..3ff8330 100644 --- a/packages/gateway/src/orchestration/impl/docker-deployment.ts +++ b/packages/gateway/src/orchestration/impl/docker-deployment.ts @@ -1,12 +1,13 @@ +import fs from "node:fs"; import path from "node:path"; import { createLogger, ErrorCode, OrchestratorError } from "@peerbot/core"; import Docker from "dockerode"; import { BaseDeploymentManager, type DeploymentInfo, + type MessagePayload, type ModuleEnvVarsBuilder, type OrchestratorConfig, - type MessagePayload, } from "../base-deployment-manager"; import { BASE_WORKER_LABELS, @@ -57,6 +58,64 @@ export class DockerDeploymentManager extends BaseDeploymentManager { } } + /** + * Check if gateway is running inside a Docker container + */ + private isRunningInContainer(): boolean { + return fs.existsSync("/.dockerenv") || process.env.CONTAINER === "true"; + } + + /** + * Get the host address that workers should use to reach the gateway + * When gateway runs on host (sidecar mode), workers use host.docker.internal + * When gateway runs in container (docker-compose mode), workers use service name + */ + private getHostAddress(): string { + if (this.isRunningInContainer()) { + return "gateway"; + } + // For host-mode development (sidecar), workers reach gateway via host.docker.internal + return "host.docker.internal"; + } + + /** + * Validate that the worker image exists locally + * Called on gateway startup to ensure workers can be created + */ + async validateWorkerImage(): Promise { + const imageName = `${this.config.worker.image.repository}:${this.config.worker.image.tag}`; + + try { + await this.docker.getImage(imageName).inspect(); + logger.info(`โœ… Worker image verified: ${imageName}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Check if it's a "not found" error + if ( + errorMessage.includes("No such image") || + errorMessage.includes("404") + ) { + logger.error( + `โŒ Worker image not found: ${imageName}\n` + + ` Please build it with: docker compose build worker\n` + + ` Or ensure 'docker compose up' builds the worker service automatically` + ); + throw new OrchestratorError( + ErrorCode.DEPLOYMENT_CREATE_FAILED, + `Worker image ${imageName} does not exist. Build it first with 'docker compose build worker'` + ); + } + + // Other error - re-throw + throw new OrchestratorError( + ErrorCode.DEPLOYMENT_CREATE_FAILED, + `Failed to validate worker image ${imageName}: ${errorMessage}` + ); + } + } + async listDeployments(): Promise { try { const containers = await this.docker.listContainers({ @@ -105,26 +164,69 @@ export class DockerDeploymentManager extends BaseDeploymentManager { } /** - * Ensures a Docker volume exists for the given thread ID. + * Ensures a Docker volume exists for the given space ID. * Uses named volumes for better isolation and security. + * Multiple threads in the same space share the same volume. */ - private async ensureVolume(threadId: string): Promise { - const volumeName = `peerbot-workspace-${threadId}`; + private async ensureVolume(spaceId: string): Promise { + const volumeName = `peerbot-workspace-${spaceId}`; + let volumeCreated = false; try { - // Check if volume already exists + // Check if volume already exists (idempotent for concurrent creation) await this.docker.getVolume(volumeName).inspect(); logger.info(`โœ… Volume ${volumeName} already exists`); } catch (error) { // Volume doesn't exist, create it - await this.docker.createVolume({ - Name: volumeName, - Labels: { - "peerbot.io/thread-id": threadId, - "peerbot.io/created": new Date().toISOString(), - }, - }); - logger.info(`โœ… Created volume: ${volumeName}`); + try { + await this.docker.createVolume({ + Name: volumeName, + Labels: { + "peerbot.io/space-id": spaceId, + "peerbot.io/created": new Date().toISOString(), + }, + }); + logger.info(`โœ… Created volume: ${volumeName}`); + volumeCreated = true; + } catch (createError: any) { + // Handle race condition: volume created by another thread + if ( + createError.statusCode === 409 || + createError.message?.includes("already exists") + ) { + logger.info(`Volume ${volumeName} was created by another thread`); + } else { + throw createError; + } + } + } + + // Fix volume permissions for new volumes + // The claude user in the worker container has UID 1001 + if (volumeCreated) { + try { + const initContainer = await this.docker.createContainer({ + Image: "alpine:latest", + Cmd: ["chown", "-R", "1001:1001", "/workspace"], + HostConfig: { + AutoRemove: true, + Mounts: [ + { + Type: "volume", + Source: volumeName, + Target: "/workspace", + }, + ], + }, + }); + await initContainer.start(); + await initContainer.wait(); + logger.info(`โœ… Fixed volume permissions for ${volumeName}`); + } catch (permError) { + logger.warn( + `โš ๏ธ Could not fix volume permissions: ${permError instanceof Error ? permError.message : String(permError)}` + ); + } } return volumeName; @@ -140,19 +242,23 @@ export class DockerDeploymentManager extends BaseDeploymentManager { (userEnvVarsRaw as Record | undefined) ?? {}; try { - // Extract thread ID from deployment name for per-thread workspace isolation + // Extract thread ID from deployment name for deployment naming const threadId = deploymentName.replace("peerbot-worker-", ""); + // Use spaceId for volume naming (shared across threads in same space) + // Fall back to threadId for backwards compatibility + const spaceId = messageData?.spaceId || threadId; + // Determine if running in Docker and resolve project paths const isRunningInDocker = process.env.DEPLOYMENT_MODE === "docker"; const projectRoot = isRunningInDocker ? process.env.PEERBOT_DEV_PROJECT_PATH || "/app" : path.join(process.cwd(), "..", ".."); - const workspaceDir = `${projectRoot}/workspaces/${threadId}`; + const workspaceDir = `${projectRoot}/workspaces/${spaceId}`; - // Ensure volume exists for production mode - const volumeName = await this.ensureVolume(threadId); + // Ensure volume exists for production mode (space-scoped) + const volumeName = await this.ensureVolume(spaceId); // Get common environment variables from base class const commonEnvVars = await this.generateEnvironmentVariables( @@ -174,19 +280,10 @@ export class DockerDeploymentManager extends BaseDeploymentManager { } } - // Configure HTTP proxy for network-isolated workers - // Network isolation is always enabled - workers always use proxy + // Environment variables from base class already include: + // HTTP_PROXY, HTTPS_PROXY, NO_PROXY, NODE_ENV, DEBUG const envVars = [ `ANTHROPIC_API_KEY=${username}:`, - // Pass NODE_ENV to worker container - `NODE_ENV=${process.env.NODE_ENV || "production"}`, - // Enable SDK debugging for crash investigation - "DEBUG=1", - // HTTP proxy configuration (always enabled for network isolation) - "HTTP_PROXY=http://gateway:8118", - "HTTPS_PROXY=http://gateway:8118", - // Don't proxy internal services - "NO_PROXY=gateway,redis,localhost,127.0.0.1", // Convert common environment variables to Docker format ...Object.entries(commonEnvVars).map( ([key, value]) => `${key}=${value}` @@ -203,6 +300,7 @@ export class DockerDeploymentManager extends BaseDeploymentManager { Labels: { ...BASE_WORKER_LABELS, "peerbot.io/created": new Date().toISOString(), + "peerbot.io/space-id": spaceId, // Docker Compose labels to associate with the project "com.docker.compose.project": composeProjectName, "com.docker.compose.service": deploymentName, // Use unique service name @@ -254,7 +352,17 @@ export class DockerDeploymentManager extends BaseDeploymentManager { this.config.worker.resources.limits.cpu ), // Always connect to internal network (network isolation always enabled) - NetworkMode: `${composeProjectName}_peerbot-internal`, + // In docker-compose mode: uses compose project prefix + // In sidecar mode: uses plain network name (WORKER_NETWORK env var) + NetworkMode: + process.env.WORKER_NETWORK || + `${composeProjectName}_peerbot-internal`, + // Linux support: add host.docker.internal mapping + // On macOS/Windows this is automatic, on Linux we need ExtraHosts + ...(process.platform === "linux" && + !this.isRunningInContainer() && { + ExtraHosts: ["host.docker.internal:host-gateway"], + }), // Security: Drop all capabilities and only add what's needed CapDrop: ["ALL"], CapAdd: process.env.WORKER_CAPABILITIES @@ -338,10 +446,6 @@ export class DockerDeploymentManager extends BaseDeploymentManager { ? deploymentId : `peerbot-worker-${deploymentId}`; - // Extract thread ID for volume cleanup - const threadId = deploymentName.replace("peerbot-worker-", ""); - const volumeName = `peerbot-workspace-${threadId}`; - try { const container = this.docker.getContainer(deploymentName); @@ -368,24 +472,9 @@ export class DockerDeploymentManager extends BaseDeploymentManager { } } - // Clean up volume (only in production mode with named volumes) - if (process.env.NODE_ENV !== "development") { - try { - const volume = this.docker.getVolume(volumeName); - await volume.remove(); - logger.info(`โœ… Removed volume: ${volumeName}`); - } catch (error) { - const dockerError = error as { statusCode?: number }; - if (dockerError.statusCode === 404) { - logger.warn(`โš ๏ธ Volume ${volumeName} not found (already deleted)`); - } else { - logger.warn( - `โš ๏ธ Failed to remove volume ${volumeName}: ${error instanceof Error ? error.message : String(error)}` - ); - // Don't throw - volume cleanup is best-effort - } - } - } + // NOTE: Space volumes are NOT deleted on deployment deletion + // They are shared across threads in the same space and persist + // for future conversations. Cleanup is done manually or via separate process. } async updateDeploymentActivity(_deploymentName: string): Promise { @@ -394,7 +483,6 @@ export class DockerDeploymentManager extends BaseDeploymentManager { } protected getDispatcherHost(): string { - // Use the Docker Compose service name for reliable network resolution - return "gateway"; + return this.getHostAddress(); } } diff --git a/packages/gateway/src/orchestration/impl/k8s-deployment.ts b/packages/gateway/src/orchestration/impl/k8s-deployment.ts index 2d1d4e1..8975bc6 100644 --- a/packages/gateway/src/orchestration/impl/k8s-deployment.ts +++ b/packages/gateway/src/orchestration/impl/k8s-deployment.ts @@ -3,15 +3,16 @@ import { createLogger, ErrorCode, OrchestratorError } from "@peerbot/core"; import { BaseDeploymentManager, type DeploymentInfo, + type MessagePayload, type ModuleEnvVarsBuilder, type OrchestratorConfig, - type MessagePayload, } from "../base-deployment-manager"; import { BASE_WORKER_LABELS, buildDeploymentInfoSummary, getVeryOldThresholdDays, resolvePlatformDeploymentMetadata, + WORKER_SECURITY, WORKER_SELECTOR_LABELS, } from "../deployment-utils"; @@ -75,6 +76,11 @@ interface SimpleDeployment { runAsGroup?: number; runAsNonRoot?: boolean; readOnlyRootFilesystem?: boolean; + allowPrivilegeEscalation?: boolean; + capabilities?: { + drop?: string[]; + add?: string[]; + }; }; resources?: { requests?: Record; @@ -96,6 +102,11 @@ interface SimpleDeployment { runAsGroup?: number; runAsNonRoot?: boolean; readOnlyRootFilesystem?: boolean; + allowPrivilegeEscalation?: boolean; + capabilities?: { + drop?: string[]; + add?: string[]; + }; }; env?: Array<{ name: string; @@ -130,6 +141,7 @@ interface SimpleDeployment { }; emptyDir?: { sizeLimit?: string; + medium?: string; }; hostPath?: { path: string; @@ -144,6 +156,7 @@ interface SimpleDeployment { export class K8sDeploymentManager extends BaseDeploymentManager { private appsV1Api: k8s.AppsV1Api; private coreV1Api: k8s.CoreV1Api; + private nodeV1Api: k8s.NodeV1Api; constructor( config: OrchestratorConfig, @@ -199,18 +212,121 @@ export class K8sDeploymentManager extends BaseDeploymentManager { // Configure K8s API clients this.appsV1Api = kc.makeApiClient(k8s.AppsV1Api); this.coreV1Api = kc.makeApiClient(k8s.CoreV1Api); + this.nodeV1Api = kc.makeApiClient(k8s.NodeV1Api); // API clients are already configured with authentication through makeApiClient logger.info( - `๐Ÿ”ง K8s client initialized with 30s timeout for namespace: ${this.config.kubernetes.namespace}` + `๐Ÿ”ง K8s client initialized for namespace: ${this.config.kubernetes.namespace}` + ); + + // Validate namespace exists and we have access + this.validateNamespace(); + + // Check runtime class availability on initialization (like Docker's gVisor check) + this.checkRuntimeClassAvailability(); + } + + /** + * Validate that the target namespace exists and we have access to it + */ + private async validateNamespace(): Promise { + const namespace = this.config.kubernetes.namespace; + + try { + await this.coreV1Api.readNamespace(namespace); + logger.info(`โœ… Namespace '${namespace}' validated`); + } catch (error) { + const k8sError = error as { statusCode?: number }; + + if (k8sError.statusCode === 404) { + logger.error( + `โŒ Namespace '${namespace}' does not exist. ` + + `Create it with: kubectl create namespace ${namespace}` + ); + throw new OrchestratorError( + ErrorCode.DEPLOYMENT_CREATE_FAILED, + `Namespace '${namespace}' does not exist`, + { namespace }, + true + ); + } else if (k8sError.statusCode === 403) { + // 403 Forbidden for namespace read is expected with namespace-scoped Roles + // The gateway can still create resources in the namespace without cluster-level namespace read permission + logger.info( + `โ„น๏ธ Namespace '${namespace}' access check skipped (namespace-scoped RBAC). ` + + `Will validate via resource operations.` + ); + // Don't throw - we're running in this namespace so it exists + } else { + logger.warn( + `โš ๏ธ Could not validate namespace '${namespace}': ${error instanceof Error ? error.message : String(error)}` + ); + // Don't throw - let operations fail with more specific errors + } + } + } + + /** + * Check if the configured RuntimeClass exists in the cluster + * Similar to Docker's checkGvisorAvailability() + */ + private async checkRuntimeClassAvailability(): Promise { + const runtimeClassName = this.config.worker.runtimeClassName || "kata"; + + try { + await this.nodeV1Api.readRuntimeClass(runtimeClassName); + logger.info( + `โœ… RuntimeClass '${runtimeClassName}' verified and will be used for worker isolation` + ); + } catch (error) { + const k8sError = error as { statusCode?: number }; + if (k8sError.statusCode === 404) { + logger.warn( + `โš ๏ธ RuntimeClass '${runtimeClassName}' not found in cluster. ` + + `Workers will use default runtime. Consider installing ${runtimeClassName} for enhanced isolation.` + ); + } else { + logger.warn( + `โš ๏ธ Failed to verify RuntimeClass '${runtimeClassName}': ${error instanceof Error ? error.message : String(error)}` + ); + } + // Clear runtime class if not available or verification failed (workers will use default) + this.config.worker.runtimeClassName = undefined; + } + } + + /** + * Validate that the worker image exists and is pullable + * Called on gateway startup to ensure workers can be created + */ + async validateWorkerImage(): Promise { + const imageName = `${this.config.worker.image.repository}:${this.config.worker.image.tag}`; + + // For K8s, we can't directly validate if the image exists without creating a pod + // Instead, we log a warning and rely on imagePullPolicy and K8s error handling + logger.info( + `โ„น๏ธ Worker image configured: ${imageName} (pullPolicy: ${this.config.worker.image.pullPolicy || "Always"})` ); + + // If pull policy is "Never", warn that image must be pre-loaded + if (this.config.worker.image.pullPolicy === "Never") { + logger.warn( + `โš ๏ธ Worker image pullPolicy is 'Never'. Ensure image ${imageName} is pre-loaded on all nodes.` + ); + } } async listDeployments(): Promise { try { + // Only list worker deployments using label selector const k8sDeployments = await this.appsV1Api.listNamespacedDeployment( - this.config.kubernetes.namespace + this.config.kubernetes.namespace, + undefined, // pretty + undefined, // allowWatchBookmarks + undefined, // _continue + undefined, // fieldSelector + "app.kubernetes.io/component=worker" // labelSelector - only worker deployments ); const now = Date.now(); @@ -257,9 +373,10 @@ export class K8sDeploymentManager extends BaseDeploymentManager { } /** - * Create a PersistentVolumeClaim for a worker deployment + * Create a PersistentVolumeClaim for a space. + * Multiple threads in the same space share the same PVC. */ - private async createPVC(pvcName: string): Promise { + private async createPVC(pvcName: string, spaceId: string): Promise { const pvc = { apiVersion: "v1", kind: "PersistentVolumeClaim", @@ -269,6 +386,7 @@ export class K8sDeploymentManager extends BaseDeploymentManager { labels: { ...BASE_WORKER_LABELS, "app.kubernetes.io/component": "worker-storage", + "peerbot.io/space-id": spaceId, }, }, spec: { @@ -285,13 +403,25 @@ export class K8sDeploymentManager extends BaseDeploymentManager { }; try { + logger.debug( + `Creating PVC: ${pvcName} in namespace ${this.config.kubernetes.namespace}` + ); await this.coreV1Api.createNamespacedPersistentVolumeClaim( this.config.kubernetes.namespace, pvc ); logger.info(`โœ… Created PVC: ${pvcName}`); } catch (error) { - const k8sError = error as { statusCode?: number }; + const k8sError = error as { + statusCode?: number; + body?: unknown; + message?: string; + }; + logger.error(`PVC creation error for ${pvcName}:`, { + statusCode: k8sError.statusCode, + message: k8sError.message, + body: k8sError.body, + }); if (k8sError.statusCode === 409) { logger.info(`PVC ${pvcName} already exists (reusing)`); } else { @@ -311,17 +441,21 @@ export class K8sDeploymentManager extends BaseDeploymentManager { `๐Ÿš€ Creating K8s deployment: ${deploymentName} for user ${userId}` ); - // Create PVC for this deployment (per-thread persistent storage) - const pvcName = `${deploymentName}-pvc`; - await this.createPVC(pvcName); + // Use spaceId for PVC naming (shared across threads in same space) + // Fall back to deployment name for backwards compatibility + const threadId = deploymentName.replace("peerbot-worker-", ""); + const spaceId = messageData?.spaceId || threadId; + const pvcName = `peerbot-workspace-${spaceId}`; + await this.createPVC(pvcName, spaceId); // Get environment variables before creating the deployment spec + // Include secrets (same as Docker behavior) - secrets are passed via env vars const envVars = await this.generateEnvironmentVariables( username, userId, deploymentName, messageData, - false, + true, // Include secrets to match Docker behavior userEnvVars ); @@ -344,14 +478,18 @@ export class K8sDeploymentManager extends BaseDeploymentManager { // Add platform-specific metadata ...resolvePlatformDeploymentMetadata(messageData), "peerbot.io/created": new Date().toISOString(), + "peerbot.io/space-id": spaceId, }, labels: { ...BASE_WORKER_LABELS }, }, spec: { serviceAccountName: "peerbot-worker", - runtimeClassName: this.config.worker.runtimeClassName || "kata", + // Only set runtimeClassName if configured and available (validated on startup) + ...(this.config.worker.runtimeClassName + ? { runtimeClassName: this.config.worker.runtimeClassName } + : {}), securityContext: { - fsGroup: 1001, + fsGroup: WORKER_SECURITY.GROUP_ID, fsGroupChangePolicy: "OnRootMismatch", }, containers: [ @@ -361,23 +499,25 @@ export class K8sDeploymentManager extends BaseDeploymentManager { imagePullPolicy: this.config.worker.image.pullPolicy || "Always", securityContext: { - runAsUser: 1001, - runAsGroup: 1001, + runAsUser: WORKER_SECURITY.USER_ID, + runAsGroup: WORKER_SECURITY.GROUP_ID, runAsNonRoot: true, - readOnlyRootFilesystem: false, + // Enable read-only root filesystem for security (matches Docker behavior) + readOnlyRootFilesystem: true, + // Prevent privilege escalation + allowPrivilegeEscalation: false, + // Drop all capabilities (matches Docker CAP_DROP: ALL) + capabilities: { + drop: ["ALL"], + }, }, env: [ - // Common environment variables from base class (includes ANTHROPIC_API_KEY) + // Common environment variables from base class + // (includes HTTP_PROXY, HTTPS_PROXY, NO_PROXY, NODE_ENV, DEBUG) ...Object.entries(envVars).map(([key, value]) => ({ name: key, value: value, })), - // Pass NODE_ENV to worker pods - { - name: "NODE_ENV", - value: process.env.NODE_ENV || "production", - }, - // Module-specific environment variables are added through base class ], resources: { requests: this.config.worker.resources.requests, @@ -388,6 +528,15 @@ export class K8sDeploymentManager extends BaseDeploymentManager { name: "workspace", mountPath: "/workspace", }, + // Tmpfs mounts for writable directories (matches Docker behavior) + { + name: "tmp", + mountPath: "/tmp", + }, + { + name: "bun-cache", + mountPath: "/home/bun/.cache", + }, ], }, ], @@ -399,6 +548,21 @@ export class K8sDeploymentManager extends BaseDeploymentManager { claimName: pvcName, }, }, + // Tmpfs volumes for temporary files (in-memory, matches Docker Tmpfs) + { + name: "tmp", + emptyDir: { + medium: "Memory", + sizeLimit: WORKER_SECURITY.TMP_SIZE_LIMIT, + }, + }, + { + name: "bun-cache", + emptyDir: { + medium: "Memory", + sizeLimit: WORKER_SECURITY.BUN_CACHE_SIZE_LIMIT, + }, + }, ], }, }, @@ -503,7 +667,11 @@ export class K8sDeploymentManager extends BaseDeploymentManager { undefined, undefined, undefined, - { headers: { "Content-Type": "application/json-patch+json" } } + { + headers: { + "Content-Type": "application/strategic-merge-patch+json", + }, + } ); } } catch (error) { @@ -518,13 +686,17 @@ export class K8sDeploymentManager extends BaseDeploymentManager { async deleteDeployment(deploymentId: string): Promise { const deploymentName = `peerbot-worker-${deploymentId}`; - const pvcName = `${deploymentName}-pvc`; - // Delete the deployment + // Delete the deployment with propagation policy try { await this.appsV1Api.deleteNamespacedDeployment( deploymentName, - this.config.kubernetes.namespace + this.config.kubernetes.namespace, + undefined, + undefined, + undefined, + undefined, + "Foreground" // Wait for pods to terminate before returning ); logger.info(`โœ… Deleted deployment: ${deploymentName}`); } catch (error) { @@ -538,22 +710,9 @@ export class K8sDeploymentManager extends BaseDeploymentManager { } } - // Delete the PVC - try { - await this.coreV1Api.deleteNamespacedPersistentVolumeClaim( - pvcName, - this.config.kubernetes.namespace - ); - logger.info(`โœ… Deleted PVC: ${pvcName}`); - } catch (error) { - const k8sError = error as { statusCode?: number }; - if (k8sError.statusCode === 404) { - logger.info(`โš ๏ธ PVC ${pvcName} not found (already deleted)`); - } else { - logger.error(`Failed to delete PVC ${pvcName}:`, error); - // Don't throw - deployment deletion should succeed even if PVC cleanup fails - } - } + // NOTE: Space PVCs are NOT deleted on deployment deletion + // They are shared across threads in the same space and persist + // for future conversations. Cleanup is done manually or via separate process. } async updateDeploymentActivity(deploymentName: string): Promise { @@ -576,7 +735,9 @@ export class K8sDeploymentManager extends BaseDeploymentManager { undefined, undefined, undefined, - { headers: { "Content-Type": "application/json-patch+json" } } + { + headers: { "Content-Type": "application/strategic-merge-patch+json" }, + } ); } catch (error) { logger.error( diff --git a/packages/gateway/src/orchestration/index.ts b/packages/gateway/src/orchestration/index.ts index 35d8e5b..ebb9a5e 100644 --- a/packages/gateway/src/orchestration/index.ts +++ b/packages/gateway/src/orchestration/index.ts @@ -137,6 +137,11 @@ export class Orchestrator { await moduleRegistry.initAll(); logger.info("โœ… Modules initialized for orchestration"); + // Validate worker image exists (Docker mode only) + if (this.deploymentManager instanceof DockerDeploymentManager) { + await this.deploymentManager.validateWorkerImage(); + } + // Start queue consumer await this.queueConsumer.start(); diff --git a/packages/gateway/src/orchestration/message-consumer.ts b/packages/gateway/src/orchestration/message-consumer.ts index b71fbde..f739ca4 100644 --- a/packages/gateway/src/orchestration/message-consumer.ts +++ b/packages/gateway/src/orchestration/message-consumer.ts @@ -6,6 +6,7 @@ import { } from "@peerbot/core"; import * as Sentry from "@sentry/node"; import type { ClaudeCredentialStore } from "../auth/claude/credential-store"; +import { platformAuthRegistry } from "../auth/platform-auth"; import type { IMessageQueue, QueueJob as SharedQueueJob, @@ -14,8 +15,8 @@ import { RedisQueue, type RedisQueueConfig } from "../infrastructure/queue"; import { type BaseDeploymentManager, generateDeploymentName, - type OrchestratorConfig, type MessagePayload, + type OrchestratorConfig, } from "./base-deployment-manager"; const logger = createLogger("orchestrator"); @@ -129,45 +130,65 @@ export class MessageConsumer { `User ${data.userId} has no credentials - sending authentication prompt` ); - // Send ephemeral authentication prompt via thread_response queue - await this.queue.createQueue("thread_response"); - // TODO: use Platform Abstraction to render the authentication prompt - await this.queue.send("thread_response", { - messageId: data.messageId, - userId: data.userId, - channelId: data.channelId, - threadId: data.threadId, - ephemeral: true, - content: JSON.stringify({ - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: "๐Ÿ” *Authentication Required*\n\nYou need to login with your Claude account to use this bot. Please visit the app home tab to authenticate.", + // Use platform auth adapter if available + const authAdapter = platformAuthRegistry.get(data.platform); + if (authAdapter) { + // Platform-specific auth prompt (e.g., WhatsApp numbered list) + await authAdapter.sendAuthPrompt( + data.userId, + data.channelId, + data.threadId, + [{ id: "claude", name: "Claude" }], + data.platformMetadata + ); + logger.info( + `โœ… Sent platform-specific auth prompt to user ${data.userId} via ${data.platform} adapter` + ); + } else { + // Fallback: Send Slack-style ephemeral message for platforms without adapter + const responseQueue = "thread_response"; + await this.queue.createQueue(responseQueue); + await this.queue.send(responseQueue, { + messageId: data.messageId, + userId: data.userId, + channelId: data.channelId, + threadId: data.threadId, + platform: data.platform, + platformMetadata: data.platformMetadata, + ephemeral: true, + content: JSON.stringify({ + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "๐Ÿ” *Authentication Required*\n\nYou need to login with your Claude account to use this bot. Please visit the app home tab to authenticate.", + }, }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "Login with Claude", + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Login with Claude", + }, + style: "primary", + action_id: "claude_auth_start", + value: "start_auth", }, - style: "primary", - action_id: "claude_auth_start", - value: "start_auth", - }, - ], - }, - ], - }), - processedMessageIds: [data.messageId], - }); - - logger.info(`โœ… Sent authentication prompt to user ${data.userId}`); + ], + }, + ], + }), + processedMessageIds: [data.messageId], + }); + logger.info( + `โœ… Sent Slack-style auth prompt to user ${data.userId}` + ); + } + return; // Don't create worker } } diff --git a/packages/gateway/src/platform.ts b/packages/gateway/src/platform.ts index 84ada2a..0402cdd 100644 --- a/packages/gateway/src/platform.ts +++ b/packages/gateway/src/platform.ts @@ -7,11 +7,13 @@ import type { } from "@peerbot/core"; import type { ClaudeCredentialStore } from "./auth/claude/credential-store"; import type { ClaudeModelPreferenceStore } from "./auth/claude/model-preference-store"; +import type { ClaudeOAuthStateStore } from "./auth/claude/oauth-state-store"; import type { McpProxy } from "./auth/mcp/proxy"; import type { WorkerGateway } from "./gateway"; import type { AnthropicProxy } from "./infrastructure/model-provider"; import type { IMessageQueue, QueueProducer } from "./infrastructure/queue"; import type { InteractionService } from "./interactions"; +import type { ResponseRenderer } from "./platform/response-renderer"; import type { InstructionService } from "./services/instruction-service"; import type { ISessionManager } from "./session"; @@ -31,6 +33,8 @@ export interface CoreServices { getMcpProxy(): McpProxy | undefined; getClaudeCredentialStore(): ClaudeCredentialStore | undefined; getClaudeModelPreferenceStore(): ClaudeModelPreferenceStore | undefined; + getClaudeOAuthStateStore(): ClaudeOAuthStateStore | undefined; + getPublicGatewayUrl(): string; getSessionManager(): ISessionManager; getInstructionService(): InstructionService | undefined; getInteractionService(): InteractionService; @@ -189,6 +193,15 @@ export interface PlatformAdapter { metadata?: Record; }> ): Promise; + + /** + * Get the response renderer for this platform. + * Used by the unified thread response consumer to route responses + * to platform-specific rendering logic. + * + * @returns ResponseRenderer instance or undefined if platform handles responses differently + */ + getResponseRenderer?(): ResponseRenderer | undefined; } // ============================================================================ diff --git a/packages/gateway/src/platform/file-handler.ts b/packages/gateway/src/platform/file-handler.ts new file mode 100644 index 0000000..2fb26f1 --- /dev/null +++ b/packages/gateway/src/platform/file-handler.ts @@ -0,0 +1,61 @@ +/** + * File handler interface for platform-specific file operations. + */ + +import type { Readable } from "node:stream"; + +export interface FileMetadata { + id: string; + name: string; + mimetype?: string; + size: number; + url: string; + downloadUrl?: string; + permalink?: string; + timestamp?: number; +} + +export interface FileUploadResult { + fileId: string; + permalink: string; + name: string; + size: number; +} + +export interface FileUploadOptions { + filename: string; + channelId: string; + threadTs?: string; + title?: string; + initialComment?: string; + sessionKey?: string; +} + +export interface IFileHandler { + downloadFile( + fileId: string, + bearerToken: string + ): Promise<{ stream: Readable; metadata: FileMetadata }>; + + uploadFile( + fileStream: Readable, + options: FileUploadOptions + ): Promise; + + generateFileToken( + sessionKey: string, + fileId: string, + expiresIn?: number + ): string; + + validateFileToken(token: string): { + valid: boolean; + sessionKey?: string; + fileId?: string; + error?: string; + }; + + getSessionFiles(sessionKey: string): string[]; + + cleanupSession(sessionKey: string): void; +} diff --git a/packages/gateway/src/platform/interaction-utils.ts b/packages/gateway/src/platform/interaction-utils.ts new file mode 100644 index 0000000..153b298 --- /dev/null +++ b/packages/gateway/src/platform/interaction-utils.ts @@ -0,0 +1,94 @@ +/** + * Shared interaction utilities for platform interaction renderers. + */ + +import type { FieldSchema } from "@peerbot/core"; + +export type InteractionDisplayType = "radio" | "single-form" | "multi-section"; + +const NUMBER_WORDS: Record = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + first: 1, + second: 2, + third: 3, + fourth: 4, + fifth: 5, +}; + +export const APPROVAL_OPTIONS = ["Yes", "No"] as const; + +/** + * Determine interaction type from options format. + */ +export function getInteractionType(options: unknown): InteractionDisplayType { + if (Array.isArray(options)) { + if (options.length === 0) return "radio"; + + const firstItem = options[0]; + if ( + typeof firstItem === "object" && + firstItem !== null && + "label" in firstItem && + "fields" in firstItem + ) { + return options.length === 1 ? "single-form" : "multi-section"; + } + return "radio"; + } + + if (options && typeof options === "object") { + const firstValue = Object.values(options)[0]; + if (firstValue && typeof firstValue === "object" && "type" in firstValue) { + return "single-form"; + } + return "multi-section"; + } + + return "radio"; +} + +export function isApprovalInteraction(interactionType: string): boolean { + return ( + interactionType === "tool_approval" || interactionType === "plan_approval" + ); +} + +export function formatNumberedOptions( + question: string, + options: string[] +): string { + const list = options.map((opt, i) => `${i + 1}. ${opt}`).join("\n"); + return `${question}\n\n${list}\n\nReply with the number of your choice.`; +} + +export function parseOptionResponse( + response: string, + options: string[] +): string | null { + const trimmed = response.trim().toLowerCase(); + + const num = parseInt(trimmed, 10); + if (!Number.isNaN(num) && num >= 1 && num <= options.length) { + return options[num - 1] ?? null; + } + + const wordNum = NUMBER_WORDS[trimmed]; + if (wordNum && wordNum >= 1 && wordNum <= options.length) { + return options[wordNum - 1] ?? null; + } + + return options.find((opt) => opt.toLowerCase() === trimmed) ?? null; +} + +export function getFieldLabel(fieldName: string, field: FieldSchema): string { + return field.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1); +} diff --git a/packages/gateway/src/platform/platform-factory.ts b/packages/gateway/src/platform/platform-factory.ts new file mode 100644 index 0000000..a976579 --- /dev/null +++ b/packages/gateway/src/platform/platform-factory.ts @@ -0,0 +1,116 @@ +/** + * Platform factory for declarative platform registration. + * Replaces conditional platform creation in gateway startup. + */ + +import { createLogger } from "@peerbot/core"; +import type { PlatformAdapter } from "../platform"; + +const logger = createLogger("platform-factory"); + +/** + * Factory interface for creating platform adapters. + */ +export interface PlatformFactory { + /** + * Platform name (e.g., "slack", "whatsapp") + */ + readonly name: string; + + /** + * Check if platform is enabled based on configuration. + */ + isEnabled(config: PlatformConfigs): boolean; + + /** + * Create platform adapter instance. + */ + create( + config: PlatformConfigs, + agentOptions: AgentOptions, + sessionTimeoutMinutes: number + ): PlatformAdapter; +} + +/** + * Agent options passed to platforms. + */ +export interface AgentOptions { + model?: string; + maxTokens?: number; + temperature?: number; + allowedTools?: string[]; + disallowedTools?: string[]; + timeoutMinutes?: number; +} + +/** + * Combined platform configurations. + */ +export interface PlatformConfigs { + slack?: any; + whatsapp?: any; + [key: string]: any; +} + +/** + * Registry of platform factories. + * Platforms register themselves on module load. + */ +class PlatformFactoryRegistry { + private factories = new Map(); + + /** + * Register a platform factory. + */ + register(factory: PlatformFactory): void { + this.factories.set(factory.name, factory); + logger.info(`Registered platform factory: ${factory.name}`); + } + + /** + * Get all registered factories. + */ + getAll(): PlatformFactory[] { + return Array.from(this.factories.values()); + } + + /** + * Get factory by name. + */ + get(name: string): PlatformFactory | undefined { + return this.factories.get(name); + } + + /** + * Create all enabled platforms. + */ + createEnabledPlatforms( + configs: PlatformConfigs, + agentOptions: AgentOptions, + sessionTimeoutMinutes: number + ): PlatformAdapter[] { + const platforms: PlatformAdapter[] = []; + + for (const factory of this.factories.values()) { + if (factory.isEnabled(configs)) { + logger.info(`Creating platform: ${factory.name}`); + const platform = factory.create( + configs, + agentOptions, + sessionTimeoutMinutes + ); + platforms.push(platform); + } else { + logger.info(`Platform ${factory.name} is disabled, skipping`); + } + } + + return platforms; + } +} + +/** + * Global platform factory registry. + */ +export const platformFactoryRegistry = new PlatformFactoryRegistry(); diff --git a/packages/gateway/src/platform/response-renderer.ts b/packages/gateway/src/platform/response-renderer.ts new file mode 100644 index 0000000..334f23a --- /dev/null +++ b/packages/gateway/src/platform/response-renderer.ts @@ -0,0 +1,81 @@ +/** + * Response renderer interface for platform-specific message rendering. + * Each platform implements this interface to handle thread responses + * in a platform-appropriate way (streaming, buffering, formatting). + */ + +import type { ThreadResponsePayload } from "../infrastructure/queue/types"; + +/** + * Interface for rendering thread responses to a specific platform. + * Implementations handle platform-specific concerns like: + * - Streaming vs buffered messages + * - Rich formatting (Slack blocks) vs plain text (WhatsApp) + * - Status indicators (thread status vs typing) + */ +export interface ResponseRenderer { + /** + * Handle streaming delta content. + * Platforms that support streaming (Slack) update messages in real-time. + * Platforms without streaming (WhatsApp) buffer content for later delivery. + * + * @param payload - The thread response payload containing delta content + * @param sessionKey - Unique key for this response session (userId:messageId) + * @returns Message ID/timestamp if a message was created/updated, null otherwise + */ + handleDelta?( + payload: ThreadResponsePayload, + sessionKey: string + ): Promise; + + /** + * Handle completion of response processing. + * Called when all content has been processed (processedMessageIds is set). + * Should finalize any streams, send buffered content, clear status indicators. + * + * @param payload - The thread response payload + * @param sessionKey - Unique key for this response session + */ + handleCompletion( + payload: ThreadResponsePayload, + sessionKey: string + ): Promise; + + /** + * Handle error response. + * Display error in platform-appropriate format. + * + * @param payload - The thread response payload containing error + * @param sessionKey - Unique key for this response session + */ + handleError( + payload: ThreadResponsePayload, + sessionKey: string + ): Promise; + + /** + * Handle status updates (heartbeat with elapsed time). + * Used to show "is running...", progress indicators, etc. + * + * @param payload - The thread response payload with statusUpdate field + */ + handleStatusUpdate?(payload: ThreadResponsePayload): Promise; + + /** + * Handle ephemeral messages (visible only to specific user). + * Used for OAuth/auth flows, temporary notifications. + * + * @param payload - The thread response payload with ephemeral content + */ + handleEphemeral?(payload: ThreadResponsePayload): Promise; + + /** + * Stop any active streams for a thread. + * Called when an interaction is created to prevent messages appearing + * after the interaction prompt. + * + * @param userId - User ID + * @param threadId - Thread identifier + */ + stopStreamForThread?(userId: string, threadId: string): Promise; +} diff --git a/packages/gateway/src/platform/unified-thread-consumer.ts b/packages/gateway/src/platform/unified-thread-consumer.ts new file mode 100644 index 0000000..74e2146 --- /dev/null +++ b/packages/gateway/src/platform/unified-thread-consumer.ts @@ -0,0 +1,162 @@ +/** + * Unified thread response consumer. + * Single consumer that routes responses to platform-specific renderers + * via the PlatformRegistry, eliminating duplicate queue filtering logic. + */ + +import { createLogger } from "@peerbot/core"; +import type { + IMessageQueue, + QueueJob, + ThreadResponsePayload, +} from "../infrastructure/queue"; +import type { PlatformRegistry } from "../platform"; +import type { ResponseRenderer } from "./response-renderer"; + +const logger = createLogger("unified-thread-consumer"); + +/** + * Unified consumer for thread_response queue. + * Routes responses to the appropriate platform adapter based on payload.platform field. + */ +export class UnifiedThreadResponseConsumer { + private isRunning = false; + + constructor( + private queue: IMessageQueue, + private platformRegistry: PlatformRegistry + ) {} + + /** + * Start consuming thread_response messages. + */ + async start(): Promise { + try { + await this.queue.start(); + await this.queue.createQueue("thread_response"); + + await this.queue.work( + "thread_response", + this.handleThreadResponse.bind(this) + ); + + this.isRunning = true; + logger.info("Unified thread response consumer started"); + } catch (error) { + logger.error("Failed to start unified thread response consumer:", error); + throw error; + } + } + + /** + * Stop the consumer. + */ + async stop(): Promise { + this.isRunning = false; + await this.queue.stop(); + logger.info("Unified thread response consumer stopped"); + } + + /** + * Handle a thread response job by routing to the appropriate platform renderer. + */ + private async handleThreadResponse( + job: QueueJob + ): Promise { + const data = job.data; + + if (!data || !data.messageId) { + logger.error(`Invalid thread response data: ${JSON.stringify(data)}`); + return; + } + + // Use platform field, fall back to teamId, then default to slack for backwards compatibility + const platformName = data.platform || data.teamId || "slack"; + + // Get platform adapter from registry + const platform = this.platformRegistry.get(platformName); + if (!platform) { + logger.warn( + `No platform adapter registered for: ${platformName}, skipping message ${data.messageId}` + ); + return; + } + + // Get renderer from platform + const renderer = platform.getResponseRenderer?.(); + if (!renderer) { + logger.warn( + `Platform ${platformName} does not provide a response renderer, skipping message ${data.messageId}` + ); + return; + } + + // Create session key for tracking + const sessionKey = `${data.userId}:${data.originalMessageId || data.messageId}`; + + logger.info( + `Processing thread response for platform=${platformName}, message=${data.messageId}, session=${sessionKey}` + ); + + try { + await this.routeToRenderer(renderer, data, sessionKey); + } catch (error) { + logger.error( + `Error processing thread response for ${platformName}:`, + error + ); + // Let queue handle retry logic + throw error; + } + } + + /** + * Route the payload to the appropriate renderer method. + */ + private async routeToRenderer( + renderer: ResponseRenderer, + data: ThreadResponsePayload, + sessionKey: string + ): Promise { + // Handle ephemeral messages (OAuth/auth flows) + if (data.ephemeral && data.content && renderer.handleEphemeral) { + await renderer.handleEphemeral(data); + return; + } + + // Handle status updates (heartbeat with elapsed time) + if (data.statusUpdate && renderer.handleStatusUpdate) { + await renderer.handleStatusUpdate(data); + return; + } + + // Handle streaming delta + if (data.delta && renderer.handleDelta) { + await renderer.handleDelta(data, sessionKey); + // Early return if no error - delta processing is complete + if (!data.error) { + return; + } + } + + // Handle error + if (data.error) { + await renderer.handleError(data, sessionKey); + // Also complete session on error + await renderer.handleCompletion(data, sessionKey); + return; + } + + // Handle completion + if (data.processedMessageIds?.length) { + await renderer.handleCompletion(data, sessionKey); + } + } + + /** + * Check if consumer is healthy. + */ + isHealthy(): boolean { + return this.isRunning; + } +} diff --git a/packages/gateway/src/proxy/http-proxy.ts b/packages/gateway/src/proxy/http-proxy.ts index c9f07ac..6b05a3e 100644 --- a/packages/gateway/src/proxy/http-proxy.ts +++ b/packages/gateway/src/proxy/http-proxy.ts @@ -73,7 +73,7 @@ function isHostnameAllowed( function extractConnectHostname(url: string): string | null { // CONNECT requests are in format: "host:port" const match = url.match(/^([^:]+):\d+$/); - return match && match[1] ? match[1] : null; + return match?.[1] ? match[1] : null; } /** @@ -100,10 +100,14 @@ function handleConnect( // Check if hostname is allowed if (!isHostnameAllowed(hostname, allowedDomains, disallowedDomains)) { logger.warn(`Blocked CONNECT to ${hostname} (not in allowlist)`); - clientSocket.write( - "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nDomain not allowed by proxy policy\r\n" - ); - clientSocket.end(); + try { + clientSocket.write( + "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nDomain not allowed by proxy policy\r\n" + ); + clientSocket.end(); + } catch { + // Client may have already disconnected + } return; } @@ -132,13 +136,43 @@ function handleConnect( }); targetSocket.on("error", (err) => { - logger.error(`Target connection error for ${hostname}:`, err.message); - clientSocket.end(); + logger.debug(`Target connection error for ${hostname}: ${err.message}`); + try { + clientSocket.end(); + } catch { + // Ignore errors when closing already-closed socket + } }); clientSocket.on("error", (err) => { - logger.error(`Client connection error for ${hostname}:`, err.message); - targetSocket.end(); + // ECONNRESET is common when clients drop connections - don't log as error + if ((err as NodeJS.ErrnoException).code === "ECONNRESET") { + logger.debug(`Client disconnected for ${hostname} (ECONNRESET)`); + } else { + logger.debug(`Client connection error for ${hostname}: ${err.message}`); + } + try { + targetSocket.end(); + } catch { + // Ignore errors when closing already-closed socket + } + }); + + // Handle close events to clean up + targetSocket.on("close", () => { + try { + clientSocket.end(); + } catch { + // Ignore + } + }); + + clientSocket.on("close", () => { + try { + targetSocket.end(); + } catch { + // Ignore + } }); } @@ -227,11 +261,14 @@ export function startHttpProxy(port: number = 8118): http.Server { }); server.listen(port, "0.0.0.0", () => { - const mode = isUnrestrictedMode(allowedDomains) - ? "unrestricted" - : allowedDomains.length > 0 - ? "allowlist" - : "complete-isolation"; + let mode: string; + if (isUnrestrictedMode(allowedDomains)) { + mode = "unrestricted"; + } else if (allowedDomains.length > 0) { + mode = "allowlist"; + } else { + mode = "complete-isolation"; + } logger.info( `๐Ÿ”’ HTTP proxy started on port ${port} (mode=${mode}, allowed=${allowedDomains.length}, disallowed=${disallowedDomains.length})` diff --git a/packages/gateway/src/routes/auth-callback.ts b/packages/gateway/src/routes/auth-callback.ts new file mode 100644 index 0000000..71cc639 --- /dev/null +++ b/packages/gateway/src/routes/auth-callback.ts @@ -0,0 +1,454 @@ +/** + * Auth Callback Routes - Handle OAuth code submission from web form. + * Used by WhatsApp users (and other non-modal platforms) to complete OAuth flow. + */ + +import { createLogger } from "@peerbot/core"; +import type { Request, Response, Router } from "express"; +import type { ClaudeCredentialStore } from "../auth/claude/credential-store"; +import type { ClaudeOAuthStateStore } from "../auth/claude/oauth-state-store"; +import { ClaudeOAuthClient } from "../auth/oauth/claude-client"; +import { platformAuthRegistry } from "../auth/platform-auth"; + +const logger = createLogger("auth-callback"); + +export interface AuthCallbackConfig { + stateStore: ClaudeOAuthStateStore; + credentialStore: ClaudeCredentialStore; +} + +/** + * Register auth callback routes on the Express app. + */ +export function registerAuthCallbackRoutes( + router: Router, + config: AuthCallbackConfig +): void { + const oauthClient = new ClaudeOAuthClient(); + + // GET /auth/callback - Serve the HTML form + router.get("/auth/callback", (_req: Request, res: Response) => { + res.setHeader("Content-Type", "text/html"); + res.send(renderCallbackPage()); + }); + + // POST /auth/callback - Process the code + router.post("/auth/callback", async (req: Request, res: Response) => { + try { + const { code: rawCode } = req.body; + + if (!rawCode || typeof rawCode !== "string") { + res.status(400).send(renderErrorPage("Missing authentication code")); + return; + } + + // Parse CODE#STATE format + const parts = rawCode.trim().split("#"); + if (parts.length !== 2) { + res + .status(400) + .send( + renderErrorPage( + "Invalid format. Expected CODE#STATE format from Claude authorization." + ) + ); + return; + } + + const [authCode, state] = parts; + + if (!authCode || !state) { + res + .status(400) + .send(renderErrorPage("Missing code or state in submission")); + return; + } + + logger.info( + { hasCode: !!authCode, hasState: !!state }, + "Processing auth code submission" + ); + + // Validate and consume state + const stateData = await config.stateStore.consume(state); + if (!stateData) { + res + .status(400) + .send( + renderErrorPage( + "Invalid or expired authentication state. Please try again from the beginning." + ) + ); + return; + } + + // Exchange code for token + const credentials = await oauthClient.exchangeCodeForToken( + authCode, + stateData.codeVerifier, + "https://console.anthropic.com/oauth/code/callback", + state + ); + + // Store credentials using spaceId for multi-tenant isolation + await config.credentialStore.setCredentials( + stateData.spaceId, + credentials + ); + logger.info( + { userId: stateData.userId, spaceId: stateData.spaceId }, + "OAuth successful via web callback" + ); + + // Send success message via platform adapter if context is available + if (stateData.context) { + const { platform, channelId } = stateData.context; + const authAdapter = platformAuthRegistry.get(platform); + if (authAdapter) { + await authAdapter.sendAuthSuccess(stateData.userId, channelId, { + id: "claude", + name: "Claude", + }); + logger.info( + { platform, channelId }, + "Sent auth success message via platform adapter" + ); + } + } + + res.send(renderSuccessPage()); + } catch (error) { + logger.error({ error }, "Failed to process auth callback"); + res + .status(500) + .send( + renderErrorPage( + "Failed to complete authentication. Please try again." + ) + ); + } + }); + + logger.info("Auth callback routes registered at /auth/callback"); +} + +function renderCallbackPage(): string { + return ` + + + + + Complete Authentication - Peerbot + + + +
+ +

Complete Authentication

+

Connect your Claude account to Peerbot

+ +
+
+ โœ“ + Clicked the authorization link in WhatsApp +
+
+ โœ“ + Authorized with Claude and received a code +
+
+ 3 + Paste the code below to complete authentication +
+
+ +
+ + +

Paste the entire code including the # symbol

+ +
+
+ +`; +} + +function renderSuccessPage(): string { + return ` + + + + + Authentication Successful - Peerbot + + + +
+
โœ…
+

Authentication Successful!

+

You're now connected to Claude. Return to WhatsApp and send your message again to start chatting.

+

You can safely close this page.

+
+ +`; +} + +function renderErrorPage(message: string): string { + return ` + + + + + Authentication Error - Peerbot + + + +
+
โŒ
+

Authentication Failed

+

Something went wrong during authentication.

+
${escapeHtml(message)}
+ Try Again +
+ +`; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/gateway/src/routes/internal/files.ts b/packages/gateway/src/routes/internal/files.ts index d9a83c9..d835931 100644 --- a/packages/gateway/src/routes/internal/files.ts +++ b/packages/gateway/src/routes/internal/files.ts @@ -3,7 +3,7 @@ import { createLogger, verifyWorkerToken } from "@peerbot/core"; import type { Request, Response } from "express"; import { Router } from "express"; import multer from "multer"; -import type { FileHandler } from "../../services/file-handler"; +import type { IFileHandler } from "../../platform/file-handler"; import type { ISessionManager } from "../../session"; const logger = createLogger("file-routes"); @@ -19,7 +19,7 @@ const upload = multer({ * Create internal file routes for worker file operations */ export function createFileRoutes( - fileHandler: FileHandler, + fileHandler: IFileHandler, _sessionManager: ISessionManager ): Router { const router = Router(); diff --git a/packages/gateway/src/services/core-services.ts b/packages/gateway/src/services/core-services.ts index 154888c..d0731e5 100644 --- a/packages/gateway/src/services/core-services.ts +++ b/packages/gateway/src/services/core-services.ts @@ -8,10 +8,10 @@ import { ClaudeOAuthStateStore } from "../auth/claude/oauth-state-store"; import { McpConfigService } from "../auth/mcp/config-service"; import { McpCredentialStore } from "../auth/mcp/credential-store"; import { McpInputStore } from "../auth/mcp/input-store"; -import { OAuthDiscoveryService } from "../auth/oauth/discovery"; import { McpOAuthModule } from "../auth/mcp/oauth-module"; import { McpOAuthStateStore } from "../auth/mcp/oauth-state-store"; import { McpProxy } from "../auth/mcp/proxy"; +import { OAuthDiscoveryService } from "../auth/oauth/discovery"; import type { GatewayConfig } from "../config"; import { WorkerGateway } from "../gateway"; import { AnthropicProxy } from "../infrastructure/model-provider"; @@ -60,6 +60,7 @@ export class CoreServices { // ============================================================================ private claudeCredentialStore?: ClaudeCredentialStore; private claudeModelPreferenceStore?: ClaudeModelPreferenceStore; + private claudeOAuthStateStore?: ClaudeOAuthStateStore; private anthropicProxy?: AnthropicProxy; // ============================================================================ @@ -184,10 +185,10 @@ export class CoreServices { // Register Claude OAuth module const systemTokenAvailable = !!this.config.anthropicProxy.anthropicApiKey; - const claudeOAuthStateStore = new ClaudeOAuthStateStore(redisClient); + this.claudeOAuthStateStore = new ClaudeOAuthStateStore(redisClient); const claudeOAuthModule = new ClaudeOAuthModule( this.claudeCredentialStore, - claudeOAuthStateStore, + this.claudeOAuthStateStore, this.claudeModelPreferenceStore, this.queue, this.config.mcp.publicGatewayUrl, @@ -369,6 +370,14 @@ export class CoreServices { return this.claudeModelPreferenceStore; } + getClaudeOAuthStateStore(): ClaudeOAuthStateStore | undefined { + return this.claudeOAuthStateStore; + } + + getPublicGatewayUrl(): string { + return this.config.mcp.publicGatewayUrl; + } + getSessionManager(): SessionManager { if (!this.sessionManager) throw new Error("Session manager not initialized"); diff --git a/packages/gateway/src/services/file-handler.ts b/packages/gateway/src/services/file-handler.ts deleted file mode 100644 index 909bb73..0000000 --- a/packages/gateway/src/services/file-handler.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { Readable } from "node:stream"; -import { createLogger, sanitizeFilename } from "@peerbot/core"; -import jwt from "jsonwebtoken"; -import type { WebClient } from "@slack/web-api"; - -const logger = createLogger("file-handler"); - -// Use existing ENCRYPTION_KEY for JWT signing (32-byte key required by system) -function getJwtSecret(): string { - const secret = process.env.ENCRYPTION_KEY; - if (!secret) { - throw new Error( - "ENCRYPTION_KEY environment variable is required for secure file token generation" - ); - } - return secret; -} - -const JWT_SECRET = getJwtSecret(); - -interface SlackFileMetadata { - id: string; - name: string; - mimetype?: string; - size: number; - url_private: string; - url_private_download: string; - permalink?: string; - timestamp: number; -} - -interface FileUploadResult { - fileId: string; - permalink: string; - name: string; - size: number; -} - -/** - * Handles file operations between Slack and workers - */ -export class FileHandler { - private uploadedFiles: Map> = new Map(); // sessionKey -> fileIds - - constructor(private slackClient: WebClient) {} - - /** - * Download a file from Slack - */ - async downloadFile( - fileId: string, - bearerToken: string - ): Promise<{ stream: Readable; metadata: SlackFileMetadata }> { - try { - // Get file info - const fileInfo = await this.slackClient.files.info({ - file: fileId, - }); - - if (!fileInfo.ok || !fileInfo.file) { - throw new Error(`Failed to get file info: ${fileInfo.error}`); - } - - const file = fileInfo.file as any; - const metadata: SlackFileMetadata = { - id: file.id, - name: file.name, - mimetype: file.mimetype, - size: file.size, - url_private: file.url_private, - url_private_download: file.url_private_download, - permalink: file.permalink, - timestamp: file.timestamp, - }; - - // Download file using the bearer token - const response = await fetch(metadata.url_private_download, { - headers: { - Authorization: `Bearer ${bearerToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to download file: ${response.statusText}`); - } - - // Convert web stream to Node.js readable stream - const nodeStream = Readable.fromWeb(response.body as any); - - return { stream: nodeStream, metadata }; - } catch (error) { - logger.error(`Failed to download file ${fileId}:`, error); - throw error; - } - } - - /** - * Upload a file to Slack - */ - async uploadFile( - fileStream: Readable, - options: { - filename: string; - channelId: string; - threadTs?: string; - title?: string; - initialComment?: string; - sessionKey?: string; - } - ): Promise { - try { - // Sanitize filename to prevent path traversal - const safeFilename = sanitizeFilename(options.filename); - - if (safeFilename !== options.filename) { - logger.warn( - `Filename sanitized from "${options.filename}" to "${safeFilename}"` - ); - } - - // Convert stream to buffer for Slack API - const chunks: Buffer[] = []; - for await (const chunk of fileStream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const fileBuffer = Buffer.concat(chunks); - - logger.info( - `Uploading file ${safeFilename} (${fileBuffer.length} bytes) to channel ${options.channelId}, thread ${options.threadTs}` - ); - - // Use files.uploadV2 for better performance - const uploadParams: any = { - channel_id: options.channelId, - filename: safeFilename, - file: fileBuffer, - title: options.title || safeFilename, - }; - - if (options.threadTs) { - uploadParams.thread_ts = options.threadTs; - } - - if (options.initialComment) { - uploadParams.initial_comment = options.initialComment; - } - - const result = await this.slackClient.files.uploadV2(uploadParams); - - if (!result.ok) { - throw new Error(`Failed to upload file: ${result.error}`); - } - - // files.uploadV2 response structure: { files: [ { id, name, ... } ] } - const files = (result as any).files; - if (!files || files.length === 0) { - throw new Error("Upload succeeded but no file info returned"); - } - - const file = files[0]; - - // Track uploaded files per session - if (options.sessionKey) { - if (!this.uploadedFiles.has(options.sessionKey)) { - this.uploadedFiles.set(options.sessionKey, new Set()); - } - this.uploadedFiles.get(options.sessionKey)!.add(file.id); - } - - logger.info(`Successfully uploaded file: ${file.id} - ${file.name}`); - - return { - fileId: file.id, - permalink: file.permalink || file.url_private, - name: file.name, - size: file.size || fileBuffer.length, - }; - } catch (error) { - logger.error(`Failed to upload file ${options.filename}:`, error); - throw error; - } - } - - /** - * Get uploaded files for a session - */ - getSessionFiles(sessionKey: string): string[] { - return Array.from(this.uploadedFiles.get(sessionKey) || []); - } - - /** - * Clean up session files - */ - cleanupSession(sessionKey: string): void { - this.uploadedFiles.delete(sessionKey); - } - - /** - * Generate a secure file token using JWT - */ - generateFileToken( - sessionKey: string, - fileId: string, - expiresIn: number = 3600 - ): string { - const payload = { - sessionKey, - fileId, - type: "file_access", - iat: Math.floor(Date.now() / 1000), - }; - - try { - const token = jwt.sign(payload, JWT_SECRET, { - expiresIn, // seconds - algorithm: "HS256", - issuer: "peerbot-gateway", - audience: "peerbot-worker", - }); - - logger.debug( - `Generated JWT file token for session ${sessionKey}, file ${fileId}` - ); - return token; - } catch (error) { - logger.error("Failed to generate file token:", error); - throw new Error("Failed to generate secure file token"); - } - } - - /** - * Validate file token using JWT verification - */ - validateFileToken(token: string): { - valid: boolean; - sessionKey?: string; - fileId?: string; - error?: string; - } { - try { - const decoded = jwt.verify(token, JWT_SECRET, { - algorithms: ["HS256"], - issuer: "peerbot-gateway", - audience: "peerbot-worker", - }); - - // Runtime type check - jwt.verify returns string | JwtPayload - if (typeof decoded === "string") { - logger.error("JWT decoded to string instead of object"); - return { valid: false, error: "Invalid token format" }; - } - - // Now we know it's JwtPayload, verify our custom fields exist - if ( - !decoded || - typeof decoded.sessionKey !== "string" || - typeof decoded.fileId !== "string" || - typeof decoded.type !== "string" - ) { - logger.error("JWT missing required fields"); - return { valid: false, error: "Invalid token structure" }; - } - - // Additional validation: ensure token type is correct - if (decoded.type !== "file_access") { - logger.warn("Invalid token type:", decoded.type); - return { valid: false, error: "Invalid token type" }; - } - - const validatedToken = decoded as { - sessionKey: string; - fileId: string; - type: string; - }; - - logger.debug( - `Validated JWT file token for session ${validatedToken.sessionKey}, file ${validatedToken.fileId}` - ); - return { - valid: true, - sessionKey: validatedToken.sessionKey, - fileId: validatedToken.fileId, - }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.warn(`File token validation failed: ${errorMsg}`); - - // Provide specific error messages for debugging - if (error instanceof jwt.TokenExpiredError) { - return { valid: false, error: "Token expired" }; - } - if (error instanceof jwt.JsonWebTokenError) { - return { valid: false, error: "Invalid token signature" }; - } - - return { valid: false, error: "Token validation failed" }; - } - } -} diff --git a/packages/gateway/src/services/instruction-service.ts b/packages/gateway/src/services/instruction-service.ts index ed9b275..f1ae30c 100644 --- a/packages/gateway/src/services/instruction-service.ts +++ b/packages/gateway/src/services/instruction-service.ts @@ -172,7 +172,7 @@ export class InstructionService { if (this.mcpConfigService) { try { mcpStatus = - (await this.mcpConfigService.getMcpStatus(context.userId)) || []; + (await this.mcpConfigService.getMcpStatus(context.spaceId)) || []; logger.info(`Got MCP status for ${mcpStatus.length} MCPs`); } catch (error) { logger.error("Failed to get MCP status:", error); diff --git a/packages/gateway/src/slack/config.ts b/packages/gateway/src/slack/config.ts index 3033a0c..26b9773 100644 --- a/packages/gateway/src/slack/config.ts +++ b/packages/gateway/src/slack/config.ts @@ -2,9 +2,22 @@ * Slack-specific configuration and constants */ -import type { AgentOptions as CoreAgentOptions } from "@peerbot/core"; +import { + type AgentOptions as CoreAgentOptions, + getOptionalEnv, + getOptionalNumber, +} from "@peerbot/core"; import type { LogLevel } from "@slack/bolt"; +// ============================================================================ +// Defaults +// ============================================================================ + +const SLACK_DEFAULTS = { + HTTP_PORT: 3000, + SLACK_API_URL: "https://slack.com/api", +} as const; + // ============================================================================ // Constants // ============================================================================ @@ -54,3 +67,56 @@ export interface MessageHandlerConfig { agentOptions: AgentOptions; sessionTimeoutMinutes: number; } + +// ============================================================================ +// Configuration Builder +// ============================================================================ + +/** + * Build Slack-specific configuration from environment variables + * Returns null if SLACK_BOT_TOKEN is not set (Slack disabled) + */ +export function buildSlackConfig(): SlackConfig | null { + const botToken = process.env.SLACK_BOT_TOKEN; + + // If no bot token, Slack is disabled + if (!botToken) { + return null; + } + + const socketMode = process.env.SLACK_HTTP_MODE !== "true"; + + return { + token: botToken, + appToken: process.env.SLACK_APP_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, + socketMode, + port: getOptionalNumber("PORT", SLACK_DEFAULTS.HTTP_PORT), + botUserId: process.env.SLACK_BOT_USER_ID, + botId: undefined, // Will be set during initialization + apiUrl: getOptionalEnv("SLACK_API_URL", SLACK_DEFAULTS.SLACK_API_URL), + }; +} + +/** + * Display Slack configuration + */ +export function displaySlackConfig( + config: SlackConfig | null, + tokenPreviewLength = 10 +): void { + if (config) { + console.log("\nSlack:"); + console.log(` Mode: ${config.socketMode ? "Socket Mode" : "HTTP Mode"}`); + console.log(` Port: ${config.port}`); + console.log( + ` Bot Token: ${config.token?.substring(0, tokenPreviewLength)}... (${config.token.length} chars)` + ); + console.log( + ` App Token: ${config.appToken ? `${config.appToken.substring(0, tokenPreviewLength)}... (${config.appToken.length} chars)` : "not set"}` + ); + console.log(` API URL: ${config.apiUrl}`); + } else { + console.log("\nSlack: disabled"); + } +} diff --git a/packages/gateway/src/slack/event-router.ts b/packages/gateway/src/slack/event-router.ts index 4e5baab..00233d4 100644 --- a/packages/gateway/src/slack/event-router.ts +++ b/packages/gateway/src/slack/event-router.ts @@ -3,13 +3,13 @@ import type { IModuleRegistry } from "@peerbot/core"; import { createLogger } from "@peerbot/core"; import type { App } from "@slack/bolt"; -import type { WebClient } from "@slack/web-api"; import type { FileDeletedEvent, FileSharedEvent, GenericMessageEvent, View, } from "@slack/types"; +import type { WebClient } from "@slack/web-api"; import type { QueueProducer } from "../infrastructure/queue"; import type { InteractionService } from "../interactions"; import type { PlatformAdapter } from "../platform"; diff --git a/packages/gateway/src/slack/events/actions.ts b/packages/gateway/src/slack/events/actions.ts index ee067ad..4538dcb 100644 --- a/packages/gateway/src/slack/events/actions.ts +++ b/packages/gateway/src/slack/events/actions.ts @@ -6,6 +6,7 @@ import type { IModuleRegistry } from "@peerbot/core"; import type { AnyBlock } from "@slack/types"; import type { WebClient } from "@slack/web-api"; import type { PlatformAdapter } from "../../platform"; +import { resolveSpace } from "../../spaces"; import type { SlackActionBody, SlackContext } from "../types"; import type { MessageHandler } from "./messages"; @@ -241,15 +242,30 @@ export class ActionHandler { let handled = false; const dispatcherModules = this.moduleRegistry.getDispatcherModules(); + // Resolve spaceId from context for module actions + const isDirectMessage = channelId.startsWith("D"); + const { spaceId } = resolveSpace({ + platform: "slack", + userId, + channelId, + isGroup: !isDirectMessage, + }); + for (const module of dispatcherModules) { if (module.handleAction) { - const moduleHandled = await module.handleAction(actionId, userId, { - channelId, - client, - body, - updateAppHome: this.updateAppHome.bind(this), - messageHandler: this.messageHandler, - }); + const moduleHandled = await module.handleAction( + actionId, + userId, + spaceId, + { + channelId, + client, + body, + spaceId, + updateAppHome: this.updateAppHome.bind(this), + messageHandler: this.messageHandler, + } + ); if (moduleHandled) { handled = true; break; @@ -258,51 +274,28 @@ export class ActionHandler { } if (!handled) { - switch (actionId) { - default: - // Handle blockkit form button clicks - if (actionId.startsWith("blockkit_form_")) { - await handleBlockkitForm( - actionId, - channelId, - messageTs, - body, - client - ); - } - // Handle executable code block buttons - else if ( - actionId.match(/^(bash|python|javascript|js|typescript|ts|sql|sh)_/) - ) { - await handleExecutableCodeBlock( - actionId, - userId, - channelId, - messageTs, - body, - client, - (context: SlackContext, userRequest: string, client: WebClient) => - this.messageHandler.handleUserRequest( - context, - userRequest, - client - ) - ); - } - // Interaction handlers (radio_, submit_, section_, next_) are registered - // via Slack Bolt app.action() in interactions.ts - else if (actionId.match(/^(radio|submit|section|next)_/)) { - // These are handled by Bolt handlers, don't log as unsupported - logger.debug( - `Interaction action ${actionId} handled by Bolt handler` - ); - } else { - logger.info( - `Unsupported action: ${actionId} from user ${userId} in channel ${channelId}` - ); - } - - break; + // Handle blockkit form button clicks + if (actionId.startsWith("blockkit_form_")) { + await handleBlockkitForm(actionId, channelId, messageTs, body, client); + } + // Handle executable code block buttons + else if ( + actionId.match(/^(bash|python|javascript|js|typescript|ts|sql|sh)_/) + ) { + await handleExecutableCodeBlock( + actionId, + userId, + channelId, + messageTs, + body, + client, + (context: SlackContext, userRequest: string, client: WebClient) => + this.messageHandler.handleUserRequest(context, userRequest, client) + ); + } else { + logger.info( + `Unsupported action: ${actionId} from user ${userId} in channel ${channelId}` + ); } } } @@ -316,6 +309,15 @@ export class ActionHandler { ); try { + // Resolve spaceId for the user's personal space (used for MCP credentials) + // Home tab is a user context, so we use user-{hash} spaceId + const { spaceId } = resolveSpace({ + platform: "slack", + userId, + channelId: userId, // Use userId as channelId for DM-like context + isGroup: false, // Personal/user space + }); + const blocks: AnyBlock[] = [ { type: "section", @@ -339,7 +341,10 @@ export class ActionHandler { "getAuthStatus" in module && typeof module.getAuthStatus === "function" ) { - const providers = await (module as any).getAuthStatus(userId); + const providers = await (module as any).getAuthStatus( + userId, + spaceId + ); allProviders.push(...providers); } else if ("renderHomeTab" in module) { // Fallback for non-OAuth modules @@ -380,17 +385,93 @@ export class ActionHandler { }, }; - // Add action button if login/logout URL is available + // Add login button for OAuth-based providers (URLs) if (provider.loginUrl && !provider.isAuthenticated) { - sectionBlock.accessory = { - type: "button", - text: { type: "plain_text", text: "Login" }, - url: provider.loginUrl, - style: "primary", - }; + // Check if it's an action_id (e.g., "action:claude_auth_start") + if (provider.loginUrl.startsWith("action:")) { + // Extract action_id + const actionId = provider.loginUrl.substring(7); // Remove "action:" prefix + sectionBlock.accessory = { + type: "button", + text: { type: "plain_text", text: "Login" }, + action_id: actionId, + style: "primary", + }; + } else { + // Regular URL + sectionBlock.accessory = { + type: "button", + text: { type: "plain_text", text: "Login" }, + url: provider.loginUrl, + style: "primary", + }; + } } blocks.push(sectionBlock); + + // Render model selector if available (Claude-specific) + if ( + provider.metadata?.availableModels && + Array.isArray(provider.metadata.availableModels) && + provider.metadata.availableModels.length > 0 + ) { + const availableModels = provider.metadata.availableModels; + const currentModel = provider.metadata.currentModel; + + const selectedModelInfo = availableModels.find( + (m: any) => m.id === currentModel + ); + + const actionElements: any[] = [ + { + type: "static_select", + placeholder: { + type: "plain_text", + text: "Select a model", + }, + action_id: "claude_select_model", + options: availableModels.map((model: any) => ({ + text: { + type: "plain_text", + text: model.display_name, + }, + value: model.id, + })), + initial_option: + currentModel && selectedModelInfo + ? { + text: { + type: "plain_text", + text: selectedModelInfo.display_name, + }, + value: currentModel, + } + : undefined, + }, + ]; + + // Add logout button if authenticated and logout URL available + if (provider.isAuthenticated && provider.logoutUrl) { + if (provider.logoutUrl.startsWith("action:")) { + const actionId = provider.logoutUrl.substring(7); + actionElements.push({ + type: "button", + text: { + type: "plain_text", + text: "Logout", + }, + style: "danger", + action_id: actionId, + }); + } + } + + blocks.push({ + type: "actions", + elements: actionElements, + }); + } } blocks.push({ type: "divider" }); diff --git a/packages/gateway/src/slack/events/messages.ts b/packages/gateway/src/slack/events/messages.ts index fb6ff6f..a230b8d 100644 --- a/packages/gateway/src/slack/events/messages.ts +++ b/packages/gateway/src/slack/events/messages.ts @@ -1,12 +1,13 @@ import { createLogger, DEFAULTS } from "@peerbot/core"; import type { WebClient } from "@slack/web-api"; import type { - QueueProducer, MessagePayload, + QueueProducer, } from "../../infrastructure/queue/queue-producer"; import type { InteractionService } from "../../interactions"; import type { ISessionManager, ThreadSession } from "../../session"; import { generateSessionKey } from "../../session"; +import { resolveSpace } from "../../spaces"; import type { MessageHandlerConfig } from "../config"; import type { SlackContext, SlackMessageEvent } from "../types"; @@ -104,6 +105,16 @@ export class MessageHandler { // Check if this is a Direct Message channel (DMs start with 'D') const isDirectMessage = context.channelId.startsWith("D"); + // Resolve space ID for multi-tenant isolation + const { spaceId } = resolveSpace({ + platform: "slack", + userId: context.userId, + channelId: context.channelId, + isGroup: !isDirectMessage, + }); + + logger.info(`Resolved spaceId: ${spaceId} (isGroup: ${!isDirectMessage})`); + // Only check thread ownership for non-DM channels if (!isDirectMessage) { const ownershipCheck = await this.sessionManager.validateThreadOwnership( @@ -220,6 +231,7 @@ export class MessageHandler { botId: this.getBotId(), threadId: threadTs, teamId: context.teamId, + spaceId, platform: "slack", messageId: context.messageTs, messageText: userRequest, @@ -261,6 +273,7 @@ export class MessageHandler { userId: context.userId, threadId: threadTs, teamId: context.teamId, + spaceId, platform: "slack", channelId: context.channelId, messageId: context.messageTs, diff --git a/packages/gateway/src/slack/interactions.ts b/packages/gateway/src/slack/interactions.ts index aefa1b5..4deeb20 100644 --- a/packages/gateway/src/slack/interactions.ts +++ b/packages/gateway/src/slack/interactions.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env bun - import { createLogger, type FieldSchema, @@ -9,53 +7,14 @@ import { import type { Block } from "@slack/types"; import type { WebClient } from "@slack/web-api"; import type { InteractionService } from "../interactions"; +import { + getFieldLabel, + getInteractionType, +} from "../platform/interaction-utils"; import { convertMarkdownToSlack } from "./converters/markdown"; const logger = createLogger("slack-interactions"); -// ============================================================================ -// SHARED UTILITIES -// ============================================================================ - -/** - * Determine interaction type from options - */ -function getInteractionType( - options: any -): "radio" | "single-form" | "multi-section" { - if (Array.isArray(options)) { - // Check if it's an array of strings (simple radio) or array of form objects (multi-section) - if (options.length === 0) { - return "radio"; - } - - const firstItem = options[0]; - - // Multi-form workflow: Array<{label: string, fields: Record}> - if ( - typeof firstItem === "object" && - firstItem !== null && - "label" in firstItem && - "fields" in firstItem - ) { - // If there's only one section, treat it as single-form (no need for section navigation) - return options.length === 1 ? "single-form" : "multi-section"; - } - - // Simple radio buttons: string[] - return "radio"; - } - - // Check if it's a single form (Record) - const firstValue = Object.values(options)[0]; - if (firstValue && typeof firstValue === "object" && "type" in firstValue) { - return "single-form"; // Record - } - - // Multi-section form (Record>) - return "multi-section"; -} - /** * Build Slack input block from field schema */ @@ -64,8 +23,7 @@ function buildFieldBlock( fieldSchema: FieldSchema, value?: any ): any { - const label = - fieldSchema.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1); + const label = getFieldLabel(fieldName, fieldSchema); const blockId = `field_${fieldName}`; if (fieldSchema.type === "text" || fieldSchema.type === "textarea") { @@ -244,6 +202,29 @@ function extractFormData(stateValues: any): Record { return formData; } +/** + * Extract fields from interaction options. + * Handles both direct Record format and array with single item format. + */ +function extractFieldsFromOptions( + options: unknown +): Record { + if (Array.isArray(options) && options.length === 1) { + const firstItem = options[0]; + if ( + typeof firstItem === "object" && + firstItem !== null && + "fields" in firstItem + ) { + return ( + firstItem as { label: string; fields: Record } + ).fields; + } + return {}; + } + return options as Record; +} + // ============================================================================ // SLACK INTERACTION RENDERER // ============================================================================ @@ -417,28 +398,7 @@ export class SlackInteractionRenderer { interaction: UserInteraction, question: string ): { text: string; blocks: Block[] } { - // Handle both formats: direct Record or array with one item - let fields: Record; - - if ( - Array.isArray(interaction.options) && - interaction.options.length === 1 - ) { - const firstItem = interaction.options[0]; - if ( - typeof firstItem === "object" && - firstItem !== null && - "fields" in firstItem - ) { - fields = ( - firstItem as { label: string; fields: Record } - ).fields; - } else { - fields = {}; - } - } else { - fields = interaction.options as Record; - } + const fields = extractFieldsFromOptions(interaction.options); const blocks: any[] = [ { @@ -618,30 +578,7 @@ export class SlackInteractionRenderer { }, }); } else if (type === "single-form") { - // Show form data as disabled fields - // Handle both formats: direct Record or array with one item - let fields: Record; - - if ( - Array.isArray(interaction.options) && - interaction.options.length === 1 - ) { - const firstItem = interaction.options[0]; - if ( - typeof firstItem === "object" && - firstItem !== null && - "fields" in firstItem - ) { - fields = ( - firstItem as { label: string; fields: Record } - ).fields; - } else { - fields = {}; - } - } else { - fields = interaction.options as Record; - } - + const fields = extractFieldsFromOptions(interaction.options); const formData = interaction.response?.formData || {}; const fieldDisplays = Object.entries(fields) @@ -676,15 +613,17 @@ export class SlackInteractionRenderer { ) : (interaction.options as Record>); + // Note: formData is FLATTENED by submitAllForms (all fields in one object) + // not nested by section like { Section1: { field1: "val" } } const allData = interaction.response?.formData || {}; for (const [sectionName, fields] of Object.entries(sections)) { - const sectionData = allData[sectionName] || {}; + // Access fields directly from flattened allData, not from nested structure const fieldDisplays = Object.entries(fields) .map(([fieldName, fieldSchema]) => buildDisabledFieldDisplay( fieldName, - sectionData[fieldName], + allData[fieldName], // Changed from allData[sectionName][fieldName] fieldSchema.label ) ) diff --git a/packages/gateway/src/slack/platform.ts b/packages/gateway/src/slack/platform.ts index a372d94..2f18d5d 100644 --- a/packages/gateway/src/slack/platform.ts +++ b/packages/gateway/src/slack/platform.ts @@ -12,13 +12,21 @@ import { WebClient } from "@slack/web-api"; import type { NextFunction, Request, Response } from "express"; import type { MessagePayload } from "../infrastructure/queue/queue-producer"; import type { CoreServices, PlatformAdapter } from "../platform"; -import { FileHandler } from "../services/file-handler"; +import { + type AgentOptions as FactoryAgentOptions, + type PlatformConfigs, + type PlatformFactory, + platformFactoryRegistry, +} from "../platform/platform-factory"; +import type { ResponseRenderer } from "../platform/response-renderer"; +import { resolveSpace } from "../spaces"; import type { AgentOptions, SlackPlatformConfig } from "./config"; import { SlackEventHandlers } from "./event-router"; +import { SlackFileHandler } from "./file-handler"; import { SocketHealthMonitor } from "./health/socket-health-monitor"; import { SlackInstructionProvider } from "./instructions/provider"; import { SlackInteractionRenderer } from "./interactions"; -import { ThreadResponseConsumer } from "./thread-processor"; +import { SlackResponseRenderer } from "./response-renderer"; const logger = createLogger("slack-platform"); @@ -32,10 +40,10 @@ export class SlackPlatform implements PlatformAdapter { private app!: App; private receiver?: ExpressReceiver; - private threadResponseConsumer?: ThreadResponseConsumer; + private responseRenderer?: SlackResponseRenderer; private socketHealthMonitor?: SocketHealthMonitor; private services!: CoreServices; - private fileHandler?: FileHandler; + private fileHandler?: SlackFileHandler; private interactionRenderer?: SlackInteractionRenderer; constructor( @@ -126,12 +134,12 @@ export class SlackPlatform implements PlatformAdapter { await this.initializeBotInfo(); // Create file handler - this.fileHandler = new FileHandler(this.app.client); + this.fileHandler = new SlackFileHandler(this.app.client); - // Create thread response consumer - this.threadResponseConsumer = new ThreadResponseConsumer( + // Create response renderer for unified thread consumer + this.responseRenderer = new SlackResponseRenderer( services.getQueue(), - this.config.slack.token, + this.app.client, moduleRegistry ); @@ -150,10 +158,7 @@ export class SlackPlatform implements PlatformAdapter { logger.info( `Stopping stream for thread ${threadId} before creating interaction` ); - await this.threadResponseConsumer?.stopStreamForThread( - userId, - threadId - ); + await this.responseRenderer?.stopStreamForThread(userId, threadId); } ); logger.info("โœ… Stream stop hook registered for interactions"); @@ -191,11 +196,6 @@ export class SlackPlatform implements PlatformAdapter { async start(): Promise { logger.info("Starting Slack platform..."); - // Start thread response consumer - if (this.threadResponseConsumer) { - await this.threadResponseConsumer.start(); - } - // Start Slack app if (this.config.slack.socketMode === false) { await this.initializeHttpMode(); @@ -223,11 +223,6 @@ export class SlackPlatform implements PlatformAdapter { // Stop Slack app await this.app.stop(); - // Stop thread response consumer - if (this.threadResponseConsumer) { - await this.threadResponseConsumer.stop(); - } - logger.info("โœ… Slack platform stopped"); } @@ -246,10 +241,17 @@ export class SlackPlatform implements PlatformAdapter { return new SlackInstructionProvider(); } + /** + * Get the response renderer for unified thread consumer + */ + getResponseRenderer(): ResponseRenderer | undefined { + return this.responseRenderer; + } + /** * Get file handler for this platform */ - getFileHandler(): FileHandler | undefined { + getFileHandler(): SlackFileHandler | undefined { return this.fileHandler; } @@ -639,6 +641,15 @@ export class SlackPlatform implements PlatformAdapter { const testUserId = process.env.TEST_USER_ID || process.env.SLACK_ADMIN_USER_ID || botUserId; + // Resolve spaceId for multi-tenant isolation + const isDirectMessage = channelId.startsWith("D"); + const { spaceId } = resolveSpace({ + platform: "slack", + userId: testUserId, + channelId, + isGroup: !isDirectMessage, + }); + // Build payload matching MessagePayload structure const payload: MessagePayload = { platform: "slack", @@ -646,6 +657,7 @@ export class SlackPlatform implements PlatformAdapter { botId: this.config.slack.botId || "", threadId, teamId: teamId || "", + spaceId, messageId, messageText: message, channelId, @@ -983,3 +995,38 @@ export class SlackPlatform implements PlatformAdapter { process.on("SIGTERM", cleanup); } } + +/** + * Slack platform factory for declarative registration. + */ +const slackFactory: PlatformFactory = { + name: "slack", + + isEnabled(configs: PlatformConfigs): boolean { + return !!configs.slack?.token; + }, + + create( + configs: PlatformConfigs, + agentOptions: FactoryAgentOptions, + sessionTimeoutMinutes: number + ) { + const platformConfig: SlackPlatformConfig = { + slack: configs.slack, + logLevel: configs.logLevel || configs.slack?.logLevel, + health: configs.health || { + checkIntervalMs: 30000, + staleThresholdMs: 300000, + protectActiveWorkers: true, + }, + }; + return new SlackPlatform( + platformConfig, + agentOptions as AgentOptions, + sessionTimeoutMinutes + ); + }, +}; + +// Register factory on module load +platformFactoryRegistry.register(slackFactory); diff --git a/packages/gateway/src/slack/thread-processor.ts b/packages/gateway/src/slack/thread-processor.ts deleted file mode 100644 index a6972aa..0000000 --- a/packages/gateway/src/slack/thread-processor.ts +++ /dev/null @@ -1,1052 +0,0 @@ -#!/usr/bin/env bun - -import type { IModuleRegistry } from "@peerbot/core"; -import { AsyncLock, createLogger, DEFAULTS, REDIS_KEYS } from "@peerbot/core"; -import type { AnyBlock } from "@slack/types"; -import { WebClient } from "@slack/web-api"; -import type Redis from "ioredis"; -import type { - IMessageQueue, - QueueJob, - ThreadResponsePayload, -} from "../infrastructure/queue"; -import { - type ModuleButton, - SlackBlockBuilder, -} from "./converters/block-builder"; -import { extractCodeBlockActions } from "./converters/blockkit"; -import { convertMarkdownToSlack } from "./converters/markdown"; - -const logger = createLogger("dispatcher"); - -/** - * Represents a single Slack chatStream session - */ -class StreamSession { - private streamTs: string | null = null; - private messageTs: string | null = null; - private started: boolean = false; - private slackClient: WebClient; - private channelId: string; - private threadTs: string; - private userId: string; - private teamId?: string; - private streamLock: AsyncLock; - - constructor( - slackClient: WebClient, - channelId: string, - threadTs: string, - userId: string, - teamId?: string - ) { - this.slackClient = slackClient; - this.channelId = channelId; - this.threadTs = threadTs; - this.userId = userId; - this.teamId = teamId; - this.streamLock = new AsyncLock(`slack-stream-${channelId}-${threadTs}`); - } - - /** - * Execute a function with exclusive stream lock to prevent race conditions - */ - private async withStreamLock(fn: () => Promise): Promise { - return this.streamLock.acquire(fn); - } - - /** - * Set "is running..." status indicator - */ - private async setRunningStatus(): Promise { - try { - await this.slackClient.apiCall("assistant.threads.setStatus", { - channel_id: this.channelId, - thread_ts: this.threadTs, - status: "is running..", - loading_messages: [ - "working on it...", - "thinking...", - "processing...", - "cooking something up...", - "crafting a response...", - "figuring it out...", - "on the case...", - "analyzing...", - "computing...", - ], - }); - logger.info( - `Set "is running" status for channel ${this.channelId}, thread ${this.threadTs}` - ); - } catch (error) { - // Non-critical - logger.warn(`Failed to set running status: ${error}`); - } - } - - /** - * Clear status indicator - */ - private async clearStatus(): Promise { - try { - await this.slackClient.apiCall("assistant.threads.setStatus", { - channel_id: this.channelId, - thread_ts: this.threadTs, - status: "", - }); - logger.info( - `Cleared status for channel ${this.channelId}, thread ${this.threadTs}` - ); - } catch (error) { - // Non-critical - logger.warn(`Failed to clear status: ${error}`); - } - } - - async appendDelta( - delta: string, - isFullReplacement: boolean = false - ): Promise { - // Use lock to prevent concurrent stream operations - return this.withStreamLock(async () => { - return this.appendDeltaUnsafe(delta, isFullReplacement); - }); - } - - /** - * Internal implementation of appendDelta without locking - * Should only be called from within withStreamLock - */ - private async appendDeltaUnsafe( - delta: string, - isFullReplacement: boolean = false - ): Promise { - // If this is a full replacement and we have an active stream, stop it first - if (isFullReplacement && this.started && this.streamTs) { - logger.info( - `๐Ÿ”„ REPLACING STREAM CONTENT: channel=${this.channelId}, thread=${this.threadTs}` - ); - await this.stop(); - this.started = false; - this.streamTs = null; - } - - if (!this.started) { - // Start new stream - logger.info( - `๐Ÿš€ STARTING NEW STREAM: channel=${this.channelId}, thread=${this.threadTs}, deltaLength=${delta.length}` - ); - const response = (await this.slackClient.apiCall("chat.startStream", { - channel: this.channelId, - thread_ts: this.threadTs, - markdown_text: convertMarkdownToSlack(delta), - recipient_user_id: this.userId, - ...(this.teamId ? { recipient_team_id: this.teamId } : {}), - })) as { - ok?: boolean; - stream_ts?: string; - ts?: string; - error?: string; - }; - - if (!response.ok) { - const error = response.error || "unknown_error"; - logger.error( - `Failed to start Slack stream for channel ${this.channelId}, thread ${this.threadTs}: ${error}` - ); - throw new Error(`chat.startStream failed: ${error}`); - } - - const streamTs = response.stream_ts || response.ts; - const messageTs = response.ts || response.stream_ts; - - if (!streamTs) { - logger.error( - `chat.startStream response missing stream_ts for channel ${this.channelId}, thread ${this.threadTs}` - ); - throw new Error("chat.startStream response missing stream_ts"); - } - - this.streamTs = streamTs; - this.messageTs = messageTs ?? streamTs; - this.started = true; - logger.info( - `โœ… Stream started with initial content (${delta.length} chars) streamTs=${streamTs}, messageTs=${this.messageTs}` - ); - - await this.setRunningStatus(); - - return this.messageTs ?? this.streamTs; - } else { - // Append to existing stream - logger.info( - `โž• APPENDING TO STREAM: channel=${this.channelId}, thread=${this.threadTs}, deltaLength=${delta.length}, streamTs=${this.streamTs}, messageTs=${this.messageTs}` - ); - if (this.streamTs && this.messageTs) { - try { - const appendParams = { - channel: this.channelId, - stream_ts: this.streamTs, - ts: this.messageTs, - markdown_text: convertMarkdownToSlack(delta), - }; - logger.info( - `chat.appendStream params: channel=${this.channelId}, stream_ts=${this.streamTs}, ts=${this.messageTs}, delta_length=${delta.length}` - ); - - const response = (await this.slackClient.apiCall( - "chat.appendStream", - appendParams - )) as { ok?: boolean; error?: string }; - - if (!response.ok) { - const error = response.error || "unknown_error"; - - // Check if this is a streaming state error - restart stream with new message - if (error === "message_not_in_streaming_state") { - logger.warn( - `โš ๏ธ Streaming state lost for ${this.streamTs}, restarting stream with new message` - ); - // Reset stream state - this.streamTs = null; - this.started = false; - // Start a fresh stream with the current delta (already locked, use unsafe version) - return this.appendDeltaUnsafe(delta, false); - } - - logger.error( - `Failed to append to Slack stream ${this.streamTs} in channel ${this.channelId}: ${error}` - ); - throw new Error(`chat.appendStream failed: ${error}`); - } - } catch (error) { - // Check if the error is about streaming state - const errorMessage = - error instanceof Error ? error.message : String(error); - if (errorMessage.includes("message_not_in_streaming_state")) { - logger.warn( - `โš ๏ธ Streaming state lost (exception), restarting stream with new message` - ); - // Reset stream state - this.streamTs = null; - this.started = false; - // Start a fresh stream with the current delta (already locked, use unsafe version) - return this.appendDeltaUnsafe(delta, false); - } - - logger.error(`Exception during chat.appendStream: ${error}`, { - streamTs: this.streamTs, - messageTs: this.messageTs, - channel: this.channelId, - error, - }); - throw error; - } - } - logger.info(`โœ… Appended ${delta.length} chars to existing stream`); - } - - return this.messageTs ?? this.streamTs; - } - - async stop(deleteMessage: boolean = false): Promise { - if (this.started && this.streamTs) { - if (!this.messageTs) { - logger.error( - `Cannot stop stream ${this.streamTs} - missing message timestamp` - ); - throw new Error("Cannot stop stream without message timestamp"); - } - - const response = (await this.slackClient.apiCall("chat.stopStream", { - channel: this.channelId, - stream_ts: this.streamTs, - ts: this.messageTs, - })) as { ok?: boolean; error?: string }; - - if (!response.ok) { - const error = response.error || "unknown_error"; - logger.error( - `Failed to stop Slack stream ${this.streamTs} in channel ${this.channelId}: ${error}` - ); - throw new Error(`chat.stopStream failed: ${error}`); - } - - // Delete the message if requested (e.g., when stopping for interaction) - if (deleteMessage && this.messageTs) { - logger.info( - `Deleting streaming message ${this.messageTs} from channel ${this.channelId}` - ); - try { - await this.slackClient.chat.delete({ - channel: this.channelId, - ts: this.messageTs, - }); - logger.info(`โœ… Deleted streaming message ${this.messageTs}`); - } catch (error) { - logger.warn( - `Failed to delete streaming message ${this.messageTs}: ${error}` - ); - // Non-critical - continue anyway - } - } - - this.streamTs = null; - this.messageTs = null; - this.started = false; - logger.info( - `Stopped Slack stream for channel ${this.channelId}, thread ${this.threadTs}` - ); - - // Clear status indicator now that stream is complete - await this.clearStatus(); - } - } - - isStarted(): boolean { - return this.started; - } - - getMessageTs(): string | null { - return this.messageTs ?? this.streamTs; - } -} - -/** - * Manages all active stream sessions - */ -class StreamSessionManager { - private sessions = new Map(); - private slackClient: WebClient; - - constructor(slackClient: WebClient) { - this.slackClient = slackClient; - } - - async handleDelta( - sessionId: string, - channelId: string, - threadTs: string, - userId: string, - delta: string, - isFullReplacement: boolean = false, - teamId?: string - ): Promise { - let session = this.sessions.get(sessionId); - - if (!session) { - // Create new session - session = new StreamSession( - this.slackClient, - channelId, - threadTs, - userId, - teamId - ); - this.sessions.set(sessionId, session); - } - - const streamTs = await session.appendDelta(delta, isFullReplacement); - return streamTs ?? session.getMessageTs(); - } - - async completeSession( - sessionId: string, - deleteMessage: boolean = false - ): Promise { - const session = this.sessions.get(sessionId); - if (session) { - await session.stop(deleteMessage); - this.sessions.delete(sessionId); - } - } - - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - async completeAllSessionsForThread( - threadTs: string, - deleteMessage: boolean = false - ): Promise { - let stoppedCount = 0; - const sessionsToStop: string[] = []; - - // Find all sessions for this thread - for (const [sessionId, session] of this.sessions.entries()) { - if ((session as any).threadTs === threadTs) { - sessionsToStop.push(sessionId); - } - } - - // Stop all matching sessions - for (const sessionId of sessionsToStop) { - await this.completeSession(sessionId, deleteMessage); - stoppedCount++; - } - - return stoppedCount; - } -} - -/** - * Consumer that listens to thread_response queue and updates Slack messages - * This handles all Slack communication that was previously done by the workerdon - */ -export class ThreadResponseConsumer { - private queue: IMessageQueue; - private redis: Redis; - private slackClient: WebClient; - private isRunning = false; - private blockBuilder: SlackBlockBuilder; - private readonly BOT_MESSAGES_PREFIX = REDIS_KEYS.BOT_MESSAGES; - private moduleRegistry: IModuleRegistry; - private streamSessionManager: StreamSessionManager; - - constructor( - queue: IMessageQueue, - slackToken: string, - moduleRegistry: IModuleRegistry - ) { - this.queue = queue; - this.slackClient = new WebClient(slackToken); - this.blockBuilder = new SlackBlockBuilder(); - this.moduleRegistry = moduleRegistry; - this.streamSessionManager = new StreamSessionManager(this.slackClient); - // Get Redis client from queue connection pool (queue must be started) - this.redis = this.queue.getRedisClient(); - } - - /** - * Stop stream for a specific thread - * Called when an interaction is created to prevent messages appearing after the interaction - */ - async stopStreamForThread(_userId: string, threadId: string): Promise { - logger.info( - `Stopping all streams for thread ${threadId} due to interaction creation - deleting messages` - ); - // Stop all sessions for this thread (session keys use messageId, not threadId) - const stoppedCount = - await this.streamSessionManager.completeAllSessionsForThread( - threadId, - true - ); - - if (stoppedCount > 0) { - logger.info( - `โœ… Stopped and deleted ${stoppedCount} stream(s) for thread ${threadId}` - ); - } else { - logger.debug(`No active streams found for thread ${threadId}`); - } - } - - /** - * Get bot message timestamp from Redis - */ - private async getBotMessageTs(sessionKey: string): Promise { - const key = `${this.BOT_MESSAGES_PREFIX}${sessionKey}`; - return await this.redis.get(key); - } - - /** - * Store bot message timestamp in Redis with 24h TTL - */ - private async setBotMessageTs( - sessionKey: string, - botMessageTs: string - ): Promise { - const key = `${this.BOT_MESSAGES_PREFIX}${sessionKey}`; - await this.redis.set(key, botMessageTs, "EX", DEFAULTS.SESSION_TTL_SECONDS); - } - - /** - * Start consuming thread_response messages - */ - async start(): Promise { - try { - await this.queue.start(); - - // Create the thread_response queue if it doesn't exist - await this.queue.createQueue("thread_response"); - - // Register job handler for thread response messages - await this.queue.work( - "thread_response", - this.handleThreadResponse.bind(this) - ); - - this.isRunning = true; - logger.info("โœ… Thread response consumer started"); - } catch (error) { - logger.error("Failed to start thread response consumer:", error); - throw error; - } - } - - /** - * Stop the consumer - */ - async stop(): Promise { - try { - this.isRunning = false; - await this.queue.stop(); - logger.info("โœ… Thread response consumer stopped"); - } catch (error) { - logger.error("Error stopping thread response consumer:", error); - throw error; - } - } - - /** - * Parse thread response job data from queue format - */ - private parseThreadResponseJob( - job: QueueJob - ): ThreadResponsePayload { - const data = job.data; - - if (!data || !data.messageId) { - throw new Error(`Invalid thread response data: ${JSON.stringify(data)}`); - } - logger.info( - `๐Ÿ“ค AGENT RESPONSE: Processing agent response for user ${data.userId}, thread ${data.threadId || "unknown"}, jobId: ${job.id}` - ); - - return data; - } - - /** - * Update thread status indicator with elapsed time - */ - private async updateThreadStatus( - channelId: string, - threadId: string, - elapsedSeconds: number, - state: string - ): Promise { - try { - // Don't update status if there's an active interaction for this thread - const activeInteractionKey = `interaction:active:${threadId}`; - const activeInteractionId = await this.redis.get(activeInteractionKey); - - if (activeInteractionId) { - logger.debug( - `Skipping status update for thread ${threadId} - active interaction ${activeInteractionId}` - ); - return; - } - - const statusText = `is ${state}...`; - const loadingMessages = [ - `still ${state}... (${elapsedSeconds}s)`, - `working on it... (${elapsedSeconds}s)`, - `${state} your request... (${elapsedSeconds}s)`, - ]; - - await this.slackClient.apiCall("assistant.threads.setStatus", { - channel_id: channelId, - thread_ts: threadId, - status: statusText, - loading_messages: loadingMessages, - }); - - logger.debug( - `Updated status for thread ${threadId}: ${state} (${elapsedSeconds}s)` - ); - } catch (error) { - logger.warn(`Failed to update thread status: ${error}`); - } - } - - /** - * Process streaming delta content - */ - private async processStreamDelta( - data: ThreadResponsePayload, - sessionKey: string - ): Promise { - if (!data.delta) { - return null; - } - - // Suppress deltas when thread has an active interaction - const activeInteractionKey = `interaction:active:${data.threadId}`; - const activeInteractionId = await this.redis.get(activeInteractionKey); - - if (activeInteractionId) { - logger.info( - `Suppressing delta for thread ${data.threadId} - active interaction ${activeInteractionId}` - ); - return null; - } - - logger.info( - `Processing stream delta length=${data.delta.length} for session ${sessionKey}, isFullReplacement=${data.isFullReplacement || false}` - ); - - return await this.streamSessionManager.handleDelta( - sessionKey, - data.channelId, - data.threadId, - data.userId, - data.delta, - data.isFullReplacement || false, - data.teamId - ); - } - - /** - * Complete streaming session and handle final content - */ - private async completeStreamingSession( - data: ThreadResponsePayload, - sessionKey: string, - _existingBotMessageTs: string | null, - _isFirstResponse: boolean - ): Promise { - const hasActiveStream = this.streamSessionManager.hasSession(sessionKey); - - if (hasActiveStream) { - logger.info(`Completing active stream for session ${sessionKey}`); - await this.streamSessionManager.completeSession(sessionKey); - } else { - // Clear status even if no session exists (handles "is scheduling..." status) - try { - await this.slackClient.apiCall("assistant.threads.setStatus", { - channel_id: data.channelId, - thread_ts: data.threadId, - status: "", - }); - logger.info( - `Cleared status for channel ${data.channelId}, thread ${data.threadId}` - ); - } catch (error) { - logger.warn(`Failed to clear status: ${error}`); - } - } - } - - /** - * Store bot message timestamp for future updates - */ - private async storeBotMessageTimestamp( - sessionKey: string, - newBotResponseTs: string, - _data: ThreadResponsePayload - ): Promise { - logger.info( - `Bot created first response with ts: ${newBotResponseTs}, storing for session ${sessionKey}` - ); - await this.setBotMessageTs(sessionKey, newBotResponseTs); - } - - /** - * Handle thread response message jobs - */ - private async handleThreadResponse( - job: QueueJob - ): Promise { - let data: ThreadResponsePayload | undefined; - - try { - data = this.parseThreadResponseJob(job); - - logger.info( - `Processing thread response job for message ${data.messageId}, originalMessageId: ${data.originalMessageId}, botResponseId: ${data.botResponseId}` - ); - - // Create a session key to track bot messages per conversation - const sessionKey = `${data.userId}:${data.originalMessageId || data.messageId}`; - - logger.info(`Using session key: ${sessionKey}`); - logger.info( - `Thread response data fields: ${Object.keys(data).join(", ")}` - ); - - // Check if we have a bot message for this Claude session - const redisBotMessageTs = await this.getBotMessageTs(sessionKey); - let existingBotMessageTs = data.botResponseId || redisBotMessageTs; - let isFirstResponse = !existingBotMessageTs; - - // Handle ephemeral messages (OAuth/auth flows) early - if (data.ephemeral && data.content) { - await this.handleEphemeralMessage(data); - return; - } - - // Handle status updates (heartbeat with elapsed time) - if (data.statusUpdate) { - await this.updateThreadStatus( - data.channelId, - data.threadId, - data.statusUpdate.elapsedSeconds, - data.statusUpdate.state - ); - return; // Early return - status updates don't need further processing - } - - // Handle streaming delta - const streamTs = await this.processStreamDelta(data, sessionKey); - if (streamTs) { - const storedTsChanged = - !redisBotMessageTs || redisBotMessageTs !== streamTs; - existingBotMessageTs = streamTs; - if (storedTsChanged) { - await this.storeBotMessageTimestamp(sessionKey, streamTs, data); - } - isFirstResponse = false; - } - - // Early return after stream delta if no other content to process - if (data.delta && !data.error) { - return; - } - - // Handle error signals - if (data.error) { - const botMessageTs = existingBotMessageTs || data.botResponseId; - await this.handleError(data, isFirstResponse, botMessageTs); - // Clean up session and clear status indicator on error - await this.completeStreamingSession( - data, - sessionKey, - existingBotMessageTs, - isFirstResponse - ); - } - - // Handle completion - if ( - Array.isArray(data.processedMessageIds) && - data.processedMessageIds.length > 0 - ) { - logger.info( - `Thread processing completed for message ${data.messageId}` - ); - await this.completeStreamingSession( - data, - sessionKey, - existingBotMessageTs, - isFirstResponse - ); - // Status is cleared automatically by StreamSession.stop() - } - } catch (error: unknown) { - // Log the error details - if (typeof error === "object" && error !== null) { - const err = error as { - data?: { error?: string }; - code?: string; - message?: string; - }; - - // Check if it's a validation error that shouldn't be retried - if ( - err.data?.error === "invalid_blocks" || - err.data?.error === "msg_too_long" || - err.data?.error === "message_not_in_streaming_state" || - err.code === "slack_webapi_platform_error" - ) { - logger.error( - `Slack validation error (not retrying): ${err.data?.error || err.message}`, - { - jobId: job.id, - messageId: data?.messageId, - threadId: data?.threadId, - error: err.data?.error || err.message, - } - ); - - // Don't throw - mark job as complete to prevent retry loops - // Note: We don't try to update the message here because: - // 1. If streaming is active, chat.update would conflict with the stream - // 2. The content has validation issues that would likely fail again - // 3. The worker should handle showing errors in its own stream content - // 4. message_not_in_streaming_state means stream already ended/never started - - // Clean up session and clear status on validation error - if ( - data?.channelId && - data?.threadId && - data?.userId && - data?.messageId - ) { - try { - const sessionKey = `${data.userId}:${data.originalMessageId || data.messageId}`; - await this.completeStreamingSession( - data, - sessionKey, - null, - false - ); - } catch (cleanupError) { - logger.warn( - `Failed to cleanup session after validation error: ${cleanupError}` - ); - // Continue anyway - we don't want cleanup errors to cause retries - } - } - return; - } - } - - logger.error(`Failed to process thread response job ${job.id}:`, error); - - // Clean up session and clear status on error - if ( - data?.channelId && - data?.threadId && - data?.userId && - data?.messageId - ) { - try { - const sessionKey = `${data.userId}:${data.originalMessageId || data.messageId}`; - await this.completeStreamingSession(data, sessionKey, null, false); - } catch (cleanupError) { - logger.warn(`Failed to cleanup session after error: ${cleanupError}`); - // Continue to throw original error - cleanup failures shouldn't mask it - } - } - - throw error; // Let the queue handle retry logic for other errors - } - } - - /** - * Handle ephemeral message (only visible to specific user) - */ - private async handleEphemeralMessage( - data: ThreadResponsePayload - ): Promise { - const { content, channelId, userId, threadId } = data; - - if (!content) return; - - try { - logger.info( - `Sending ephemeral message to user ${userId} in channel ${channelId}` - ); - - // Parse content (could be JSON blocks or markdown) - const { text, blocks } = await this.parseMessageContent(content, data); - - // Send as ephemeral message - await this.slackClient.chat.postEphemeral({ - channel: channelId, - user: userId, - thread_ts: threadId, // Send in thread if applicable - text, - blocks, - }); - - logger.info(`Ephemeral message sent successfully to user ${userId}`); - } catch (error: unknown) { - if (error instanceof Error) { - logger.error(`Failed to send ephemeral message: ${error.message}`); - } else { - logger.error(`Failed to send ephemeral message: ${error}`); - } - throw error; - } - } - - /** - * Parse message content - handles JSON blocks or markdown - */ - private async parseMessageContent( - content: string, - data: ThreadResponsePayload - ): Promise<{ text: string; blocks: AnyBlock[] }> { - // Check if content is JSON with blocks (from authentication prompt) - try { - const parsed = JSON.parse(content); - if (parsed.blocks && Array.isArray(parsed.blocks)) { - logger.debug( - `Content is pre-formatted blocks - blocks count: ${parsed.blocks.length}` - ); - return { - text: parsed.blocks[0]?.text?.text || "Authentication required", - blocks: parsed.blocks, - }; - } - } catch { - // Not JSON or not blocks format - continue to markdown processing - } - - // Process as markdown - logger.debug( - `Processing content for Slack - content length: ${content?.length || 0}` - ); - - // Extract code block actions and process markdown - const { processedContent, actionButtons: codeBlockButtons } = - extractCodeBlockActions(content); - const text = convertMarkdownToSlack(processedContent); - - logger.debug( - `Extracted ${codeBlockButtons.length} code block action buttons` - ); - - // Get action buttons from modules - const moduleButtons = await this.getModuleActionButtons( - data.userId, - data.channelId, - data.threadId, - data.moduleData - ); - - // Combine all action buttons - const allActionButtons = [...codeBlockButtons, ...moduleButtons]; - - // Use block builder to create proper blocks with validation - const result = this.blockBuilder.buildBlocks(text, { - actionButtons: allActionButtons, - includeActionButtons: true, - }); - - return { text: result.text, blocks: result.blocks }; - } - - /** - * Handle error messages - */ - private async handleError( - data: ThreadResponsePayload, - isFirstResponse: boolean, - botMessageTs?: string - ): Promise { - const { error, channelId, threadId, userId } = data; - - if (!error) return; - - try { - logger.info( - `Sending error message to channel ${channelId}, thread ${threadId}` - ); - - // Get action buttons from modules - const actionButtons = await this.getModuleActionButtons( - userId, - data.channelId, - data.threadId, - data.moduleData - ); - - // Use block builder for error blocks - const errorResult = this.blockBuilder.buildErrorBlocks( - error, - actionButtons - ); - - if (isFirstResponse) { - // Create new error message - const postResult = await this.slackClient.chat.postMessage({ - channel: channelId, - thread_ts: threadId, - text: errorResult.text, - mrkdwn: true, - blocks: errorResult.blocks, - unfurl_links: true, - unfurl_media: true, - }); - logger.info(`Error message created: ${postResult.ok}`); - } else { - // Update existing message with error - use the passed botMessageTs or fallback - const botTs = botMessageTs || data.botResponseId || threadId; - const updateResult = await this.slackClient.chat.update({ - channel: channelId, - ts: botTs, - text: errorResult.text, - blocks: errorResult.blocks, - }); - logger.info(`Error message update result: ${updateResult.ok}`); - } - } catch (updateError: unknown) { - const err = updateError as { message?: string }; - logger.error( - `Failed to send error message to Slack: ${err.message || updateError}` - ); - throw updateError; - } - } - - /** - * Get action buttons from all registered modules - * Extracted to deduplicate code between message and error handling - */ - private async getModuleActionButtons( - userId: string, - channelId: string, - threadTs: string, - moduleData?: Record - ): Promise { - const dispatcherModules = this.moduleRegistry.getDispatcherModules(); - - // Generate buttons from all modules in parallel for better performance - const buttonPromises = dispatcherModules.map(async (module) => { - try { - const moduleButtons = await module.generateActionButtons({ - userId, - channelId, - threadTs, - slackClient: this.slackClient, - moduleData: moduleData?.[module.name], - }); - - // Validate and convert buttons - const validButtons: ModuleButton[] = []; - for (const btn of moduleButtons) { - if (!btn.text || !btn.action_id) { - logger.warn( - `Invalid button from module ${module.name}: missing text or action_id`, - btn - ); - continue; - } - - validButtons.push({ - text: btn.text, - action_id: btn.action_id, - style: btn.style, - value: btn.value, - }); - } - - return validButtons; - } catch (error) { - logger.error( - `Failed to get action buttons from module ${module.name}:`, - error - ); - // Return empty array on error instead of failing entire operation - return []; - } - }); - - // Wait for all modules to complete and flatten results - const buttonArrays = await Promise.all(buttonPromises); - const actionButtons = buttonArrays.flat(); - - return actionButtons; - } - - /** - * Check if consumer is running and healthy - */ - isHealthy(): boolean { - return this.isRunning; - } - - /** - * Get current status - */ - getStatus(): { - isRunning: boolean; - } { - return { - isRunning: this.isRunning, - }; - } -} diff --git a/packages/gateway/src/spaces/index.ts b/packages/gateway/src/spaces/index.ts new file mode 100644 index 0000000..60c186c --- /dev/null +++ b/packages/gateway/src/spaces/index.ts @@ -0,0 +1 @@ +export * from "./space-resolver"; diff --git a/packages/gateway/src/spaces/space-resolver.ts b/packages/gateway/src/spaces/space-resolver.ts new file mode 100644 index 0000000..b8700d9 --- /dev/null +++ b/packages/gateway/src/spaces/space-resolver.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; + +export interface SpaceContext { + platform: string; + userId: string; + channelId: string; + isGroup: boolean; +} + +export interface ResolvedSpace { + spaceId: string; + spaceType: "user" | "group"; +} + +/** + * Hash a platform ID to a fixed-length identifier. + * Uses first 8 chars of SHA256 for uniqueness with K8s label compatibility. + */ +export function hashPlatformId(id: string): string { + return createHash("sha256").update(id).digest("hex").substring(0, 8); +} + +/** + * Resolve space from platform context. + * + * Space ID format: + * - DM/User: user-{hash8} (hash of platform:user:{userId}) + * - Group/Channel: group-{hash8} (hash of platform:group:{channelId}) + */ +export function resolveSpace(context: SpaceContext): ResolvedSpace { + const { platform, userId, channelId, isGroup } = context; + + if (isGroup) { + const hash = hashPlatformId(`${platform}:group:${channelId}`); + return { + spaceId: `group-${hash}`, + spaceType: "group", + }; + } + + const hash = hashPlatformId(`${platform}:user:${userId}`); + return { + spaceId: `user-${hash}`, + spaceType: "user", + }; +} + +/** + * Detect if context represents a group/channel based on platform heuristics. + * Use when isGroup is not explicitly available. + */ +export function isGroupContext(platform: string, channelId: string): boolean { + switch (platform) { + case "slack": + // Slack: D = DM, C = channel, G = private channel + return channelId.startsWith("C") || channelId.startsWith("G"); + case "whatsapp": + // WhatsApp: group JIDs end with @g.us + return channelId.endsWith("@g.us"); + default: + return false; + } +} diff --git a/packages/gateway/src/whatsapp/auth-adapter.ts b/packages/gateway/src/whatsapp/auth-adapter.ts new file mode 100644 index 0000000..cdc480f --- /dev/null +++ b/packages/gateway/src/whatsapp/auth-adapter.ts @@ -0,0 +1,294 @@ +/** + * WhatsApp Auth Adapter - Platform-specific authentication handling. + * Handles numbered provider selection and OAuth flow messaging. + */ + +import { createLogger } from "@peerbot/core"; +import type { + ClaudeOAuthStateStore, + OAuthPlatformContext, +} from "../auth/claude/oauth-state-store"; +import { ClaudeOAuthClient } from "../auth/oauth/claude-client"; +import type { AuthProvider, PlatformAuthAdapter } from "../auth/platform-auth"; +import type { BaileysClient } from "./connection/baileys-client"; + +const logger = createLogger("whatsapp-auth-adapter"); + +interface PendingAuth { + userId: string; + spaceId: string; + providers: AuthProvider[]; + createdAt: number; +} + +// 5 minute TTL for pending auth sessions +const PENDING_AUTH_TTL_MS = 5 * 60 * 1000; + +/** + * WhatsApp-specific authentication adapter. + * Renders auth prompts as numbered text lists and handles reply-based selection. + */ +export class WhatsAppAuthAdapter implements PlatformAuthAdapter { + private pendingAuthSessions = new Map(); + private oauthClient = new ClaudeOAuthClient(); + + constructor( + private client: BaileysClient, + private stateStore: ClaudeOAuthStateStore, + private publicGatewayUrl: string + ) { + // Cleanup expired sessions periodically + setInterval(() => this.cleanupExpiredSessions(), 60 * 1000); + } + + /** + * Send authentication required prompt with numbered provider list. + */ + async sendAuthPrompt( + userId: string, + channelId: string, + _threadId: string, // Not used for WhatsApp + providers: AuthProvider[], + platformMetadata?: Record + ): Promise { + // Use jid from metadata if available + const chatJid = (platformMetadata?.jid as string) || channelId; + const spaceId = (platformMetadata?.spaceId as string) || channelId; + + // Build numbered list message + const lines = [ + "*Authentication Required*", + "", + "Choose a provider to authenticate:", + ]; + + providers.forEach((provider, index) => { + lines.push(`${index + 1}. ${provider.name}`); + }); + + lines.push(""); + lines.push("Reply with the number of your choice."); + + const message = lines.join("\n"); + + try { + await this.client.sendMessage(chatJid, { text: message }); + logger.info( + { chatJid, userId, spaceId, providerCount: providers.length }, + "Sent auth prompt" + ); + + // Store pending auth session with spaceId for multi-tenant isolation + this.pendingAuthSessions.set(chatJid, { + userId, + spaceId, + providers, + createdAt: Date.now(), + }); + } catch (error) { + logger.error({ error, chatJid }, "Failed to send auth prompt"); + throw error; + } + } + + /** + * Send authentication success message. + */ + async sendAuthSuccess( + userId: string, + channelId: string, + provider: AuthProvider + ): Promise { + const message = [ + `*Authentication Successful!*`, + "", + `You're now connected to ${provider.name}.`, + "", + "Send your message again to continue.", + ].join("\n"); + + try { + await this.client.sendMessage(channelId, { text: message }); + logger.info( + { channelId, userId, provider: provider.id }, + "Sent auth success message" + ); + } catch (error) { + logger.error({ error, channelId }, "Failed to send auth success message"); + } + } + + /** + * Handle potential auth response (numbered selection). + * Returns true if the message was handled as an auth response. + */ + async handleAuthResponse( + channelId: string, + userId: string, + text: string + ): Promise { + const pending = this.pendingAuthSessions.get(channelId); + if (!pending) { + return false; + } + + // Check if session expired + if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) { + this.pendingAuthSessions.delete(channelId); + return false; + } + + // Parse selection (supports "1", "2", etc.) + const selection = this.parseSelection(text, pending.providers.length); + if (selection === null) { + return false; + } + + const selectedProvider = pending.providers[selection]; + if (!selectedProvider) { + return false; + } + + logger.info( + { channelId, userId, selection, provider: selectedProvider.id }, + "User selected auth provider" + ); + + // Remove pending session + this.pendingAuthSessions.delete(channelId); + + // Initiate OAuth flow for selected provider + await this.initiateOAuth( + channelId, + pending.userId, + pending.spaceId, + selectedProvider + ); + + return true; + } + + /** + * Parse user selection from text. + * Returns 0-indexed selection or null if invalid. + */ + private parseSelection(text: string, maxOptions: number): number | null { + const trimmed = text.trim().toLowerCase(); + + // Try parsing as number + const num = parseInt(trimmed, 10); + if (!Number.isNaN(num) && num >= 1 && num <= maxOptions) { + return num - 1; + } + + // Try word-based selection + const wordToNum: Record = { + one: 1, + two: 2, + three: 3, + four: 4, + first: 1, + second: 2, + third: 3, + fourth: 4, + }; + + const wordNum = wordToNum[trimmed]; + if (wordNum && wordNum <= maxOptions) { + return wordNum - 1; + } + + return null; + } + + /** + * Initiate OAuth flow for selected provider. + */ + private async initiateOAuth( + chatJid: string, + userId: string, + spaceId: string, + provider: AuthProvider + ): Promise { + // Generate PKCE code verifier + const codeVerifier = this.oauthClient.generateCodeVerifier(); + + // Create platform context for callback routing + const context: OAuthPlatformContext = { + platform: "whatsapp", + channelId: chatJid, + }; + + // Store state with platform context and spaceId + const state = await this.stateStore.create( + userId, + spaceId, + codeVerifier, + context + ); + + // Build OAuth URL - redirect to Anthropic console callback + // User will get CODE#STATE to paste in our web form + const authUrl = this.oauthClient.buildAuthUrl( + state, + codeVerifier, + "https://console.anthropic.com/oauth/code/callback" + ); + + // Build callback URL for code entry + const callbackUrl = `${this.publicGatewayUrl}/auth/callback`; + + // Send OAuth instructions + const message = [ + `*Step 1:* Visit this link to authorize with ${provider.name}:`, + "", + authUrl, + "", + `*Step 2:* After authorizing, you'll see a code like \`ABC123#XYZ789\``, + "", + `*Step 3:* Go to this page and paste the code:`, + "", + callbackUrl, + "", + "_The code expires in 5 minutes._", + ].join("\n"); + + try { + await this.client.sendMessage(chatJid, { text: message }); + logger.info( + { chatJid, userId, provider: provider.id, state }, + "Sent OAuth instructions" + ); + } catch (error) { + logger.error({ error, chatJid }, "Failed to send OAuth instructions"); + } + } + + /** + * Check if there's a pending auth session for this chat. + */ + hasPendingAuth(channelId: string): boolean { + const pending = this.pendingAuthSessions.get(channelId); + if (!pending) return false; + + // Check if expired + if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) { + this.pendingAuthSessions.delete(channelId); + return false; + } + + return true; + } + + /** + * Cleanup expired pending auth sessions. + */ + private cleanupExpiredSessions(): void { + const now = Date.now(); + for (const [key, session] of this.pendingAuthSessions) { + if (now - session.createdAt > PENDING_AUTH_TTL_MS) { + this.pendingAuthSessions.delete(key); + } + } + } +} diff --git a/packages/gateway/src/whatsapp/config.ts b/packages/gateway/src/whatsapp/config.ts new file mode 100644 index 0000000..a135a23 --- /dev/null +++ b/packages/gateway/src/whatsapp/config.ts @@ -0,0 +1,168 @@ +/** + * WhatsApp platform configuration. + */ + +import { getOptionalBoolean, getOptionalNumber } from "@peerbot/core"; + +export interface WhatsAppConfig { + /** Enable WhatsApp platform */ + enabled: boolean; + + /** + * Base64-encoded credentials JSON from QR link flow. + * This is the primary way to provide credentials. + */ + credentials?: string; + + /** + * Allowed phone numbers that can interact with the bot. + * Empty array means allow all. + * Format: E.164 (e.g., "+1234567890") + */ + allowFrom: string[]; + + /** Allow messages from group chats */ + allowGroups: boolean; + + /** Require @mention in group chats to respond */ + requireMention: boolean; + + /** Allow self-chat mode for testing (respond to own messages) */ + selfChatEnabled: boolean; + + /** Max reconnection attempts before giving up */ + reconnectMaxAttempts: number; + + /** Base delay for reconnection in ms */ + reconnectBaseDelay: number; + + /** Max delay for reconnection in ms */ + reconnectMaxDelay: number; + + /** Exponential backoff factor */ + reconnectFactor: number; + + /** Jitter factor (0-1) for reconnection delay */ + reconnectJitter: number; + + /** Max characters per message (WhatsApp limit is ~65536, but 4096 is practical) */ + messageChunkSize: number; + + /** Typing indicator duration in ms */ + typingTimeout: number; + + /** Max messages to keep in conversation history per chat */ + maxHistoryMessages: number; + + /** Conversation history TTL in seconds (default: 24 hours) */ + historyTtlSeconds: number; +} + +export const DEFAULT_WHATSAPP_CONFIG: WhatsAppConfig = { + enabled: false, + credentials: undefined, + allowFrom: [], + allowGroups: true, + requireMention: true, + selfChatEnabled: false, + reconnectMaxAttempts: 5, + reconnectBaseDelay: 2000, + reconnectMaxDelay: 60000, + reconnectFactor: 1.8, + reconnectJitter: 0.25, + messageChunkSize: 4096, + typingTimeout: 5000, + maxHistoryMessages: 10, + historyTtlSeconds: 86400, // 24 hours +}; + +/** + * Build WhatsApp config from environment variables. + */ +export function buildWhatsAppConfig(): WhatsAppConfig | null { + if (!getOptionalBoolean("WHATSAPP_ENABLED", false)) { + return null; + } + + const allowFromEnv = process.env.WHATSAPP_ALLOW_FROM; + const allowFrom = allowFromEnv + ? allowFromEnv + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const defaults = DEFAULT_WHATSAPP_CONFIG; + + return { + enabled: true, + credentials: process.env.WHATSAPP_CREDENTIALS, + allowFrom, + allowGroups: getOptionalBoolean( + "WHATSAPP_ALLOW_GROUPS", + defaults.allowGroups + ), + requireMention: getOptionalBoolean( + "WHATSAPP_REQUIRE_MENTION", + defaults.requireMention + ), + selfChatEnabled: getOptionalBoolean( + "WHATSAPP_SELF_CHAT", + defaults.selfChatEnabled + ), + reconnectMaxAttempts: getOptionalNumber( + "WHATSAPP_RECONNECT_MAX_ATTEMPTS", + defaults.reconnectMaxAttempts + ), + reconnectBaseDelay: getOptionalNumber( + "WHATSAPP_RECONNECT_BASE_DELAY", + defaults.reconnectBaseDelay + ), + reconnectMaxDelay: getOptionalNumber( + "WHATSAPP_RECONNECT_MAX_DELAY", + defaults.reconnectMaxDelay + ), + reconnectFactor: parseFloat( + process.env.WHATSAPP_RECONNECT_FACTOR ?? String(defaults.reconnectFactor) + ), + reconnectJitter: parseFloat( + process.env.WHATSAPP_RECONNECT_JITTER ?? String(defaults.reconnectJitter) + ), + messageChunkSize: getOptionalNumber( + "WHATSAPP_MESSAGE_CHUNK_SIZE", + defaults.messageChunkSize + ), + typingTimeout: getOptionalNumber( + "WHATSAPP_TYPING_TIMEOUT", + defaults.typingTimeout + ), + maxHistoryMessages: getOptionalNumber( + "WHATSAPP_MAX_HISTORY_MESSAGES", + defaults.maxHistoryMessages + ), + historyTtlSeconds: getOptionalNumber( + "WHATSAPP_HISTORY_TTL_SECONDS", + defaults.historyTtlSeconds + ), + }; +} + +/** + * Display WhatsApp configuration + */ +export function displayWhatsAppConfig(config: WhatsAppConfig | null): void { + if (config) { + console.log("\nWhatsApp:"); + console.log(` Enabled: ${config.enabled}`); + console.log( + ` Credentials: ${config.credentials ? "configured" : "not set (QR auth required)"}` + ); + console.log(` Allow Groups: ${config.allowGroups}`); + console.log(` Require Mention: ${config.requireMention}`); + console.log( + ` Allow From: ${config.allowFrom.length > 0 ? config.allowFrom.join(", ") : "all"}` + ); + } else { + console.log("\nWhatsApp: disabled"); + } +} diff --git a/packages/gateway/src/whatsapp/connection/auth-state.ts b/packages/gateway/src/whatsapp/connection/auth-state.ts new file mode 100644 index 0000000..e73c404 --- /dev/null +++ b/packages/gateway/src/whatsapp/connection/auth-state.ts @@ -0,0 +1,202 @@ +/** + * WhatsApp credential storage using environment variable or file. + * Credentials are stored as base64-encoded JSON. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { createLogger } from "@peerbot/core"; +import type { + AuthenticationCreds, + SignalDataTypeMap, +} from "@whiskeysockets/baileys"; +import { BufferJSON, initAuthCreds } from "@whiskeysockets/baileys"; + +const logger = createLogger("whatsapp-auth"); + +/** + * In-memory auth state that can be serialized/deserialized from env var. + */ +export interface AuthState { + creds: AuthenticationCreds; + keys: Map>; +} + +/** + * Load credentials from base64-encoded environment variable or file. + * If envValue is a file path (starts with / or ./), read from file. + * Otherwise, treat as base64-encoded credentials string. + */ +export function loadCredentialsFromEnv(envValue?: string): AuthState | null { + if (!envValue) { + return null; + } + + let base64Content: string; + + // Check if envValue is a file path + if (envValue.startsWith("/") || envValue.startsWith("./")) { + try { + if (!existsSync(envValue)) { + logger.error({ path: envValue }, "Credentials file not found"); + return null; + } + base64Content = readFileSync(envValue, "utf-8").trim(); + logger.info({ path: envValue }, "Loaded WhatsApp credentials from file"); + } catch (err) { + logger.error( + { error: String(err), path: envValue }, + "Failed to read credentials file" + ); + return null; + } + } else { + base64Content = envValue; + } + + try { + const json = Buffer.from(base64Content, "base64").toString("utf-8"); + const data = JSON.parse(json, BufferJSON.reviver); + + if (!data.creds) { + logger.warn("Invalid credentials: missing creds field"); + return null; + } + + const keys = new Map>(); + if (data.keys && typeof data.keys === "object") { + for (const [category, values] of Object.entries(data.keys)) { + keys.set(category, values as Record); + } + } + + return { + creds: data.creds, + keys, + }; + } catch (err) { + logger.error({ error: String(err) }, "Failed to parse credentials"); + return null; + } +} + +/** + * Serialize auth state to base64-encoded JSON for storage. + */ +export function serializeCredentials(state: AuthState): string { + const keysObj: Record> = {}; + for (const [category, values] of state.keys.entries()) { + keysObj[category] = values; + } + + const data = { + creds: state.creds, + keys: keysObj, + }; + + const json = JSON.stringify(data, BufferJSON.replacer); + return Buffer.from(json).toString("base64"); +} + +/** + * Create auth state adapter for Baileys. + * This provides the interface Baileys expects for credential management. + */ +export function createAuthState(initialState: AuthState | null): { + state: { + creds: AuthenticationCreds; + keys: { + get: ( + type: T, + ids: string[] + ) => Promise<{ [id: string]: SignalDataTypeMap[T] | undefined }>; + set: ( + data: { + [T in keyof SignalDataTypeMap]?: { + [id: string]: SignalDataTypeMap[T] | null; + }; + } + ) => Promise; + }; + }; + saveCreds: () => Promise; + getSerializedState: () => string; +} { + // Initialize with provided state or fresh credentials + const creds = initialState?.creds || initAuthCreds(); + const keys = initialState?.keys || new Map>(); + + const state = { + creds, + keys: { + get: async ( + type: T, + ids: string[] + ): Promise<{ [id: string]: SignalDataTypeMap[T] }> => { + const categoryData = keys.get(type) || {}; + const result: { [id: string]: SignalDataTypeMap[T] } = {}; + + for (const id of ids) { + const value = categoryData[id]; + if (value !== undefined) { + result[id] = value as SignalDataTypeMap[T]; + } + } + + return result; + }, + set: async ( + data: { + [T in keyof SignalDataTypeMap]?: { + [id: string]: SignalDataTypeMap[T] | null; + }; + } + ): Promise => { + for (const [category, values] of Object.entries(data)) { + if (!values) continue; + + let categoryData = keys.get(category); + if (!categoryData) { + categoryData = {}; + keys.set(category, categoryData); + } + + for (const [id, value] of Object.entries(values)) { + if (value === null) { + delete categoryData[id]; + } else { + categoryData[id] = value as unknown as Record< + string, + unknown + >[string]; + } + } + } + }, + }, + }; + + const getSerializedState = (): string => { + return serializeCredentials({ creds: state.creds, keys }); + }; + + const saveCreds = async (): Promise => { + // Return serialized state - caller is responsible for persisting + return getSerializedState(); + }; + + return { + state, + saveCreds, + getSerializedState, + }; +} + +/** + * Log credentials update instruction for the user. + */ +export function logCredentialsUpdateInstruction(serialized: string): void { + logger.info( + "WhatsApp credentials updated. To persist, update your environment:" + ); + logger.info(`WHATSAPP_CREDENTIALS=${serialized}`); +} diff --git a/packages/gateway/src/whatsapp/connection/baileys-client.ts b/packages/gateway/src/whatsapp/connection/baileys-client.ts new file mode 100644 index 0000000..6f17b04 --- /dev/null +++ b/packages/gateway/src/whatsapp/connection/baileys-client.ts @@ -0,0 +1,437 @@ +/** + * Baileys WebSocket client wrapper. + * Manages WhatsApp Web connection lifecycle. + */ + +import { EventEmitter } from "node:events"; +import { createLogger } from "@peerbot/core"; +import { + type AnyMessageContent, + type BaileysEventMap, + type ConnectionState, + DisconnectReason, + fetchLatestBaileysVersion, + makeCacheableSignalKeyStore, + makeWASocket, + type WASocket, +} from "@whiskeysockets/baileys"; +import pino from "pino"; +import qrcode from "qrcode-terminal"; + +import type { WhatsAppConfig } from "../config"; +import type { ConnectionCloseReason, WhatsAppConnectionStatus } from "../types"; +import { e164ToJid, jidToE164 } from "../types"; +import { + createAuthState, + loadCredentialsFromEnv, + logCredentialsUpdateInstruction, +} from "./auth-state"; +import { ReconnectionManager } from "./reconnection"; + +const logger = createLogger("whatsapp-client"); + +export interface BaileysClientEvents { + connected: []; + disconnected: [reason: ConnectionCloseReason]; + qr: [qrCode: string]; + logout: []; + credentialsUpdated: [serialized: string]; + message: [message: BaileysEventMap["messages.upsert"]]; + reaction: [reaction: BaileysEventMap["messages.reaction"]]; + messageUpdate: [update: BaileysEventMap["messages.update"]]; +} + +/** + * Baileys client wrapper with connection management. + */ +export class BaileysClient extends EventEmitter { + private socket: WASocket | null = null; + private config: WhatsAppConfig; + private reconnectionManager: ReconnectionManager; + private status: WhatsAppConnectionStatus; + private authState: ReturnType | null = null; + private isShuttingDown = false; + + constructor(config: WhatsAppConfig) { + super(); + this.config = config; + this.reconnectionManager = new ReconnectionManager({ + initialMs: config.reconnectBaseDelay, + maxMs: config.reconnectMaxDelay, + factor: config.reconnectFactor, + jitter: config.reconnectJitter, + maxAttempts: config.reconnectMaxAttempts, + }); + this.status = { + connected: false, + reconnectAttempts: 0, + qrPending: false, + }; + } + + /** + * Get current connection status. + */ + getStatus(): WhatsAppConnectionStatus { + return { ...this.status }; + } + + /** + * Check if connected. + */ + isConnected(): boolean { + return this.status.connected; + } + + /** + * Get the bot's own JID. + */ + getSelfJid(): string | null { + return this.socket?.user?.id ?? null; + } + + /** + * Get the bot's own E.164 number. + */ + getSelfE164(): string | null { + const jid = this.getSelfJid(); + return jid ? jidToE164(jid) : null; + } + + /** + * Connect to WhatsApp. + */ + async connect(): Promise { + this.isShuttingDown = false; + + // Load credentials from env - required for production + const initialState = loadCredentialsFromEnv(this.config.credentials); + if (!initialState) { + throw new Error( + "WhatsApp credentials not configured. " + + "Run 'bun packages/gateway/src/cli/index.ts whatsapp-setup' to obtain WHATSAPP_CREDENTIALS." + ); + } + + this.authState = createAuthState(initialState); + await this.createSocket(); + } + + /** + * Create the Baileys socket. + */ + private async createSocket(): Promise { + if (!this.authState) { + throw new Error("Auth state not initialized"); + } + + // Silent pino logger for Baileys + const baileysLogger = pino({ level: "silent" }) as unknown as ReturnType< + typeof pino + >; + + const { version } = await fetchLatestBaileysVersion(); + + this.socket = makeWASocket({ + auth: { + creds: this.authState.state.creds, + keys: makeCacheableSignalKeyStore( + this.authState.state.keys as any, + baileysLogger + ), + }, + version, + logger: baileysLogger, + printQRInTerminal: false, + browser: ["peerbot", "gateway", "1.0.0"], + syncFullHistory: false, + markOnlineOnConnect: false, + }); + + this.setupEventHandlers(); + } + + /** + * Setup event handlers for Baileys socket. + */ + private setupEventHandlers(): void { + if (!this.socket) return; + + // Connection state updates + this.socket.ev.on("connection.update", (update) => { + this.handleConnectionUpdate(update); + }); + + // Credential updates + this.socket.ev.on("creds.update", async () => { + if (this.authState) { + const serialized = await this.authState.saveCreds(); + this.emit("credentialsUpdated", serialized); + logCredentialsUpdateInstruction(serialized); + } + }); + + // Message updates + this.socket.ev.on("messages.upsert", (upsert) => { + logger.info( + { type: upsert.type, messageCount: upsert.messages?.length }, + "Received messages.upsert event" + ); + this.status.lastMessageAt = new Date(); + this.emit("message", upsert); + }); + + // Message reactions + this.socket.ev.on("messages.reaction", (reactions) => { + logger.info({ count: reactions.length }, "Received message reactions"); + this.emit("reaction", reactions); + }); + + // Message updates (edits, deletes) + this.socket.ev.on("messages.update", (updates) => { + logger.info({ count: updates.length }, "Received message updates"); + this.emit("messageUpdate", updates); + }); + + // WebSocket error handling + if ( + this.socket.ws && + typeof (this.socket.ws as unknown as { on?: unknown }).on === "function" + ) { + this.socket.ws.on("error", (err: Error) => { + logger.error({ error: String(err) }, "WebSocket error"); + }); + } + } + + /** + * Handle connection state updates. + */ + private handleConnectionUpdate(update: Partial): void { + const { connection, lastDisconnect, qr } = update; + + // QR code for authentication + if (qr) { + this.status.qrPending = true; + logger.info("QR code received, scan with WhatsApp Linked Devices"); + qrcode.generate(qr, { small: true }); + this.emit("qr", qr); + } + + // Connection opened + if (connection === "open") { + this.status.connected = true; + this.status.qrPending = false; + this.status.lastConnectedAt = new Date(); + this.status.reconnectAttempts = 0; + this.reconnectionManager.reset(); + + const selfE164 = this.getSelfE164(); + logger.info({ selfE164 }, "WhatsApp connected"); + this.emit("connected"); + + // Send available presence + this.socket?.sendPresenceUpdate("available").catch((err) => { + logger.warn( + { error: String(err) }, + "Failed to send available presence" + ); + }); + } + + // Connection closed + if (connection === "close") { + this.status.connected = false; + const statusCode = this.getStatusCode(lastDisconnect?.error); + const isLoggedOut = statusCode === DisconnectReason.loggedOut; + + this.status.lastDisconnectReason = isLoggedOut + ? "logged_out" + : `status_${statusCode}`; + + const reason: ConnectionCloseReason = { + status: statusCode, + isLoggedOut, + error: lastDisconnect?.error, + }; + + // Log full error details for debugging + const errorMessage = + lastDisconnect?.error instanceof Error + ? lastDisconnect.error.message + : String(lastDisconnect?.error || "unknown"); + const errorOutput = (lastDisconnect?.error as any)?.output; + + logger.warn( + { + statusCode, + isLoggedOut, + errorMessage, + errorPayload: errorOutput?.payload, + errorStatusCode: errorOutput?.statusCode, + }, + "WhatsApp disconnected" + ); + this.emit("disconnected", reason); + + if (isLoggedOut) { + logger.error("Session logged out, credentials invalidated"); + this.emit("logout"); + } else if (!this.isShuttingDown) { + this.handleReconnect(); + } + } + } + + /** + * Extract status code from error. + */ + private getStatusCode(err: unknown): number | undefined { + return ( + (err as { output?: { statusCode?: number } })?.output?.statusCode ?? + (err as { status?: number })?.status + ); + } + + /** + * Handle reconnection logic. + */ + private async handleReconnect(): Promise { + if (!this.reconnectionManager.shouldReconnect()) { + logger.error( + { attempts: this.reconnectionManager.getAttempts() }, + "Max reconnection attempts reached" + ); + return; + } + + const delay = this.reconnectionManager.getCurrentDelay(); + logger.info( + { delay, attempt: this.reconnectionManager.getAttempts() + 1 }, + "Scheduling reconnection" + ); + + const shouldRetry = await this.reconnectionManager.waitForNextAttempt(); + if (!shouldRetry || this.isShuttingDown) { + return; + } + + this.status.reconnectAttempts = this.reconnectionManager.getAttempts(); + + try { + await this.createSocket(); + } catch (err) { + logger.error({ error: String(err) }, "Reconnection failed"); + this.handleReconnect(); + } + } + + /** + * Disconnect from WhatsApp. + */ + async disconnect(): Promise { + this.isShuttingDown = true; + this.reconnectionManager.abort(); + + if (this.socket) { + try { + this.socket.ws?.close(); + } catch (err) { + logger.warn({ error: String(err) }, "Error closing socket"); + } + this.socket = null; + } + + this.status.connected = false; + } + + /** + * Send a text message. + */ + async sendMessage( + to: string, + content: AnyMessageContent + ): Promise<{ messageId: string }> { + if (!this.socket) { + throw new Error("Not connected to WhatsApp"); + } + + // Normalize JID - keep @lid as-is (it's a linked device ID, NOT a phone number) + const jid = to.includes("@") ? to : e164ToJid(to); + // Note: @lid JIDs must be sent as-is - WhatsApp routes them internally + // Do NOT convert @lid to @s.whatsapp.net as the digits are not phone numbers + + logger.info( + { jid, contentType: Object.keys(content)[0] }, + "Sending WhatsApp message" + ); + const result = await this.socket.sendMessage(jid, content); + return { messageId: result?.key?.id ?? "unknown" }; + } + + /** + * Send typing indicator. + */ + async sendTyping(to: string, duration?: number): Promise { + if (!this.socket) return; + + const jid = to.includes("@") ? to : e164ToJid(to); + await this.socket.sendPresenceUpdate("composing", jid); + + // Auto-clear typing after duration + if (duration) { + setTimeout(async () => { + try { + await this.socket?.sendPresenceUpdate("paused", jid); + } catch { + // Ignore + } + }, duration); + } + } + + /** + * Mark messages as read. + */ + async markRead( + remoteJid: string, + messageId: string, + participant?: string + ): Promise { + if (!this.socket) return; + + await this.socket.readMessages([ + { remoteJid, id: messageId, participant, fromMe: false }, + ]); + } + + /** + * Get group metadata. + */ + async getGroupMetadata(groupJid: string): Promise<{ + subject?: string; + participants?: string[]; + }> { + if (!this.socket) { + return {}; + } + + try { + const meta = await this.socket.groupMetadata(groupJid); + const participants = meta.participants + ?.map((p) => jidToE164(p.id) ?? p.id) + .filter(Boolean); + + return { + subject: meta.subject, + participants, + }; + } catch (err) { + logger.warn( + { groupJid, error: String(err) }, + "Failed to fetch group metadata" + ); + return {}; + } + } +} diff --git a/packages/gateway/src/whatsapp/connection/reconnection.ts b/packages/gateway/src/whatsapp/connection/reconnection.ts new file mode 100644 index 0000000..ec2e07e --- /dev/null +++ b/packages/gateway/src/whatsapp/connection/reconnection.ts @@ -0,0 +1,154 @@ +/** + * Reconnection logic with exponential backoff and jitter. + * Adapted from clawdbot/src/web/reconnect.ts + */ + +import type { ReconnectPolicy } from "../types"; + +export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = { + initialMs: 2000, + maxMs: 30000, + factor: 1.8, + jitter: 0.25, + maxAttempts: 5, +}; + +const clamp = (val: number, min: number, max: number) => + Math.max(min, Math.min(max, val)); + +/** + * Normalize and validate reconnect policy values. + */ +export function normalizeReconnectPolicy( + overrides?: Partial +): ReconnectPolicy { + const merged = { + ...DEFAULT_RECONNECT_POLICY, + ...overrides, + }; + + merged.initialMs = Math.max(250, merged.initialMs); + merged.maxMs = Math.max(merged.initialMs, merged.maxMs); + merged.factor = clamp(merged.factor, 1.1, 10); + merged.jitter = clamp(merged.jitter, 0, 1); + merged.maxAttempts = Math.max(0, Math.floor(merged.maxAttempts)); + + return merged; +} + +/** + * Compute backoff delay with exponential growth and jitter. + */ +export function computeBackoff( + policy: ReconnectPolicy, + attempt: number +): number { + const base = policy.initialMs * policy.factor ** Math.max(attempt - 1, 0); + const jitter = base * policy.jitter * Math.random(); + return Math.min(policy.maxMs, Math.round(base + jitter)); +} + +/** + * Sleep with abort signal support. + */ +export function sleepWithAbort( + ms: number, + abortSignal?: AbortSignal +): Promise { + if (ms <= 0) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + cleanup(); + reject(new Error("aborted")); + }; + + const cleanup = () => { + clearTimeout(timer); + abortSignal?.removeEventListener("abort", onAbort); + }; + + if (abortSignal) { + abortSignal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +/** + * Reconnection manager that handles automatic reconnection with backoff. + */ +export class ReconnectionManager { + private attempts: number = 0; + private policy: ReconnectPolicy; + private abortController: AbortController | null = null; + + constructor(policy?: Partial) { + this.policy = normalizeReconnectPolicy(policy); + } + + /** + * Get current attempt count. + */ + getAttempts(): number { + return this.attempts; + } + + /** + * Check if we should attempt reconnection. + */ + shouldReconnect(): boolean { + return this.attempts < this.policy.maxAttempts; + } + + /** + * Reset attempt counter (call after successful connection). + */ + reset(): void { + this.attempts = 0; + this.abortController?.abort(); + this.abortController = null; + } + + /** + * Abort any pending reconnection. + */ + abort(): void { + this.abortController?.abort(); + this.abortController = null; + } + + /** + * Attempt reconnection with backoff delay. + * Returns true if reconnection should be attempted, false if max attempts reached. + */ + async waitForNextAttempt(): Promise { + this.attempts++; + + if (this.attempts > this.policy.maxAttempts) { + return false; + } + + const delay = computeBackoff(this.policy, this.attempts); + this.abortController = new AbortController(); + + try { + await sleepWithAbort(delay, this.abortController.signal); + return true; + } catch { + // Aborted + return false; + } + } + + /** + * Get delay for current attempt (for logging). + */ + getCurrentDelay(): number { + return computeBackoff(this.policy, this.attempts + 1); + } +} diff --git a/packages/gateway/src/whatsapp/converters/markdown.ts b/packages/gateway/src/whatsapp/converters/markdown.ts new file mode 100644 index 0000000..04b4d3d --- /dev/null +++ b/packages/gateway/src/whatsapp/converters/markdown.ts @@ -0,0 +1,185 @@ +import { createLogger } from "@peerbot/core"; +import { marked } from "marked"; + +const logger = createLogger("whatsapp-markdown"); + +/** + * Custom renderer for converting markdown to WhatsApp's formatting syntax. + * + * WhatsApp supports: + * - Bold: *text* + * - Italic: _text_ + * - Strikethrough: ~text~ + * - Monospace: `text` (inline) or ```text``` (block) + * - Block quotes: > text + * - Lists: - item or 1. item + */ +class WhatsAppRenderer extends marked.Renderer { + heading(text: string, _level: number): string { + // WhatsApp doesn't have native headings - make them bold + let processedText = text; + + // Convert markdown bold (**text**) to WhatsApp bold (*text*) + processedText = processedText.replace(/\*\*(.+?)\*\*/g, "*$1*"); + + // Convert markdown bold (__text__) to WhatsApp bold (*text*) + processedText = processedText.replace(/__(.+?)__/g, "*$1*"); + + // Make heading text bold if not already + if (!processedText.startsWith("*") || !processedText.endsWith("*")) { + processedText = `*${processedText}*`; + } + + return `${processedText}\n\n`; + } + + paragraph(text: string): string { + return `${text}\n\n`; + } + + strong(text: string): string { + return `*${text}*`; + } + + em(text: string): string { + return `_${text}_`; + } + + del(text: string): string { + // Strikethrough in WhatsApp is ~text~ + return `~${text}~`; + } + + code(text: string): string { + // Code block - use triple backticks + return `\`\`\`\n${text}\n\`\`\``; + } + + codespan(text: string): string { + // Inline code + return `\`${text}\``; + } + + blockquote(quote: string): string { + const trimmed = quote.trim(); + const lines = trimmed.split("\n"); + return `${lines.map((line) => `> ${line.trim()}`).join("\n")}\n\n`; + } + + list(body: string, ordered: boolean, start: number | ""): string { + if (ordered) { + // Renumber the list items + let counter = typeof start === "number" ? start : 1; + const lines = body.trim().split("\n"); + const renumbered = lines.map((line) => { + if (line.startsWith("โ€ข ")) { + return `${counter++}. ${line.substring(2)}`; + } + return line; + }); + return `${renumbered.join("\n")}\n\n`; + } + return `${body}\n`; + } + + listitem(text: string): string { + return `โ€ข ${text.trim()}\n`; + } + + link(href: string, _title: string | null | undefined, text: string): string { + // WhatsApp doesn't support rich links - show text and URL + if (text === href || !text) { + return href; + } + return `${text}: ${href}`; + } + + image(href: string, _title: string | null, text: string): string { + // Images can't be rendered inline - show as link + if (text) { + return `[Image: ${text}] ${href}`; + } + return `[Image] ${href}`; + } + + br(): string { + return "\n"; + } + + hr(): string { + return "\n---\n\n"; + } +} + +/** + * Convert markdown to WhatsApp's formatting syntax. + */ +export function convertMarkdownToWhatsApp(content: string): string { + if (typeof content !== "string") { + logger.warn( + `convertMarkdownToWhatsApp received non-string content (type: ${typeof content}), converting to string` + ); + content = + typeof content === "object" ? JSON.stringify(content) : String(content); + } + + // Handle empty content + if (!content.trim()) { + return content; + } + + const renderer = new WhatsAppRenderer(); + + // Pre-process triple backtick code blocks + const preprocessed = content.replace( + /```(\w*)\n?([\s\S]*?)```/g, + (match, lang, code) => { + if (code?.trim()) { + const langAttr = lang ? ` class="language-${lang}"` : ""; + return `
${code.trim()}
`; + } + return match; + } + ); + + marked.setOptions({ + renderer: renderer, + breaks: true, + gfm: true, + }); + + try { + let processed = marked.parse(preprocessed) as string; + + // Clean up extra whitespace + processed = processed.replace(/\n{3,}/g, "\n\n").trim(); + + // Convert code blocks back to WhatsApp format (triple backticks) + processed = processed.replace( + /
([\s\S]*?)<\/code><\/pre>/g,
+      (_match, _language, code) => {
+        const decodedCode = code
+          .replace(/</g, "<")
+          .replace(/>/g, ">")
+          .replace(/&/g, "&")
+          .replace(/"/g, '"')
+          .replace(/'/g, "'");
+
+        return `\`\`\`\n${decodedCode.trim()}\n\`\`\``;
+      }
+    );
+
+    // Clean up remaining HTML entities
+    processed = processed
+      .replace(/>/g, ">")
+      .replace(/</g, "<")
+      .replace(/&/g, "&")
+      .replace(/"/g, '"')
+      .replace(/'/g, "'");
+
+    return processed;
+  } catch (error) {
+    logger.error("Failed to parse markdown:", error);
+    return content;
+  }
+}
diff --git a/packages/gateway/src/whatsapp/events/message-handler.ts b/packages/gateway/src/whatsapp/events/message-handler.ts
new file mode 100644
index 0000000..747dddb
--- /dev/null
+++ b/packages/gateway/src/whatsapp/events/message-handler.ts
@@ -0,0 +1,824 @@
+/**
+ * WhatsApp message handler.
+ * Processes inbound messages and enqueues them for worker processing.
+ * Adapted from clawdbot/src/web/inbound.ts
+ */
+
+import { createLogger } from "@peerbot/core";
+import {
+  type BaileysEventMap,
+  extractMessageContent,
+  normalizeMessageContent,
+  type proto,
+  type WAMessage,
+} from "@whiskeysockets/baileys";
+import type {
+  MessagePayload,
+  QueueProducer,
+} from "../../infrastructure/queue/queue-producer";
+import type { ISessionManager } from "../../session";
+import { resolveSpace } from "../../spaces";
+import type { WhatsAppAuthAdapter } from "../auth-adapter";
+import type { WhatsAppConfig } from "../config";
+import type { BaileysClient } from "../connection/baileys-client";
+import type { ExtractedMedia, WhatsAppFileHandler } from "../file-handler";
+import {
+  isGroupJid,
+  jidToE164,
+  normalizeE164,
+  type WhatsAppContext,
+} from "../types";
+
+const logger = createLogger("whatsapp-message-handler");
+
+interface AgentOptions {
+  model?: string;
+  maxTokens?: number;
+  temperature?: number;
+  allowedTools?: string[];
+  disallowedTools?: string[];
+  timeoutMinutes?: number;
+}
+
+interface StoredMessage {
+  id: string;
+  text: string;
+  fromMe: boolean;
+  senderName?: string;
+  timestamp: number;
+}
+
+interface ConversationHistory {
+  messages: StoredMessage[];
+  lastUpdated: number;
+}
+
+/**
+ * WhatsApp message handler.
+ */
+export class WhatsAppMessageHandler {
+  private seen = new Set();
+  private groupMetaCache = new Map<
+    string,
+    { subject?: string; participants?: string[]; expires: number }
+  >();
+  private conversationHistory = new Map();
+  private readonly GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
+  private isRunning = false;
+  private authAdapter?: WhatsAppAuthAdapter;
+  private fileHandler?: WhatsAppFileHandler;
+
+  constructor(
+    private client: BaileysClient,
+    private config: WhatsAppConfig,
+    private queueProducer: QueueProducer,
+    _sessionManager: ISessionManager, // Reserved for future use
+    private agentOptions: AgentOptions
+  ) {}
+
+  /**
+   * Set the file handler for extracting media.
+   */
+  setFileHandler(handler: WhatsAppFileHandler): void {
+    this.fileHandler = handler;
+  }
+
+  /**
+   * Set the auth adapter for handling auth responses.
+   */
+  setAuthAdapter(adapter: WhatsAppAuthAdapter): void {
+    this.authAdapter = adapter;
+  }
+
+  /**
+   * Start listening for messages.
+   */
+  start(): void {
+    if (this.isRunning) return;
+    this.isRunning = true;
+
+    logger.info(
+      `WhatsApp message handler config: selfChatEnabled=${this.config.selfChatEnabled}, allowFrom=${JSON.stringify(this.config.allowFrom)}, requireMention=${this.config.requireMention}`
+    );
+
+    this.client.on("message", (upsert) => {
+      logger.info("Message handler received event from client");
+      this.handleMessagesUpsert(upsert).catch((err) => {
+        logger.error({ error: String(err) }, "Error handling message upsert");
+      });
+    });
+
+    // Handle reactions (for potential future use, e.g., thumbs up = approve)
+    this.client.on("reaction", (reactions) => {
+      this.handleReactions(
+        reactions as BaileysEventMap["messages.reaction"]
+      ).catch((err) => {
+        logger.error({ error: String(err) }, "Error handling reactions");
+      });
+    });
+
+    // Handle message updates (edits, deletes)
+    this.client.on("messageUpdate", (updates) => {
+      this.handleMessageUpdates(
+        updates as BaileysEventMap["messages.update"]
+      ).catch((err) => {
+        logger.error({ error: String(err) }, "Error handling message updates");
+      });
+    });
+
+    // Periodically cleanup expired histories
+    setInterval(() => this.cleanupExpiredHistories(), 60 * 60 * 1000); // Every hour
+
+    logger.info("WhatsApp message handler started");
+  }
+
+  /**
+   * Stop listening for messages.
+   */
+  stop(): void {
+    this.isRunning = false;
+    logger.info("WhatsApp message handler stopped");
+  }
+
+  /**
+   * Handle message upsert events from Baileys.
+   */
+  private async handleMessagesUpsert(
+    upsert: BaileysEventMap["messages.upsert"]
+  ): Promise {
+    logger.info(
+      { type: upsert.type, messageCount: upsert.messages?.length },
+      "handleMessagesUpsert called"
+    );
+
+    if (upsert.type !== "notify" && upsert.type !== "append") {
+      logger.debug({ type: upsert.type }, "Skipping non-notify/append upsert");
+      return;
+    }
+
+    for (const msg of upsert.messages ?? []) {
+      await this.processMessage(msg, upsert.type);
+    }
+  }
+
+  /**
+   * Process a single message.
+   */
+  private async processMessage(
+    msg: WAMessage,
+    upsertType: string
+  ): Promise {
+    const id = msg.key?.id;
+    if (!id) {
+      logger.debug("Skipping message: no ID");
+      return;
+    }
+
+    // Dedupe on message ID (Baileys can emit retries)
+    if (this.seen.has(id)) {
+      logger.debug({ id }, "Skipping duplicate message");
+      return;
+    }
+    this.seen.add(id);
+
+    const remoteJid = msg.key?.remoteJid;
+    if (!remoteJid) {
+      logger.debug({ id }, "Skipping message: no remoteJid");
+      return;
+    }
+
+    // Ignore status/broadcast traffic
+    if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) {
+      logger.debug({ id, remoteJid }, "Skipping status/broadcast message");
+      return;
+    }
+
+    const isGroup = isGroupJid(remoteJid);
+    const participantJid = msg.key?.participant;
+
+    // Get sender info
+    const senderJid = isGroup ? participantJid : remoteJid;
+    const senderE164 = senderJid ? jidToE164(senderJid) : null;
+
+    // Get self info
+    const selfJid = this.client.getSelfJid();
+    const selfE164 = this.client.getSelfE164();
+
+    // Check if this is from ourselves
+    const isFromMe = msg.key?.fromMe === true;
+    const isSelfChat = senderE164 === selfE164;
+
+    logger.info(
+      `Processing message: id=${id}, remoteJid=${remoteJid}, isFromMe=${isFromMe}, isSelfChat=${isSelfChat}, senderE164=${senderE164}, selfE164=${selfE164}, selfChatEnabled=${this.config.selfChatEnabled}, messageStubType=${msg.messageStubType}, hasMessage=${!!msg.message}`
+    );
+
+    // Skip stub messages (system notifications, failed decryption, etc.)
+    // messageStubType 2 = CIPHERTEXT (failed to decrypt)
+    if (msg.messageStubType) {
+      logger.info(`Skipping stub message: type=${msg.messageStubType}`);
+      return;
+    }
+
+    // Skip messages with no content (decryption failed)
+    if (!msg.message) {
+      logger.warn(`Message ${id} has no content - possible decryption failure`);
+      return;
+    }
+
+    // Skip protocol messages (not user messages)
+    const messageKeys = Object.keys(msg.message);
+    if (messageKeys.length === 1 && messageKeys[0] === "protocolMessage") {
+      logger.debug(`Skipping protocol message ${id}`);
+      return;
+    }
+    if (
+      messageKeys.includes("protocolMessage") &&
+      !messageKeys.includes("conversation") &&
+      !messageKeys.includes("extendedTextMessage")
+    ) {
+      logger.debug(`Skipping protocol-only message ${id}`);
+      return;
+    }
+
+    // Skip own messages unless self-chat is enabled
+    if (isFromMe && !this.config.selfChatEnabled) {
+      logger.info("Skipping own message - selfChat not enabled");
+      return;
+    }
+
+    // Authorization check for non-group messages
+    if (!isGroup && !this.isAllowedSender(senderE164)) {
+      logger.info(
+        `Blocked unauthorized sender: ${senderE164}, allowFrom=${JSON.stringify(this.config.allowFrom)}`
+      );
+      return;
+    }
+
+    logger.info("Message passed authorization checks");
+
+    // Get group metadata if needed
+    let groupSubject: string | undefined;
+    let groupParticipants: string[] | undefined;
+    if (isGroup) {
+      const meta = await this.getGroupMeta(remoteJid);
+      groupSubject = meta.subject;
+      groupParticipants = meta.participants;
+    }
+
+    // Check mention requirement for groups and self-chat
+    const mentionedJids = this.extractMentionedJids(msg.message);
+    const wasMentioned = selfJid
+      ? (mentionedJids?.includes(selfJid) ?? false)
+      : false;
+
+    // For self-chat, require mention to prevent loops (bot replies don't have mentions)
+    if (isSelfChat && this.config.requireMention && !wasMentioned) {
+      // Check for text trigger patterns like "@bot" in message body
+      const bodyText = this.extractText(msg.message) || "";
+      const hasTriggerPattern = /^@\w+/i.test(bodyText.trim());
+      if (!hasTriggerPattern) {
+        logger.info(
+          `Skipping self-chat message without trigger pattern: ${id}`
+        );
+        return;
+      }
+    }
+
+    if (isGroup && this.config.requireMention && !wasMentioned) {
+      return;
+    }
+
+    // Mark as read (unless self-chat)
+    if (!isSelfChat) {
+      await this.client
+        .markRead(remoteJid, id, participantJid || undefined)
+        .catch((err) => {
+          logger.debug(
+            { error: String(err) },
+            "Failed to mark message as read"
+          );
+        });
+    }
+
+    // Skip history/offline catch-up messages (but allow self-chat messages)
+    if (upsertType === "append" && !isSelfChat) {
+      logger.info(`Skipping history/append message: ${id}`);
+      return;
+    }
+
+    logger.info(
+      `About to extract text from message ${id}, upsertType=${upsertType}`
+    );
+
+    // Debug: Log full message structure
+    const msgJson = JSON.stringify(msg, null, 2);
+    logger.info(`FULL_MESSAGE_DEBUG: ${msgJson.substring(0, 2000)}`);
+
+    // Extract media files if file handler is available
+    let extractedFiles: ExtractedMedia[] = [];
+    if (this.fileHandler) {
+      try {
+        extractedFiles = await this.fileHandler.extractMediaFromMessage(msg);
+        if (extractedFiles.length > 0) {
+          logger.info(
+            { messageId: id, fileCount: extractedFiles.length },
+            "Extracted media files from message"
+          );
+        }
+      } catch (err) {
+        logger.error(
+          { error: String(err), messageId: id },
+          "Failed to extract media"
+        );
+      }
+    }
+
+    // Extract message text
+    let body = this.extractText(msg.message);
+    if (!body) {
+      // If we have files but no text, use a placeholder indicating files
+      if (extractedFiles.length > 0) {
+        const fileNames = extractedFiles.map((f) => f.name).join(", ");
+        body = `[Attached: ${fileNames}]`;
+      } else {
+        body = this.extractMediaPlaceholder(msg.message);
+        if (!body) {
+          logger.info(`No text or media placeholder found in message ${id}`);
+          return;
+        }
+      }
+    }
+
+    logger.info(`Message ${id} has body: ${body.substring(0, 50)}...`);
+
+    // Check if this is an auth response (e.g., "1" to select provider)
+    if (this.authAdapter && !isGroup) {
+      const userId = senderE164 || senderJid || "";
+      try {
+        const handled = await this.authAdapter.handleAuthResponse(
+          remoteJid,
+          userId,
+          body
+        );
+        if (handled) {
+          logger.info({ remoteJid, body }, "Message handled as auth response");
+          return;
+        }
+      } catch (err) {
+        logger.error({ error: String(err) }, "Error handling auth response");
+      }
+    }
+
+    // Extract reply context
+    const replyContext = this.describeReplyContext(msg.message);
+
+    // Build context
+    const context: WhatsAppContext = {
+      senderJid: senderJid || remoteJid,
+      senderE164: senderE164 ?? undefined,
+      senderName: msg.pushName ?? undefined,
+      chatJid: remoteJid,
+      isGroup,
+      groupSubject,
+      groupParticipants,
+      messageId: id,
+      timestamp: msg.messageTimestamp
+        ? Number(msg.messageTimestamp) * 1000
+        : undefined,
+      quotedMessage: replyContext ?? undefined,
+      mentionedJids,
+      wasMentioned,
+      selfJid: selfJid ?? undefined,
+      selfE164: selfE164 ?? undefined,
+    };
+
+    logger.info(
+      {
+        from: senderE164 || senderJid,
+        chatJid: remoteJid,
+        isGroup,
+        body: body.substring(0, 100),
+      },
+      "Inbound message"
+    );
+
+    // Store incoming message in conversation history
+    this.storeMessageInHistory(remoteJid, {
+      id,
+      text: body,
+      fromMe: false,
+      senderName: msg.pushName ?? undefined,
+      timestamp: msg.messageTimestamp
+        ? Number(msg.messageTimestamp) * 1000
+        : Date.now(),
+    });
+
+    // Get conversation history for context
+    const conversationHistory = this.getConversationHistory(remoteJid);
+
+    // Enqueue for processing
+    await this.enqueueMessage(
+      id,
+      body,
+      context,
+      extractedFiles,
+      conversationHistory
+    );
+  }
+
+  /**
+   * Check if sender is allowed.
+   */
+  private isAllowedSender(senderE164: string | null): boolean {
+    if (!senderE164) return false;
+
+    const { allowFrom, selfChatEnabled } = this.config;
+    const selfE164 = this.client.getSelfE164();
+
+    // Self-chat always allowed if enabled
+    if (selfChatEnabled && senderE164 === selfE164) {
+      return true;
+    }
+
+    // Empty allowFrom means allow all
+    if (!allowFrom || allowFrom.length === 0) {
+      return true;
+    }
+
+    // Check wildcard
+    if (allowFrom.includes("*")) {
+      return true;
+    }
+
+    // Check if sender is in allowlist
+    const normalizedAllowFrom = allowFrom.map(normalizeE164);
+    return normalizedAllowFrom.includes(normalizeE164(senderE164));
+  }
+
+  /**
+   * Get group metadata with caching.
+   */
+  private async getGroupMeta(
+    jid: string
+  ): Promise<{ subject?: string; participants?: string[] }> {
+    const cached = this.groupMetaCache.get(jid);
+    if (cached && cached.expires > Date.now()) {
+      return cached;
+    }
+
+    const meta = await this.client.getGroupMetadata(jid);
+    const entry = {
+      ...meta,
+      expires: Date.now() + this.GROUP_META_TTL_MS,
+    };
+    this.groupMetaCache.set(jid, entry);
+    return meta;
+  }
+
+  /**
+   * Enqueue message for worker processing.
+   */
+  private async enqueueMessage(
+    messageId: string,
+    body: string,
+    context: WhatsAppContext,
+    files: ExtractedMedia[] = [],
+    conversationHistory: Array<{
+      role: "user" | "assistant";
+      content: string;
+      name?: string;
+    }> = []
+  ): Promise {
+    // Use chat JID as channel, message ID as thread for routing
+    // For group chats, each message starts a new "thread"
+    const threadId = context.quotedMessage?.id || messageId;
+
+    // Resolve space ID for multi-tenant isolation
+    const { spaceId } = resolveSpace({
+      platform: "whatsapp",
+      userId: context.senderE164 || context.senderJid,
+      channelId: context.chatJid,
+      isGroup: context.isGroup,
+    });
+
+    // Build file metadata for payload
+    const fileMetadata = files.map((f) => ({
+      id: f.id,
+      name: f.name,
+      mimetype: f.mimetype,
+      size: f.size,
+    }));
+
+    const payload: MessagePayload = {
+      platform: "whatsapp",
+      userId: context.senderE164 || context.senderJid,
+      botId: "whatsapp",
+      threadId,
+      teamId: context.isGroup ? context.chatJid : "whatsapp", // Group JID for groups, "whatsapp" for DMs
+      spaceId,
+      messageId,
+      messageText: body,
+      channelId: context.chatJid,
+      platformMetadata: {
+        jid: context.chatJid,
+        senderJid: context.senderJid,
+        senderE164: context.senderE164,
+        senderName: context.senderName,
+        isGroup: context.isGroup,
+        groupSubject: context.groupSubject,
+        quotedMessageId: context.quotedMessage?.id,
+        wasMentioned: context.wasMentioned,
+        responseChannel: context.chatJid,
+        responseId: messageId,
+        files: fileMetadata.length > 0 ? fileMetadata : undefined,
+        conversationHistory:
+          conversationHistory.length > 0 ? conversationHistory : undefined,
+      },
+      agentOptions: {
+        ...this.agentOptions,
+      },
+    };
+
+    await this.queueProducer.enqueueMessage(payload);
+    logger.info(
+      {
+        messageId,
+        threadId,
+        chatJid: context.chatJid,
+        fileCount: files.length,
+        historyCount: conversationHistory.length,
+      },
+      "Message enqueued"
+    );
+  }
+
+  /**
+   * Extract text from message.
+   */
+  private extractText(
+    rawMessage: proto.IMessage | null | undefined
+  ): string | undefined {
+    if (!rawMessage) {
+      logger.info("extractText: rawMessage is null/undefined");
+      return undefined;
+    }
+
+    logger.info(
+      `extractText: rawMessage keys = ${Object.keys(rawMessage).join(", ")}`
+    );
+
+    const message = normalizeMessageContent(rawMessage);
+    if (!message) {
+      logger.info("extractText: normalizeMessageContent returned null");
+      return undefined;
+    }
+
+    logger.info(
+      `extractText: normalized message keys = ${Object.keys(message).join(", ")}`
+    );
+
+    const extracted = extractMessageContent(message);
+    const candidates = [message, extracted !== message ? extracted : undefined];
+
+    for (const candidate of candidates) {
+      if (!candidate) continue;
+
+      // Check conversation
+      if (
+        typeof candidate.conversation === "string" &&
+        candidate.conversation.trim()
+      ) {
+        return candidate.conversation.trim();
+      }
+
+      // Check extended text
+      const extended = candidate.extendedTextMessage?.text;
+      if (extended?.trim()) return extended.trim();
+
+      // Check captions
+      const caption =
+        candidate.imageMessage?.caption ??
+        candidate.videoMessage?.caption ??
+        candidate.documentMessage?.caption;
+      if (caption?.trim()) return caption.trim();
+    }
+
+    return undefined;
+  }
+
+  /**
+   * Extract media placeholder text.
+   */
+  private extractMediaPlaceholder(
+    rawMessage: proto.IMessage | null | undefined
+  ): string | undefined {
+    if (!rawMessage) return undefined;
+
+    const message = normalizeMessageContent(rawMessage);
+    if (!message) return undefined;
+
+    if (message.imageMessage) return "";
+    if (message.videoMessage) return "";
+    if (message.audioMessage) return "";
+    if (message.documentMessage) return "";
+    if (message.stickerMessage) return "";
+
+    return undefined;
+  }
+
+  /**
+   * Extract mentioned JIDs from message.
+   */
+  private extractMentionedJids(
+    rawMessage: proto.IMessage | null | undefined
+  ): string[] | undefined {
+    if (!rawMessage) return undefined;
+
+    const message = normalizeMessageContent(rawMessage);
+    if (!message) return undefined;
+
+    const candidates: Array = [
+      message.extendedTextMessage?.contextInfo?.mentionedJid,
+      message.imageMessage?.contextInfo?.mentionedJid,
+      message.videoMessage?.contextInfo?.mentionedJid,
+      message.documentMessage?.contextInfo?.mentionedJid,
+      message.audioMessage?.contextInfo?.mentionedJid,
+    ];
+
+    const flattened = candidates.flatMap((arr) => arr ?? []).filter(Boolean);
+    if (flattened.length === 0) return undefined;
+
+    return Array.from(new Set(flattened));
+  }
+
+  /**
+   * Extract reply context from message.
+   */
+  private describeReplyContext(
+    rawMessage: proto.IMessage | null | undefined
+  ): { id?: string; body: string; sender: string } | null {
+    if (!rawMessage) return null;
+
+    const message = normalizeMessageContent(rawMessage);
+    if (!message) return null;
+
+    // Get context info from various message types
+    const contextInfo =
+      message.extendedTextMessage?.contextInfo ??
+      message.imageMessage?.contextInfo ??
+      message.videoMessage?.contextInfo ??
+      message.documentMessage?.contextInfo ??
+      message.audioMessage?.contextInfo;
+
+    if (!contextInfo?.quotedMessage) return null;
+
+    const quoted = normalizeMessageContent(contextInfo.quotedMessage);
+    if (!quoted) return null;
+
+    const body =
+      this.extractText(quoted) || this.extractMediaPlaceholder(quoted);
+    if (!body) return null;
+
+    const senderJid = contextInfo.participant;
+    const senderE164 = senderJid ? jidToE164(senderJid) : null;
+
+    return {
+      id: contextInfo.stanzaId || undefined,
+      body,
+      sender: senderE164 || senderJid || "unknown",
+    };
+  }
+
+  /**
+   * Store a message in conversation history.
+   */
+  private storeMessageInHistory(chatJid: string, message: StoredMessage): void {
+    const history = this.conversationHistory.get(chatJid) || {
+      messages: [],
+      lastUpdated: Date.now(),
+    };
+
+    // Add message to history
+    history.messages.push(message);
+    history.lastUpdated = Date.now();
+
+    // Trim to max messages
+    while (history.messages.length > this.config.maxHistoryMessages) {
+      history.messages.shift();
+    }
+
+    this.conversationHistory.set(chatJid, history);
+  }
+
+  /**
+   * Get conversation history for a chat.
+   * Returns messages in chronological order with role annotation.
+   */
+  private getConversationHistory(chatJid: string): Array<{
+    role: "user" | "assistant";
+    content: string;
+    name?: string;
+  }> {
+    const history = this.conversationHistory.get(chatJid);
+    if (!history) return [];
+
+    // Check TTL
+    const ttlMs = this.config.historyTtlSeconds * 1000;
+    if (Date.now() - history.lastUpdated > ttlMs) {
+      this.conversationHistory.delete(chatJid);
+      return [];
+    }
+
+    return history.messages.map((msg) => ({
+      role: msg.fromMe ? ("assistant" as const) : ("user" as const),
+      content: msg.text,
+      name: msg.senderName,
+    }));
+  }
+
+  /**
+   * Store an outgoing (bot) message in history.
+   * Called from response renderer when sending messages.
+   */
+  storeOutgoingMessage(chatJid: string, text: string): void {
+    this.storeMessageInHistory(chatJid, {
+      id: `outgoing_${Date.now()}`,
+      text,
+      fromMe: true,
+      timestamp: Date.now(),
+    });
+  }
+
+  /**
+   * Cleanup expired conversation histories.
+   */
+  private cleanupExpiredHistories(): void {
+    const now = Date.now();
+    const ttlMs = this.config.historyTtlSeconds * 1000;
+
+    for (const [chatJid, history] of this.conversationHistory) {
+      if (now - history.lastUpdated > ttlMs) {
+        this.conversationHistory.delete(chatJid);
+      }
+    }
+  }
+
+  /**
+   * Handle message reactions.
+   * Could be used to trigger actions based on specific reactions.
+   */
+  private async handleReactions(
+    reactions: BaileysEventMap["messages.reaction"]
+  ): Promise {
+    for (const reaction of reactions) {
+      const { key, reaction: reactionData } = reaction;
+      const emoji = reactionData.text;
+      const messageId = key.id;
+      const chatJid = key.remoteJid;
+
+      logger.info(
+        { emoji, messageId, chatJid, from: key.participant },
+        "Received reaction"
+      );
+
+      // Potential future use cases:
+      // - thumbs up on a tool approval message = approve
+      // - thumbs down = reject
+      // - checkmark = acknowledge
+      // For now, just log the reaction
+    }
+  }
+
+  /**
+   * Handle message updates (edits, deletes).
+   * Could be used to update conversation history when messages are edited.
+   */
+  private async handleMessageUpdates(
+    updates: BaileysEventMap["messages.update"]
+  ): Promise {
+    for (const update of updates) {
+      const { key, update: updateData } = update;
+      const messageId = key.id;
+      const chatJid = key.remoteJid;
+
+      // Check if message was edited
+      if (updateData.message) {
+        logger.info(
+          { messageId, chatJid, hasNewMessage: true },
+          "Message was edited"
+        );
+
+        // Could update the message in conversation history here
+        // For now, just log the event
+      }
+
+      // Check if message was deleted (stub type indicates deletion)
+      if (updateData.messageStubType) {
+        logger.info(
+          { messageId, chatJid, stubType: updateData.messageStubType },
+          "Message was deleted or has stub update"
+        );
+      }
+    }
+  }
+}
diff --git a/packages/gateway/src/whatsapp/file-handler.ts b/packages/gateway/src/whatsapp/file-handler.ts
new file mode 100644
index 0000000..a912694
--- /dev/null
+++ b/packages/gateway/src/whatsapp/file-handler.ts
@@ -0,0 +1,407 @@
+/**
+ * WhatsApp file handler implementation.
+ * Handles media download from incoming messages and upload back to users.
+ */
+
+import { randomUUID } from "node:crypto";
+import { Readable } from "node:stream";
+import { createLogger, sanitizeFilename } from "@peerbot/core";
+import {
+  type AnyMessageContent,
+  downloadMediaMessage,
+  type WAMessage,
+} from "@whiskeysockets/baileys";
+import jwt from "jsonwebtoken";
+import pino from "pino";
+
+// Silent logger for Baileys download operations
+const baileysLogger = pino({ level: "silent" }) as unknown as ReturnType<
+  typeof pino
+>;
+
+import type {
+  FileMetadata,
+  FileUploadOptions,
+  FileUploadResult,
+  IFileHandler,
+} from "../platform/file-handler";
+import type { BaileysClient } from "./connection/baileys-client";
+
+const logger = createLogger("whatsapp-file-handler");
+
+function getJwtSecret(): string {
+  const secret = process.env.ENCRYPTION_KEY;
+  if (!secret) {
+    throw new Error("ENCRYPTION_KEY required for file token generation");
+  }
+  return secret;
+}
+
+export interface ExtractedMedia {
+  id: string;
+  name: string;
+  mimetype: string;
+  size: number;
+  buffer: Buffer;
+}
+
+/**
+ * WhatsApp file handler.
+ * Stores extracted media in memory for worker download.
+ */
+export class WhatsAppFileHandler implements IFileHandler {
+  private fileStore = new Map<
+    string,
+    { buffer: Buffer; metadata: FileMetadata }
+  >();
+  private uploadedFiles = new Map>();
+  private jwtSecret: string;
+
+  constructor(private client: BaileysClient) {
+    this.jwtSecret = getJwtSecret();
+  }
+
+  /**
+   * Extract media files from a WhatsApp message.
+   * Downloads the media and stores it for later retrieval.
+   */
+  async extractMediaFromMessage(msg: WAMessage): Promise {
+    const files: ExtractedMedia[] = [];
+    const message = msg.message;
+    if (!message) return files;
+
+    // Check for various media types
+    const mediaTypes: Array<{
+      key: string;
+      extension: string;
+    }> = [
+      { key: "imageMessage", extension: "jpg" },
+      { key: "videoMessage", extension: "mp4" },
+      { key: "audioMessage", extension: "ogg" },
+      { key: "documentMessage", extension: "bin" },
+      { key: "stickerMessage", extension: "webp" },
+    ];
+
+    for (const { key, extension } of mediaTypes) {
+      const mediaContent = (message as any)[key];
+      if (!mediaContent) continue;
+
+      try {
+        logger.info(
+          { messageId: msg.key?.id, mediaType: key },
+          "Downloading media from message"
+        );
+
+        const buffer = await downloadMediaMessage(
+          msg,
+          "buffer",
+          {},
+          {
+            logger: baileysLogger as any,
+            reuploadRequest: (this.client as any).socket?.updateMediaMessage,
+          }
+        );
+
+        if (!buffer || !(buffer instanceof Buffer)) {
+          logger.warn(
+            { messageId: msg.key?.id },
+            "Downloaded media is not a buffer"
+          );
+          continue;
+        }
+
+        const fileId = randomUUID();
+        const mimeType = mediaContent.mimetype || `application/${extension}`;
+
+        // Get filename from document or generate one
+        let fileName: string;
+        if (key === "documentMessage" && mediaContent.fileName) {
+          fileName = mediaContent.fileName;
+        } else {
+          const ext = mimeType.split("/")[1]?.split(";")[0] || extension;
+          fileName = `${key.replace("Message", "")}_${Date.now()}.${ext}`;
+        }
+
+        const metadata: FileMetadata = {
+          id: fileId,
+          name: fileName,
+          mimetype: mimeType,
+          size: buffer.length,
+          url: `internal://whatsapp/${fileId}`,
+        };
+
+        // Store for later retrieval
+        this.fileStore.set(fileId, { buffer, metadata });
+
+        files.push({
+          id: fileId,
+          name: fileName,
+          mimetype: mimeType,
+          size: buffer.length,
+          buffer,
+        });
+
+        logger.info(
+          { fileId, fileName, mimeType, size: buffer.length },
+          "Extracted media from WhatsApp message"
+        );
+
+        // Auto-cleanup after 1 hour
+        setTimeout(
+          () => {
+            this.fileStore.delete(fileId);
+          },
+          60 * 60 * 1000
+        );
+      } catch (error) {
+        logger.error(
+          { error: String(error), messageId: msg.key?.id, mediaType: key },
+          "Failed to download media from message"
+        );
+      }
+    }
+
+    return files;
+  }
+
+  /**
+   * Download a file by ID.
+   * Returns the file stream and metadata.
+   */
+  async downloadFile(
+    fileId: string,
+    _bearerToken: string
+  ): Promise<{ stream: Readable; metadata: FileMetadata }> {
+    const entry = this.fileStore.get(fileId);
+    if (!entry) {
+      throw new Error(`File not found: ${fileId}`);
+    }
+
+    return {
+      stream: Readable.from(entry.buffer),
+      metadata: entry.metadata,
+    };
+  }
+
+  /**
+   * Upload a file to WhatsApp.
+   * Sends the file as a message to the specified channel.
+   */
+  async uploadFile(
+    fileStream: Readable,
+    options: FileUploadOptions
+  ): Promise {
+    const safeFilename = sanitizeFilename(options.filename);
+
+    // Collect stream into buffer
+    const chunks: Buffer[] = [];
+    for await (const chunk of fileStream) {
+      chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+    }
+    const fileBuffer = Buffer.concat(chunks);
+
+    logger.info(
+      {
+        filename: safeFilename,
+        size: fileBuffer.length,
+        channelId: options.channelId,
+      },
+      "Uploading file to WhatsApp"
+    );
+
+    // Determine media type from filename
+    const content = this.buildMediaContent(
+      fileBuffer,
+      safeFilename,
+      options.title
+    );
+
+    // Send the media message
+    const result = await this.client.sendMessage(options.channelId, content);
+
+    const fileId = randomUUID();
+
+    // Track uploaded file
+    if (options.sessionKey) {
+      if (!this.uploadedFiles.has(options.sessionKey)) {
+        this.uploadedFiles.set(options.sessionKey, new Set());
+      }
+      this.uploadedFiles.get(options.sessionKey)!.add(fileId);
+    }
+
+    return {
+      fileId,
+      permalink: `whatsapp://${options.channelId}/${result.messageId}`,
+      name: safeFilename,
+      size: fileBuffer.length,
+    };
+  }
+
+  /**
+   * Build the appropriate media content based on file type.
+   */
+  private buildMediaContent(
+    buffer: Buffer,
+    filename: string,
+    caption?: string
+  ): AnyMessageContent {
+    const ext = filename.split(".").pop()?.toLowerCase() || "";
+
+    // Image types
+    if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
+      return {
+        image: buffer,
+        caption: caption || filename,
+      };
+    }
+
+    // Video types
+    if (["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) {
+      return {
+        video: buffer,
+        caption: caption || filename,
+      };
+    }
+
+    // Audio types
+    if (["mp3", "ogg", "wav", "m4a", "opus"].includes(ext)) {
+      return {
+        audio: buffer,
+        ptt: false,
+        mimetype: this.getMimeType(ext),
+      };
+    }
+
+    // Default: send as document
+    return {
+      document: buffer,
+      fileName: filename,
+      mimetype: this.getMimeType(ext),
+      caption: caption,
+    };
+  }
+
+  /**
+   * Get MIME type from extension.
+   */
+  private getMimeType(ext: string): string {
+    const mimeTypes: Record = {
+      jpg: "image/jpeg",
+      jpeg: "image/jpeg",
+      png: "image/png",
+      gif: "image/gif",
+      webp: "image/webp",
+      mp4: "video/mp4",
+      mov: "video/quicktime",
+      avi: "video/x-msvideo",
+      mkv: "video/x-matroska",
+      webm: "video/webm",
+      mp3: "audio/mpeg",
+      ogg: "audio/ogg",
+      wav: "audio/wav",
+      m4a: "audio/mp4",
+      opus: "audio/opus",
+      pdf: "application/pdf",
+      doc: "application/msword",
+      docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+      xls: "application/vnd.ms-excel",
+      xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+      txt: "text/plain",
+      json: "application/json",
+      csv: "text/csv",
+    };
+
+    return mimeTypes[ext] || "application/octet-stream";
+  }
+
+  /**
+   * Generate a JWT token for file access.
+   */
+  generateFileToken(
+    sessionKey: string,
+    fileId: string,
+    expiresIn = 3600
+  ): string {
+    return jwt.sign(
+      {
+        sessionKey,
+        fileId,
+        type: "file_access",
+        iat: Math.floor(Date.now() / 1000),
+      },
+      this.jwtSecret,
+      {
+        expiresIn,
+        algorithm: "HS256",
+        issuer: "peerbot-gateway",
+        audience: "peerbot-worker",
+      }
+    );
+  }
+
+  /**
+   * Validate a file access token.
+   */
+  validateFileToken(token: string): {
+    valid: boolean;
+    sessionKey?: string;
+    fileId?: string;
+    error?: string;
+  } {
+    try {
+      const decoded = jwt.verify(token, this.jwtSecret, {
+        algorithms: ["HS256"],
+        issuer: "peerbot-gateway",
+        audience: "peerbot-worker",
+      });
+
+      if (
+        typeof decoded === "string" ||
+        typeof decoded.sessionKey !== "string" ||
+        typeof decoded.fileId !== "string" ||
+        decoded.type !== "file_access"
+      ) {
+        return { valid: false, error: "Invalid token structure" };
+      }
+
+      return {
+        valid: true,
+        sessionKey: decoded.sessionKey,
+        fileId: decoded.fileId,
+      };
+    } catch (error) {
+      if (error instanceof jwt.TokenExpiredError) {
+        return { valid: false, error: "Token expired" };
+      }
+      return { valid: false, error: "Invalid token" };
+    }
+  }
+
+  /**
+   * Get files uploaded in a session.
+   */
+  getSessionFiles(sessionKey: string): string[] {
+    return Array.from(this.uploadedFiles.get(sessionKey) || []);
+  }
+
+  /**
+   * Cleanup session data.
+   */
+  cleanupSession(sessionKey: string): void {
+    this.uploadedFiles.delete(sessionKey);
+  }
+
+  /**
+   * Check if a file exists in the store.
+   */
+  hasFile(fileId: string): boolean {
+    return this.fileStore.has(fileId);
+  }
+
+  /**
+   * Get raw file buffer (for internal use).
+   */
+  getFileBuffer(fileId: string): Buffer | null {
+    return this.fileStore.get(fileId)?.buffer || null;
+  }
+}
diff --git a/packages/gateway/src/whatsapp/index.ts b/packages/gateway/src/whatsapp/index.ts
new file mode 100644
index 0000000..18bd72d
--- /dev/null
+++ b/packages/gateway/src/whatsapp/index.ts
@@ -0,0 +1,20 @@
+/**
+ * WhatsApp platform module exports.
+ */
+
+export {
+  buildWhatsAppConfig,
+  DEFAULT_WHATSAPP_CONFIG,
+  type WhatsAppConfig,
+} from "./config";
+export { BaileysClient } from "./connection/baileys-client";
+export { WhatsAppMessageHandler } from "./events/message-handler";
+export { WhatsAppInteractionRenderer } from "./interactions";
+export {
+  type AgentOptions,
+  WhatsAppPlatform,
+  type WhatsAppPlatformConfig,
+} from "./platform";
+export { WhatsAppResponseRenderer } from "./response-renderer";
+export { runWhatsAppSetup } from "./setup";
+export * from "./types";
diff --git a/packages/gateway/src/whatsapp/interactions.ts b/packages/gateway/src/whatsapp/interactions.ts
new file mode 100644
index 0000000..73f8391
--- /dev/null
+++ b/packages/gateway/src/whatsapp/interactions.ts
@@ -0,0 +1,407 @@
+/**
+ * WhatsApp interaction renderer.
+ * Uses list messages with numbered text fallback.
+ */
+
+import {
+  createLogger,
+  type UserInteraction,
+  type UserSuggestion,
+} from "@peerbot/core";
+import type { AnyMessageContent } from "@whiskeysockets/baileys";
+import type { InteractionService } from "../interactions";
+import {
+  APPROVAL_OPTIONS,
+  formatNumberedOptions,
+  isApprovalInteraction,
+  parseOptionResponse,
+} from "../platform/interaction-utils";
+import type { WhatsAppConfig } from "./config";
+import type { BaileysClient } from "./connection/baileys-client";
+
+const logger = createLogger("whatsapp-interactions");
+
+/**
+ * WhatsApp interaction renderer.
+ */
+const INTERACTION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
+
+// Maximum options for list messages (WhatsApp limit is 10)
+const MAX_LIST_OPTIONS = 10;
+
+// Use native list messages (more reliable than buttons, but may not work on all clients)
+const USE_NATIVE_LIST = true;
+
+export class WhatsAppInteractionRenderer {
+  // Queue of pending interactions per chat (FIFO order)
+  private pendingInteractions = new Map<
+    string,
+    Array<{
+      interactionId: string;
+      question: string;
+      options: string[];
+      chatJid: string;
+    }>
+  >();
+  private interactionTimeouts = new Map();
+
+  constructor(
+    private client: BaileysClient,
+    private interactionService: InteractionService,
+    _config: WhatsAppConfig // Reserved for future configuration
+  ) {}
+
+  /**
+   * Register handler for button/text responses.
+   */
+  registerButtonHandler(): void {
+    // Listen for messages that might be responses to interactions
+    this.client.on("message", async (upsert) => {
+      if (upsert.type !== "notify") return;
+
+      for (const msg of upsert.messages ?? []) {
+        await this.handlePossibleResponse(msg);
+      }
+    });
+
+    // Subscribe to interaction:created events to render them
+    this.interactionService.on("interaction:created", (interaction) => {
+      // Only handle WhatsApp interactions (teamId is "whatsapp" for WhatsApp messages)
+      if (interaction.teamId !== "whatsapp") return;
+
+      this.renderInteraction(interaction).catch((error) => {
+        logger.error("Failed to render interaction:", error);
+      });
+    });
+
+    logger.info("WhatsApp interaction button handler registered");
+  }
+
+  /**
+   * Handle a possible response to an interaction.
+   */
+  private async handlePossibleResponse(msg: any): Promise {
+    const remoteJid = msg.key?.remoteJid;
+    if (!remoteJid) return;
+
+    // Check if there's a pending interaction queue for this chat
+    const queue = this.pendingInteractions.get(remoteJid);
+    if (!queue || queue.length === 0) return;
+
+    // Get the first pending interaction (FIFO)
+    const pending = queue[0];
+    if (!pending) return;
+
+    // Extract values before any queue modifications
+    const { interactionId, options } = pending;
+
+    // Extract response text
+    const text = this.extractText(msg.message);
+    if (!text) return;
+
+    // Try to match the response
+    const selectedIndex = this.parseResponse(text, options);
+    if (selectedIndex === null) {
+      // Invalid response, ask again
+      await this.sendInvalidResponseMessage(remoteJid, options);
+      return;
+    }
+
+    // Respond to the interaction
+    const selectedOption = options[selectedIndex];
+    try {
+      await this.interactionService.respond(interactionId, {
+        answer: selectedOption,
+      });
+
+      // Remove this interaction from queue
+      queue.shift();
+
+      // If queue is empty, clear timeout
+      if (queue.length === 0) {
+        this.pendingInteractions.delete(remoteJid);
+        const timeout = this.interactionTimeouts.get(remoteJid);
+        if (timeout) {
+          clearTimeout(timeout);
+          this.interactionTimeouts.delete(remoteJid);
+        }
+      } else {
+        // Show the next pending interaction
+        const nextPending = queue[0];
+        if (nextPending) {
+          const nextMessage = formatNumberedOptions(
+            nextPending.question,
+            nextPending.options
+          );
+          await this.client.sendMessage(remoteJid, { text: nextMessage });
+        }
+      }
+
+      logger.info(
+        { interactionId, selectedOption, remainingInQueue: queue.length },
+        "Interaction response recorded"
+      );
+    } catch (err) {
+      logger.error(
+        { error: String(err), interactionId },
+        "Failed to record interaction response"
+      );
+    }
+  }
+
+  /**
+   * Parse user response to get selected option index.
+   * Returns index (0-based) or null if invalid.
+   */
+  private parseResponse(text: string, options: string[]): number | null {
+    const selected = parseOptionResponse(text, options);
+    if (selected === null) {
+      // Try partial match as fallback (WhatsApp-specific)
+      const normalized = text.trim().toLowerCase();
+      const partialIndex = options.findIndex(
+        (opt) =>
+          opt.toLowerCase().includes(normalized) ||
+          normalized.includes(opt.toLowerCase())
+      );
+      if (partialIndex !== -1) {
+        return partialIndex;
+      }
+      return null;
+    }
+    return options.indexOf(selected);
+  }
+
+  /**
+   * Send invalid response message.
+   */
+  private async sendInvalidResponseMessage(
+    chatJid: string,
+    options: string[]
+  ): Promise {
+    const message = formatNumberedOptions(
+      "Invalid response. Please reply with a number:",
+      options
+    );
+    await this.client.sendMessage(chatJid, { text: message });
+  }
+
+  /**
+   * Render a user interaction.
+   */
+  async renderInteraction(interaction: UserInteraction): Promise {
+    const { id, channelId, interactionType, question, options } = interaction;
+
+    // Get chat JID from metadata or channel
+    const chatJid = (interaction as any).platformMetadata?.jid || channelId;
+
+    logger.info(
+      { interactionId: id, chatJid, interactionType },
+      "Rendering interaction"
+    );
+
+    let optionsToStore: string[] | null = null;
+
+    // Check if options is an array (radio) or object (form)
+    const isRadioOptions = Array.isArray(options);
+
+    // Determine the options to use
+    let effectiveOptions: string[] = [];
+    if (isRadioOptions && options.length > 0) {
+      effectiveOptions = options;
+      optionsToStore = options;
+    } else if (isApprovalInteraction(interactionType)) {
+      effectiveOptions = [...APPROVAL_OPTIONS];
+      optionsToStore = effectiveOptions;
+    }
+
+    // Check if this is the first interaction in queue (only show first)
+    const existingQueue = this.pendingInteractions.get(chatJid);
+    const isFirstInQueue = !existingQueue || existingQueue.length === 0;
+
+    // Store in queue if needed
+    if (optionsToStore) {
+      this.storePendingInteraction(chatJid, id, question, optionsToStore);
+    }
+
+    // Only send message if this is the first interaction in queue
+    if (!isFirstInQueue) {
+      logger.info(
+        { interactionId: id, chatJid, queuePosition: existingQueue!.length },
+        "Interaction queued (will show after previous is answered)"
+      );
+      return;
+    }
+
+    // Try to send as list message first, fall back to numbered text
+    if (
+      effectiveOptions.length > 0 &&
+      effectiveOptions.length <= MAX_LIST_OPTIONS
+    ) {
+      const sent = await this.sendInteractionMessage(
+        chatJid,
+        question,
+        effectiveOptions
+      );
+      if (sent) {
+        logger.info({ interactionId: id, chatJid }, "Interaction rendered");
+        return;
+      }
+    }
+
+    // Fallback to numbered text
+    let message: string;
+    if (effectiveOptions.length > 0) {
+      message = formatNumberedOptions(question, effectiveOptions);
+    } else if (interactionType === "form") {
+      message = `${question}\n\nPlease type your response:`;
+    } else {
+      message = question;
+    }
+
+    await this.client.sendMessage(chatJid, { text: message });
+    logger.info(
+      { interactionId: id, chatJid, fallback: true },
+      "Interaction rendered (text fallback)"
+    );
+  }
+
+  /**
+   * Send interaction message, trying list message first.
+   * Returns true if sent successfully, false if should fall back.
+   */
+  private async sendInteractionMessage(
+    chatJid: string,
+    question: string,
+    options: string[]
+  ): Promise {
+    if (!USE_NATIVE_LIST) {
+      return false;
+    }
+
+    try {
+      // Build list message
+      const listContent: AnyMessageContent = {
+        text: question,
+        buttonText: "Choose Option",
+        sections: [
+          {
+            title: "Options",
+            rows: options.map((opt, i) => ({
+              title: opt.length > 24 ? `${opt.substring(0, 21)}...` : opt,
+              description: opt.length > 24 ? opt : undefined,
+              rowId: String(i + 1),
+            })),
+          },
+        ],
+      } as any; // Baileys types may not include all list message fields
+
+      await this.client.sendMessage(chatJid, listContent);
+      return true;
+    } catch (err) {
+      logger.warn(
+        { error: String(err), chatJid },
+        "List message failed, falling back to numbered text"
+      );
+      return false;
+    }
+  }
+
+  /**
+   * Render suggestions.
+   * WhatsApp doesn't have native suggestions, so we send as regular message.
+   */
+  async renderSuggestion(suggestion: UserSuggestion): Promise {
+    const { channelId, prompts } = suggestion;
+
+    if (!prompts || prompts.length === 0) return;
+
+    const chatJid = (suggestion as any).platformMetadata?.jid || channelId;
+
+    // Build suggestion message
+    const lines = ["Here are some suggestions:"];
+    for (const s of prompts) {
+      lines.push(`โ€ข ${s.title}`);
+      if (s.message) {
+        lines.push(`  ${s.message}`);
+      }
+    }
+
+    const message = lines.join("\n");
+    await this.client.sendMessage(chatJid, { text: message });
+
+    logger.info(
+      { chatJid, promptCount: prompts.length },
+      "Suggestions rendered"
+    );
+  }
+
+  /**
+   * Store pending interaction in queue with timeout cleanup.
+   */
+  private storePendingInteraction(
+    chatJid: string,
+    interactionId: string,
+    question: string,
+    options: string[]
+  ): void {
+    // Get or create queue
+    let queue = this.pendingInteractions.get(chatJid);
+    if (!queue) {
+      queue = [];
+      this.pendingInteractions.set(chatJid, queue);
+    }
+
+    // Add to queue
+    queue.push({
+      interactionId,
+      question,
+      options,
+      chatJid,
+    });
+
+    logger.info(
+      { interactionId, chatJid, queueLength: queue.length },
+      "Added interaction to queue"
+    );
+
+    // Reset timeout (extends on each new interaction)
+    const existingTimeout = this.interactionTimeouts.get(chatJid);
+    if (existingTimeout) {
+      clearTimeout(existingTimeout);
+    }
+
+    // Set timeout to auto-cleanup entire queue
+    const timeout = setTimeout(() => {
+      const currentQueue = this.pendingInteractions.get(chatJid);
+      if (currentQueue && currentQueue.length > 0) {
+        logger.warn(
+          { chatJid, pendingCount: currentQueue.length },
+          "Interaction queue timed out"
+        );
+      }
+      this.pendingInteractions.delete(chatJid);
+      this.interactionTimeouts.delete(chatJid);
+    }, INTERACTION_TIMEOUT_MS);
+
+    this.interactionTimeouts.set(chatJid, timeout);
+  }
+
+  /**
+   * Extract text from message.
+   */
+  private extractText(message: any): string | undefined {
+    if (!message) return undefined;
+
+    // Try direct conversation
+    if (message.conversation) {
+      return message.conversation.trim();
+    }
+
+    // Try extended text
+    if (message.extendedTextMessage?.text) {
+      return message.extendedTextMessage.text.trim();
+    }
+
+    return undefined;
+  }
+}
diff --git a/packages/gateway/src/whatsapp/platform.ts b/packages/gateway/src/whatsapp/platform.ts
new file mode 100644
index 0000000..422d397
--- /dev/null
+++ b/packages/gateway/src/whatsapp/platform.ts
@@ -0,0 +1,392 @@
+/**
+ * WhatsApp platform adapter implementing PlatformAdapter interface.
+ */
+
+import {
+  createLogger,
+  type UserInteraction,
+  type UserSuggestion,
+} from "@peerbot/core";
+import { platformAuthRegistry } from "../auth/platform-auth";
+import type { CoreServices, PlatformAdapter } from "../platform";
+import type { IFileHandler } from "../platform/file-handler";
+import {
+  type AgentOptions as FactoryAgentOptions,
+  type PlatformConfigs,
+  type PlatformFactory,
+  platformFactoryRegistry,
+} from "../platform/platform-factory";
+import type { ResponseRenderer } from "../platform/response-renderer";
+import { resolveSpace } from "../spaces";
+import { WhatsAppAuthAdapter } from "./auth-adapter";
+import type { WhatsAppConfig } from "./config";
+import { BaileysClient } from "./connection/baileys-client";
+import { WhatsAppMessageHandler } from "./events/message-handler";
+import { WhatsAppFileHandler } from "./file-handler";
+import { WhatsAppInteractionRenderer } from "./interactions";
+import { WhatsAppResponseRenderer } from "./response-renderer";
+import { jidToE164 } from "./types";
+
+const logger = createLogger("whatsapp-platform");
+
+export interface WhatsAppPlatformConfig {
+  whatsapp: WhatsAppConfig;
+}
+
+export interface AgentOptions {
+  model?: string;
+  maxTokens?: number;
+  temperature?: number;
+  allowedTools?: string[];
+  disallowedTools?: string[];
+  timeoutMinutes?: number;
+}
+
+/**
+ * WhatsApp platform adapter.
+ * Handles all WhatsApp-specific functionality using Baileys.
+ */
+export class WhatsAppPlatform implements PlatformAdapter {
+  readonly name = "whatsapp";
+
+  private client!: BaileysClient;
+  private services!: CoreServices;
+  private messageHandler?: WhatsAppMessageHandler;
+  private responseRenderer?: WhatsAppResponseRenderer;
+  private interactionRenderer?: WhatsAppInteractionRenderer;
+  private authAdapter?: WhatsAppAuthAdapter;
+  private fileHandler?: WhatsAppFileHandler;
+
+  constructor(
+    private readonly config: WhatsAppPlatformConfig,
+    private readonly agentOptions: AgentOptions,
+    private readonly sessionTimeoutMinutes: number
+  ) {}
+
+  /**
+   * Initialize with core services.
+   */
+  async initialize(services: CoreServices): Promise {
+    logger.info("Initializing WhatsApp platform...");
+    this.services = services;
+
+    // Create Baileys client
+    this.client = new BaileysClient(this.config.whatsapp);
+
+    // Create file handler for media
+    this.fileHandler = new WhatsAppFileHandler(this.client);
+
+    // Create message handler
+    this.messageHandler = new WhatsAppMessageHandler(
+      this.client,
+      this.config.whatsapp,
+      services.getQueueProducer(),
+      services.getSessionManager(),
+      this.agentOptions
+    );
+
+    // Connect file handler to message handler
+    this.messageHandler.setFileHandler(this.fileHandler);
+
+    // Create response renderer for unified thread consumer
+    this.responseRenderer = new WhatsAppResponseRenderer(
+      this.client,
+      this.config.whatsapp
+    );
+
+    // Wire up conversation history tracking
+    this.responseRenderer.setStoreOutgoingCallback((chatJid, text) => {
+      this.messageHandler?.storeOutgoingMessage(chatJid, text);
+    });
+
+    // Create interaction renderer
+    this.interactionRenderer = new WhatsAppInteractionRenderer(
+      this.client,
+      services.getInteractionService(),
+      this.config.whatsapp
+    );
+
+    // Register beforeCreate hook to stop streams before interaction
+    const interactionService = services.getInteractionService();
+    interactionService.setBeforeCreateHook(
+      async (userId: string, threadId: string) => {
+        logger.info({ userId, threadId }, "Stopping stream before interaction");
+        // WhatsApp doesn't have streaming, so this is a no-op
+      }
+    );
+
+    // Register interaction button handler
+    this.interactionRenderer.registerButtonHandler();
+
+    // Create and register auth adapter
+    const stateStore = services.getClaudeOAuthStateStore();
+    const publicGatewayUrl = services.getPublicGatewayUrl();
+    if (stateStore) {
+      this.authAdapter = new WhatsAppAuthAdapter(
+        this.client,
+        stateStore,
+        publicGatewayUrl
+      );
+      platformAuthRegistry.register("whatsapp", this.authAdapter);
+
+      // Connect auth adapter to message handler for auth response handling
+      if (this.messageHandler) {
+        this.messageHandler.setAuthAdapter(this.authAdapter);
+      }
+
+      logger.info("WhatsApp auth adapter registered");
+    }
+
+    logger.info("WhatsApp platform initialized");
+  }
+
+  /**
+   * Get the auth adapter for handling auth responses.
+   */
+  getAuthAdapter(): WhatsAppAuthAdapter | undefined {
+    return this.authAdapter;
+  }
+
+  /**
+   * Get the file handler for media operations.
+   */
+  getFileHandler(): IFileHandler | undefined {
+    return this.fileHandler;
+  }
+
+  /**
+   * Start the platform (connect to WhatsApp).
+   */
+  async start(): Promise {
+    logger.info("Starting WhatsApp platform...");
+
+    // Setup message handler BEFORE connecting (to catch early messages)
+    if (this.messageHandler) {
+      this.messageHandler.start();
+    }
+
+    // Connect to WhatsApp
+    await this.client.connect();
+
+    logger.info("WhatsApp platform started");
+  }
+
+  /**
+   * Stop the platform gracefully.
+   */
+  async stop(): Promise {
+    logger.info("Stopping WhatsApp platform...");
+
+    // Stop message handler
+    if (this.messageHandler) {
+      this.messageHandler.stop();
+    }
+
+    // Disconnect from WhatsApp
+    await this.client.disconnect();
+
+    logger.info("WhatsApp platform stopped");
+  }
+
+  /**
+   * Check if platform is healthy.
+   */
+  isHealthy(): boolean {
+    return this.client?.isConnected() ?? false;
+  }
+
+  /**
+   * Get the response renderer for unified thread consumer.
+   */
+  getResponseRenderer(): ResponseRenderer | undefined {
+    return this.responseRenderer;
+  }
+
+  /**
+   * Build platform-specific deployment metadata.
+   */
+  buildDeploymentMetadata(
+    threadId: string,
+    channelId: string,
+    platformMetadata: Record
+  ): Record {
+    const jid = platformMetadata?.jid || channelId;
+    const e164 = jidToE164(jid) || jid;
+
+    return {
+      chat_id: jid,
+      phone_number: e164,
+      thread_id: threadId,
+      is_group: String(platformMetadata?.isGroup || false),
+    };
+  }
+
+  /**
+   * Render a blocking user interaction.
+   */
+  async renderInteraction(interaction: UserInteraction): Promise {
+    if (this.interactionRenderer) {
+      await this.interactionRenderer.renderInteraction(interaction);
+    }
+  }
+
+  /**
+   * Render non-blocking suggestions.
+   * WhatsApp doesn't have native suggested prompts, so we send as regular message.
+   */
+  async renderSuggestion(suggestion: UserSuggestion): Promise {
+    if (this.interactionRenderer) {
+      await this.interactionRenderer.renderSuggestion(suggestion);
+    }
+  }
+
+  /**
+   * Set thread status indicator.
+   * WhatsApp uses typing indicator instead.
+   */
+  async setThreadStatus(
+    channelId: string,
+    _threadId: string, // Not used for WhatsApp
+    status: string | null
+  ): Promise {
+    if (status && this.client) {
+      // Show typing indicator
+      await this.client.sendTyping(
+        channelId,
+        this.config.whatsapp.typingTimeout
+      );
+    }
+    // Clear status is a no-op - typing auto-expires
+  }
+
+  /**
+   * Check if token matches platform credentials.
+   * WhatsApp doesn't use tokens in the same way.
+   */
+  isOwnBotToken(_token: string): boolean {
+    // We don't have a simple token to compare
+    return false;
+  }
+
+  /**
+   * Send a message for testing/automation.
+   * If sending to self (self-chat mode), queues message directly to worker.
+   */
+  async sendMessage(
+    _token: string, // Not used for WhatsApp
+    channel: string,
+    message: string,
+    options?: {
+      threadId?: string;
+      files?: Array<{ buffer: Buffer; filename: string }>;
+    }
+  ): Promise<{
+    channel: string;
+    messageId: string;
+    threadId: string;
+    threadUrl?: string;
+    queued?: boolean;
+  }> {
+    if (!this.client?.isConnected()) {
+      throw new Error("WhatsApp not connected");
+    }
+
+    // Replace @me with nothing (WhatsApp doesn't have bot mentions)
+    const cleanMessage = message.replace(/@me\s*/g, "").trim();
+
+    // Check if this is a self-chat message (sending to bot's own number)
+    const selfE164 = this.client.getSelfE164();
+    const normalizedChannel = channel.startsWith("+") ? channel : `+${channel}`;
+    const isSelfMessage =
+      this.config.whatsapp.selfChatEnabled && normalizedChannel === selfE164;
+
+    // Send the actual WhatsApp message
+    const result = await this.client.sendMessage(channel, {
+      text: cleanMessage,
+    });
+
+    // If self-chat, queue the message directly to bypass event handler filter
+    if (isSelfMessage) {
+      const queueProducer = this.services.getQueueProducer();
+      const messageId = result.messageId;
+      const threadId = options?.threadId || messageId;
+
+      // Use TEST_USER_ID if available, otherwise use bot's number
+      const testUserId = process.env.TEST_USER_ID || selfE164 || channel;
+
+      // Resolve spaceId for multi-tenant isolation (DM context for self-chat)
+      const { spaceId } = resolveSpace({
+        platform: "whatsapp",
+        userId: testUserId,
+        channelId: channel,
+        isGroup: false,
+      });
+
+      const payload = {
+        userId: testUserId,
+        threadId,
+        messageId,
+        channelId: channel,
+        teamId: "whatsapp",
+        spaceId,
+        botId: selfE164 || "whatsapp-bot",
+        platform: "whatsapp",
+        messageText: cleanMessage,
+        platformMetadata: {
+          remoteJid: `${channel.replace("+", "")}@s.whatsapp.net`,
+          isSelfChat: true,
+          isFromMe: false, // Pretend it's from user for processing
+        },
+        agentOptions: {
+          ...this.agentOptions,
+          timeoutMinutes: this.sessionTimeoutMinutes.toString(),
+        },
+      };
+
+      await queueProducer.enqueueMessage(payload);
+      logger.info(`Queued self-chat message ${messageId} to worker queue`);
+
+      return {
+        channel,
+        messageId,
+        threadId,
+        queued: true,
+      };
+    }
+
+    return {
+      channel,
+      messageId: result.messageId,
+      threadId: options?.threadId || result.messageId,
+    };
+  }
+}
+
+/**
+ * WhatsApp platform factory for declarative registration.
+ */
+const whatsappFactory: PlatformFactory = {
+  name: "whatsapp",
+
+  isEnabled(configs: PlatformConfigs): boolean {
+    return configs.whatsapp?.enabled === true;
+  },
+
+  create(
+    configs: PlatformConfigs,
+    agentOptions: FactoryAgentOptions,
+    sessionTimeoutMinutes: number
+  ) {
+    const platformConfig: WhatsAppPlatformConfig = {
+      whatsapp: configs.whatsapp,
+    };
+    return new WhatsAppPlatform(
+      platformConfig,
+      agentOptions,
+      sessionTimeoutMinutes
+    );
+  },
+};
+
+// Register factory on module load
+platformFactoryRegistry.register(whatsappFactory);
diff --git a/packages/gateway/src/whatsapp/response-renderer.ts b/packages/gateway/src/whatsapp/response-renderer.ts
new file mode 100644
index 0000000..088cdb0
--- /dev/null
+++ b/packages/gateway/src/whatsapp/response-renderer.ts
@@ -0,0 +1,368 @@
+/**
+ * WhatsApp response renderer.
+ * Handles buffered responses with progressive chunks,
+ * plain text formatting, and typing indicators.
+ */
+
+import { createLogger } from "@peerbot/core";
+import type { ThreadResponsePayload } from "../infrastructure/queue";
+import type { ResponseRenderer } from "../platform/response-renderer";
+import type { WhatsAppConfig } from "./config";
+import type { BaileysClient } from "./connection/baileys-client";
+import { convertMarkdownToWhatsApp } from "./converters/markdown";
+
+const logger = createLogger("whatsapp-response-renderer");
+
+// Progressive chunk settings
+const CHUNK_INTERVAL_MS = 30000; // Send chunk every 30 seconds
+const MIN_CHUNK_SIZE = 500; // Minimum characters before sending a chunk
+
+/**
+ * Callback type for storing outgoing messages in conversation history.
+ */
+export type StoreOutgoingMessageCallback = (
+  chatJid: string,
+  text: string
+) => void;
+
+/**
+ * WhatsApp response renderer implementation.
+ * Buffers streaming content and sends progressive chunks every 30 seconds.
+ */
+export class WhatsAppResponseRenderer implements ResponseRenderer {
+  private responseBuffer = new Map();
+  private typingTimers = new Map();
+  private lastSendTime = new Map();
+  private chunkTimers = new Map();
+  private storeOutgoingCallback?: StoreOutgoingMessageCallback;
+
+  constructor(
+    private client: BaileysClient,
+    private config: WhatsAppConfig
+  ) {}
+
+  /**
+   * Set callback for storing outgoing messages in conversation history.
+   */
+  setStoreOutgoingCallback(callback: StoreOutgoingMessageCallback): void {
+    this.storeOutgoingCallback = callback;
+  }
+
+  async handleDelta(
+    payload: ThreadResponsePayload,
+    _sessionKey: string // Not used for WhatsApp
+  ): Promise {
+    if (payload.delta === undefined) {
+      return null;
+    }
+
+    const chatJid = this.getChatJid(payload);
+    const key = `${chatJid}:${payload.threadId}`;
+
+    if (payload.isFullReplacement) {
+      this.responseBuffer.set(key, payload.delta);
+    } else {
+      const existing = this.responseBuffer.get(key) || "";
+      this.responseBuffer.set(key, existing + payload.delta);
+    }
+
+    // Initialize last send time if not set
+    if (!this.lastSendTime.has(key)) {
+      this.lastSendTime.set(key, Date.now());
+    }
+
+    // Check if we should send a progressive chunk
+    const buffer = this.responseBuffer.get(key) || "";
+    const timeSinceLastSend = Date.now() - (this.lastSendTime.get(key) || 0);
+
+    if (
+      buffer.length >= MIN_CHUNK_SIZE &&
+      timeSinceLastSend >= CHUNK_INTERVAL_MS
+    ) {
+      await this.sendProgressiveChunk(chatJid, key, buffer);
+    } else {
+      // Keep showing typing while buffering
+      await this.client.sendTyping(chatJid, this.config.typingTimeout);
+
+      // Set up a timer to send chunk after 30s if still buffering
+      this.scheduleChunkTimer(chatJid, key);
+    }
+
+    return null; // WhatsApp doesn't return message IDs during streaming
+  }
+
+  /**
+   * Send a progressive chunk and reset buffer.
+   */
+  private async sendProgressiveChunk(
+    chatJid: string,
+    key: string,
+    content: string
+  ): Promise {
+    // Clear any pending chunk timer
+    this.clearChunkTimer(key);
+
+    // Add continuation indicator
+    const chunkText = `${content}\n\n_...continuing..._`;
+
+    try {
+      await this.sendMessage(chatJid, chunkText);
+      logger.info(
+        { chatJid, chunkLength: content.length },
+        "Sent progressive chunk"
+      );
+    } catch (err) {
+      logger.error(
+        { error: String(err), chatJid },
+        "Failed to send progressive chunk"
+      );
+    }
+
+    // Clear buffer and update last send time
+    this.responseBuffer.set(key, "");
+    this.lastSendTime.set(key, Date.now());
+  }
+
+  /**
+   * Schedule a timer to send chunk after interval.
+   */
+  private scheduleChunkTimer(chatJid: string, key: string): void {
+    // Don't schedule if already scheduled
+    if (this.chunkTimers.has(key)) return;
+
+    const timer = setTimeout(async () => {
+      const buffer = this.responseBuffer.get(key) || "";
+      if (buffer.length >= MIN_CHUNK_SIZE) {
+        await this.sendProgressiveChunk(chatJid, key, buffer);
+      }
+      this.chunkTimers.delete(key);
+    }, CHUNK_INTERVAL_MS);
+
+    this.chunkTimers.set(key, timer);
+  }
+
+  /**
+   * Clear chunk timer for a key.
+   */
+  private clearChunkTimer(key: string): void {
+    const timer = this.chunkTimers.get(key);
+    if (timer) {
+      clearTimeout(timer);
+      this.chunkTimers.delete(key);
+    }
+  }
+
+  async handleCompletion(
+    payload: ThreadResponsePayload,
+    _sessionKey: string
+  ): Promise {
+    const chatJid = this.getChatJid(payload);
+    const key = `${chatJid}:${payload.threadId}`;
+
+    // Clear timers
+    this.clearTypingTimer(chatJid);
+    this.clearChunkTimer(key);
+
+    // Send any remaining buffered content (final chunk, no "continuing" indicator)
+    const buffered = this.responseBuffer.get(key);
+    if (buffered?.trim()) {
+      await this.sendMessage(chatJid, buffered);
+    }
+
+    // Cleanup all state for this response
+    this.responseBuffer.delete(key);
+    this.lastSendTime.delete(key);
+  }
+
+  async handleError(
+    payload: ThreadResponsePayload,
+    _sessionKey: string
+  ): Promise {
+    if (!payload.error) return;
+
+    const chatJid = this.getChatJid(payload);
+    const key = `${chatJid}:${payload.threadId}`;
+
+    // Clear timers
+    this.clearTypingTimer(chatJid);
+    this.clearChunkTimer(key);
+
+    // Clear all state
+    this.responseBuffer.delete(key);
+    this.lastSendTime.delete(key);
+
+    // Send error message
+    const errorMessage = `Error: ${payload.error}`;
+    await this.sendMessage(chatJid, errorMessage);
+  }
+
+  async handleStatusUpdate(payload: ThreadResponsePayload): Promise {
+    if (!payload.statusUpdate) return;
+
+    const chatJid = this.getChatJid(payload);
+
+    // Show typing indicator
+    await this.client.sendTyping(chatJid, this.config.typingTimeout);
+
+    // Refresh typing indicator periodically
+    this.clearTypingTimer(chatJid);
+
+    const timer = setTimeout(async () => {
+      await this.client.sendTyping(chatJid, this.config.typingTimeout);
+    }, this.config.typingTimeout - 1000);
+
+    this.typingTimers.set(chatJid, timer);
+  }
+
+  async handleEphemeral(payload: ThreadResponsePayload): Promise {
+    if (!payload.content) return;
+
+    const chatJid = this.getChatJid(payload);
+
+    // Try to parse as JSON (Slack blocks format)
+    try {
+      const parsed = JSON.parse(payload.content);
+      if (parsed.blocks && Array.isArray(parsed.blocks)) {
+        // Extract text from Slack blocks, preserving formatting
+        // Slack and WhatsApp use the same syntax (*bold*, _italic_)
+        const textParts: string[] = [];
+        for (const block of parsed.blocks) {
+          if (block.type === "section" && block.text?.text) {
+            textParts.push(block.text.text);
+          }
+        }
+        if (textParts.length > 0) {
+          const message = textParts.join("\n\n");
+          await this.sendMessage(chatJid, message);
+          return;
+        }
+      }
+    } catch {
+      // Not JSON - send as plain text
+    }
+
+    await this.sendMessage(chatJid, payload.content);
+  }
+
+  /**
+   * Get chat JID from payload metadata.
+   */
+  private getChatJid(payload: ThreadResponsePayload): string {
+    const platformMetadata = (payload as any).platformMetadata || {};
+    return (
+      platformMetadata.jid ||
+      platformMetadata.responseChannel ||
+      payload.channelId
+    );
+  }
+
+  /**
+   * Clear typing timer for a chat.
+   */
+  private clearTypingTimer(chatJid: string): void {
+    if (this.typingTimers.has(chatJid)) {
+      clearTimeout(this.typingTimers.get(chatJid)!);
+      this.typingTimers.delete(chatJid);
+    }
+  }
+
+  /**
+   * Send a message, converting markdown and chunking if necessary.
+   */
+  private async sendMessage(chatJid: string, text: string): Promise {
+    // Convert markdown to WhatsApp formatting
+    const formatted = convertMarkdownToWhatsApp(text);
+    const chunks = this.chunkMessage(formatted, this.config.messageChunkSize);
+
+    for (const chunk of chunks) {
+      try {
+        await this.client.sendMessage(chatJid, { text: chunk });
+
+        // Small delay between chunks to maintain order
+        if (chunks.length > 1) {
+          await this.delay(500);
+        }
+      } catch (err) {
+        logger.error(
+          { error: String(err), chatJid, chunkLength: chunk.length },
+          "Failed to send message chunk"
+        );
+        throw err;
+      }
+    }
+
+    // Store in conversation history
+    if (this.storeOutgoingCallback) {
+      this.storeOutgoingCallback(chatJid, text);
+    }
+
+    logger.info(
+      { chatJid, chunks: chunks.length, totalLength: text.length },
+      "Message sent"
+    );
+  }
+
+  /**
+   * Chunk message into smaller parts.
+   */
+  private chunkMessage(text: string, maxLength: number): string[] {
+    if (text.length <= maxLength) {
+      return [text];
+    }
+
+    const chunks: string[] = [];
+    let remaining = text;
+
+    while (remaining.length > 0) {
+      if (remaining.length <= maxLength) {
+        chunks.push(remaining);
+        break;
+      }
+
+      // Try to break at a natural point
+      let breakPoint = maxLength;
+
+      // Look for newline
+      const newlineIndex = remaining.lastIndexOf("\n", maxLength);
+      if (newlineIndex > maxLength * 0.5) {
+        breakPoint = newlineIndex + 1;
+      } else {
+        // Look for space
+        const spaceIndex = remaining.lastIndexOf(" ", maxLength);
+        if (spaceIndex > maxLength * 0.5) {
+          breakPoint = spaceIndex + 1;
+        }
+      }
+
+      chunks.push(remaining.substring(0, breakPoint).trim());
+      remaining = remaining.substring(breakPoint).trim();
+    }
+
+    return chunks.filter((c) => c.length > 0);
+  }
+
+  /**
+   * Delay helper.
+   */
+  private delay(ms: number): Promise {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
+  /**
+   * Cleanup resources (clear timers).
+   */
+  cleanup(): void {
+    for (const timer of this.typingTimers.values()) {
+      clearTimeout(timer);
+    }
+    this.typingTimers.clear();
+
+    for (const timer of this.chunkTimers.values()) {
+      clearTimeout(timer);
+    }
+    this.chunkTimers.clear();
+
+    this.responseBuffer.clear();
+    this.lastSendTime.clear();
+  }
+}
diff --git a/packages/gateway/src/whatsapp/setup.ts b/packages/gateway/src/whatsapp/setup.ts
new file mode 100644
index 0000000..60b1691
--- /dev/null
+++ b/packages/gateway/src/whatsapp/setup.ts
@@ -0,0 +1,167 @@
+/**
+ * WhatsApp CLI setup - one-time QR code authentication.
+ * Outputs WHATSAPP_CREDENTIALS for env var.
+ */
+
+import {
+  DisconnectReason,
+  fetchLatestBaileysVersion,
+  makeWASocket,
+} from "@whiskeysockets/baileys";
+import pino from "pino";
+import qrcode from "qrcode-terminal";
+import {
+  createAuthState,
+  loadCredentialsFromEnv,
+} from "./connection/auth-state";
+
+const MAX_RETRIES = 3;
+
+/**
+ * Run WhatsApp setup - shows QR, waits for connection, outputs credentials.
+ */
+export async function runWhatsAppSetup(): Promise {
+  console.log("\n๐Ÿ“ฑ WhatsApp Setup\n");
+
+  // Check if already have credentials
+  const existingCreds = process.env.WHATSAPP_CREDENTIALS;
+  if (existingCreds) {
+    const existing = loadCredentialsFromEnv(existingCreds);
+    if (existing) {
+      console.log("โš ๏ธ  Existing credentials found in WHATSAPP_CREDENTIALS");
+      console.log(
+        "   This will create NEW credentials (old ones will be invalidated)\n"
+      );
+    }
+  }
+
+  console.log("Scan the QR code with WhatsApp:\n");
+  console.log("  1. Open WhatsApp on your phone");
+  console.log("  2. Go to Settings โ†’ Linked Devices");
+  console.log("  3. Tap 'Link a Device'");
+  console.log("  4. Scan the QR code below\n");
+
+  const authState = createAuthState(null);
+  const baileysLogger = pino({ level: "silent" });
+
+  let credentialsOutput: string | null = null;
+  let attempt = 0;
+
+  while (attempt < MAX_RETRIES) {
+    attempt++;
+
+    const { version } = await fetchLatestBaileysVersion();
+
+    const socket = makeWASocket({
+      auth: {
+        creds: authState.state.creds,
+        keys: authState.state.keys as any,
+      },
+      version,
+      logger: baileysLogger as any,
+      printQRInTerminal: false,
+      browser: ["peerbot", "setup", "1.0.0"],
+      syncFullHistory: false,
+      markOnlineOnConnect: false,
+    });
+
+    // Handle credential updates
+    socket.ev.on("creds.update", async () => {
+      credentialsOutput = authState.getSerializedState();
+    });
+
+    // Wait for connection
+    let qrCount = 0;
+    const result = await new Promise<"success" | "retry" | "fail">(
+      (resolve) => {
+        socket.ev.on("connection.update", (update) => {
+          const { connection, lastDisconnect, qr } = update;
+
+          if (qr) {
+            qrCount++;
+            if (qrCount > 1) {
+              console.log(`\n๐Ÿ”„ QR code refreshed (attempt ${qrCount})...\n`);
+            }
+            qrcode.generate(qr, { small: true });
+          }
+
+          if (connection === "open") {
+            resolve("success");
+          }
+
+          if (connection === "close") {
+            const statusCode = (lastDisconnect?.error as any)?.output
+              ?.statusCode;
+            const errorMessage =
+              (lastDisconnect?.error as any)?.message || "Unknown error";
+
+            // Retryable errors
+            if (
+              statusCode === DisconnectReason.restartRequired ||
+              statusCode === DisconnectReason.timedOut ||
+              statusCode === 515
+            ) {
+              console.log(
+                `\nโš ๏ธ  Connection issue (${statusCode}): ${errorMessage}`
+              );
+              if (attempt < MAX_RETRIES) {
+                console.log(
+                  `   Retrying... (attempt ${attempt + 1}/${MAX_RETRIES})\n`
+                );
+              }
+              resolve("retry");
+            } else if (statusCode === DisconnectReason.loggedOut) {
+              console.error(`\nโŒ Logged out - session invalidated`);
+              resolve("fail");
+            } else {
+              console.error(
+                `\nโŒ Connection closed: status=${statusCode}, error=${errorMessage}`
+              );
+              resolve("fail");
+            }
+          }
+        });
+      }
+    );
+
+    // Cleanup socket - ignore errors during cleanup
+    try {
+      socket.ws?.close();
+    } catch {
+      // Intentionally empty - socket cleanup errors are non-fatal
+    }
+
+    if (result === "success") {
+      break;
+    } else if (result === "fail") {
+      throw new Error("WhatsApp setup failed. Please try again.");
+    }
+    // result === "retry" - loop continues
+
+    // Small delay before retry
+    await new Promise((r) => setTimeout(r, 1000));
+  }
+
+  if (!credentialsOutput) {
+    credentialsOutput = authState.getSerializedState();
+  }
+
+  // Get final credentials
+  const finalCredentials = credentialsOutput;
+
+  // Output
+  console.log("\nโœ… WhatsApp connected successfully!\n");
+  console.log("Add this to your environment:\n");
+  console.log("โ”€".repeat(60));
+  console.log(`WHATSAPP_CREDENTIALS=${finalCredentials}`);
+  console.log("โ”€".repeat(60));
+  console.log("\nAlso set:");
+  console.log("  WHATSAPP_ENABLED=true");
+  console.log("\nOptional:");
+  console.log(
+    "  WHATSAPP_ALLOW_FROM=+1234567890  # Restrict to specific numbers"
+  );
+  console.log(
+    "  WHATSAPP_REQUIRE_MENTION=true    # Require @mention in groups\n"
+  );
+}
diff --git a/packages/gateway/src/whatsapp/types.ts b/packages/gateway/src/whatsapp/types.ts
new file mode 100644
index 0000000..e1cc058
--- /dev/null
+++ b/packages/gateway/src/whatsapp/types.ts
@@ -0,0 +1,195 @@
+/**
+ * WhatsApp-specific type definitions.
+ */
+
+import type { AnyMessageContent } from "@whiskeysockets/baileys";
+
+/**
+ * WhatsApp context extracted from incoming messages.
+ */
+export interface WhatsAppContext {
+  /** Sender's JID (e.g., "1234567890@s.whatsapp.net") */
+  senderJid: string;
+
+  /** Sender's E.164 phone number (e.g., "+1234567890") */
+  senderE164?: string;
+
+  /** Sender's display name (push name) */
+  senderName?: string;
+
+  /** Chat JID - same as sender for DMs, group JID for groups */
+  chatJid: string;
+
+  /** Whether this is a group chat */
+  isGroup: boolean;
+
+  /** Group subject/name if in a group */
+  groupSubject?: string;
+
+  /** Group participants if in a group */
+  groupParticipants?: string[];
+
+  /** Message ID */
+  messageId: string;
+
+  /** Message timestamp */
+  timestamp?: number;
+
+  /** Quoted message context if replying */
+  quotedMessage?: {
+    id?: string;
+    body: string;
+    sender: string;
+  };
+
+  /** JIDs mentioned in the message */
+  mentionedJids?: string[];
+
+  /** Whether the bot was mentioned (for group chats) */
+  wasMentioned?: boolean;
+
+  /** Bot's own JID */
+  selfJid?: string;
+
+  /** Bot's own E.164 number */
+  selfE164?: string;
+}
+
+/**
+ * Inbound message structure after processing.
+ */
+export interface WhatsAppInboundMessage {
+  /** Unique message ID */
+  id: string;
+
+  /** Message text content */
+  body: string;
+
+  /** WhatsApp context */
+  context: WhatsAppContext;
+
+  /** Media attachment if present */
+  media?: {
+    path: string;
+    mimeType: string;
+    fileName?: string;
+  };
+
+  /** Helper to send typing indicator */
+  sendComposing: () => Promise;
+
+  /** Helper to reply with text */
+  reply: (text: string) => Promise;
+
+  /** Helper to send media */
+  sendMedia: (payload: AnyMessageContent) => Promise;
+}
+
+/**
+ * Connection status for health monitoring.
+ */
+export interface WhatsAppConnectionStatus {
+  connected: boolean;
+  reconnectAttempts: number;
+  lastConnectedAt?: Date;
+  lastDisconnectReason?: string;
+  lastMessageAt?: Date;
+  qrPending: boolean;
+}
+
+/**
+ * Reconnection policy configuration.
+ */
+export interface ReconnectPolicy {
+  initialMs: number;
+  maxMs: number;
+  factor: number;
+  jitter: number;
+  maxAttempts: number;
+}
+
+/**
+ * Close reason for connection.
+ */
+export interface ConnectionCloseReason {
+  status?: number;
+  isLoggedOut: boolean;
+  error?: unknown;
+}
+
+/**
+ * WhatsApp credentials structure (Baileys auth state).
+ */
+export interface WhatsAppCredentials {
+  creds: Record;
+  keys: Record;
+}
+
+/**
+ * Result of sending a message.
+ */
+export interface SendMessageResult {
+  messageId: string;
+  toJid: string;
+}
+
+/**
+ * Media kind for file handling.
+ */
+export type MediaKind =
+  | "image"
+  | "video"
+  | "audio"
+  | "document"
+  | "sticker"
+  | "unknown";
+
+/**
+ * Helper to convert JID to E.164 format.
+ */
+export function jidToE164(jid: string): string | null {
+  if (!jid) return null;
+  // Handle JID formats:
+  // - 447512972810@s.whatsapp.net (standard)
+  // - 447512972810:13@s.whatsapp.net (with device ID)
+  // - 167564575514790@lid (linked ID format)
+  const match = jid.match(/^(\d+)(?::\d+)?@/);
+  if (!match) return null;
+  return `+${match[1]}`;
+}
+
+/**
+ * Helper to convert E.164 to WhatsApp JID.
+ */
+export function e164ToJid(e164: string): string {
+  // Remove + and add @s.whatsapp.net
+  const digits = e164.replace(/\D/g, "");
+  return `${digits}@s.whatsapp.net`;
+}
+
+/**
+ * Normalize phone number to E.164 format.
+ */
+export function normalizeE164(phone: string): string {
+  const digits = phone.replace(/\D/g, "");
+  return digits.startsWith("+") ? digits : `+${digits}`;
+}
+
+/**
+ * Check if a JID is a group JID.
+ */
+export function isGroupJid(jid: string): boolean {
+  return jid.endsWith("@g.us");
+}
+
+/**
+ * Detect media kind from MIME type.
+ */
+export function mediaKindFromMime(mimeType?: string | null): MediaKind {
+  if (!mimeType) return "unknown";
+  if (mimeType.startsWith("image/")) return "image";
+  if (mimeType.startsWith("video/")) return "video";
+  if (mimeType.startsWith("audio/")) return "audio";
+  if (mimeType === "image/webp") return "sticker";
+  return "document";
+}
diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts
index 37ce1aa..1ff1bc6 100644
--- a/packages/github/src/index.ts
+++ b/packages/github/src/index.ts
@@ -419,6 +419,7 @@ Note: GitHub authentication is handled via MCP (Model Context Protocol)`;
   async handleAction(
     actionId: string,
     userId: string,
+    _spaceId: string,
     context: HandleActionContext
   ): Promise {
     // Handle GitHub PR creation button
diff --git a/packages/worker/package.json b/packages/worker/package.json
index 24f0d0d..ba0b446 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -45,6 +45,7 @@
     "zod": "^4.1.12"
   },
   "devDependencies": {
+    "@types/cors": "^2.8.19",
     "@types/node": "^20.0.0",
     "typescript": "^5.8.3"
   }
diff --git a/packages/worker/scripts/worker-entrypoint.sh b/packages/worker/scripts/worker-entrypoint.sh
index 603a43c..713c264 100644
--- a/packages/worker/scripts/worker-entrypoint.sh
+++ b/packages/worker/scripts/worker-entrypoint.sh
@@ -41,14 +41,12 @@ fi
 # Setup workspace directory
 echo "๐Ÿ“ Setting up workspace directory..."
 WORKSPACE_DIR="/workspace"
-mkdir -p "$WORKSPACE_DIR"
-
-# Fix permissions for bind-mounted workspace
-# This is needed because bind mounts inherit host permissions
-if [ -d "$WORKSPACE_DIR" ] && [ "$(stat -c %U "$WORKSPACE_DIR")" = "root" ]; then
-    echo "๐Ÿ”ง Fixing workspace permissions (bind mount detected)..."
-    sudo chown -R claude:claude "$WORKSPACE_DIR" 2>/dev/null || echo "โš ๏ธ  Could not change workspace ownership"
-    chmod 755 "$WORKSPACE_DIR" 2>/dev/null || echo "โš ๏ธ  Could not change workspace permissions"
+
+# Workspace permissions are fixed by gateway before container starts
+# Just verify we can write to it
+if [ ! -w "$WORKSPACE_DIR" ]; then
+    echo "โŒ Error: Cannot write to workspace directory $WORKSPACE_DIR"
+    exit 1
 fi
 
 cd "$WORKSPACE_DIR"
diff --git a/packages/worker/src/claude/custom-tools.ts b/packages/worker/src/claude/custom-tools.ts
index 9b3aea5..fe85da7 100644
--- a/packages/worker/src/claude/custom-tools.ts
+++ b/packages/worker/src/claude/custom-tools.ts
@@ -23,16 +23,18 @@ export function createCustomToolsServer(
       "UploadUserFile",
       "Use this whenever you create a visualization, chart, image, document, report, or any file that helps answer the user's request. This is how you share your work with the user.",
       {
+        // @ts-expect-error - SDK tool() typing issue with Zod schemas
         file_path: z
           .string()
           .describe(
             "Path to the file to show (absolute or relative to workspace)"
           ),
+        // @ts-expect-error - SDK tool() typing issue with Zod schemas
         description: z
           .string()
           .optional()
           .describe("Optional description of what the file contains or shows"),
-      },
+      } as const,
       async (args) => {
         try {
           logger.info(
@@ -179,7 +181,9 @@ export function createCustomToolsServer(
         "AskUserQuestion",
         "Ask the user a question with options. Supports three patterns: (1) Simple buttons: pass string array for immediate response. (2) Single form: pass object with field schemas to open a modal. (3) Multi-form workflow: pass array of {label, fields} to let user fill multiple forms before submitting.",
         {
+          // @ts-expect-error - SDK tool() typing issue with Zod schemas
           question: z.string().describe("The question to ask the user"),
+          // @ts-expect-error - SDK tool() typing issue with Zod schemas
           options: z.union([
             z
               .array(z.string())
@@ -188,6 +192,7 @@ export function createCustomToolsServer(
               ),
             z
               .record(
+                z.string(),
                 z.object({
                   type: z.enum([
                     "text",
@@ -210,8 +215,13 @@ export function createCustomToolsServer(
                 z.object({
                   label: z
                     .string()
-                    .describe("Button label that opens this form"),
+                    .describe(
+                      "Short section label (1-2 words max, under 25 chars). " +
+                        "Examples: 'Personal Info', 'Work History', 'Preferences'. " +
+                        "Avoid long descriptive names - keep it concise for button display."
+                    ),
                   fields: z.record(
+                    z.string(),
                     z.object({
                       type: z.enum([
                         "text",
@@ -232,7 +242,7 @@ export function createCustomToolsServer(
               )
               .describe("Array of forms for multi-step workflow"),
           ]),
-        },
+        } as const,
         async (args) => {
           try {
             logger.info(`AskUserQuestion: ${args.question}`);
diff --git a/packages/worker/src/claude/sdk-adapter.ts b/packages/worker/src/claude/sdk-adapter.ts
index 61b4b19..8255d6e 100644
--- a/packages/worker/src/claude/sdk-adapter.ts
+++ b/packages/worker/src/claude/sdk-adapter.ts
@@ -66,6 +66,7 @@ const TOOL_APPROVAL_OPTIONS = [
 // Also auto-allow AskUserQuestion since it's specifically for asking the user questions
 // File operations (Write, Edit) are safe in sandboxed environment
 const AUTO_ALLOW_TOOLS = [
+  "Bash",
   "Read",
   "Write",
   "Edit",
@@ -147,7 +148,7 @@ export async function runClaudeWithSDK(
     const sdkOptions: SDKOptions = {
       model: options.model,
       cwd: workingDirectory || process.cwd(),
-      permissionMode: "plan", // Start in plan mode - Claude plans without executing
+      permissionMode: "default", // Normal execution mode - tools run with canUseTool callback
       strictMcpConfig: false, // Allow MCP failures without stopping execution
       env: {
         ...process.env,
@@ -336,9 +337,27 @@ export async function runClaudeWithSDK(
 
         try {
           isWaitingForInteraction = true;
+
+          // Format tool input for display
+          let inputSummary = "";
+          if (input) {
+            if (typeof input === "string") {
+              inputSummary = input;
+            } else if (input.command) {
+              // Bash tool
+              inputSummary = `\`${input.command}\``;
+            } else if (input.file_path) {
+              // File operations
+              inputSummary = input.file_path;
+            } else {
+              // Generic JSON display
+              inputSummary = JSON.stringify(input, null, 2);
+            }
+          }
+
           const toolResponse = await client.askUser({
             interactionType: "tool_approval",
-            question: `Claude wants to execute the \`${toolName}\` tool. Do you want to allow this?`,
+            question: `Claude wants to execute \`${toolName}\`:\n${inputSummary}\n\nAllow this?`,
             options: TOOL_APPROVAL_OPTIONS as any,
             metadata: {
               toolName,
diff --git a/packages/worker/src/core/base-worker.ts b/packages/worker/src/core/base-worker.ts
index c9a27cf..5f45365 100644
--- a/packages/worker/src/core/base-worker.ts
+++ b/packages/worker/src/core/base-worker.ts
@@ -172,6 +172,7 @@ export abstract class BaseWorker implements WorkerExecutor {
         this.getCoreInstructionProvider(),
         {
           userId: this.config.userId,
+          spaceId: this.config.spaceId,
           sessionKey: this.config.sessionKey,
           workingDirectory: this.workspaceManager.getCurrentWorkingDirectory(),
           availableProjects: listAppDirectories(
diff --git a/packages/worker/src/core/types.ts b/packages/worker/src/core/types.ts
index f8fc467..7f8bc8f 100644
--- a/packages/worker/src/core/types.ts
+++ b/packages/worker/src/core/types.ts
@@ -39,6 +39,7 @@ export interface WorkerExecutor {
 export interface WorkerConfig {
   sessionKey: string;
   userId: string;
+  spaceId: string; // Space identifier for multi-tenant isolation
   channelId: string;
   threadId?: string;
   userPrompt: string; // Base64 encoded
diff --git a/packages/worker/src/gateway/sse-client.ts b/packages/worker/src/gateway/sse-client.ts
index ee243f8..2d9a4a1 100644
--- a/packages/worker/src/gateway/sse-client.ts
+++ b/packages/worker/src/gateway/sse-client.ts
@@ -28,6 +28,7 @@ const PlatformMetadataSchema = z
   })
   .and(
     z.record(
+      z.string(),
       z.union([
         z.string(),
         z.number(),
@@ -50,6 +51,7 @@ const AgentOptionsSchema = z
   })
   .and(
     z.record(
+      z.string(),
       z.union([
         z.string(),
         z.number(),
@@ -64,6 +66,7 @@ const JobEventSchema = z.object({
   payload: z.object({
     botId: z.string(),
     userId: z.string(),
+    spaceId: z.string(),
     threadId: z.string(),
     platform: z.string(),
     channelId: z.string(),
@@ -72,6 +75,7 @@ const JobEventSchema = z.object({
     platformMetadata: PlatformMetadataSchema,
     agentOptions: AgentOptionsSchema,
     jobId: z.string().optional(),
+    teamId: z.string().optional(), // Optional for WhatsApp (top-level) and Slack (in platformMetadata)
   }),
   processedIds: z.array(z.string()).optional(),
 });
@@ -80,7 +84,7 @@ const InteractionEventSchema = z.object({
   interactionId: z.string(),
   response: z.object({
     answer: z.string().optional(),
-    formData: z.record(z.any()).optional(),
+    formData: z.record(z.string(), z.any()).optional(),
     timestamp: z.number(),
   }),
 });
@@ -552,6 +556,7 @@ export class GatewayClient {
     return {
       sessionKey: `session-${payload.threadId}`,
       userId: payload.userId,
+      spaceId: payload.spaceId,
       channelId: payload.channelId,
       threadId: payload.threadId,
       userPrompt: Buffer.from(payload.messageText).toString("base64"),
@@ -562,9 +567,11 @@ export class GatewayClient {
       botResponseId: platformMetadata.botResponseId
         ? String(platformMetadata.botResponseId)
         : undefined,
-      teamId: platformMetadata.teamId
-        ? String(platformMetadata.teamId)
-        : undefined,
+      // Check both payload.teamId (WhatsApp) and platformMetadata.teamId (Slack)
+      teamId:
+        (payload.teamId ?? platformMetadata.teamId)
+          ? String(payload.teamId ?? platformMetadata.teamId)
+          : undefined,
       platform: payload.platform,
       platformMetadata: platformMetadata, // Include full platformMetadata for files and other metadata
       agentOptions: JSON.stringify(agentOptions),
diff --git a/packages/worker/src/gateway/types.ts b/packages/worker/src/gateway/types.ts
index 706c8a2..83a0dd1 100644
--- a/packages/worker/src/gateway/types.ts
+++ b/packages/worker/src/gateway/types.ts
@@ -22,6 +22,7 @@ interface PlatformMetadata {
 export interface MessagePayload {
   botId: string;
   userId: string;
+  spaceId: string;
   threadId: string;
   platform: string;
   channelId: string;
@@ -30,6 +31,7 @@ export interface MessagePayload {
   platformMetadata: PlatformMetadata;
   agentOptions: AgentOptions;
   jobId?: string; // Optional job ID from gateway
+  teamId?: string; // Optional team ID (WhatsApp uses top-level, Slack uses platformMetadata)
 }
 
 /**
diff --git a/scripts/test-bot.sh b/scripts/test-bot.sh
index d1aa8db..7347317 100755
--- a/scripts/test-bot.sh
+++ b/scripts/test-bot.sh
@@ -1,28 +1,71 @@
 #!/bin/bash
 set -e
 
-# Bot testing script with multi-message support
+# Bot testing script with multi-message and multi-platform support
 # Usage: ./scripts/test-bot.sh "message 1" ["message 2"] ["message 3"] ...
 # Or: ./scripts/test-bot.sh (uses default test message)
-# Environment: TEST_CHANNEL, SLACK_BOT_TOKEN, TEST_TIMEOUT (default: 30s)
+#
+# Environment variables:
+#   TEST_PLATFORM   - "slack" or "whatsapp" (default: auto-detect from enabled platforms)
+#   TEST_CHANNEL    - Channel ID (Slack) or phone number (WhatsApp)
+#   TEST_TIMEOUT    - Timeout in seconds (default: 30)
+#
+# Platform-specific:
+#   Slack: QA_SLACK_CHANNEL, SLACK_BOT_TOKEN
+#   WhatsApp: WHATSAPP_SELF_PHONE (defaults to bot's own number for self-chat)
 
 # Load .env if it exists
 if [ -f .env ]; then
-    export $(grep -v '^#' .env | grep -E 'SLACK_BOT_TOKEN|TEST_CHANNEL|TEST_USER_ID' | sed 's/#.*//' | xargs)
+    export $(grep -v '^#' .env | grep -E 'SLACK_BOT_TOKEN|WHATSAPP_ENABLED|WHATSAPP_SELF_CHAT|QA_SLACK_CHANNEL|TEST_PLATFORM|TEST_CHANNEL' | sed 's/#.*//' | xargs)
 fi
 
-# If not exists, fail with error
-if [ -z "$QA_SLACK_CHANNEL" ]; then
-    echo "โŒ QA_SLACK_CHANNEL environment variable is required"
-    exit 1
+# Auto-detect platform if not specified
+if [ -z "$TEST_PLATFORM" ]; then
+    if [ "$WHATSAPP_ENABLED" = "true" ]; then
+        TEST_PLATFORM="whatsapp"
+    elif [ -n "$SLACK_BOT_TOKEN" ]; then
+        TEST_PLATFORM="slack"
+    else
+        echo "โŒ No platform configured. Set TEST_PLATFORM=slack or TEST_PLATFORM=whatsapp"
+        exit 1
+    fi
 fi
 
 TIMEOUT="${TEST_TIMEOUT:-30}"
 
-if [ -z "$SLACK_BOT_TOKEN" ]; then
-    echo "โŒ SLACK_BOT_TOKEN environment variable is required"
-    exit 1
-fi
+# Platform-specific setup
+case "$TEST_PLATFORM" in
+    slack)
+        if [ -z "$SLACK_BOT_TOKEN" ]; then
+            echo "โŒ SLACK_BOT_TOKEN environment variable is required for Slack"
+            exit 1
+        fi
+        AUTH_TOKEN="$SLACK_BOT_TOKEN"
+        CHANNEL="${TEST_CHANNEL:-$QA_SLACK_CHANNEL}"
+        if [ -z "$CHANNEL" ]; then
+            echo "โŒ QA_SLACK_CHANNEL or TEST_CHANNEL environment variable is required for Slack"
+            exit 1
+        fi
+        ;;
+    whatsapp)
+        # WhatsApp uses a simple auth token or empty (handled by gateway)
+        AUTH_TOKEN="${WHATSAPP_AUTH_TOKEN:-whatsapp-test}"
+        CHANNEL="${TEST_CHANNEL:-$WHATSAPP_SELF_PHONE}"
+        if [ -z "$CHANNEL" ]; then
+            # For self-chat mode, we can use "self" as a special channel
+            if [ "$WHATSAPP_SELF_CHAT" = "true" ]; then
+                CHANNEL="self"
+            else
+                echo "โŒ TEST_CHANNEL or WHATSAPP_SELF_PHONE environment variable is required for WhatsApp"
+                exit 1
+            fi
+        fi
+        ;;
+    *)
+        echo "โŒ Unknown platform: $TEST_PLATFORM. Use 'slack' or 'whatsapp'"
+        exit 1
+        ;;
+esac
 
 # Get messages from arguments or use default
 if [ $# -eq 0 ]; then
@@ -32,7 +75,8 @@ else
 fi
 
 echo "๐Ÿงช Testing bot with ${#MESSAGES[@]} message(s)"
-echo "๐Ÿ“ Channel: $QA_SLACK_CHANNEL"
+echo "๐Ÿ“ฑ Platform: $TEST_PLATFORM"
+echo "๐Ÿ“ Channel: $CHANNEL"
 echo "โฑ๏ธ  Timeout: ${TIMEOUT}s"
 echo ""
 
@@ -42,85 +86,102 @@ LAST_THREAD_ID=""
 for i in "${!MESSAGES[@]}"; do
     MESSAGE="${MESSAGES[$i]}"
     MSG_NUM=$((i + 1))
-    
+
     echo "[$MSG_NUM/${#MESSAGES[@]}] ๐Ÿ“ค Sending: $MESSAGE"
-    
-    # Build request body
+
+    # Escape message for JSON (handle newlines, quotes, backslashes)
+    ESCAPED_MESSAGE=$(printf '%s' "$MESSAGE" | jq -Rs .)
+
+    # Build request body using jq for proper JSON
     if [ -n "$LAST_THREAD_ID" ]; then
-        BODY="{\"platform\":\"slack\",\"channel\":\"$QA_SLACK_CHANNEL\",\"message\":\"$MESSAGE\",\"threadId\":\"$LAST_THREAD_ID\"}"
+        BODY=$(jq -n \
+            --arg platform "$TEST_PLATFORM" \
+            --arg channel "$CHANNEL" \
+            --argjson message "$ESCAPED_MESSAGE" \
+            --arg threadId "$LAST_THREAD_ID" \
+            '{platform: $platform, channel: $channel, message: $message, threadId: $threadId}')
     else
-        BODY="{\"platform\":\"slack\",\"channel\":\"$QA_SLACK_CHANNEL\",\"message\":\"$MESSAGE\"}"
+        BODY=$(jq -n \
+            --arg platform "$TEST_PLATFORM" \
+            --arg channel "$CHANNEL" \
+            --argjson message "$ESCAPED_MESSAGE" \
+            '{platform: $platform, channel: $channel, message: $message}')
     fi
-    
+
     # Send message
     RESPONSE=$(curl -s -X POST http://localhost:8080/api/messaging/send \
-      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
+      -H "Authorization: Bearer $AUTH_TOKEN" \
       -H "Content-Type: application/json" \
       -d "$BODY")
-    
+
     # Check success
     if ! echo "$RESPONSE" | jq -e '.success' > /dev/null 2>&1; then
         echo "   โŒ Failed to send message $MSG_NUM:"
-        echo "$RESPONSE" | jq
+        echo "$RESPONSE" | jq 2>/dev/null || echo "$RESPONSE"
         exit 1
     fi
-    
+
     CHANNEL_ID=$(echo "$RESPONSE" | jq -r '.channel')
     THREAD_ID=$(echo "$RESPONSE" | jq -r '.threadId')
     MESSAGE_ID=$(echo "$RESPONSE" | jq -r '.messageId')
     QUEUED=$(echo "$RESPONSE" | jq -r '.queued')
-    
+
     echo "   โœ… Sent: messageId=$MESSAGE_ID, queued=$QUEUED"
-    
+
     # Save thread for subsequent messages
     LAST_THREAD_ID="$THREAD_ID"
-    
-    # If queued, verify in logs; otherwise poll for response
-    if [ "$QUEUED" = "true" ]; then
-        echo "   ๐Ÿ“‹ Queued directly - checking logs..."
-        sleep 2
-        
-        # Check logs for processing
-        LOGS=$(docker compose -f docker-compose.dev.yml logs gateway --tail 50 2>/dev/null || echo "")
-        if echo "$LOGS" | grep -q "Processing message job.*$MESSAGE_ID"; then
-            echo "   โœ… Message processed"
-        else
-            echo "   โš ๏ธ  Message queued but processing not confirmed"
-        fi
-    else
-        echo "   โณ Waiting for bot response..."
-        START_TIME=$(date +%s)
-        
-        while true; do
-            CURRENT_TIME=$(date +%s)
-            ELAPSED=$((CURRENT_TIME - START_TIME))
-            
-            if [ $ELAPSED -ge $TIMEOUT ]; then
-                echo "   โŒ Timeout: No bot response within ${TIMEOUT}s"
-                exit 1
+
+    # Platform-specific response handling
+    case "$TEST_PLATFORM" in
+        slack)
+            # For Slack, we can poll for responses via API
+            if [ "$QUEUED" = "true" ]; then
+                echo "   ๐Ÿ“‹ Queued directly - checking logs..."
+                sleep 2
+            else
+                echo "   โณ Waiting for bot response..."
+                START_TIME=$(date +%s)
+
+                while true; do
+                    CURRENT_TIME=$(date +%s)
+                    ELAPSED=$((CURRENT_TIME - START_TIME))
+
+                    if [ $ELAPSED -ge $TIMEOUT ]; then
+                        echo "   โŒ Timeout: No bot response within ${TIMEOUT}s"
+                        exit 1
+                    fi
+
+                    # Check for replies in thread
+                    REPLIES=$(curl -s -X POST https://slack.com/api/conversations.replies \
+                        -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
+                        -H "Content-Type: application/x-www-form-urlencoded" \
+                        -d "channel=$CHANNEL_ID&ts=$THREAD_ID&limit=20")
+
+                    # Check if we got bot messages after our message
+                    BOT_RESPONSE=$(echo "$REPLIES" | jq -r '.messages[]? | select(.bot_id != null) | select(.ts > "'"$MESSAGE_ID"'") | .text' | head -1)
+
+                    if [ -n "$BOT_RESPONSE" ]; then
+                        echo "   โœ… Bot responded:"
+                        echo "      $(echo "$BOT_RESPONSE" | head -c 200)..."
+                        break
+                    fi
+
+                    sleep 2
+                done
             fi
-            
-            # Check for replies in thread
-            REPLIES=$(curl -s -X POST https://slack.com/api/conversations.replies \
-                -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-                -H "Content-Type: application/x-www-form-urlencoded" \
-                -d "channel=$CHANNEL_ID&ts=$THREAD_ID&limit=20")
-            
-            # Check if we got bot messages after our message
-            BOT_RESPONSE=$(echo "$REPLIES" | jq -r '.messages[]? | select(.bot_id != null) | select(.ts > "'"$MESSAGE_ID"'") | .text' | head -1)
-            
-            if [ -n "$BOT_RESPONSE" ]; then
-                echo "   โœ… Bot responded:"
-                echo "      $(echo "$BOT_RESPONSE" | head -c 200)..."
-                break
+            ;;
+        whatsapp)
+            # For WhatsApp, we can't poll for responses - just wait and check logs
+            echo "   ๐Ÿ“‹ Message sent to WhatsApp - check your phone for response"
+            if [ "$QUEUED" = "true" ]; then
+                echo "   โณ Waiting for processing..."
+                sleep 5
             fi
-            
-            sleep 2
-        done
-    fi
-    
+            ;;
+    esac
+
     echo ""
 done
 
-echo "๐ŸŽ‰ All tests PASSED!"
+echo "๐ŸŽ‰ All messages sent successfully!"
 exit 0