diff --git a/Dockerfile b/Dockerfile index 61f65e7..41db56e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,15 @@ ENV NODE_ENV production # Uncomment the following line in case you want to disable telemetry during runtime. # ENV NEXT_TELEMETRY_DISABLED 1 +# Install fonts for Sharp text rendering +RUN apk add --no-cache \ + fontconfig \ + ttf-dejavu \ + ttf-liberation \ + ttf-opensans \ + font-noto \ + && fc-cache -f + RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/Dockerfile.bot b/Dockerfile.bot new file mode 100644 index 0000000..65497cf --- /dev/null +++ b/Dockerfile.bot @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1 +FROM node:20-alpine + +WORKDIR /app + +ENV NODE_ENV production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Install tsx globally for running TypeScript scripts +RUN npm install -g tsx@^4.20.5 + +# Copy package files and install dependencies +COPY package.json package-lock.json* ./ +RUN npm ci --production + +# Copy only the necessary files for the Discord bot +COPY --chown=nextjs:nodejs src ./src +COPY --chown=nextjs:nodejs scripts ./scripts +COPY --chown=nextjs:nodejs tsconfig.json ./tsconfig.json + +USER nextjs + +# Default command runs the Discord bot +CMD ["tsx", "scripts/discord-bot.ts"] diff --git a/docker-compose.yml b/docker-compose.yml index c3b5660..6484fc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,16 +8,51 @@ services: ports: - "3001:3000" environment: + # Next.js Configuration - NODE_ENV=production - - CLICKHOUSE_HOST=clickhouse - - CLICKHOUSE_PORT=8123 - - CLICKHOUSE_USER=default - - CLICKHOUSE_PASSWORD=password + - PORT=3000 + - HOSTNAME=0.0.0.0 + + # ClickHouse Database Configuration + - CLICKHOUSE_HOST=${CLICKHOUSE_HOST:-clickhouse} + - CLICKHOUSE_PORT=${CLICKHOUSE_PORT:-8123} + - CLICKHOUSE_USER=${CLICKHOUSE_USER:-default} + - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password} + + # Next.js API Configuration + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-} restart: unless-stopped init: true networks: - shared-network + discord-bot: + build: + context: . + dockerfile: Dockerfile.bot + environment: + # Node.js Configuration + - NODE_ENV=production + + # ClickHouse Database Configuration + - CLICKHOUSE_HOST=${CLICKHOUSE_HOST:-clickhouse} + - CLICKHOUSE_PORT=${CLICKHOUSE_PORT:-8123} + - CLICKHOUSE_USER=${CLICKHOUSE_USER:-default} + - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password} + + # Discord Bot Configuration + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} + - MESH_REGION=${MESH_REGION:-seattle} + - POLL_INTERVAL=${POLL_INTERVAL:-1000} + - MAX_ROWS_PER_POLL=${MAX_ROWS_PER_POLL:-50} + - PRIVATE_KEYS=${PRIVATE_KEYS:-} + restart: unless-stopped + init: true + networks: + - shared-network + depends_on: + - meshexplorer + networks: shared-network: external: true \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index af6a757..b4b25b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-dom": "^19.1.0", "react-leaflet": "^5.0.0", "react-tiny-popover": "^8.1.6", + "sharp": "^0.34.3", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -43,6 +44,7 @@ "eslint": "^9", "eslint-config-next": "15.3.4", "tailwindcss": "^4", + "tsx": "^4.20.5", "tw-animate-css": "^1.3.4", "typescript": "^5" } @@ -125,9 +127,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -143,6 +145,422 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -423,9 +841,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", - "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", "cpu": [ "arm64" ], @@ -440,13 +858,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" + "@img/sharp-libvips-darwin-arm64": "1.2.0" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz", - "integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", "cpu": [ "x64" ], @@ -461,13 +879,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.1.0" + "@img/sharp-libvips-darwin-x64": "1.2.0" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", "cpu": [ "arm64" ], @@ -480,9 +898,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", - "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", "cpu": [ "x64" ], @@ -495,9 +913,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", - "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", "cpu": [ "arm" ], @@ -510,9 +928,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", - "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", "cpu": [ "arm64" ], @@ -525,9 +943,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", "cpu": [ "ppc64" ], @@ -540,9 +958,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", - "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", "cpu": [ "s390x" ], @@ -555,9 +973,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", - "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", "cpu": [ "x64" ], @@ -570,9 +988,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", - "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", "cpu": [ "arm64" ], @@ -585,9 +1003,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", - "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", "cpu": [ "x64" ], @@ -600,9 +1018,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz", - "integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", "cpu": [ "arm" ], @@ -617,13 +1035,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.1.0" + "@img/sharp-libvips-linux-arm": "1.2.0" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz", - "integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", "cpu": [ "arm64" ], @@ -638,13 +1056,34 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.1.0" + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz", - "integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", "cpu": [ "s390x" ], @@ -659,13 +1098,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.1.0" + "@img/sharp-libvips-linux-s390x": "1.2.0" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz", - "integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", "cpu": [ "x64" ], @@ -680,13 +1119,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.1.0" + "@img/sharp-libvips-linux-x64": "1.2.0" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz", - "integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", "cpu": [ "arm64" ], @@ -701,13 +1140,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz", - "integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", "cpu": [ "x64" ], @@ -722,19 +1161,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz", - "integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", "cpu": [ "wasm32" ], "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.3" + "@emnapi/runtime": "^1.4.4" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -744,9 +1183,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz", - "integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", "cpu": [ "arm64" ], @@ -762,9 +1201,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz", - "integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", "cpu": [ "ia32" ], @@ -780,9 +1219,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz", - "integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", "cpu": [ "x64" ], @@ -2628,7 +3067,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -2657,7 +3095,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -2972,7 +3409,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "devOptional": true, "engines": { "node": ">=8" } @@ -3213,6 +3649,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3790,6 +4267,20 @@ "node": ">= 0.12" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4164,8 +4655,7 @@ "node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/is-async-function": { "version": "2.1.1", @@ -5985,7 +6475,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "bin": { "semver": "bin/semver.js" }, @@ -6045,11 +6534,10 @@ } }, "node_modules/sharp": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz", - "integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", "hasInstallScript": true, - "optional": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", @@ -6062,27 +6550,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.2", - "@img/sharp-darwin-x64": "0.34.2", - "@img/sharp-libvips-darwin-arm64": "1.1.0", - "@img/sharp-libvips-darwin-x64": "1.1.0", - "@img/sharp-libvips-linux-arm": "1.1.0", - "@img/sharp-libvips-linux-arm64": "1.1.0", - "@img/sharp-libvips-linux-ppc64": "1.1.0", - "@img/sharp-libvips-linux-s390x": "1.1.0", - "@img/sharp-libvips-linux-x64": "1.1.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", - "@img/sharp-libvips-linuxmusl-x64": "1.1.0", - "@img/sharp-linux-arm": "0.34.2", - "@img/sharp-linux-arm64": "0.34.2", - "@img/sharp-linux-s390x": "0.34.2", - "@img/sharp-linux-x64": "0.34.2", - "@img/sharp-linuxmusl-arm64": "0.34.2", - "@img/sharp-linuxmusl-x64": "0.34.2", - "@img/sharp-wasm32": "0.34.2", - "@img/sharp-win32-arm64": "0.34.2", - "@img/sharp-win32-ia32": "0.34.2", - "@img/sharp-win32-x64": "0.34.2" + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" } }, "node_modules/shebang-command": { @@ -6182,7 +6671,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, "dependencies": { "is-arrayish": "^0.3.1" } @@ -6610,6 +7098,25 @@ "resolved": "https://registry.npmjs.org/tsv/-/tsv-0.2.0.tgz", "integrity": "sha512-GG6xbOP85giXXom0dS6z9uyDsxktznjpa1AuDlPrIXDqDnbhjr9Vk6Us8iz6U1nENL4CPS2jZDvIjEdaZsmc4Q==" }, + "node_modules/tsx": { + "version": "4.20.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", + "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", + "dev": true, + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index a98cb90..611b298 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "discord-bot": "tsx scripts/discord-bot.ts", + "discord-bot:dev": "tsx watch scripts/discord-bot.ts" }, "dependencies": { "@clickhouse/client": "^1.11.2", @@ -32,6 +34,7 @@ "react-dom": "^19.1.0", "react-leaflet": "^5.0.0", "react-tiny-popover": "^8.1.6", + "sharp": "^0.34.3", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -44,6 +47,7 @@ "eslint": "^9", "eslint-config-next": "15.3.4", "tailwindcss": "^4", + "tsx": "^4.20.5", "tw-animate-css": "^1.3.4", "typescript": "^5" } diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9337364 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,205 @@ +# MeshCore Discord Bot + +This directory contains scripts for running long-running background processes alongside the Next.js server. + +## Discord Bot + +The Discord bot (`discord-bot.ts`) subscribes to the ClickHouse message stream with decryption enabled for the Seattle region and posts new messages to Discord via webhook. + +### Features + +- **Real-time streaming**: Subscribes to ClickHouse chat message stream +- **Message decryption**: Automatically decrypts messages using known keys +- **Discord integration**: Posts messages to Discord via webhook +- **Message updates**: Messages with the same ID update existing Discord messages instead of posting new ones +- **Error handling**: Graceful error handling with Discord error notifications +- **Skip initial messages**: Only processes new messages, not historical ones + +### Configuration + +The bot is configured via environment variables: + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `DISCORD_WEBHOOK_URL` | Discord webhook URL | - | Yes | +| `MESH_REGION` | Mesh region to monitor | `seattle` | No | +| `POLL_INTERVAL` | Polling interval in milliseconds | `1000` | No | +| `MAX_ROWS_PER_POLL` | Maximum rows to fetch per poll | `50` | No | +| `PRIVATE_KEYS` | Comma-separated list of private keys | - | No | + +### Usage + +#### Development + +```bash +# Install dependencies +npm install + +# Run with hot reload +npm run discord-bot:dev +``` + +#### Production + +```bash +# Run the bot +npm run discord-bot +``` + +#### Environment Setup + +Create a `.env.local` file in the project root: + +```bash +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_URL +MESH_REGION=seattle +POLL_INTERVAL=1000 +MAX_ROWS_PER_POLL=50 +PRIVATE_KEYS=key1,key2,key3 +``` + +### Discord Webhook Setup + +1. Go to your Discord server settings +2. Navigate to Integrations > Webhooks +3. Create a new webhook +4. Copy the webhook URL +5. Set it as the `DISCORD_WEBHOOK_URL` environment variable + +### Message Format + +Messages are posted to Discord with the following format: + +- **Username**: MeshCore Chat +- **Avatar**: Meshtastic logo +- **Embed**: Rich embed with message details including: + - Sender and message text + - Channel hash + - Message ID + - Region + - Timestamps + +### Error Handling + +- Decryption failures are logged and skipped +- Network errors are retried automatically +- Critical errors are posted to Discord +- Graceful shutdown on SIGINT/SIGTERM + +### Architecture + +The bot consists of several components: + +- **`discord-bot.ts`**: Main bot script with message processing logic +- **`lib/discord.ts`**: Discord webhook client and message formatting utilities +- **ClickHouse streaming**: Uses existing streaming infrastructure from the main app +- **Message decryption**: Leverages existing meshcore decryption utilities + +### Docker Deployment + +The project includes Docker support for easy deployment with both the Next.js server and Discord bot. + +#### Prerequisites + +- Docker and Docker Compose installed +- ClickHouse running on the Docker host (default port 8123) +- External Docker network `shared-network` must exist + +#### Setup + +1. **Create the required external network:** + ```bash + docker network create shared-network + ``` + +2. **Set up environment variables:** + ```bash + # Copy the example file + cp scripts/docker.env.example .env + + # Edit .env with your configuration + nano .env + ``` + +3. **Build and start services:** + ```bash + # Start both Next.js server and Discord bot + docker-compose up --build + + # Or run in background + docker-compose up -d --build + ``` + +4. **Access the application:** + - Next.js server: http://localhost:3001 + - Discord bot: Runs in background, check logs with `docker-compose logs discord-bot` + +#### Docker Services + +- **meshexplorer**: Next.js web application (uses `Dockerfile`) +- **discord-bot**: Discord bot for chat message streaming (uses `Dockerfile.bot`) + +#### Docker Images + +The project uses two separate Dockerfiles for optimal image sizes: + +- **`Dockerfile`**: Optimized for Next.js standalone output (smaller, faster) +- **`Dockerfile.bot`**: Optimized for TypeScript execution with tsx (includes source files) + +#### Environment Variables + +All configuration is loaded from environment variables: + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `CLICKHOUSE_HOST` | ClickHouse server hostname | `clickhouse` | No | +| `CLICKHOUSE_PORT` | ClickHouse server port | `8123` | No | +| `CLICKHOUSE_USER` | ClickHouse username | `default` | No | +| `CLICKHOUSE_PASSWORD` | ClickHouse password | `password` | No | +| `NEXT_PUBLIC_API_URL` | Override API base URL | - | No | +| `DISCORD_WEBHOOK_URL` | Discord webhook URL | - | Yes | +| `MESH_REGION` | Mesh region to monitor | `seattle` | No | +| `POLL_INTERVAL` | Polling interval in milliseconds | `1000` | No | +| `MAX_ROWS_PER_POLL` | Maximum rows to fetch per poll | `50` | No | +| `PRIVATE_KEYS` | Comma-separated private keys | - | No | + +#### Docker Commands + +```bash +# View logs +docker-compose logs -f discord-bot +docker-compose logs -f meshexplorer + +# Restart services +docker-compose restart discord-bot +docker-compose restart meshexplorer + +# Stop services +docker-compose down + +# Rebuild and restart +docker-compose up --build --force-recreate + +# Build individual services +docker-compose build meshexplorer +docker-compose build discord-bot + +# Run only specific service +docker-compose up meshexplorer +docker-compose up discord-bot + +# Build and run Discord bot only +docker build -f Dockerfile.bot -t meshexplorer-bot . +docker run --env-file .env meshexplorer-bot +``` + +### Monitoring + +The bot logs important events: + +- Message processing status +- Decryption success/failure +- Discord API calls +- Error conditions + +Check the console output for real-time monitoring of bot activity. diff --git a/scripts/discord-bot.ts b/scripts/discord-bot.ts new file mode 100644 index 0000000..74291b9 --- /dev/null +++ b/scripts/discord-bot.ts @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +/** + * Discord Bot for MeshCore Chat Messages + * + * This script subscribes to the ClickHouse message stream with decryption enabled + * for the Seattle region and posts new messages to Discord via webhook. + * + * Messages with the same ID will update the existing Discord message instead of + * posting a new one. + */ + +import { createClickHouseStreamer, createChatMessagesStreamerConfig } from '../src/lib/clickhouse/streaming'; +import { decryptMeshcoreGroupMessage } from '../src/lib/meshcore'; +import { DiscordWebhookClient, formatMeshcoreMessageForDiscord } from './lib/discord'; + +interface BotConfig { + webhookUrl: string; + region: string; + pollInterval: number; + maxRowsPerPoll: number; + privateKeys: string[]; +} + +class MeshCoreDiscordBot { + private config: BotConfig; + private discordClient: DiscordWebhookClient; + private isRunning = false; + private streamer: any; + + constructor(config: BotConfig) { + this.config = config; + this.discordClient = new DiscordWebhookClient(config.webhookUrl); + } + + async start() { + if (this.isRunning) { + console.log('Bot is already running'); + return; + } + + this.isRunning = true; + console.log('Starting MeshCore Discord Bot...'); + console.log(`Region: ${this.config.region}`); + console.log(`Poll interval: ${this.config.pollInterval}ms`); + console.log(`Max rows per poll: ${this.config.maxRowsPerPoll}`); + + // Create streaming configuration + const streamerConfig = createChatMessagesStreamerConfig(undefined, this.config.region); + streamerConfig.pollInterval = this.config.pollInterval; + streamerConfig.maxRowsPerPoll = this.config.maxRowsPerPoll; + streamerConfig.skipInitialMessages = true; // Skip initial messages, only get new ones + + this.streamer = createClickHouseStreamer(streamerConfig); + + try { + // Start streaming + for await (const result of this.streamer({})) { + await this.processMessage(result.row); + } + } catch (error) { + console.error('Streaming error:', error); + throw error; + } + } + + private async processMessage(message: any) { + try { + console.log(`Processing message ${message.message_id} from channel ${message.channel_hash}`); + + // Decrypt the message + const decrypted = await this.decryptMessage(message); + + if (!decrypted) { + console.log(`Failed to decrypt message ${message.message_id}, skipping Discord post`); + return; + } + + // Format message for Discord + const discordMessage = formatMeshcoreMessageForDiscord(message, decrypted); + + // Post or update message in Discord + await this.discordClient.postOrUpdateMessage(message.message_id, discordMessage); + + console.log(`Successfully processed message ${message.message_id}: ${decrypted.text}`); + + } catch (error) { + console.error(`Error processing message ${message.message_id}:`, error); + + // Don't send error messages to Discord for processing errors + // Just log them for monitoring + } + } + + private async decryptMessage(message: any): Promise { + const PUBLIC_MESHCORE_KEY = "izOH6cXN6mrJ5e26oRXNcg=="; + const allKeys = [PUBLIC_MESHCORE_KEY, ...this.config.privateKeys]; + + try { + const decrypted = await decryptMeshcoreGroupMessage({ + encrypted_message: message.encrypted_message, + mac: message.mac, + channel_hash: message.channel_hash, + knownKeys: allKeys, + parse: true + }); + + return decrypted; + } catch (error) { + console.warn(`Decryption failed for message ${message.message_id}:`, error); + return null; + } + } + + stop() { + this.isRunning = false; + console.log('Stopping MeshCore Discord Bot...'); + } + + getStatus() { + return { + isRunning: this.isRunning, + region: this.config.region, + messageMappings: this.discordClient.getAllMappings().size + }; + } +} + +// Main execution +async function main() { + // Get configuration from environment variables + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + const region = process.env.MESH_REGION || 'seattle'; + const pollInterval = parseInt(process.env.POLL_INTERVAL || '1000', 10); + const maxRowsPerPoll = parseInt(process.env.MAX_ROWS_PER_POLL || '50', 10); + const privateKeys = process.env.PRIVATE_KEYS ? process.env.PRIVATE_KEYS.split(',').filter(key => key.trim()) : []; + + // Validate required configuration + if (!webhookUrl) { + console.error('Error: DISCORD_WEBHOOK_URL environment variable is required'); + process.exit(1); + } + + // Validate webhook URL format + if (!webhookUrl.startsWith('https://discord.com/api/webhooks/')) { + console.error('Error: DISCORD_WEBHOOK_URL must be a valid Discord webhook URL'); + process.exit(1); + } + + // Validate region + const allowedRegions = ['seattle', 'portland', 'boston']; + if (!allowedRegions.includes(region)) { + console.error(`Error: MESH_REGION must be one of: ${allowedRegions.join(', ')}`); + process.exit(1); + } + + console.log('Configuration:'); + console.log(` Webhook URL: ${webhookUrl.substring(0, 50)}...`); + console.log(` Region: ${region}`); + console.log(` Poll interval: ${pollInterval}ms`); + console.log(` Max rows per poll: ${maxRowsPerPoll}`); + console.log(` Private keys: ${privateKeys.length}`); + + // Create and start the bot + const bot = new MeshCoreDiscordBot({ + webhookUrl, + region, + pollInterval, + maxRowsPerPoll, + privateKeys + }); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nReceived SIGINT, shutting down gracefully...'); + bot.stop(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + console.log('\nReceived SIGTERM, shutting down gracefully...'); + bot.stop(); + process.exit(0); + }); + + // Handle uncaught exceptions + process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + bot.stop(); + process.exit(1); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + bot.stop(); + process.exit(1); + }); + + try { + await bot.start(); + } catch (error) { + console.error('Bot failed to start:', error); + process.exit(1); + } +} + +// Run the bot +if (require.main === module) { + main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); +} + +export { MeshCoreDiscordBot }; diff --git a/scripts/lib/discord.ts b/scripts/lib/discord.ts new file mode 100644 index 0000000..166b760 --- /dev/null +++ b/scripts/lib/discord.ts @@ -0,0 +1,190 @@ +/** + * Discord webhook integration utilities for posting and updating messages + */ + +export interface DiscordWebhookMessage { + content?: string; + username?: string; + avatar_url?: string; + embeds?: DiscordEmbed[]; + flags?: number; +} + +export interface DiscordEmbed { + title?: string; + description?: string; + color?: number; + fields?: DiscordEmbedField[]; + timestamp?: string; + footer?: DiscordEmbedFooter; +} + +export interface DiscordEmbedField { + name: string; + value: string; + inline?: boolean; +} + +export interface DiscordEmbedFooter { + text: string; + icon_url?: string; +} + +export interface DiscordWebhookResponse { + id: string; + channel_id: string; + content: string; + timestamp: string; + edited_timestamp?: string; +} + +export class DiscordWebhookClient { + private webhookUrl: string; + private messageIdMap: Map = new Map(); // message_id -> discord_message_id + + constructor(webhookUrl: string) { + this.webhookUrl = webhookUrl; + } + + /** + * Post a new message to Discord + */ + async postMessage(message: DiscordWebhookMessage): Promise { + // Add wait=true to get the message ID in response + const url = new URL(this.webhookUrl); + url.searchParams.set('wait', 'true'); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + throw new Error(`Discord webhook failed: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } + + /** + * Update an existing Discord message + */ + async updateMessage(discordMessageId: string, message: DiscordWebhookMessage): Promise { + // Use the correct URL format for updating messages + const updateUrl = `${this.webhookUrl}/messages/${discordMessageId}`; + + const response = await fetch(updateUrl, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Discord webhook update failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + return await response.json(); + } + + /** + * Post or update a message based on message ID mapping + */ + async postOrUpdateMessage( + messageId: string, + message: DiscordWebhookMessage + ): Promise { + const existingDiscordId = this.messageIdMap.get(messageId); + + if (existingDiscordId) { + // Update existing message + try { + console.log(`Updating Discord message ${existingDiscordId} for meshcore message ${messageId}`); + const result = await this.updateMessage(existingDiscordId, message); + console.log(`Successfully updated Discord message ${existingDiscordId} for meshcore message ${messageId}`); + return result; + } catch (error) { + console.warn(`Failed to update Discord message ${existingDiscordId} for meshcore message ${messageId}, posting new message:`, error); + // If update fails, remove the old mapping and post a new message + this.messageIdMap.delete(messageId); + const result = await this.postMessage(message); + this.messageIdMap.set(messageId, result.id); + console.log(`Posted new Discord message ${result.id} for meshcore message ${messageId} (after update failure)`); + return result; + } + } else { + // Post new message + console.log(`Posting new Discord message for meshcore message ${messageId}`); + const result = await this.postMessage(message); + this.messageIdMap.set(messageId, result.id); + console.log(`Posted new Discord message ${result.id} for meshcore message ${messageId}`); + return result; + } + } + + /** + * Get the Discord message ID for a given meshcore message ID + */ + getDiscordMessageId(messageId: string): string | undefined { + return this.messageIdMap.get(messageId); + } + + /** + * Remove a message ID mapping (useful for cleanup) + */ + removeMessageMapping(messageId: string): boolean { + return this.messageIdMap.delete(messageId); + } + + /** + * Get all message ID mappings + */ + getAllMappings(): Map { + return new Map(this.messageIdMap); + } + + /** + * Clear all message ID mappings + */ + clearMappings(): void { + this.messageIdMap.clear(); + } +} + +/** + * Format a meshcore chat message for Discord + */ +export function formatMeshcoreMessageForDiscord( + message: any, + decrypted?: { + timestamp: number; + msgType: number; + sender: string; + text: string; + rawText: string; + } +): DiscordWebhookMessage { + const sender = decrypted?.sender || 'Unknown'; + const text = decrypted?.text || '[Encrypted Message]'; + + // Calculate how many times the message was heard + const heardCount = message.origin_path_info ? message.origin_path_info.length : 0; + + // Format the message content with the requested format + const content = `${text}\n-# _Heard ${heardCount} times by [MeshExplorer](https://map.w0z.is/messages)_`; + + // Generate profile picture URL using the new API + const profilePictureUrl = `https://map.w0z.is/api/meshcore/profilepicture.png?name=${encodeURIComponent(sender)}`; + + return { + username: sender, + avatar_url: profilePictureUrl, + content: content, + flags: 4 // SUPPRESS_EMBEDS flag + }; +} \ No newline at end of file diff --git a/src/app/api/meshcore/profilepicture.png/route.ts b/src/app/api/meshcore/profilepicture.png/route.ts new file mode 100644 index 0000000..329b647 --- /dev/null +++ b/src/app/api/meshcore/profilepicture.png/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { getColourForName, getNameIconLabel } from "@/lib/meshcore-map-nodeutils"; +import sharp from "sharp"; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const nodeName = searchParams.get("name"); + + if (!nodeName) { + return NextResponse.json({ + error: "Node name parameter is required", + code: "MISSING_NODE_NAME" + }, { status: 400 }); + } + + // Get the color and label for the node name + const backgroundColor = getColourForName(nodeName); + const label = getNameIconLabel(nodeName); + + // Generate PNG profile picture + const pngBuffer = await generateProfilePicturePNG(backgroundColor, label); + + // Return as PNG with proper content type + return new NextResponse(pngBuffer, { + status: 200, + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour + }, + }); + } catch (error) { + console.error("Error generating profile picture:", error); + + return NextResponse.json({ + error: "Failed to generate profile picture", + code: "INTERNAL_ERROR" + }, { status: 500 }); + } +} + +async function generateProfilePicturePNG(backgroundColor: string, label: string): Promise { + const size = 512; // Square size + const fontSize = 192; // Font size for the label + + // Create SVG string with properly centered text + const svg = ` + + + ${escapeXml(label)} +`; + + // Convert SVG to PNG using Sharp + const pngBuffer = await sharp(Buffer.from(svg)) + .png() + .toBuffer(); + + return pngBuffer; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} \ No newline at end of file diff --git a/src/app/api/meshcore/stream/chat/route.ts b/src/app/api/meshcore/stream/chat/route.ts new file mode 100644 index 0000000..1e42097 --- /dev/null +++ b/src/app/api/meshcore/stream/chat/route.ts @@ -0,0 +1,122 @@ +import { NextRequest } from 'next/server'; +import { createClickHouseStreamer, createChatMessagesStreamerConfig } from '@/lib/clickhouse/streaming'; +import { decryptMeshcoreGroupMessage } from '@/lib/meshcore'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + + // Get and validate input parameters + const channelId = searchParams.get('channel_id'); + const region = searchParams.get('region'); + const decrypt = searchParams.has('decrypt'); + const privateKeys = searchParams.getAll('privateKeys'); + const pollInterval = searchParams.get('pollInterval'); + const maxRows = searchParams.get('maxRows'); + const skipInitialMessages = searchParams.has('skipInitialMessages'); + + // Validate region against allowed values (same as packets endpoint) + const allowedRegions = ['seattle', 'portland', 'boston']; + const validRegion = region && allowedRegions.includes(region) ? region : undefined; + + // Validate channel ID (should be hex string if provided) + let validChannelId: string | undefined; + if (channelId && /^[0-9A-Fa-f]+$/.test(channelId)) { + validChannelId = channelId.toLowerCase(); + } else if (channelId) { + // If channelId is provided but not valid hex, return error + return new Response(JSON.stringify({ error: 'Invalid channel_id format. Must be a hex string.' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate poll interval (100ms to 10s, default 1s for chat) + const validPollInterval = Math.max(100, Math.min(10000, parseInt(pollInterval || '1000', 10))); + + // Validate max rows (10 to 1000, default 500 for chat) + const validMaxRows = Math.max(10, Math.min(1000, parseInt(maxRows || '500', 10))); + + // Prepare decryption keys if decryption is requested + let allKeys: string[] = []; + if (decrypt) { + const PUBLIC_MESHCORE_KEY = "izOH6cXN6mrJ5e26oRXNcg=="; + allKeys = [PUBLIC_MESHCORE_KEY, ...privateKeys]; + } + + // Create streaming configuration with validated parameters + const config = createChatMessagesStreamerConfig(validChannelId, validRegion); + + // Override config with validated values + config.pollInterval = validPollInterval; + config.maxRowsPerPoll = validMaxRows; + config.skipInitialMessages = skipInitialMessages; + + const streamer = createClickHouseStreamer(config); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + try { + // Build parameters object with validated values + const params: Record = {}; + if (validChannelId) params.channelId = validChannelId; + + for await (const result of streamer(params)) { + let outputData = result.row; + + // Apply decryption if requested + if (decrypt && allKeys.length > 0) { + try { + const decrypted = await decryptMeshcoreGroupMessage({ + encrypted_message: result.row.encrypted_message, + mac: result.row.mac, + channel_hash: result.row.channel_hash, + knownKeys: allKeys, + parse: true + }); + + if (decrypted) { + outputData = { + ...result.row, + decrypted + }; + } + } catch (error) { + // Skip messages that fail to decrypt, just send the original + console.warn("Failed to decrypt streaming message:", error); + } + } + + const data = JSON.stringify(outputData); + controller.enqueue(encoder.encode(`${data}\n`)); + } + } catch (error) { + console.error('Meshcore chat streaming error:', error); + const errorData = JSON.stringify({ + type: 'error', + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date().toISOString() + }); + + controller.enqueue(encoder.encode(`${errorData}\n`)); + } finally { + controller.close(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control', + 'Access-Control-Allow-Methods': 'GET' + } + }); +} + + + diff --git a/src/app/api/meshcore/stream/packets/route.ts b/src/app/api/meshcore/stream/packets/route.ts new file mode 100644 index 0000000..0d530ca --- /dev/null +++ b/src/app/api/meshcore/stream/packets/route.ts @@ -0,0 +1,104 @@ +import { NextRequest } from 'next/server'; +import { createClickHouseStreamer, createMeshcorePacketsStreamerConfig } from '@/lib/clickhouse/streaming'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + + // Validate and sanitize input parameters + const region = searchParams.get('region'); + const payloadType = searchParams.get('payloadType'); + const routeType = searchParams.get('routeType'); + const originPubkey = searchParams.get('originPubkey'); + const pollInterval = searchParams.get('pollInterval'); + const maxRows = searchParams.get('maxRows'); + + // Validate region against allowed values + const allowedRegions = ['seattle', 'portland', 'boston']; + const validRegion = region && allowedRegions.includes(region) ? region : undefined; + + // Validate payload type (0-15 based on the schema) + let validPayloadType: number | undefined; + if (payloadType) { + const parsed = parseInt(payloadType, 10); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 15) { + validPayloadType = parsed; + } + } + + // Validate route type (0-3 based on the schema) + let validRouteType: number | undefined; + if (routeType) { + const parsed = parseInt(routeType, 10); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 3) { + validRouteType = parsed; + } + } + + // Validate origin pubkey (should be hex string) + let validOriginPubkey: string | undefined; + if (originPubkey && /^[0-9A-Fa-f]+$/.test(originPubkey)) { + validOriginPubkey = originPubkey.toUpperCase(); + } + + // Validate poll interval (100ms to 10s) + const validPollInterval = Math.max(100, Math.min(10000, parseInt(pollInterval || '500', 10))); + + // Validate max rows (10 to 10000) + const validMaxRows = Math.max(10, Math.min(10000, parseInt(maxRows || '10', 10))); + + // Create streaming configuration with validated parameters + const config = createMeshcorePacketsStreamerConfig( + validRegion, + validPayloadType, + validRouteType, + validOriginPubkey + ); + + // Override config with validated values + config.pollInterval = validPollInterval; + config.maxRowsPerPoll = validMaxRows; + + const streamer = createClickHouseStreamer(config); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + try { + // Build parameters object with validated values + const params: Record = {}; + if (validPayloadType !== undefined) params.payloadType = validPayloadType; + if (validRouteType !== undefined) params.routeType = validRouteType; + if (validOriginPubkey) params.originPubkey = validOriginPubkey; + + for await (const result of streamer(params)) { + const data = JSON.stringify(result.row); + + controller.enqueue(encoder.encode(`${data}\n`)); + } + } catch (error) { + console.error('Meshcore packets streaming error:', error); + const errorData = JSON.stringify({ + type: 'error', + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date().toISOString() + }); + + controller.enqueue(encoder.encode(`${errorData}\n\n`)); + } finally { + controller.close(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control', + 'Access-Control-Allow-Methods': 'GET' + } + }); +} diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 1b6b93c..bd59b44 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -33,7 +33,7 @@ export default function ChatBox({ className = "", startExpanded = false, }: ChatBoxProps) { - const { config } = useConfig(); + const { config, openKeyModal } = useConfig(); const meshcoreKeys: TabItem[] = [ { channelName: "Public", privateKey: "izOH6cXN6mrJ5e26oRXNcg==" }, ...(config?.meshcoreKeys || []), @@ -79,8 +79,7 @@ export default function ChatBox({ autoRefreshEnabled: !minimized, }); - // Only show tabs if more than one channel (or if we have all messages tab) - const showTabs = allTabs.length > 1; + // Always show tabs // Set up intersection observer for infinite scrolling const loadMoreTriggerRef = useIntersectionObserver( @@ -152,29 +151,34 @@ export default function ChatBox({ {!minimized && config?.selectedRegion && ( <> - {showTabs && ( -
-
- {allTabs.map((key, idx) => ( - - ))} -
+
+
+ {allTabs.map((key, idx) => ( + + ))} +
- )} +
( diff --git a/src/components/ChatMessageItem.tsx b/src/components/ChatMessageItem.tsx index cf0986b..dc8b9d5 100644 --- a/src/components/ChatMessageItem.tsx +++ b/src/components/ChatMessageItem.tsx @@ -6,6 +6,7 @@ import PathVisualization, { PathData } from "./PathVisualization"; import NodeLinkWithHover from "./NodeLinkWithHover"; export interface ChatMessage { + message_id: string; ingest_timestamp: string; origins: string[]; mesh_timestamp: string; @@ -14,7 +15,7 @@ export interface ChatMessage { mac: string; encrypted_message: string; message_count: number; - origin_key_path_array: Array<[string, string, string]>; // Array of [origin, pubkey, path] tuples + origin_path_info: Array<[string, string, string, string, string]>; // Array of [origin, origin_pubkey, path, broker, topic] tuples } @@ -89,19 +90,19 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow const parsed = decryptionResult?.decrypted || null; const error = decryptionResult?.error || null; - const originKeyPathArray = useMemo(() => - msg.origin_key_path_array && msg.origin_key_path_array.length > 0 ? msg.origin_key_path_array : [], - [msg.origin_key_path_array] + const originPathInfo = useMemo(() => + msg.origin_path_info && msg.origin_path_info.length > 0 ? msg.origin_path_info : [], + [msg.origin_path_info] ); // Convert to PathData format for the new component const pathData: PathData[] = useMemo(() => - originKeyPathArray.map(([origin, pubkey, path]) => ({ + originPathInfo.map(([origin, origin_pubkey, path, broker, topic]) => ({ origin, - pubkey, + pubkey: origin_pubkey, path })), - [originKeyPathArray] + [originPathInfo] ); @@ -179,11 +180,12 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow export default React.memo(ChatMessageItem, (prevProps, nextProps) => { // Only re-render if these key properties change return ( + prevProps.msg.message_id === nextProps.msg.message_id && prevProps.msg.ingest_timestamp === nextProps.msg.ingest_timestamp && prevProps.msg.encrypted_message === nextProps.msg.encrypted_message && prevProps.msg.mac === nextProps.msg.mac && prevProps.msg.channel_hash === nextProps.msg.channel_hash && - prevProps.msg.origin_key_path_array?.length === nextProps.msg.origin_key_path_array?.length && + prevProps.msg.origin_path_info?.length === nextProps.msg.origin_path_info?.length && prevProps.showErrorRow === nextProps.showErrorRow ); }); \ No newline at end of file diff --git a/src/components/ConfigContext.tsx b/src/components/ConfigContext.tsx index 4dddb53..1a8d75d 100644 --- a/src/components/ConfigContext.tsx +++ b/src/components/ConfigContext.tsx @@ -88,10 +88,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const openConfig = () => setOpen(true); const closeConfig = () => setOpen(false); + const openKeyModal = () => setKeyModalOpen(true); + return ( - + {children} - {open && setKeyModalOpen(true)} />} + {open && } {keyModalOpen && ( - Manage Meshcore Private Keys + Manage Channel Keys
@@ -297,7 +299,7 @@ function validateMeshcoreKey(key: string): string | null { function MeshcoreKeyModal({ config, setConfig, onClose }: { config: Config, setConfig: (c: Config) => void, onClose: () => void }) { return ( - +

These keys will be used to decrypt messages. Your keys are never shared with the server, so your messages remain secure.

@@ -406,7 +408,7 @@ function MeshcoreKeyModal({ config, setConfig, onClose }: { config: Config, setC }); }} > - Add Meshcore Key + Add Channel Key
); diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index ab7030e..3f780f7 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -114,17 +114,52 @@ export function useChatMessages({ if (!oldData?.pages?.[0]) return oldData; const newMessages = autoRefreshQuery.data; - const firstPage = oldData.pages[0]; - // Add new messages to the beginning of the first page - const updatedFirstPage = { - ...firstPage, - messages: [...newMessages, ...firstPage.messages] - }; + // Get all existing messages from all pages + const allExistingMessages = oldData.pages.flatMap((page: any) => page.messages); + + // Process new messages: replace duplicates and collect truly new ones + const trulyNewMessages: ChatMessage[] = []; + const updatedExistingMessages = [...allExistingMessages]; + + for (const newMessage of newMessages) { + const existingIndex = updatedExistingMessages.findIndex( + (msg: ChatMessage) => msg.message_id === newMessage.message_id + ); + + if (existingIndex !== -1) { + // Replace existing message with the new one + updatedExistingMessages[existingIndex] = newMessage; + } else { + // This is a truly new message + trulyNewMessages.push(newMessage); + } + } + + // Combine truly new messages with updated existing messages + // Sort by ingest_timestamp to maintain order + const allMessages = [...trulyNewMessages, ...updatedExistingMessages] + .sort((a, b) => new Date(b.ingest_timestamp).getTime() - new Date(a.ingest_timestamp).getTime()); + + // Redistribute messages back into pages + const updatedPages = []; + let currentPageMessages = []; + + for (let i = 0; i < allMessages.length; i++) { + currentPageMessages.push(allMessages[i]); + + if (currentPageMessages.length === PAGE_SIZE || i === allMessages.length - 1) { + updatedPages.push({ + ...oldData.pages[Math.floor(i / PAGE_SIZE)] || { hasMore: false }, + messages: currentPageMessages, + }); + currentPageMessages = []; + } + } return { ...oldData, - pages: [updatedFirstPage, ...oldData.pages.slice(1)] + pages: updatedPages, }; }); } diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index a4793d4..6563b54 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -1,6 +1,6 @@ "use server"; import { clickhouse } from "./clickhouse"; -import { generateRegionWhereClauseFromArray, generateRegionWhereClause } from "@/lib/regionFilters"; +import { generateRegionWhereClauseFromArray, generateRegionWhereClause, detectRegionFromBrokerTopic, detectRegion } from "@/lib/regionFilters"; import { getRegionConfig } from "@/lib/regions"; export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }: { minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[], lastSeen?: string | null } = {}) { @@ -78,7 +78,7 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel } const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - const query = `SELECT ingest_timestamp, mesh_timestamp, channel_hash, mac, hex(encrypted_message) AS encrypted_message, message_count, origin_key_path_array FROM meshcore_public_channel_messages ${whereClause} ORDER BY ingest_timestamp DESC LIMIT {limit:UInt32}`; + const query = `SELECT ingest_timestamp, mesh_timestamp, channel_hash, mac, hex(encrypted_message) AS encrypted_message, message_count, origin_path_info, message_id FROM meshcore_public_channel_messages ${whereClause} ORDER BY ingest_timestamp DESC LIMIT {limit:UInt32}`; const resultSet = await clickhouse.query({ query, query_params: params, format: 'JSONEachRow' }); const rows = await resultSet.json(); return rows as Array<{ @@ -88,7 +88,8 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel mac: string; encrypted_message: string; message_count: number; - origin_key_path_array: Array<[string, string, string]>; // Array of [origin, pubkey, path] tuples + origin_path_info: Array<[string, string, string, string, string]>; // Array of [origin, origin_pubkey, path, broker, topic] tuples + message_id: string; }>; } catch (error) { console.error('ClickHouse error in getLatestChatMessages:', error); @@ -102,41 +103,7 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel * @param topic Topic string * @returns The detected region name or null if no region matches */ -function detectRegionFromBrokerTopic(broker: string | null, topic: string | null): string | null { - if (!broker || !topic) return null; - - // Check each region configuration - const regions = ['seattle', 'portland', 'boston']; - for (const regionName of regions) { - const regionConfig = getRegionConfig(regionName); - if (!regionConfig) continue; - - // Check if this topic/broker combination matches the region - if (broker === regionConfig.broker && regionConfig.topics.includes(topic)) { - return regionName; - } - } - - return null; -} - -/** - * Combined region detection that tries MQTT topics first, then advert data - * @param mqttTopics Array of MQTT topic information - * @param advertBroker Broker from advert data - * @param advertTopic Topic from advert data - * @returns The detected region name or null if no region matches - */ -function detectRegion(mqttTopics: Array<{ topic: string; broker: string }>, advertBroker: string | null, advertTopic: string | null): string | null { - // First try MQTT topics (more reliable for uplinked nodes) - for (const mqttTopic of mqttTopics) { - const region = detectRegionFromBrokerTopic(mqttTopic.broker, mqttTopic.topic); - if (region) return region; - } - - // Fallback to advert data (works for non-uplinked nodes) - return detectRegionFromBrokerTopic(advertBroker, advertTopic); -} +// Region detection functions moved to regionFilters.ts export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) { try { diff --git a/src/lib/clickhouse/streaming.ts b/src/lib/clickhouse/streaming.ts new file mode 100644 index 0000000..2917ff7 --- /dev/null +++ b/src/lib/clickhouse/streaming.ts @@ -0,0 +1,345 @@ +import { clickhouse } from './clickhouse'; +import { generateRegionConditionForStreaming, generateRegionArrayConditionForStreaming } from '../regionFilters'; + +/** + * Configuration for the ClickHouse streaming poller + */ +export interface StreamingConfig { + /** The base query template with placeholders for parameters */ + queryTemplate: string; + /** Time column to use for polling (must be a DateTime/DateTime64 column) */ + timeColumn: string; + /** Polling interval in milliseconds (default: 1000) */ + pollInterval?: number; + /** Maximum number of rows to fetch per poll (default: 1000) */ + maxRowsPerPoll?: number; + /** Custom WHERE clause to add to the query (optional) */ + additionalWhereClause?: string; + /** Skip initial messages and only stream new ones (default: false) */ + skipInitialMessages?: boolean; +} + +/** + * Parameters that can be passed to the streaming function + */ +export interface StreamingParams { + /** Additional query parameters to be passed to ClickHouse */ + [key: string]: any; +} + +/** + * Result of a streaming poll + */ +export interface StreamingResult { + /** The new row found in this poll */ + row: T; + /** The timestamp of the last row (for next poll) */ + lastTimestamp: string; +} + +/** + * Creates a generator function that polls ClickHouse for new rows based on a time column + * + * @param config Configuration for the streaming poller + * @returns A generator function that yields new rows as they become available + * + * @example + * ```typescript + * const config: StreamingConfig = { + * queryTemplate: ` + * SELECT * FROM meshcore_adverts + * WHERE {timeColumn:DateTime64} > {lastTimestamp:DateTime64} + * ORDER BY {timeColumn:DateTime64} ASC + * LIMIT {maxRows:UInt32} + * `, + * timeColumn: 'ingest_timestamp', + * pollInterval: 2000, + * maxRowsPerPoll: 500 + * }; + * + * const streamer = createClickHouseStreamer(config); + * + * // Use in an async generator + * async function* pollForNewRows() { + * for await (const result of streamer({})) { + * console.log(`Found ${result.rowCount} new rows`); + * yield result.rows; + * } + * } + * ``` + */ +export function createClickHouseStreamer(config: StreamingConfig) { + const { + queryTemplate, + timeColumn, + pollInterval = 1000, + maxRowsPerPoll = 1000, + additionalWhereClause, + skipInitialMessages = false + } = config; + + return async function* streamer(params: StreamingParams = {}): AsyncGenerator, void, unknown> { + let lastTimestamp: string | null = null; + let isFirstPoll = true; + + while (true) { + try { + // Build the query parameters + const queryParams: Record = { + maxRows: maxRowsPerPoll, + lastTimestamp: lastTimestamp || '1970-01-01 00:00:00', + ...params + }; + + // Build the final query + let finalQuery = queryTemplate; + + // Add additional WHERE clause if provided + if (additionalWhereClause) { + // Insert additional WHERE clause before ORDER BY + const orderByIndex = finalQuery.toUpperCase().indexOf('ORDER BY'); + if (orderByIndex !== -1) { + finalQuery = finalQuery.slice(0, orderByIndex) + + ` AND ${additionalWhereClause} ` + + finalQuery.slice(orderByIndex); + } else { + // If no ORDER BY, add at the end before LIMIT + const limitIndex = finalQuery.toUpperCase().indexOf('LIMIT'); + if (limitIndex !== -1) { + finalQuery = finalQuery.slice(0, limitIndex) + + ` AND ${additionalWhereClause} ` + + finalQuery.slice(limitIndex); + } else { + finalQuery += ` AND ${additionalWhereClause}`; + } + } + } + + // Execute the query + const resultSet = await clickhouse.query({ + query: finalQuery, + query_params: queryParams, + format: 'JSONEachRow' + }); + + const rows = await resultSet.json() as T[]; + + // Only yield if we have new rows + if (rows.length > 0) { + // Update timestamp from the latest row (first in DESC order) + if (rows[0] && typeof rows[0] === 'object' && timeColumn in rows[0]) { + lastTimestamp = (rows[0] as any)[timeColumn]; + } + + rows.reverse(); + + // Skip initial messages if configured + if (skipInitialMessages && isFirstPoll) { + console.log(`Skipping ${rows.length} initial messages`); + isFirstPoll = false; + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, pollInterval)); + continue; + } + + // Yield separate events for each row + for (const row of rows) { + yield { + row, + lastTimestamp: lastTimestamp || '1970-01-01 00:00:00' + }; + } + } + + isFirstPoll = false; + + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, pollInterval)); + + } catch (error) { + console.error('Error in ClickHouse streaming poll:', error); + + // Yield error result + yield { + row: {} as T, // Empty row for error case + lastTimestamp: lastTimestamp || '1970-01-01 00:00:00', + error: error instanceof Error ? error.message : 'Unknown error' + } as StreamingResult & { error: string }; + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + }; +} + +/** + * Helper function to create a streaming configuration for meshcore adverts + */ +export function createMeshcoreAdvertsStreamerConfig( + region?: string, + additionalFilters?: Record +): StreamingConfig { + let additionalWhereClause = ''; + + if (region) { + // Add region filtering based on broker and topic + additionalWhereClause = generateRegionConditionForStreaming(region); + } + + // Add any additional filters + if (additionalFilters) { + const filterClauses = Object.entries(additionalFilters).map(([key, value]) => { + if (Array.isArray(value)) { + return `${key} IN {${key}:Array(String)}`; + } else if (typeof value === 'string') { + return `${key} = {${key}:String}`; + } else if (typeof value === 'number') { + return `${key} = {${key}:${Number.isInteger(value) ? 'Int64' : 'Float64'}}`; + } else if (typeof value === 'boolean') { + return `${key} = {${key}:UInt8}`; + } + return ''; + }).filter(Boolean); + + if (filterClauses.length > 0) { + additionalWhereClause += (additionalWhereClause ? ' AND ' : '') + filterClauses.join(' AND '); + } + } + + return { + queryTemplate: ` + SELECT + public_key, + node_name, + latitude, + longitude, + has_location, + is_repeater, + is_chat_node, + is_room_server, + has_name, + broker, + topic, + ingest_timestamp, + mesh_timestamp, + adv_timestamp + FROM meshcore_adverts + WHERE ingest_timestamp > {lastTimestamp:DateTime64} + ORDER BY ingest_timestamp DESC + LIMIT {maxRows:UInt32} + `, + timeColumn: 'ingest_timestamp', + pollInterval: 2000, + maxRowsPerPoll: 1000, + additionalWhereClause: additionalWhereClause || undefined + }; +} + +/** + * Helper function to create a streaming configuration for chat messages + */ +export function createChatMessagesStreamerConfig( + channelId?: string, + region?: string +): StreamingConfig { + let additionalWhereClause = ''; + + if (channelId) { + additionalWhereClause = `channel_hash = {channelId:String}`; + } + + if (region) { + // Add region filtering for chat messages using origin_path_info + const regionClause = generateRegionArrayConditionForStreaming(region); + if (regionClause) { + additionalWhereClause += (additionalWhereClause ? ' AND ' : '') + regionClause; + } + } + + return { + queryTemplate: ` + SELECT + ingest_timestamp, + mesh_timestamp, + channel_hash, + mac, + hex(encrypted_message) AS encrypted_message, + message_count, + origin_path_info, + message_id + FROM meshcore_public_channel_messages + WHERE ingest_timestamp > {lastTimestamp:DateTime64} + ORDER BY ingest_timestamp DESC + LIMIT {maxRows:UInt32} + `, + timeColumn: 'ingest_timestamp', + pollInterval: 250, + maxRowsPerPoll: 50, + additionalWhereClause: additionalWhereClause || undefined + }; +} + +/** + * Helper function to create a streaming configuration for meshcore packets + */ +export function createMeshcorePacketsStreamerConfig( + region?: string, + payloadType?: number, + routeType?: number, + originPubkey?: string +): StreamingConfig { + let additionalWhereClause = ''; + + if (region) { + // Add region filtering based on broker and topic + const regionClause = generateRegionConditionForStreaming(region); + if (regionClause) { + additionalWhereClause = regionClause; + } + } + + // Add payload type filter if specified + if (payloadType !== undefined) { + const payloadTypeClause = `payload_type = {payloadType:UInt8}`; + additionalWhereClause += (additionalWhereClause ? ' AND ' : '') + payloadTypeClause; + } + + // Add route type filter if specified + if (routeType !== undefined) { + const routeTypeClause = `route_type = {routeType:UInt8}`; + additionalWhereClause += (additionalWhereClause ? ' AND ' : '') + routeTypeClause; + } + + // Add origin pubkey filter if specified + if (originPubkey) { + const originClause = `hex(origin_pubkey) = {originPubkey:String}`; + additionalWhereClause += (additionalWhereClause ? ' AND ' : '') + originClause; + } + + return { + queryTemplate: ` + SELECT + ingest_timestamp, + mesh_timestamp, + broker, + topic, + hex(packet) AS packet, + path_len, + hex(path) AS path, + route_type, + payload_type, + payload_version, + header, + hex(origin_pubkey) AS origin_pubkey + FROM meshcore_packets + WHERE ingest_timestamp > {lastTimestamp:DateTime64} + ORDER BY ingest_timestamp DESC + LIMIT {maxRows:UInt32} + `, + timeColumn: 'ingest_timestamp', + pollInterval: 500, // More frequent polling for packets + maxRowsPerPoll: 50, // Limit rows per poll for packets + additionalWhereClause: additionalWhereClause || undefined + }; +} diff --git a/src/lib/regionFilters.ts b/src/lib/regionFilters.ts index 33498b2..ea54230 100644 --- a/src/lib/regionFilters.ts +++ b/src/lib/regionFilters.ts @@ -1,4 +1,7 @@ -import { getRegionConfig } from "@/lib/regions"; +import { getRegionConfig, generateRegionCondition, generateRegionArrayCondition, detectRegionFromBrokerTopic, detectRegion } from "@/lib/regions"; + +// Re-export region detection functions for backward compatibility +export { detectRegionFromBrokerTopic, detectRegion }; /** * Generates a ClickHouse WHERE clause for filtering by region using broker and topic fields @@ -11,36 +14,16 @@ export function generateRegionWhereClause(region?: string, tableAlias: string = return { whereClause: '', params: {} }; } - const regionConfig = getRegionConfig(region); - if (!regionConfig) { - return { whereClause: '', params: {} }; - } - - const alias = tableAlias ? `${tableAlias}.` : ''; - - if (region === 'seattle') { - return { - whereClause: `${alias}broker = 'tcp://mqtt.davekeogh.com:1883' AND (${alias}topic = 'meshcore' OR ${alias}topic = 'meshcore/salish')`, - params: {} - }; - } else if (region === 'portland') { - return { - whereClause: `${alias}broker = 'tcp://mqtt.davekeogh.com:1883' AND ${alias}topic = 'meshcore/pdx'`, - params: {} - }; - } else if (region === 'boston') { - return { - whereClause: `${alias}broker = 'tcp://mqtt.davekeogh.com:1883' AND ${alias}topic = 'meshcore/bos'`, - params: {} - }; - } - - return { whereClause: '', params: {} }; + const regionCondition = generateRegionCondition(region, tableAlias); + return { + whereClause: regionCondition, + params: {} + }; } /** - * Generates a ClickHouse WHERE clause for filtering by region using topic_broker_array - * This is for views that already have the topic_broker_array field + * Generates a ClickHouse WHERE clause for filtering by region using origin_path_info + * This is for views that have the origin_path_info field * @param region The region name to filter by * @returns Object containing the where clause and parameters */ @@ -49,27 +32,29 @@ export function generateRegionWhereClauseFromArray(region?: string) { return { whereClause: '', params: {} }; } - const regionConfig = getRegionConfig(region); - if (!regionConfig) { - return { whereClause: '', params: {} }; - } - - if (region === 'seattle') { - return { - whereClause: "arrayExists(x -> x.1 = 'tcp://mqtt.davekeogh.com:1883' AND (x.2 = 'meshcore' OR x.2 = 'meshcore/salish'), topic_broker_array)", - params: {} - }; - } else if (region === 'portland') { - return { - whereClause: "arrayExists(x -> x.1 = 'tcp://mqtt.davekeogh.com:1883' AND x.2 = 'meshcore/pdx', topic_broker_array)", - params: {} - }; - } else if (region === 'boston') { - return { - whereClause: "arrayExists(x -> x.1 = 'tcp://mqtt.davekeogh.com:1883' AND x.2 = 'meshcore/bos', topic_broker_array)", - params: {} - }; - } - - return { whereClause: '', params: {} }; + const arrayCondition = generateRegionArrayCondition(region); + return { + whereClause: arrayCondition, + params: {} + }; +} + +/** + * Generates a simple region condition string for streaming queries + * @param region The region name to filter by + * @returns The condition string or empty string if no region specified + */ +export function generateRegionConditionForStreaming(region?: string): string { + if (!region) return ''; + return generateRegionCondition(region); +} + +/** + * Generates a region condition string for streaming queries using origin_path_info + * @param region The region name to filter by + * @returns The condition string or empty string if no region specified + */ +export function generateRegionArrayConditionForStreaming(region?: string): string { + if (!region) return ''; + return generateRegionArrayCondition(region); } diff --git a/src/lib/regions.ts b/src/lib/regions.ts index c353f7c..90a180d 100644 --- a/src/lib/regions.ts +++ b/src/lib/regions.ts @@ -38,3 +38,73 @@ export function getRegionFriendlyNames(): { name: string; friendlyName: string } return REGIONS.map(region => ({ name: region.name, friendlyName: region.friendlyName })); } +/** + * Detects a region from broker and topic combination + * @param broker The MQTT broker URL + * @param topic The MQTT topic + * @returns The region name or null if no match found + */ +export function detectRegionFromBrokerTopic(broker: string | null, topic: string | null): string | null { + if (!broker || !topic) return null; + + // Check each region configuration + for (const region of REGIONS) { + // Check if this topic/broker combination matches the region + if (broker === region.broker && region.topics.includes(topic)) { + return region.name; + } + } + + return null; +} + +/** + * Combined region detection that tries MQTT topics first, then advert data + * @param mqttTopics Array of MQTT topic information + * @param advertBroker Broker from advert data + * @param advertTopic Topic from advert data + * @returns The detected region name or null if no region matches + */ +export function detectRegion(mqttTopics: Array<{ topic: string; broker: string }>, advertBroker: string | null, advertTopic: string | null): string | null { + // First try MQTT topics (more reliable for uplinked nodes) + for (const mqttTopic of mqttTopics) { + const region = detectRegionFromBrokerTopic(mqttTopic.broker, mqttTopic.topic); + if (region) return region; + } + + // Fallback to advert data (works for non-uplinked nodes) + return detectRegionFromBrokerTopic(advertBroker, advertTopic); +} + +/** + * Generates a broker/topic condition string for a region + * @param regionName The region name + * @param alias Optional table alias for the query + * @returns The condition string or empty string if region not found + */ +export function generateRegionCondition(regionName: string, alias: string = ''): string { + const regionConfig = getRegionConfig(regionName); + if (!regionConfig) return ''; + + const prefix = alias ? `${alias}.` : ''; + const topicConditions = regionConfig.topics.map(topic => `${prefix}topic = '${topic}'`); + const topicClause = topicConditions.length > 1 ? `(${topicConditions.join(' OR ')})` : topicConditions[0]; + + return `${prefix}broker = '${regionConfig.broker}' AND ${topicClause}`; +} + +/** + * Generates an array condition string for a region (for origin_path_info fields) + * @param regionName The region name + * @returns The array condition string or empty string if region not found + */ +export function generateRegionArrayCondition(regionName: string): string { + const regionConfig = getRegionConfig(regionName); + if (!regionConfig) return ''; + + const topicConditions = regionConfig.topics.map(topic => `x.5 = '${topic}'`); + const topicClause = topicConditions.length > 1 ? `(${topicConditions.join(' OR ')})` : topicConditions[0]; + + return `arrayExists(x -> x.4 = '${regionConfig.broker}' AND ${topicClause}, origin_path_info)`; +} +