Discord bot, profile picture endpoint, channel management ux, streaming apis, refactor region logic

This commit is contained in:
ajvpot
2025-09-13 02:57:52 +02:00
parent 2afbe80c1b
commit 8ac7d5eece
19 changed files with 2150 additions and 247 deletions

View File

@@ -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

26
Dockerfile.bot Normal file
View File

@@ -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"]

View File

@@ -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

721
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}

205
scripts/README.md Normal file
View File

@@ -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.

215
scripts/discord-bot.ts Normal file
View File

@@ -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<any> {
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 };

190
scripts/lib/discord.ts Normal file
View File

@@ -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<string, string> = new Map(); // message_id -> discord_message_id
constructor(webhookUrl: string) {
this.webhookUrl = webhookUrl;
}
/**
* Post a new message to Discord
*/
async postMessage(message: DiscordWebhookMessage): Promise<DiscordWebhookResponse> {
// 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<DiscordWebhookResponse> {
// 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<DiscordWebhookResponse> {
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<string, string> {
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
};
}

View File

@@ -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<Buffer> {
const size = 512; // Square size
const fontSize = 192; // Font size for the label
// Create SVG string with properly centered text
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" fill="${backgroundColor}"/>
<text
x="50%"
y="50%"
text-anchor="middle"
dominant-baseline="central"
font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif"
font-size="${fontSize}"
fill="white"
>${escapeXml(label)}</text>
</svg>`;
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -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<string, any> = {};
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'
}
});
}

View File

@@ -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<string, any> = {};
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'
}
});
}

View File

@@ -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 && (
<div
className={`border-b border-gray-200 dark:border-neutral-800 ${
startExpanded ? "px-4 py-2" : "mb-2"
}`}
>
<div className="flex gap-1 overflow-x-auto scrollbar-hide">
{allTabs.map((key, idx) => (
<button
key={key.privateKey + idx}
className={`px-2 py-1 text-xs rounded-t font-mono whitespace-nowrap flex-shrink-0 ${
idx === selectedTab
? "bg-gray-100 dark:bg-neutral-800 text-blue-700 dark:text-blue-400 border-b-2 border-blue-500"
: "bg-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-neutral-800"
}`}
onClick={() => setSelectedTab(idx)}
>
{key.channelName || getChannelIdFromKey(key.privateKey).toUpperCase()}
</button>
))}
</div>
<div
className={`border-b border-gray-200 dark:border-neutral-800 ${
startExpanded ? "px-4 py-2" : "mb-2"
}`}
>
<div className="flex gap-1 overflow-x-auto scrollbar-hide">
{allTabs.map((key, idx) => (
<button
key={key.privateKey + idx}
className={`px-2 py-1 text-xs rounded-t font-mono whitespace-nowrap flex-shrink-0 ${
idx === selectedTab
? "bg-gray-100 dark:bg-neutral-800 text-blue-700 dark:text-blue-400 border-b-2 border-blue-500"
: "bg-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-neutral-800"
}`}
onClick={() => setSelectedTab(idx)}
>
{key.channelName || getChannelIdFromKey(key.privateKey).toUpperCase()}
</button>
))}
<button
className="px-2 py-1 text-xs rounded-t whitespace-nowrap flex-shrink-0 bg-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-neutral-800"
onClick={() => openKeyModal()}
title="Manage channel keys"
>
+
</button>
</div>
)}
</div>
<div
className={`flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 ${
@@ -191,7 +195,7 @@ export default function ChatBox({
{/* Messages */}
{(startExpanded ? messages : messages.toReversed()).map((msg, i) => (
<ChatMessageItem
key={`${msg.ingest_timestamp}-${msg.origin_key_path_array?.length || 0}`}
key={`${msg.message_id}-${msg.origin_path_info?.length || 0}`}
msg={msg}
showErrorRow={selectedKey.isAllMessages}
/>

View File

@@ -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
);
});

View File

@@ -88,10 +88,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const openConfig = () => setOpen(true);
const closeConfig = () => setOpen(false);
const openKeyModal = () => setKeyModalOpen(true);
return (
<ConfigContext.Provider value={{ config, setConfig, openConfig, configButtonRef }}>
<ConfigContext.Provider value={{ config, setConfig, openConfig, openKeyModal, configButtonRef }}>
{children}
{open && <ConfigPopover config={config} setConfig={setConfig} onClose={closeConfig} anchorRef={configButtonRef} onOpenKeyModal={() => setKeyModalOpen(true)} />}
{open && <ConfigPopover config={config} setConfig={setConfig} onClose={closeConfig} anchorRef={configButtonRef} onOpenKeyModal={openKeyModal} />}
{keyModalOpen && (
<MeshcoreKeyModal
config={config}
@@ -243,7 +245,7 @@ function ConfigPopover({ config, setConfig, onClose, anchorRef, onOpenKeyModal }
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 w-full"
onClick={onOpenKeyModal}
>
Manage Meshcore Private Keys
Manage Channel Keys
</button>
</div>
<div className="mb-2">
@@ -297,7 +299,7 @@ function validateMeshcoreKey(key: string): string | null {
function MeshcoreKeyModal({ config, setConfig, onClose }: { config: Config, setConfig: (c: Config) => void, onClose: () => void }) {
return (
<Modal isOpen={true} onClose={onClose} title="Meshcore Private Keys" maxWidth="90vw">
<Modal isOpen={true} onClose={onClose} title="Meshcore Channel Keys" maxWidth="90vw">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-300">
These keys will be used to decrypt messages. <b>Your keys are never shared with the server</b>, so your messages remain secure.
</p>
@@ -406,7 +408,7 @@ function MeshcoreKeyModal({ config, setConfig, onClose }: { config: Config, setC
});
}}
>
Add Meshcore Key
Add Channel Key
</button>
</Modal>
);

View File

@@ -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,
};
});
}

View File

@@ -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 {

View File

@@ -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<T = any> {
/** 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<T = any>(config: StreamingConfig) {
const {
queryTemplate,
timeColumn,
pollInterval = 1000,
maxRowsPerPoll = 1000,
additionalWhereClause,
skipInitialMessages = false
} = config;
return async function* streamer(params: StreamingParams = {}): AsyncGenerator<StreamingResult<T>, void, unknown> {
let lastTimestamp: string | null = null;
let isFirstPoll = true;
while (true) {
try {
// Build the query parameters
const queryParams: Record<string, any> = {
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<T> & { 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<string, any>
): 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
};
}

View File

@@ -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);
}

View File

@@ -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)`;
}