From 84a141b28557c1c746b80eae3b197905a6a9d1a5 Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 17:23:31 -0500 Subject: [PATCH 01/10] Add contributor setup files and stop tracking .env --- .env | 181 -- .env.example | 64 + .gitignore | 15 + README.md | 17 + package-lock.json | 4463 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 38 + requirements.txt | 9 + 7 files changed, 4606 insertions(+), 181 deletions(-) delete mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 requirements.txt diff --git a/.env b/.env deleted file mode 100644 index fa574f5..0000000 --- a/.env +++ /dev/null @@ -1,181 +0,0 @@ -################################################################# -## DISCORD BOT SETTINGS ## -################################################################# - -# --- Required --- -DISCORD_TOKEN=your-discord-bot-token-here -CLIENT_ID=your-discord-client-id - -# --- Audio Storage Mode --- -# Options: local (stores in /audio) | s3 (Amazon S3 bucket) -STORAGE_MODE=local - -# --- S3 Settings (only used if STORAGE_MODE=s3) --- -S3_ENDPOINT=https://s3.example.com/ -S3_BUCKET_NAME=scanner-map-bucket -S3_ACCESS_KEY_ID=your-s3-key-id -S3_SECRET_ACCESS_KEY=your-s3-secret-key - - -################################################################# -## SERVER & NETWORK SETTINGS ## -################################################################# - -# Port for SDRTrunk/TrunkRecorder uploads -BOT_PORT=3306 - -# Port for web interface/API server -WEBSERVER_PORT=8080 - -# Public domain or IP for generating playback/share links -PUBLIC_DOMAIN=scannermap.net - -# Timezone for logs & timestamps (use IANA format, e.g. "US/Eastern" or "America/New_York") -TIMEZONE=US/Eastern - - -################################################################# -## AUTHENTICATION & API KEY SETTINGS ## -################################################################# - -# API keys for inbound SDRTrunk uploads -API_KEY_FILE=data/apikeys.json - -# Enable password protection on the web interface -ENABLE_AUTH=false -WEBSERVER_PASSWORD=changeme - - -################################################################# -## GEOCODING & LOCATION SETTINGS ## -################################################################# - -# --- Geocoding Providers (REQUIRED: Set at least one) --- -# These APIs are used for address autocomplete in the web interface and geocoding validation -# You must provide at least one API key for the system to work properly - -# Google Maps API Key -# - Get your key: https://console.cloud.google.com/apis/credentials -# - Enable: Maps JavaScript API, Places API, Geocoding API -GOOGLE_MAPS_API_KEY= - -# LocationIQ API Key -# - Get your key: https://locationiq.com/register -LOCATIONIQ_API_KEY= - -# Default hints to help geocoder resolve incomplete addresses -GEOCODING_CITY="Silver Spring" -GEOCODING_STATE=MD -GEOCODING_COUNTRY=US - -# Restrict matches to specific counties / cities -GEOCODING_TARGET_COUNTIES="Montgomery County" -TARGET_CITIES_LIST=Ashton-Sandy Spring,Aspen Hill,Bethesda,...etc - - -################################################################# -## TRANSCRIPTION SETTINGS ## -################################################################# - -# Provider: local | remote | openai | icad -TRANSCRIPTION_MODE=local - -# --- Local (if TRANSCRIPTION_MODE=local) --- -TRANSCRIPTION_DEVICE=cuda # cuda | cpu - -# --- Remote Faster-Whisper (if TRANSCRIPTION_MODE=remote) --- -FASTER_WHISPER_SERVER_URL=http://127.0.0.1:9912 -WHISPER_MODEL=large-v3-turbo - -# --- ICAD (if TRANSCRIPTION_MODE=icad) --- -ICAD_URL=http://127.0.0.1:9912 -ICAD_API_KEY=your-icad-api-key -ICAD_PROFILE=large|test - -# --- OpenAI Transcription (if TRANSCRIPTION_MODE=openai) --- -OPENAI_API_KEY=your-openai-api-key -OPENAI_TRANSCRIPTION_MODEL=whisper-1 - -# The sampling temperature, between 0 and 1. -# Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. -# If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. -OPENAI_TRANSCRIPTION_TEMPERATURE=0 - -# Custom prompt to improve scanner audio transcription quality -OPENAI_TRANSCRIPTION_PROMPT="Scanner audio: police, fire, EMS radio communications. Transcribe addresses, unit numbers, and emergency details accurately leave black or grabbled audio blank." - -################################################################# -## AI ADDRESS EXTRACTION & SUMMARIES ## -################################################################# - -# AI Provider: ollama | openai -AI_PROVIDER=openai - -# --- Ollama (local LLM) --- -OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=llama3.1:8b - -# --- OpenAI (cloud LLM) --- -# Uses same OPENAI_API_KEY above -OPENAI_MODEL=gpt-4o-mini - -# --- Summaries --- -SUMMARY_LOOKBACK_HOURS=1 -ASK_AI_LOOKBACK_HOURS=8 - - -################################################################# -## TALK GROUP MAPPINGS ## -################################################################# - -ENABLE_MAPPED_TALK_GROUPS=true -MAPPED_TALK_GROUPS=4005,4000,6000,6005,6010 - -# Examples -TALK_GROUP_6010="Silver Spring / Montgomery County MD" -TALK_GROUP_4005="Silver Spring / Montgomery County MD" -TALK_GROUP_6000="Any town in Montgomery County MD" - - -################################################################# -## TWO-TONE DETECTION SETTINGS ## -################################################################# - -ENABLE_TWO_TONE_MODE=false -TWO_TONE_TALK_GROUPS=4005,4000 -TWO_TONE_QUEUE_SIZE=1 - -TONE_DETECTION_TYPE=auto - -# --- Two-tone params --- -TWO_TONE_MIN_TONE_LENGTH=0.7 -TWO_TONE_MAX_TONE_LENGTH=3.0 -TWO_TONE_BW_HZ=50 -TWO_TONE_MIN_PAIR_SEPARATION_HZ=100 - -# --- Pulsed tone params --- -PULSED_MIN_CYCLES=3 -PULSED_MIN_ON_MS=50 -PULSED_MAX_ON_MS=500 -PULSED_MIN_OFF_MS=25 -PULSED_MAX_OFF_MS=800 -PULSED_BANDWIDTH_HZ=50 - -# --- Long tone params --- -LONG_TONE_MIN_LENGTH=0.5 -LONG_TONE_BANDWIDTH_HZ=75 - -# --- General detection --- -TONE_DETECTION_THRESHOLD=0.3 -TONE_FREQUENCY_BAND=300,1500 -TONE_TIME_RESOLUTION_MS=15 - - -################################################################# -## MODE COMBINATIONS (INFO) ## -################################################################# -# 1. MAPPED ONLY: ENABLE_MAPPED_TALK_GROUPS=true, ENABLE_TWO_TONE_MODE=false -# 2. TWO-TONE ONLY: ENABLE_MAPPED_TALK_GROUPS=false, ENABLE_TWO_TONE_MODE=true -# 3. HYBRID: Both true → mapped + tone-based extra -# 4. DISABLED: Both false → transcription only -################################################################# diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6072af9 --- /dev/null +++ b/.env.example @@ -0,0 +1,64 @@ +DISCORD_TOKEN= +BOT_PORT=3306 +WEBSERVER_PORT=3001 +PUBLIC_DOMAIN=localhost +TIMEZONE=US/Eastern + +API_KEY_FILE=data/apikeys.json +ENABLE_AUTH=false +SESSION_DURATION_DAYS=7 +MAX_SESSIONS_PER_USER=5 + +GOOGLE_MAPS_API_KEY= +LOCATIONIQ_API_KEY= + +STORAGE_MODE=local +S3_ENDPOINT= +S3_BUCKET_NAME= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= + +AI_PROVIDER=ollama +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=llama3.1:8b + +TRANSCRIPTION_MODE=local +FASTER_WHISPER_SERVER_URL=http://localhost:8000 +WHISPER_MODEL=large-v3 +TRANSCRIPTION_DEVICE=cpu +PYTHON_COMMAND=python +AUTO_UPDATE_PYTHON_PACKAGES=true + +ICAD_URL= +ICAD_PROFILE= +ICAD_API_KEY= + +OPENAI_TRANSCRIPTION_PROMPT= +OPENAI_TRANSCRIPTION_MODEL= +OPENAI_TRANSCRIPTION_TEMPERATURE= + +MAPPED_TALK_GROUPS= +ENABLE_MAPPED_TALK_GROUPS=true +SUMMARY_LOOKBACK_HOURS=1 +ASK_AI_LOOKBACK_HOURS=8 +MAX_CONCURRENT_TRANSCRIPTIONS=3 + +ENABLE_TWO_TONE_MODE=false +TWO_TONE_TALK_GROUPS= +TWO_TONE_QUEUE_SIZE=1 +TONE_DETECTION_TYPE= +TWO_TONE_MIN_TONE_LENGTH= +TWO_TONE_MAX_TONE_LENGTH= +PULSED_MIN_CYCLES= +PULSED_MIN_ON_MS= +PULSED_MAX_ON_MS= +PULSED_MIN_OFF_MS= +PULSED_MAX_OFF_MS= +PULSED_BANDWIDTH_HZ= +LONG_TONE_MIN_LENGTH= +LONG_TONE_BANDWIDTH_HZ= +TONE_DETECTION_THRESHOLD= +TONE_FREQUENCY_BAND= +TONE_TIME_RESOLUTION_MS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6c180 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +.venv/ +node_modules/ +audio/ +data/ +logs/ +combined.log +error.log +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +*.pyc +__pycache__/ diff --git a/README.md b/README.md index 63fd3c5..4a19530 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,27 @@ source .venv/bin/activate # Linux node bot.js ``` +### Manual contributor setup +If you're developing locally without the installer scripts: + +```bash +npm install +python -m venv .venv +source .venv/bin/activate # Linux +# .venv\Scripts\Activate.ps1 # Windows PowerShell +pip install -r requirements.txt +cp .env.example .env +npm start +``` + +Use `.env.example` as the committed template and keep real secrets only in your local `.env`. + --- ## ⚙️ Configuration +Copy `.env.example` to `.env` and fill in your environment-specific values. Do not commit real `.env` files, API keys, or storage credentials. + All main settings are in `.env`. Key options: - `DISCORD_TOKEN` — your bot token diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..39256e3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4463 @@ +{ + "name": "scanner-map", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scanner-map", + "version": "1.0.0", + "dependencies": { + "@discordjs/voice": "^0.18.0", + "@snazzah/davey": "^0.1.2", + "aws-sdk": "^2.1692.0", + "bcrypt": "^5.1.1", + "busboy": "^1.6.0", + "csv-parser": "^3.2.0", + "discord.js": "^14.20.0", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "form-data": "^4.0.4", + "moment-timezone": "^0.6.0", + "node-cache": "^5.1.2", + "node-fetch": "^2.7.0", + "openai": "^4.104.0", + "opusscript": "^0.0.8", + "prism-media": "^1.3.5", + "public-ip": "^8.0.0", + "socket.io": "^4.8.1", + "sqlite3": "^5.1.7", + "uuid": "^11.1.0", + "winston": "^3.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/builders/node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/formatters/node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/rest/node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util/node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/voice": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.18.0.tgz", + "integrity": "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg==", + "license": "Apache-2.0", + "dependencies": { + "@types/ws": "^8.5.12", + "discord-api-types": "^0.37.103", + "prism-media": "^1.3.5", + "tslib": "^2.6.3", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@snazzah/davey": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.11.tgz", + "integrity": "sha512-oBN+msHzPnm1M5DDx3wVD7iBwpNXFUtkh2MrAbUJu0OhKjliLChi28hq++mu1+qdMpAVQO5JKAvQQxYVbyneiw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "url": "https://github.com/sponsors/Snazzah" + }, + "optionalDependencies": { + "@snazzah/davey-android-arm-eabi": "0.1.11", + "@snazzah/davey-android-arm64": "0.1.11", + "@snazzah/davey-darwin-arm64": "0.1.11", + "@snazzah/davey-darwin-x64": "0.1.11", + "@snazzah/davey-freebsd-x64": "0.1.11", + "@snazzah/davey-linux-arm-gnueabihf": "0.1.11", + "@snazzah/davey-linux-arm64-gnu": "0.1.11", + "@snazzah/davey-linux-arm64-musl": "0.1.11", + "@snazzah/davey-linux-x64-gnu": "0.1.11", + "@snazzah/davey-linux-x64-musl": "0.1.11", + "@snazzah/davey-wasm32-wasi": "0.1.11", + "@snazzah/davey-win32-arm64-msvc": "0.1.11", + "@snazzah/davey-win32-ia32-msvc": "0.1.11", + "@snazzah/davey-win32-x64-msvc": "0.1.11" + } + }, + "node_modules/@snazzah/davey-android-arm-eabi": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.11.tgz", + "integrity": "sha512-T1RYbNYKN6tLOcGIDKJd8OI6FBSEemwL7DOYdTMmhqfhhMr3YVN8WOhfoxGg63OcnpTN2e2c5tdY2bAx25RmQQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-android-arm64": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.11.tgz", + "integrity": "sha512-ksJn/x2VU8h6w9eku1HT96ugSRZ7lKVkKNKbFleaFN+U99DJaPM+gMu2YvnFU4V54HR06ZBnRihnVG6VLXQpDw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-darwin-arm64": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.11.tgz", + "integrity": "sha512-E1d7PbaaVMO3Lj9EiAPqOVbuV0xg5+PsHzHH097DDXiD1+zUDXvJaTnUWsnm5z50pJniHpi4GtaYmk+ieB/guA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-darwin-x64": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.11.tgz", + "integrity": "sha512-Tl4TI/LTmgJZepgbgVMYDi8RqlAkPtPg1OEBPl7a9Tn3AwR36Vs6lyIT1cs/lGy/ds/+B+mKI4rPObN1cyILTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-freebsd-x64": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.11.tgz", + "integrity": "sha512-T8Iw9FXkuI1T+YBAFzh9v/TXf9IOTOSqnd/BFpTRTrlW72PR2lhIidzSmg027VxO7r5pX47iFwiOkb9I/NU/EA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-linux-arm-gnueabihf": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.11.tgz", + "integrity": "sha512-1Txj+8pqA8uq/OGtaUaBFWAPnNMQzFgIywj0iA7EI4xZl+mab48/pv+YZ1pNb/suC6ynsW44oB9efiXSdcUAgA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-linux-arm64-gnu": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.11.tgz", + "integrity": "sha512-ERzF5nM/IYW1BcN3wLXpEwBCGLFf0kGJUVhaV6yfiInz0tkU8UmvrrgpaMaACfMjIhfWdq5CcX+aTkXo/saNcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-linux-arm64-musl": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.11.tgz", + "integrity": "sha512-e6pX6Hiabtz99q+H/YHNkm9JVlpqN8HGh0qPib8G2+UY4/SSH8WvqWipk3v581dMy2oyCHt7MOoY1aU1P1N/xA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-linux-x64-gnu": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.11.tgz", + "integrity": "sha512-TW5bSoqChOJMbvsDb4wAATYrxmAXuNnse7wFNVSAJUaZKSeRfZbu3UAiPWSNn7GwLwSfU6hg322KZUn8IWCuvg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-linux-x64-musl": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.11.tgz", + "integrity": "sha512-5j6Pmc+Wzv5lSxVP6quA7teYRJXibkZqQyYGfTDnTsUOO5dPpcojpqlXlkhyvsA1OAQTj4uxbOCciN3cVWwzug==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-wasm32-wasi": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.11.tgz", + "integrity": "sha512-rKOwZ/0J8lp+4VEyOdMDBRP9KR+PksZpa9V1Qn0veMzy4FqTVKthkxwGqewheFe0SFg9fdvt798l/PBFrfDeZw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@snazzah/davey-win32-arm64-msvc": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.11.tgz", + "integrity": "sha512-5fptJU4tX901m3mj0SHiBljMrPT4ZEsynbBhR7bK1yn9TY1jjyhN8EFi7QF5IWtUEni+0mia2BCMHZ5ZkmFZqQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-win32-ia32-msvc": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.11.tgz", + "integrity": "sha512-ualexn8SeLsiMHhWfzVrzRcjHgcBapg++FPaVgJJxoh2S/jCRiklXOu3luqIZdJdNKvhe2V9SwO/cImPeIIBKw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@snazzah/davey-win32-x64-msvc": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.11.tgz", + "integrity": "sha512-muNhc8UKXtknzsH/w4AIkbPR2I8BuvApn0pDXar0IEvY8PCjqU/M8MPbOOEYwQVvQRMwVTgExtxzrkBPSXB4nA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sdk": { + "version": "2.1693.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1693.0.tgz", + "integrity": "sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==", + "deprecated": "The AWS SDK for JavaScript (v2) has reached end-of-support, and no longer receives updates. Please migrate your code to use AWS SDK for JavaScript (v3). More info https://a.co/cUPnyil", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-regexp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz", + "integrity": "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==", + "license": "MIT", + "dependencies": { + "is-regexp": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csv-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.1.tgz", + "integrity": "sha512-v8RPMSglouR9od735SnwSxLBbCJqEPSbgm1R5qfr8yIiMUCEFjox56kRZid0SvgHJEkxeIEu3+a9QS3YRh7CuA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", + "license": "MIT" + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dns-socket": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/dns-socket/-/dns-socket-4.2.2.tgz", + "integrity": "sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.4" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", + "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "license": "MIT", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-0.1.1.tgz", + "integrity": "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", + "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ip": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-5.0.1.tgz", + "integrity": "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==", + "license": "MIT", + "dependencies": { + "ip-regex": "^5.0.0", + "super-regex": "^0.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.2.tgz", + "integrity": "sha512-lDsQv8FoGdBUdf0+TjGsq2orxKuXdwFlQ6Zw6TX3xIcTwTfEpCLyKqvEauvCHJ8iu3KBV8+uPhlv70YsNGdUBQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT" + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "license": "Apache-2.0", + "peerDependencies": { + "@discordjs/opus": ">=0.8.0 <1.0.0", + "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", + "node-opus": "^0.3.3", + "opusscript": "^0.0.8" + }, + "peerDependenciesMeta": { + "@discordjs/opus": { + "optional": true + }, + "ffmpeg-static": { + "optional": true + }, + "node-opus": { + "optional": true + }, + "opusscript": { + "optional": true + } + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/public-ip": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/public-ip/-/public-ip-8.0.0.tgz", + "integrity": "sha512-XzVyz98rNQiTRciAC+I4w45fWWxM9KKedDGNtH4unPwBcWo2Y9n7kgPXqlTiWqKN0EFlIIU1i8yrWOy9mxgZ8g==", + "license": "MIT", + "dependencies": { + "dns-socket": "^4.2.2", + "is-ip": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/sqlite3/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/super-regex": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz", + "integrity": "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==", + "license": "MIT", + "dependencies": { + "clone-regexp": "^3.0.0", + "function-timeout": "^0.1.0", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "license": "MIT", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..615c553 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "scanner-map", + "version": "1.0.0", + "private": true, + "description": "Real-time mapping system for radio calls with transcription, geocoding, and Discord integration.", + "main": "bot.js", + "scripts": { + "start": "node bot.js", + "web": "node webserver.js", + "import-talkgroups": "node import_csv.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@discordjs/voice": "^0.18.0", + "@snazzah/davey": "^0.1.2", + "aws-sdk": "^2.1692.0", + "bcrypt": "^5.1.1", + "busboy": "^1.6.0", + "csv-parser": "^3.2.0", + "discord.js": "^14.20.0", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "form-data": "^4.0.4", + "moment-timezone": "^0.6.0", + "node-cache": "^5.1.2", + "node-fetch": "^2.7.0", + "openai": "^4.104.0", + "opusscript": "^0.0.8", + "prism-media": "^1.3.5", + "public-ip": "^8.0.0", + "socket.io": "^4.8.1", + "sqlite3": "^5.1.7", + "uuid": "^11.1.0", + "winston": "^3.18.3" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..edc127b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +boto3 +faster-whisper +icad-tone-detection +numpy +pydub +python-dotenv +torch +torchaudio +torchvision From 24362f23b21d05a0bfbe356a70bb2b97a37d0226 Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 19:05:52 -0500 Subject: [PATCH 02/10] Add architecture foundation modules --- .github/workflows/ci.yml | 30 +++++ demo/sample-calls.json | 35 ++++++ docs/modernization-roadmap.md | 58 ++++++++++ package.json | 6 +- scripts/check-config.js | 20 ++++ scripts/generate-demo-data.js | 46 ++++++++ src/config/index.js | 197 +++++++++++++++++++++++++++++++++ src/db/migrations.js | 123 ++++++++++++++++++++ src/ingestion/normalizeCall.js | 116 +++++++++++++++++++ src/permissions/roles.js | 28 +++++ test/config.test.js | 41 +++++++ test/ingestion.test.js | 49 ++++++++ test/migrations.test.js | 18 +++ test/permissions.test.js | 16 +++ 14 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 demo/sample-calls.json create mode 100644 docs/modernization-roadmap.md create mode 100644 scripts/check-config.js create mode 100644 scripts/generate-demo-data.js create mode 100644 src/config/index.js create mode 100644 src/db/migrations.js create mode 100644 src/ingestion/normalizeCall.js create mode 100644 src/permissions/roles.js create mode 100644 test/config.test.js create mode 100644 test/ingestion.test.js create mode 100644 test/migrations.test.js create mode 100644 test/permissions.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7798712 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + smoke: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Syntax check + run: npm run check:syntax + + - name: Unit tests + run: npm test diff --git a/demo/sample-calls.json b/demo/sample-calls.json new file mode 100644 index 0000000..3dcc716 --- /dev/null +++ b/demo/sample-calls.json @@ -0,0 +1,35 @@ +[ + { + "id": 1, + "talk_group_id": "1001", + "timestamp": 1779033180, + "transcription": "Engine 12 responding to a medical call near Main Street and Oak Avenue.", + "audio_file_path": "", + "address": "Main Street and Oak Avenue", + "lat": 39.083997, + "lon": -77.152758, + "category": "Medical Call" + }, + { + "id": 2, + "talk_group_id": "2001", + "timestamp": 1779033360, + "transcription": "Units checking a vehicle collision near the northbound ramp.", + "audio_file_path": "", + "address": "Northbound ramp", + "lat": 39.099721, + "lon": -77.184516, + "category": "Vehicle Collision" + }, + { + "id": 3, + "talk_group_id": "3001", + "timestamp": 1779033540, + "transcription": "Police responding for a disturbance at the shopping center.", + "audio_file_path": "", + "address": "Shopping center", + "lat": 39.045753, + "lon": -77.118741, + "category": "Disturbance" + } +] diff --git a/docs/modernization-roadmap.md b/docs/modernization-roadmap.md new file mode 100644 index 0000000..b5d0dea --- /dev/null +++ b/docs/modernization-roadmap.md @@ -0,0 +1,58 @@ +# Scanner Map Modernization Roadmap + +This roadmap breaks the larger architecture work into reviewable PRs. Each phase should preserve current behavior while creating room for deeper changes. + +## Phase 1: Foundations + +- Add shared config parsing and validation. +- Add a migration module that can replace scattered table creation over time. +- Add ingestion normalization helpers for SDRTrunk, TrunkRecorder, and rdio-scanner compatible uploads. +- Add a local demo data generator. +- Add smoke tests and CI. +- Add role and permission primitives that can back future RBAC. + +## Phase 2: Runtime Integration + +- Replace scattered `process.env` reads in `bot.js`, `webserver.js`, and `geocoding.js` with the shared config module. +- Move database initialization to the migration runner. +- Route upload handling through the ingestion normalization helpers. +- Keep the old endpoint behavior intact while shrinking request-handler complexity. + +## Phase 3: Reliable Processing Queue + +- Persist call processing jobs in SQLite or a dedicated queue backend. +- Track job state: pending, processing, failed, complete, and retryable. +- Retry transcription, geocoding, categorization, Discord publishing, and storage steps independently. +- Add admin visibility for queue depth, failed jobs, and processing latency. + +## Phase 4: Local Demo And Developer Mode + +- Add a demo server mode that serves sample calls without SDRTrunk, TrunkRecorder, Discord, geocoding keys, or audio hardware. +- Add sample talkgroups, categories, and map markers. +- Make frontend work possible with one command. + +## Phase 5: Frontend Modules + +- Split `public/app.js` into modules for map setup, markers, audio playback, live feed, auth, talkgroup modal, purge modal, and geocoding search. +- Gate verbose browser logging behind a debug flag. +- Add targeted browser smoke tests once the local demo mode exists. + +## Phase 6: Data Model And Retention + +- Add schema versioning and repeatable migrations. +- Add indexes for common call history, talkgroup, timestamp, and category queries. +- Add configurable retention rules for calls and audio. +- Add database maintenance docs for long-running deployments. + +## Phase 7: Roles And Permissions + +- Add a `role` column for users and migrate existing admin users. +- Replace ad hoc admin checks with permission checks. +- Introduce viewer, editor, moderator, and admin roles. +- Add UI controls only when the current user has the matching permission. + +## Phase 8: Adapter Architecture + +- Formalize ingestion adapters for SDRTrunk, TrunkRecorder, and rdio-scanner compatible uploads. +- Add adapter tests with real-world fixture payloads. +- Make future upload sources additive instead of route-handler rewrites. diff --git a/package.json b/package.json index 615c553..9d729a7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "scripts": { "start": "node bot.js", "web": "node webserver.js", - "import-talkgroups": "node import_csv.js" + "import-talkgroups": "node import_csv.js", + "check:config": "node scripts/check-config.js", + "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js", + "demo:data": "node scripts/generate-demo-data.js", + "test": "node --test test/*.test.js" }, "engines": { "node": ">=18" diff --git a/scripts/check-config.js b/scripts/check-config.js new file mode 100644 index 0000000..bea382d --- /dev/null +++ b/scripts/check-config.js @@ -0,0 +1,20 @@ +try { + require('dotenv').config(); +} catch { + // Allows this checker to run before npm install; CI still installs dependencies. +} + +const { loadConfig, redactConfig } = require('../src/config'); + +const result = loadConfig(process.env); + +if (!result.isValid) { + console.error('Configuration validation failed:'); + for (const error of result.errors) { + console.error(`- ${error.key}: ${error.message}`); + } + process.exit(1); +} + +console.log('Configuration looks valid.'); +console.log(JSON.stringify(redactConfig(result.config), null, 2)); diff --git a/scripts/generate-demo-data.js b/scripts/generate-demo-data.js new file mode 100644 index 0000000..968bbed --- /dev/null +++ b/scripts/generate-demo-data.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); + +const outputDir = path.join(__dirname, '..', 'data'); +const outputFile = path.join(outputDir, 'demo-calls.json'); + +const now = Math.floor(Date.now() / 1000); +const calls = [ + { + id: 1, + talk_group_id: '1001', + timestamp: now - 420, + transcription: 'Engine 12 responding to a medical call near Main Street and Oak Avenue.', + audio_file_path: '', + address: 'Main Street and Oak Avenue', + lat: 39.083997, + lon: -77.152758, + category: 'Medical Call' + }, + { + id: 2, + talk_group_id: '2001', + timestamp: now - 240, + transcription: 'Units checking a vehicle collision near the northbound ramp.', + audio_file_path: '', + address: 'Northbound ramp', + lat: 39.099721, + lon: -77.184516, + category: 'Vehicle Collision' + }, + { + id: 3, + talk_group_id: '3001', + timestamp: now - 60, + transcription: 'Police responding for a disturbance at the shopping center.', + audio_file_path: '', + address: 'Shopping center', + lat: 39.045753, + lon: -77.118741, + category: 'Disturbance' + } +]; + +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(outputFile, `${JSON.stringify(calls, null, 2)}\n`); +console.log(`Wrote ${calls.length} demo calls to ${outputFile}`); diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..cb603ab --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,197 @@ +const DEFAULTS = { + botPort: 3306, + webserverPort: 3001, + publicDomain: 'localhost', + timezone: 'US/Eastern', + apiKeyFile: 'data/apikeys.json', + enableAuth: false, + sessionDurationDays: 7, + maxSessionsPerUser: 5, + storageMode: 'local', + aiProvider: 'ollama', + openaiModel: 'gpt-4o-mini', + ollamaUrl: 'http://localhost:11434', + ollamaModel: 'llama3.1:8b', + transcriptionMode: 'local', + whisperModel: 'large-v3', + transcriptionDevice: 'cpu', + pythonCommand: 'python', + autoUpdatePythonPackages: true, + summaryLookbackHours: 1, + askAiLookbackHours: 8, + maxConcurrentTranscriptions: 3, + enableMappedTalkGroups: true, + enableTwoToneMode: false, + twoToneQueueSize: 1 +}; + +const SECRET_KEYS = new Set([ + 'discordToken', + 'googleMapsApiKey', + 'locationIqApiKey', + 's3AccessKeyId', + 's3SecretAccessKey', + 'openaiApiKey', + 'icadApiKey', + 'webserverPassword' +]); + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase()); +} + +function parseNumber(value, fallback, { integer = false, min = undefined } = {}) { + if (value === undefined || value === null || value === '') return fallback; + const parsed = integer ? parseInt(value, 10) : parseFloat(value); + if (Number.isNaN(parsed)) return fallback; + if (min !== undefined && parsed < min) return fallback; + return parsed; +} + +function parseList(value) { + if (!value) return []; + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function requireWhen(errors, condition, key, message) { + if (condition) errors.push({ key, message }); +} + +function loadConfig(env = process.env) { + const config = { + discordToken: env.DISCORD_TOKEN || '', + clientId: env.CLIENT_ID || '', + botPort: parseNumber(env.BOT_PORT, DEFAULTS.botPort, { integer: true, min: 1 }), + webserverPort: parseNumber(env.WEBSERVER_PORT, DEFAULTS.webserverPort, { integer: true, min: 1 }), + publicDomain: env.PUBLIC_DOMAIN || DEFAULTS.publicDomain, + timezone: env.TIMEZONE || DEFAULTS.timezone, + apiKeyFile: env.API_KEY_FILE || DEFAULTS.apiKeyFile, + enableAuth: parseBoolean(env.ENABLE_AUTH, DEFAULTS.enableAuth), + webserverPassword: env.WEBSERVER_PASSWORD || '', + sessionDurationDays: parseNumber(env.SESSION_DURATION_DAYS, DEFAULTS.sessionDurationDays, { integer: true, min: 1 }), + maxSessionsPerUser: parseNumber(env.MAX_SESSIONS_PER_USER, DEFAULTS.maxSessionsPerUser, { integer: true, min: 1 }), + googleMapsApiKey: env.GOOGLE_MAPS_API_KEY || '', + locationIqApiKey: env.LOCATIONIQ_API_KEY || '', + storageMode: (env.STORAGE_MODE || DEFAULTS.storageMode).toLowerCase(), + s3Endpoint: env.S3_ENDPOINT || '', + s3BucketName: env.S3_BUCKET_NAME || '', + s3AccessKeyId: env.S3_ACCESS_KEY_ID || '', + s3SecretAccessKey: env.S3_SECRET_ACCESS_KEY || '', + aiProvider: (env.AI_PROVIDER || DEFAULTS.aiProvider).toLowerCase(), + openaiApiKey: env.OPENAI_API_KEY || '', + openaiModel: env.OPENAI_MODEL || DEFAULTS.openaiModel, + ollamaUrl: env.OLLAMA_URL || DEFAULTS.ollamaUrl, + ollamaModel: env.OLLAMA_MODEL || DEFAULTS.ollamaModel, + transcriptionMode: (env.TRANSCRIPTION_MODE || DEFAULTS.transcriptionMode).toLowerCase(), + fasterWhisperServerUrl: env.FASTER_WHISPER_SERVER_URL || '', + whisperModel: env.WHISPER_MODEL || DEFAULTS.whisperModel, + transcriptionDevice: (env.TRANSCRIPTION_DEVICE || DEFAULTS.transcriptionDevice).toLowerCase(), + pythonCommand: env.PYTHON_COMMAND || DEFAULTS.pythonCommand, + autoUpdatePythonPackages: parseBoolean(env.AUTO_UPDATE_PYTHON_PACKAGES, DEFAULTS.autoUpdatePythonPackages), + icadUrl: env.ICAD_URL || '', + icadProfile: env.ICAD_PROFILE || '', + icadApiKey: env.ICAD_API_KEY || '', + openaiTranscriptionPrompt: env.OPENAI_TRANSCRIPTION_PROMPT || '', + openaiTranscriptionModel: env.OPENAI_TRANSCRIPTION_MODEL || '', + openaiTranscriptionTemperature: env.OPENAI_TRANSCRIPTION_TEMPERATURE || '', + mappedTalkGroups: parseList(env.MAPPED_TALK_GROUPS), + enableMappedTalkGroups: parseBoolean(env.ENABLE_MAPPED_TALK_GROUPS, DEFAULTS.enableMappedTalkGroups), + summaryLookbackHours: parseNumber(env.SUMMARY_LOOKBACK_HOURS, DEFAULTS.summaryLookbackHours, { min: 0 }), + askAiLookbackHours: parseNumber(env.ASK_AI_LOOKBACK_HOURS, DEFAULTS.askAiLookbackHours, { min: 0 }), + maxConcurrentTranscriptions: parseNumber(env.MAX_CONCURRENT_TRANSCRIPTIONS, DEFAULTS.maxConcurrentTranscriptions, { integer: true, min: 1 }), + enableTwoToneMode: parseBoolean(env.ENABLE_TWO_TONE_MODE, DEFAULTS.enableTwoToneMode), + twoToneTalkGroups: parseList(env.TWO_TONE_TALK_GROUPS), + twoToneQueueSize: parseNumber(env.TWO_TONE_QUEUE_SIZE, DEFAULTS.twoToneQueueSize, { integer: true, min: 1 }), + toneDetectionType: env.TONE_DETECTION_TYPE || '', + twoToneMinToneLength: env.TWO_TONE_MIN_TONE_LENGTH || '', + twoToneMaxToneLength: env.TWO_TONE_MAX_TONE_LENGTH || '', + pulsedMinCycles: env.PULSED_MIN_CYCLES || '', + pulsedMinOnMs: env.PULSED_MIN_ON_MS || '', + pulsedMaxOnMs: env.PULSED_MAX_ON_MS || '', + pulsedMinOffMs: env.PULSED_MIN_OFF_MS || '', + pulsedMaxOffMs: env.PULSED_MAX_OFF_MS || '', + pulsedBandwidthHz: env.PULSED_BANDWIDTH_HZ || '', + longToneMinLength: env.LONG_TONE_MIN_LENGTH || '', + longToneBandwidthHz: env.LONG_TONE_BANDWIDTH_HZ || '', + toneDetectionThreshold: env.TONE_DETECTION_THRESHOLD || '', + toneFrequencyBand: env.TONE_FREQUENCY_BAND || '', + toneTimeResolutionMs: env.TONE_TIME_RESOLUTION_MS || '' + }; + + const errors = validateConfig(config); + return { config, errors, isValid: errors.length === 0 }; +} + +function validateConfig(config) { + const errors = []; + const storageModes = new Set(['local', 's3']); + const aiProviders = new Set(['ollama', 'openai']); + const transcriptionModes = new Set(['local', 'remote', 'openai', 'icad']); + const transcriptionDevices = new Set(['cpu', 'cuda']); + + requireWhen(errors, !storageModes.has(config.storageMode), 'STORAGE_MODE', 'Must be local or s3.'); + requireWhen(errors, !aiProviders.has(config.aiProvider), 'AI_PROVIDER', 'Must be ollama or openai.'); + requireWhen(errors, !transcriptionModes.has(config.transcriptionMode), 'TRANSCRIPTION_MODE', 'Must be local, remote, openai, or icad.'); + requireWhen(errors, !transcriptionDevices.has(config.transcriptionDevice), 'TRANSCRIPTION_DEVICE', 'Must be cpu or cuda.'); + + requireWhen(errors, config.enableAuth && !config.webserverPassword, 'WEBSERVER_PASSWORD', 'Required when ENABLE_AUTH=true.'); + requireWhen(errors, config.storageMode === 's3' && !config.s3Endpoint, 'S3_ENDPOINT', 'Required when STORAGE_MODE=s3.'); + requireWhen(errors, config.storageMode === 's3' && !config.s3BucketName, 'S3_BUCKET_NAME', 'Required when STORAGE_MODE=s3.'); + requireWhen(errors, config.storageMode === 's3' && !config.s3AccessKeyId, 'S3_ACCESS_KEY_ID', 'Required when STORAGE_MODE=s3.'); + requireWhen(errors, config.storageMode === 's3' && !config.s3SecretAccessKey, 'S3_SECRET_ACCESS_KEY', 'Required when STORAGE_MODE=s3.'); + requireWhen(errors, config.aiProvider === 'openai' && !config.openaiApiKey, 'OPENAI_API_KEY', 'Required when AI_PROVIDER=openai.'); + requireWhen(errors, config.aiProvider === 'ollama' && !config.ollamaUrl, 'OLLAMA_URL', 'Required when AI_PROVIDER=ollama.'); + requireWhen(errors, config.aiProvider === 'ollama' && !config.ollamaModel, 'OLLAMA_MODEL', 'Required when AI_PROVIDER=ollama.'); + requireWhen(errors, config.transcriptionMode === 'remote' && !config.fasterWhisperServerUrl, 'FASTER_WHISPER_SERVER_URL', 'Required when TRANSCRIPTION_MODE=remote.'); + requireWhen(errors, config.transcriptionMode === 'openai' && !config.openaiApiKey, 'OPENAI_API_KEY', 'Required when TRANSCRIPTION_MODE=openai.'); + requireWhen(errors, config.transcriptionMode === 'icad' && !config.icadUrl, 'ICAD_URL', 'Required when TRANSCRIPTION_MODE=icad.'); + + const toneKeys = [ + ['TWO_TONE_TALK_GROUPS', config.twoToneTalkGroups.length > 0], + ['TONE_DETECTION_TYPE', config.toneDetectionType], + ['TWO_TONE_MIN_TONE_LENGTH', config.twoToneMinToneLength], + ['TWO_TONE_MAX_TONE_LENGTH', config.twoToneMaxToneLength], + ['PULSED_MIN_CYCLES', config.pulsedMinCycles], + ['PULSED_MIN_ON_MS', config.pulsedMinOnMs], + ['PULSED_MAX_ON_MS', config.pulsedMaxOnMs], + ['PULSED_MIN_OFF_MS', config.pulsedMinOffMs], + ['PULSED_MAX_OFF_MS', config.pulsedMaxOffMs], + ['PULSED_BANDWIDTH_HZ', config.pulsedBandwidthHz], + ['LONG_TONE_MIN_LENGTH', config.longToneMinLength], + ['LONG_TONE_BANDWIDTH_HZ', config.longToneBandwidthHz], + ['TONE_DETECTION_THRESHOLD', config.toneDetectionThreshold], + ['TONE_FREQUENCY_BAND', config.toneFrequencyBand], + ['TONE_TIME_RESOLUTION_MS', config.toneTimeResolutionMs] + ]; + + if (config.enableTwoToneMode) { + for (const [key, value] of toneKeys) { + requireWhen(errors, !value, key, 'Required when ENABLE_TWO_TONE_MODE=true.'); + } + } + + return errors; +} + +function redactConfig(config) { + return Object.fromEntries( + Object.entries(config).map(([key, value]) => { + if (SECRET_KEYS.has(key) && value) return [key, '[redacted]']; + return [key, value]; + }) + ); +} + +module.exports = { + DEFAULTS, + loadConfig, + parseBoolean, + parseList, + parseNumber, + redactConfig, + validateConfig +}; diff --git a/src/db/migrations.js b/src/db/migrations.js new file mode 100644 index 0000000..45e97b0 --- /dev/null +++ b/src/db/migrations.js @@ -0,0 +1,123 @@ +const BASE_MIGRATIONS = [ + { + id: '001_create_core_tables', + statements: [ + `CREATE TABLE IF NOT EXISTS transcriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + talk_group_id TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + transcription TEXT, + audio_file_path TEXT, + address TEXT, + lat REAL, + lon REAL, + category TEXT + )`, + `CREATE TABLE IF NOT EXISTS global_keywords ( + keyword TEXT UNIQUE, + talk_group_id TEXT + )`, + `CREATE TABLE IF NOT EXISTS talk_groups ( + id TEXT PRIMARY KEY, + hex TEXT, + alpha_tag TEXT, + mode TEXT, + description TEXT, + tag TEXT, + county TEXT + )`, + `CREATE TABLE IF NOT EXISTS frequencies ( + id INTEGER PRIMARY KEY, + frequency TEXT, + description TEXT + )`, + `CREATE TABLE IF NOT EXISTS audio_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transcription_id INTEGER, + audio_data BLOB, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) + )` + ] + }, + { + id: '002_create_auth_tables', + requires: ({ enableAuth }) => enableAuth, + statements: [ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, + ip_address TEXT, + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )` + ] + } +]; + +function getMigrationPlan(options = {}) { + return BASE_MIGRATIONS.filter((migration) => { + if (!migration.requires) return true; + return migration.requires(options); + }); +} + +function run(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function onRun(err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +function all(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + +async function applyMigrations(db, options = {}) { + await run(db, `CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + + const appliedRows = await all(db, 'SELECT id FROM schema_migrations'); + const applied = new Set(appliedRows.map((row) => row.id)); + const appliedNow = []; + + for (const migration of getMigrationPlan(options)) { + if (applied.has(migration.id)) continue; + + for (const statement of migration.statements) { + await run(db, statement); + } + + await run(db, 'INSERT INTO schema_migrations (id) VALUES (?)', [migration.id]); + appliedNow.push(migration.id); + } + + return appliedNow; +} + +module.exports = { + BASE_MIGRATIONS, + applyMigrations, + getMigrationPlan +}; diff --git a/src/ingestion/normalizeCall.js b/src/ingestion/normalizeCall.js new file mode 100644 index 0000000..9fecb7c --- /dev/null +++ b/src/ingestion/normalizeCall.js @@ -0,0 +1,116 @@ +function parseJsonField(value, fallback = null) { + if (!value || typeof value !== 'string') return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function extractSourceFromFilename(filename) { + if (!filename) return undefined; + const match = filename.match(/FROM_(\d+)/); + return match ? match[1] : undefined; +} + +function normalizeSdrTrunkCall(fields = {}, fileInfo = {}) { + const filenameSource = extractSourceFromFilename(fileInfo.originalFilename); + + return { + provider: 'sdrtrunk', + filename: fileInfo.originalFilename || '', + talkGroupID: fields.talkgroup || fields.talk_group_id || '', + systemName: fields.systemLabel || fields.system || '', + talkGroupName: fields.talkgroupLabel || fields.talkgroupName || '', + talkGroupGroup: fields.talkgroupGroup || '', + dateTime: fields.dateTime || fields.start_time || '', + source: fields.source || filenameSource || '', + talkerAlias: fields.talkerAlias || '', + frequency: fields.frequency || '', + metadata: { ...fields }, + isTrunkRecorder: false + }; +} + +function enrichTrunkRecorderFields(fields = {}) { + const enriched = { ...fields }; + const metaData = parseJsonField(fields.meta, {}); + + if (metaData && typeof metaData === 'object') { + const directCopies = [ + 'freq', + 'freq_error', + 'signal', + 'noise', + 'emergency', + 'priority', + 'encrypted', + 'call_length', + 'start_time', + 'stop_time', + 'tdma_slot', + 'phase2_tdma', + 'color_code' + ]; + + for (const key of directCopies) { + if (metaData[key] !== undefined && enriched[key] === undefined) { + enriched[key === 'freq' ? 'frequency' : key] = metaData[key]; + } + } + + if (Array.isArray(metaData.srcList) && metaData.srcList.length > 0) { + const validSource = metaData.srcList.find((src) => src.src && src.src !== -1); + if (validSource) { + enriched.source = enriched.source || String(validSource.src); + if (validSource.tag && String(validSource.tag).trim()) { + enriched.talkerAlias = enriched.talkerAlias || String(validSource.tag).trim(); + } + } + enriched.srcList = enriched.srcList || JSON.stringify(metaData.srcList); + } + + if (Array.isArray(metaData.freqList)) { + enriched.freqList = enriched.freqList || JSON.stringify(metaData.freqList); + } + } + + return enriched; +} + +function normalizeTrunkRecorderCall(fields = {}, fileInfo = {}) { + const enriched = enrichTrunkRecorderFields(fields); + + return { + provider: 'trunk-recorder', + filename: fileInfo.originalFilename || enriched.filename || '', + talkGroupID: enriched.talkgroup || enriched.talk_group_id || enriched.talkGroupID || '', + systemName: enriched.system || enriched.systemName || enriched.systemLabel || '', + talkGroupName: enriched.talkgroupLabel || enriched.talkgroupName || enriched.talkGroupName || '', + talkGroupGroup: enriched.talkgroupGroup || '', + dateTime: enriched.dateTime || enriched.start_time || '', + source: enriched.source || '', + talkerAlias: enriched.talkerAlias || '', + frequency: enriched.frequency || enriched.freq || '', + metadata: enriched, + isTrunkRecorder: true + }; +} + +function normalizeIncomingCall({ source, fields = {}, fileInfo = {} } = {}) { + if (source === 'sdrtrunk') return normalizeSdrTrunkCall(fields, fileInfo); + if (source === 'trunk-recorder' || source === 'rdio-scanner') { + return normalizeTrunkRecorderCall(fields, fileInfo); + } + + return normalizeTrunkRecorderCall(fields, fileInfo); +} + +module.exports = { + enrichTrunkRecorderFields, + extractSourceFromFilename, + normalizeIncomingCall, + normalizeSdrTrunkCall, + normalizeTrunkRecorderCall, + parseJsonField +}; diff --git a/src/permissions/roles.js b/src/permissions/roles.js new file mode 100644 index 0000000..6fc7c44 --- /dev/null +++ b/src/permissions/roles.js @@ -0,0 +1,28 @@ +const ROLES = { + VIEWER: 'viewer', + EDITOR: 'editor', + MODERATOR: 'moderator', + ADMIN: 'admin' +}; + +const ROLE_PERMISSIONS = { + [ROLES.VIEWER]: ['calls:read', 'audio:read'], + [ROLES.EDITOR]: ['calls:read', 'audio:read', 'markers:update'], + [ROLES.MODERATOR]: ['calls:read', 'audio:read', 'markers:update', 'calls:purge'], + [ROLES.ADMIN]: ['calls:read', 'audio:read', 'markers:update', 'calls:purge', 'users:manage', 'sessions:manage'] +}; + +function permissionsForRole(role) { + return ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS[ROLES.VIEWER]; +} + +function hasPermission(role, permission) { + return permissionsForRole(role).includes(permission); +} + +module.exports = { + ROLES, + ROLE_PERMISSIONS, + hasPermission, + permissionsForRole +}; diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..9ef6d3e --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,41 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { loadConfig, parseBoolean, parseList, redactConfig } = require('../src/config'); + +test('parseBoolean accepts common truthy values', () => { + assert.equal(parseBoolean('true'), true); + assert.equal(parseBoolean('1'), true); + assert.equal(parseBoolean('yes'), true); + assert.equal(parseBoolean('false'), false); +}); + +test('parseList trims and drops empty entries', () => { + assert.deepEqual(parseList('1001, 1002, ,2001'), ['1001', '1002', '2001']); +}); + +test('loadConfig reports conditional validation errors together', () => { + const result = loadConfig({ + STORAGE_MODE: 's3', + AI_PROVIDER: 'openai', + TRANSCRIPTION_MODE: 'remote' + }); + + assert.equal(result.isValid, false); + assert.deepEqual( + result.errors.map((error) => error.key), + ['S3_ENDPOINT', 'S3_BUCKET_NAME', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'OPENAI_API_KEY', 'FASTER_WHISPER_SERVER_URL'] + ); +}); + +test('redactConfig hides secret values', () => { + const redacted = redactConfig({ + discordToken: 'secret', + openaiApiKey: 'secret', + publicDomain: 'localhost' + }); + + assert.equal(redacted.discordToken, '[redacted]'); + assert.equal(redacted.openaiApiKey, '[redacted]'); + assert.equal(redacted.publicDomain, 'localhost'); +}); diff --git a/test/ingestion.test.js b/test/ingestion.test.js new file mode 100644 index 0000000..ad5315b --- /dev/null +++ b/test/ingestion.test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + extractSourceFromFilename, + normalizeIncomingCall, + normalizeTrunkRecorderCall +} = require('../src/ingestion/normalizeCall'); + +test('extractSourceFromFilename reads SDRTrunk FROM source IDs', () => { + assert.equal(extractSourceFromFilename('CALL_FROM_123456_TO_1001.mp3'), '123456'); + assert.equal(extractSourceFromFilename('call.mp3'), undefined); +}); + +test('normalizeIncomingCall maps SDRTrunk fields to the internal call shape', () => { + const call = normalizeIncomingCall({ + source: 'sdrtrunk', + fileInfo: { originalFilename: 'CALL_FROM_55_TO_1001.mp3' }, + fields: { + talkgroup: '1001', + systemLabel: 'County', + talkgroupLabel: 'Fire Dispatch', + dateTime: '2026-05-17T12:00:00Z' + } + }); + + assert.equal(call.provider, 'sdrtrunk'); + assert.equal(call.talkGroupID, '1001'); + assert.equal(call.source, '55'); + assert.equal(call.isTrunkRecorder, false); +}); + +test('normalizeTrunkRecorderCall extracts source and alias from meta srcList', () => { + const call = normalizeTrunkRecorderCall({ + talkgroup: '2001', + meta: JSON.stringify({ + start_time: 1779030000, + freq: 853000000, + srcList: [{ src: -1 }, { src: 9901, tag: 'Unit 12' }], + freqList: [{ freq: 853000000 }] + }) + }); + + assert.equal(call.provider, 'trunk-recorder'); + assert.equal(call.talkGroupID, '2001'); + assert.equal(call.source, '9901'); + assert.equal(call.talkerAlias, 'Unit 12'); + assert.equal(call.frequency, 853000000); +}); diff --git a/test/migrations.test.js b/test/migrations.test.js new file mode 100644 index 0000000..5bbe56b --- /dev/null +++ b/test/migrations.test.js @@ -0,0 +1,18 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { getMigrationPlan } = require('../src/db/migrations'); + +test('migration plan includes core tables by default', () => { + assert.deepEqual( + getMigrationPlan({ enableAuth: false }).map((migration) => migration.id), + ['001_create_core_tables'] + ); +}); + +test('migration plan includes auth tables when auth is enabled', () => { + assert.deepEqual( + getMigrationPlan({ enableAuth: true }).map((migration) => migration.id), + ['001_create_core_tables', '002_create_auth_tables'] + ); +}); diff --git a/test/permissions.test.js b/test/permissions.test.js new file mode 100644 index 0000000..4669ebc --- /dev/null +++ b/test/permissions.test.js @@ -0,0 +1,16 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { ROLES, hasPermission, permissionsForRole } = require('../src/permissions/roles'); + +test('admin can manage users', () => { + assert.equal(hasPermission(ROLES.ADMIN, 'users:manage'), true); +}); + +test('viewer cannot update markers', () => { + assert.equal(hasPermission(ROLES.VIEWER, 'markers:update'), false); +}); + +test('unknown roles fall back to viewer permissions', () => { + assert.deepEqual(permissionsForRole('unknown'), ['calls:read', 'audio:read']); +}); From fd99cdd07845eb7596c64f79ad7cade851454981 Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 20:45:45 -0500 Subject: [PATCH 03/10] Wire foundation modules into runtime paths --- bot.js | 156 ++++++++++++--------------------- src/ingestion/normalizeCall.js | 13 ++- test/ingestion.test.js | 13 +++ webserver.js | 18 +++- 4 files changed, 90 insertions(+), 110 deletions(-) diff --git a/bot.js b/bot.js index d97112e..7f674af 100644 --- a/bot.js +++ b/bot.js @@ -1,6 +1,9 @@ // bot.js - Main Discord bot application with integrated webserver and initialization require('dotenv').config(); +const { loadConfig } = require('./src/config'); +const { applyMigrations } = require('./src/db/migrations'); +const { normalizeIncomingCall } = require('./src/ingestion/normalizeCall'); // Get environment variables first, before any usage const { @@ -69,6 +72,15 @@ const { TONE_TIME_RESOLUTION_MS } = process.env; +const startupConfig = loadConfig(process.env); +if (!startupConfig.isValid) { + console.error('FATAL: Invalid configuration:'); + for (const error of startupConfig.errors) { + console.error(`- ${error.key}: ${error.message}`); + } + process.exit(1); +} + // --- VALIDATE AI-RELATED ENV VARS --- if (!AI_PROVIDER) { console.error("FATAL: AI_PROVIDER is not set in the .env file. Please specify 'ollama' or 'openai'."); @@ -944,92 +956,17 @@ function ensureApiKey() { } // Function to initialize database tables -function initializeDatabase() { - return new Promise((resolve, reject) => { - logger.info('Initializing database tables...'); - - db.serialize(() => { - let tablesCreated = 0; - let totalTables = ENABLE_AUTH?.toLowerCase() === 'true' ? 7 : 5; - - const tableCreated = (err, tableName) => { - if (err) { - logger.error(`Error creating ${tableName} table:`, err); - reject(err); - return; - } - tablesCreated++; - if (tablesCreated === totalTables) { - logger.info('Database tables initialized successfully.'); - resolve(); - } - }; - - db.run(`CREATE TABLE IF NOT EXISTS transcriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - talk_group_id TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - transcription TEXT, - audio_file_path TEXT, - address TEXT, - lat REAL, - lon REAL, - category TEXT - )`, (err) => tableCreated(err, 'transcriptions')); - - db.run(`CREATE TABLE IF NOT EXISTS global_keywords ( - keyword TEXT UNIQUE, - talk_group_id TEXT - )`, (err) => tableCreated(err, 'global_keywords')); - - db.run(`CREATE TABLE IF NOT EXISTS talk_groups ( - id TEXT PRIMARY KEY, - hex TEXT, - alpha_tag TEXT, - mode TEXT, - description TEXT, - tag TEXT, - county TEXT - )`, (err) => tableCreated(err, 'talk_groups')); - - db.run(`CREATE TABLE IF NOT EXISTS frequencies ( - id INTEGER PRIMARY KEY, - frequency TEXT, - description TEXT - )`, (err) => tableCreated(err, 'frequencies')); - - db.run(`CREATE TABLE IF NOT EXISTS audio_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - transcription_id INTEGER, - audio_data BLOB, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) - )`, (err) => tableCreated(err, 'audio_files')); - - // Authentication tables (if auth is enabled) - if (ENABLE_AUTH?.toLowerCase() === 'true') { - db.run(`CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - salt TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, (err) => tableCreated(err, 'users')); - - db.run(`CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - token TEXT UNIQUE NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL, - last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, - ip_address TEXT, - user_agent TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, (err) => tableCreated(err, 'sessions')); - } - }); +async function initializeDatabase() { + logger.info('Initializing database tables...'); + const applied = await applyMigrations(db, { + enableAuth: ENABLE_AUTH?.toLowerCase() === 'true' }); + + if (applied.length > 0) { + logger.info(`Applied database migrations: ${applied.join(', ')}`); + } else { + logger.info('Database schema already up to date.'); + } } // Function to create admin user if authentication is enabled @@ -1456,18 +1393,24 @@ app.post('/api/call-upload', (req, res) => { logger.info(`Received SDRTrunk audio: ${customFilename}`); + const normalizedCall = normalizeIncomingCall({ + source: 'sdrtrunk', + fields, + fileInfo + }); + handleNewAudio({ filename: customFilename, path: saveTo, - talkGroupID: fields.talkgroup, - systemName: fields.systemLabel, - talkGroupName: fields.talkgroupLabel, - dateTime: fields.dateTime, // Pass the original fields.dateTime for SDRTrunk - source: fields.source, - talkerAlias: fields.talkerAlias, // Add talkerAlias field from SDRTrunk - frequency: fields.frequency, - talkGroupGroup: fields.talkgroupGroup, - isTrunkRecorder: false + talkGroupID: normalizedCall.talkGroupID, + systemName: normalizedCall.systemName, + talkGroupName: normalizedCall.talkGroupName, + dateTime: normalizedCall.dateTime, + source: normalizedCall.source, + talkerAlias: normalizedCall.talkerAlias, + frequency: normalizedCall.frequency, + talkGroupGroup: normalizedCall.talkGroupGroup, + isTrunkRecorder: normalizedCall.isTrunkRecorder }); return sendResponse(200, 'Call imported successfully.'); @@ -1781,17 +1724,26 @@ app.post('/api/call-upload', (req, res) => { // Log fields before passing to handleNewAudio logger.info(`[UPLOAD] Preparing to call handleNewAudio, fields.srcList=${fields.srcList ? (typeof fields.srcList === 'string' ? `string(${fields.srcList.length} chars)` : `object`) : 'null/undefined'}, fields.freqList=${fields.freqList ? 'exists' : 'null/undefined'}`); + const normalizedCall = normalizeIncomingCall({ + source: inferredSourceSystem === 'rdio-scanner' ? 'rdio-scanner' : 'trunk-recorder', + fields: { + ...fields, + dateTime: Math.floor(callDateTime.getTime() / 1000) + }, + fileInfo + }); + handleNewAudio({ filename: customFilename, path: saveTo, - talkGroupID: fields.talkgroup, - systemName: fields.systemLabel, - talkGroupName: fields.talkgroupLabel, + talkGroupID: normalizedCall.talkGroupID, + systemName: normalizedCall.systemName, + talkGroupName: normalizedCall.talkGroupName, dateTime: Math.floor(callDateTime.getTime() / 1000), // Pass Unix timestamp (seconds) - source: fields.source, - talkerAlias: fields.talkerAlias, // <-- OTA alias from Trunk Recorder - frequency: fields.frequency, - talkGroupGroup: fields.talkgroupGroup, + source: normalizedCall.source, + talkerAlias: normalizedCall.talkerAlias, // <-- OTA alias from Trunk Recorder + frequency: normalizedCall.frequency, + talkGroupGroup: normalizedCall.talkGroupGroup, // Detect TrunkRecorder more reliably: check for TrunkRecorder-specific fields isTrunkRecorder: inferredSourceSystem === 'TrunkRecorder' || (fields.srcList && fields.srcList.trim() !== '' && fields.srcList.trim() !== '[]') || @@ -6497,4 +6449,4 @@ process.on('SIGINT', () => { process.exit(0); }); }); -}); \ No newline at end of file +}); diff --git a/src/ingestion/normalizeCall.js b/src/ingestion/normalizeCall.js index 9fecb7c..c77d97d 100644 --- a/src/ingestion/normalizeCall.js +++ b/src/ingestion/normalizeCall.js @@ -78,11 +78,12 @@ function enrichTrunkRecorderFields(fields = {}) { return enriched; } -function normalizeTrunkRecorderCall(fields = {}, fileInfo = {}) { +function normalizeTrunkRecorderCall(fields = {}, fileInfo = {}, options = {}) { const enriched = enrichTrunkRecorderFields(fields); + const provider = options.provider || 'trunk-recorder'; return { - provider: 'trunk-recorder', + provider, filename: fileInfo.originalFilename || enriched.filename || '', talkGroupID: enriched.talkgroup || enriched.talk_group_id || enriched.talkGroupID || '', systemName: enriched.system || enriched.systemName || enriched.systemLabel || '', @@ -93,16 +94,20 @@ function normalizeTrunkRecorderCall(fields = {}, fileInfo = {}) { talkerAlias: enriched.talkerAlias || '', frequency: enriched.frequency || enriched.freq || '', metadata: enriched, - isTrunkRecorder: true + isTrunkRecorder: provider === 'trunk-recorder' }; } function normalizeIncomingCall({ source, fields = {}, fileInfo = {} } = {}) { if (source === 'sdrtrunk') return normalizeSdrTrunkCall(fields, fileInfo); - if (source === 'trunk-recorder' || source === 'rdio-scanner') { + if (source === 'trunk-recorder') { return normalizeTrunkRecorderCall(fields, fileInfo); } + if (source === 'rdio-scanner') { + return normalizeTrunkRecorderCall(fields, fileInfo, { provider: 'rdio-scanner' }); + } + return normalizeTrunkRecorderCall(fields, fileInfo); } diff --git a/test/ingestion.test.js b/test/ingestion.test.js index ad5315b..04e094b 100644 --- a/test/ingestion.test.js +++ b/test/ingestion.test.js @@ -47,3 +47,16 @@ test('normalizeTrunkRecorderCall extracts source and alias from meta srcList', ( assert.equal(call.talkerAlias, 'Unit 12'); assert.equal(call.frequency, 853000000); }); + +test('normalizeIncomingCall preserves rdio-scanner as a non-TrunkRecorder provider', () => { + const call = normalizeIncomingCall({ + source: 'rdio-scanner', + fields: { + talkgroup: '3001', + dateTime: '2026-05-17T12:00:00Z' + } + }); + + assert.equal(call.provider, 'rdio-scanner'); + assert.equal(call.isTrunkRecorder, false); +}); diff --git a/webserver.js b/webserver.js index 0e628f8..e24e14f 100644 --- a/webserver.js +++ b/webserver.js @@ -1,7 +1,8 @@ // webserver.js - Web interface for viewing and managing calls with optional authentication -require('dotenv').config(); -const AWS = require('aws-sdk'); // Add AWS SDK +require('dotenv').config(); +const { loadConfig } = require('./src/config'); +const AWS = require('aws-sdk'); // Add AWS SDK const express = require('express'); const sqlite3 = require('sqlite3').verbose(); @@ -40,7 +41,16 @@ const { OPENAI_MODEL = 'gpt-4o-mini', // A good, fast, and cheap model for this task OLLAMA_URL = 'http://localhost:11434', OLLAMA_MODEL = 'llama3.1:8b' -} = process.env; +} = process.env; + +const startupConfig = loadConfig(process.env); +if (!startupConfig.isValid) { + console.error('ERROR: Invalid configuration:'); + for (const error of startupConfig.errors) { + console.error(`- ${error.key}: ${error.message}`); + } + process.exit(1); +} // Validate required environment variables const requiredVars = ['WEBSERVER_PORT', 'PUBLIC_DOMAIN']; @@ -1804,4 +1814,4 @@ process.on('SIGINT', () => { process.exit(0); }); }); -}); \ No newline at end of file +}); From 95172dec0d002e6eef406c479fd9d12145fbdf7a Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 20:51:19 -0500 Subject: [PATCH 04/10] Add persistent processing job records --- bot.js | 53 ++++++++++++++- src/db/migrations.js | 27 +++++++- src/jobs/processingJobs.js | 129 ++++++++++++++++++++++++++++++++++++ test/migrations.test.js | 4 +- test/processingJobs.test.js | 25 +++++++ 5 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/jobs/processingJobs.js create mode 100644 test/processingJobs.test.js diff --git a/bot.js b/bot.js index 7f674af..987ea4f 100644 --- a/bot.js +++ b/bot.js @@ -4,6 +4,13 @@ require('dotenv').config(); const { loadConfig } = require('./src/config'); const { applyMigrations } = require('./src/db/migrations'); const { normalizeIncomingCall } = require('./src/ingestion/normalizeCall'); +const { + JOB_TYPES, + createProcessingJob, + markJobCompleted, + markJobFailed, + markJobProcessing +} = require('./src/jobs/processingJobs'); // Get environment variables first, before any usage const { @@ -2901,6 +2908,12 @@ function handleNewAudio(audioData) { phase2_tdma, color_code } = audioData; + + const safelyUpdateProcessingJob = (actionDescription, updateFn) => { + updateFn().catch((jobError) => { + logger.warn(`Could not ${actionDescription}: ${jobError.message}`); + }); + }; // Log srcList for debugging logger.info(`[handleNewAudio] Received audio data for ${filename}, srcList=${srcList ? (typeof srcList === 'string' ? `string(${srcList.substring(0, 100)}...)` : `object`) : 'null/undefined'}, isTrunkRecorder=${isTrunkRecorder}`); @@ -3011,7 +3024,7 @@ function handleNewAudio(audioData) { db.run( `INSERT INTO transcriptions (talk_group_id, timestamp, transcription, audio_file_path, address, lat, lon) VALUES (?, ?, ?, ?, NULL, NULL, NULL)`, [talkGroupID, unixTimestampSeconds, '', storagePath], // Use the Unix timestamp - function (err) { + async function (err) { if (err) { logger.error(`Error inserting initial transcription record for ${filename}:`, err); // If DB insert fails, delete the temp file @@ -3022,8 +3035,25 @@ function handleNewAudio(audioData) { } const transcriptionId = this.lastID; // Get the ID from the database insert + let transcriptionJobId = null; logger.info(`Created transcription record ID ${transcriptionId} using storage path: ${storagePath}`); + try { + transcriptionJobId = await createProcessingJob(db, { + transcriptionId, + jobType: JOB_TYPES.TRANSCRIPTION, + payload: { + filename, + talkGroupID, + transcriptionMode: effectiveTranscriptionMode, + storageMode: STORAGE_MODE + } + }); + logger.info(`Created transcription job ${transcriptionJobId} for transcription ID ${transcriptionId}`); + } catch (jobError) { + logger.warn(`Could not create transcription job for ID ${transcriptionId}: ${jobError.message}`); + } + // Conditionally insert audio blob for Listen Live feature (local storage only) if (STORAGE_MODE !== 's3') { db.run( @@ -3164,6 +3194,12 @@ function handleNewAudio(audioData) { logger.warn(warningMsg); updateTranscription(transcriptionId, "", async () => { logger.info(`Updated DB with empty transcription for ID ${transcriptionId}`); + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job completed', () => markJobCompleted(db, transcriptionJobId, { + empty: true, + reason: 'no_transcription' + })); + } // *** IMPORTANT: Check for two-tone even with empty transcription *** // Tone files might contain only tones without voice content @@ -3234,6 +3270,12 @@ function handleNewAudio(audioData) { }); }); } + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job completed', () => markJobCompleted(db, transcriptionJobId, { + empty: false, + transcriptionLength: transcriptionText.length + })); + } logger.info(`Successfully processed: ${filename}`); }); }; @@ -3241,6 +3283,9 @@ function handleNewAudio(audioData) { // --- Choose transcription method based on mode --- logger.info(`Initiating transcription for ID ${transcriptionId} using mode: ${effectiveTranscriptionMode}`); + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job processing', () => markJobProcessing(db, transcriptionJobId)); + } if (effectiveTranscriptionMode === 'openai') { // OpenAI API transcription mode @@ -3373,6 +3418,9 @@ function handleNewAudio(audioData) { logger.error(`Error uploading audio to S3 for transcription ID ${transcriptionId} (key: ${storagePath}):`, s3Err); } // If S3 upload fails, should we delete the DB record? + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job failed', () => markJobFailed(db, transcriptionJobId, s3Err)); + } db.run('DELETE FROM transcriptions WHERE id = ?', [transcriptionId], () => {}); fs.unlink(tempPath, (errUnlink) => { // Delete temp file on S3 error if (errUnlink) logger.error(`Error deleting temp file after S3 upload error ${tempPath}:`, errUnlink); @@ -3390,6 +3438,9 @@ function handleNewAudio(audioData) { if (renameErr) { logger.error(`Error moving temp file ${tempPath} to final location ${finalLocalPath}:`, renameErr); // If rename fails, delete DB record and original temp file + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job failed', () => markJobFailed(db, transcriptionJobId, renameErr)); + } db.run('DELETE FROM transcriptions WHERE id = ?', [transcriptionId], () => {}); fs.unlink(tempPath, (errUnlink) => { if (errUnlink) logger.error(`Error deleting temp file after rename error ${tempPath}:`, errUnlink); diff --git a/src/db/migrations.js b/src/db/migrations.js index 45e97b0..36e519a 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -36,7 +36,7 @@ const BASE_MIGRATIONS = [ transcription_id INTEGER, audio_data BLOB, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) + FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) ON DELETE SET NULL )` ] }, @@ -64,6 +64,31 @@ const BASE_MIGRATIONS = [ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )` ] + }, + { + id: '003_create_call_jobs', + statements: [ + `CREATE TABLE IF NOT EXISTS call_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transcription_id INTEGER, + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + priority INTEGER NOT NULL DEFAULT 0, + run_after DATETIME, + payload_json TEXT, + result_json TEXT, + last_error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + completed_at DATETIME, + FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) + )`, + `CREATE INDEX IF NOT EXISTS idx_call_jobs_status_priority ON call_jobs (status, priority DESC, created_at ASC)`, + `CREATE INDEX IF NOT EXISTS idx_call_jobs_transcription_type ON call_jobs (transcription_id, job_type)` + ] } ]; diff --git a/src/jobs/processingJobs.js b/src/jobs/processingJobs.js new file mode 100644 index 0000000..345fb1e --- /dev/null +++ b/src/jobs/processingJobs.js @@ -0,0 +1,129 @@ +const JOB_TYPES = { + TRANSCRIPTION: 'transcription', + ADDRESS_EXTRACTION: 'address_extraction', + GEOCODING: 'geocoding', + DISCORD_PUBLISH: 'discord_publish' +}; + +const JOB_STATUS = { + PENDING: 'pending', + PROCESSING: 'processing', + COMPLETED: 'completed', + FAILED: 'failed', + RETRYABLE: 'retryable' +}; + +function serializeJson(value) { + if (value === undefined) return null; + return JSON.stringify(value); +} + +function parseJson(value, fallback = null) { + if (!value) return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function run(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function onRun(err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +function get(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +async function createProcessingJob(db, { + transcriptionId, + jobType, + payload = {}, + priority = 0, + maxAttempts = 3, + runAfter = null +}) { + const result = await run( + db, + `INSERT INTO call_jobs ( + transcription_id, job_type, status, priority, max_attempts, run_after, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + transcriptionId, + jobType, + JOB_STATUS.PENDING, + priority, + maxAttempts, + runAfter, + serializeJson(payload) + ] + ); + + return result.lastID; +} + +async function markJobProcessing(db, jobId) { + await run( + db, + `UPDATE call_jobs + SET status = ?, attempts = attempts + 1, started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [JOB_STATUS.PROCESSING, jobId] + ); +} + +async function markJobCompleted(db, jobId, result = {}) { + await run( + db, + `UPDATE call_jobs + SET status = ?, result_json = ?, completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [JOB_STATUS.COMPLETED, serializeJson(result), jobId] + ); +} + +async function markJobFailed(db, jobId, error, { retryable = false } = {}) { + const status = retryable ? JOB_STATUS.RETRYABLE : JOB_STATUS.FAILED; + const message = error instanceof Error ? error.message : String(error || 'Unknown error'); + + await run( + db, + `UPDATE call_jobs + SET status = ?, last_error = ?, completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [status, message, jobId] + ); +} + +async function getJobById(db, jobId) { + const row = await get(db, 'SELECT * FROM call_jobs WHERE id = ?', [jobId]); + if (!row) return null; + + return { + ...row, + payload: parseJson(row.payload_json, {}), + result: parseJson(row.result_json, null) + }; +} + +module.exports = { + JOB_STATUS, + JOB_TYPES, + createProcessingJob, + getJobById, + markJobCompleted, + markJobFailed, + markJobProcessing, + parseJson, + serializeJson +}; diff --git a/test/migrations.test.js b/test/migrations.test.js index 5bbe56b..3c1fd3e 100644 --- a/test/migrations.test.js +++ b/test/migrations.test.js @@ -6,13 +6,13 @@ const { getMigrationPlan } = require('../src/db/migrations'); test('migration plan includes core tables by default', () => { assert.deepEqual( getMigrationPlan({ enableAuth: false }).map((migration) => migration.id), - ['001_create_core_tables'] + ['001_create_core_tables', '003_create_call_jobs'] ); }); test('migration plan includes auth tables when auth is enabled', () => { assert.deepEqual( getMigrationPlan({ enableAuth: true }).map((migration) => migration.id), - ['001_create_core_tables', '002_create_auth_tables'] + ['001_create_core_tables', '002_create_auth_tables', '003_create_call_jobs'] ); }); diff --git a/test/processingJobs.test.js b/test/processingJobs.test.js new file mode 100644 index 0000000..14d1984 --- /dev/null +++ b/test/processingJobs.test.js @@ -0,0 +1,25 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + JOB_STATUS, + JOB_TYPES, + parseJson, + serializeJson +} = require('../src/jobs/processingJobs'); + +test('job constants define the first durable processing states', () => { + assert.equal(JOB_TYPES.TRANSCRIPTION, 'transcription'); + assert.equal(JOB_STATUS.PENDING, 'pending'); + assert.equal(JOB_STATUS.PROCESSING, 'processing'); + assert.equal(JOB_STATUS.COMPLETED, 'completed'); +}); + +test('serializeJson and parseJson preserve payload objects', () => { + const payload = { transcriptionId: 42, mode: 'local' }; + assert.deepEqual(parseJson(serializeJson(payload)), payload); +}); + +test('parseJson returns fallback for invalid JSON', () => { + assert.deepEqual(parseJson('{bad json', { ok: false }), { ok: false }); +}); From 3855458a7df98ef0bbea6c72a235a6a2dd534faa Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 22:07:40 -0500 Subject: [PATCH 05/10] Add setup console and settings APIs --- package.json | 2 +- public/index.html | 1 + public/settings.html | 82 +++++++ public/settings.js | 77 +++++++ public/setup.css | 227 ++++++++++++++++++++ public/setup.html | 118 +++++++++++ public/setup.js | 128 +++++++++++ src/db/migrations.js | 27 ++- src/jobs/processingJobs.js | 67 ++++++ src/settings/settingsService.js | 277 ++++++++++++++++++++++++ src/setup/checks.js | 94 ++++++++ test/migrations.test.js | 4 +- test/processingJobs.test.js | 42 ++++ test/settingsService.test.js | 100 +++++++++ test/setupChecks.test.js | 18 ++ webserver.js | 365 ++++++++++++++++++++++++-------- 16 files changed, 1534 insertions(+), 95 deletions(-) create mode 100644 public/settings.html create mode 100644 public/settings.js create mode 100644 public/setup.css create mode 100644 public/setup.html create mode 100644 public/setup.js create mode 100644 src/settings/settingsService.js create mode 100644 src/setup/checks.js create mode 100644 test/settingsService.test.js create mode 100644 test/setupChecks.test.js diff --git a/package.json b/package.json index 9d729a7..13b2119 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "web": "node webserver.js", "import-talkgroups": "node import_csv.js", "check:config": "node scripts/check-config.js", - "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js", + "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js && node --check public/setup.js && node --check public/settings.js", "demo:data": "node scripts/generate-demo-data.js", "test": "node --test test/*.test.js" }, diff --git a/public/index.html b/public/index.html index 809b2fe..094eb9c 100644 --- a/public/index.html +++ b/public/index.html @@ -482,6 +482,7 @@ Add User View Users Manage Sessions + Settings Console Call Purge diff --git a/public/settings.html b/public/settings.html new file mode 100644 index 0000000..89224b5 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,82 @@ + + + + + + Scanner Map Settings + + + +
+
+
+

Scanner Map Settings

+

Manage runtime settings, write-only secrets, and setup diagnostics.

+
+ Back to Map +
+ +
+ + +
+
+

General

+
+
+
+
+
+
+
+ +
+

Ingestion

+
+
+
+
+
+
+ +
+

Providers

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

Diagnostics

+
+ + +
+
+
+ +
+ + +
+
+
+
+ + + diff --git a/public/settings.js b/public/settings.js new file mode 100644 index 0000000..47d24ae --- /dev/null +++ b/public/settings.js @@ -0,0 +1,77 @@ +const normalKeys = [ + 'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours', + 'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode', + 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', + 'fasterWhisperServerUrl' +]; +const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey']; + +function showStep(id) { + document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id)); + document.querySelectorAll('.step-button').forEach((button) => button.classList.toggle('active', button.dataset.step === id)); +} + +document.querySelectorAll('.step-button').forEach((button) => button.addEventListener('click', () => showStep(button.dataset.step))); + +async function jsonFetch(url, options = {}) { + const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || response.statusText); + return data; +} + +async function loadSettings() { + const data = await jsonFetch('/api/settings'); + for (const key of normalKeys) { + const input = document.getElementById(key); + if (input && data.settings[key]) input.value = data.settings[key].value; + } + for (const key of secretKeys) { + const input = document.getElementById(key); + if (input && data.secrets[key]?.configured) input.placeholder = 'Configured - enter a new value to replace'; + } +} + +document.getElementById('save-settings').addEventListener('click', async () => { + const result = document.getElementById('save-result'); + try { + const payload = {}; + for (const key of normalKeys) { + const input = document.getElementById(key); + if (input) payload[key] = input.value; + } + const saved = await jsonFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }); + + for (const key of secretKeys) { + const input = document.getElementById(key); + if (input && input.value) { + await jsonFetch(`/api/settings/secrets/${key}`, { method: 'PUT', body: JSON.stringify({ value: input.value }) }); + input.value = ''; + input.placeholder = 'Configured - enter a new value to replace'; + } + } + + result.textContent = saved.requiresRestart ? 'Saved. Restart required for some changes.' : 'Saved.'; + } catch (error) { + result.textContent = error.message; + } +}); + +document.getElementById('run-diagnostics').addEventListener('click', async () => { + const output = document.getElementById('diagnostic-output'); + const checks = await jsonFetch('/api/settings/checks'); + output.innerHTML = `
${JSON.stringify(checks, null, 2)}
`; +}); + +document.getElementById('load-jobs').addEventListener('click', async () => { + const output = document.getElementById('diagnostic-output'); + const [summary, recent] = await Promise.all([ + jsonFetch('/api/jobs/summary'), + jsonFetch('/api/jobs/recent?limit=10') + ]); + output.innerHTML = `
${JSON.stringify({ summary, recent }, null, 2)}
`; +}); + +loadSettings().catch((error) => { + document.getElementById('save-result').textContent = error.message; +}); diff --git a/public/setup.css b/public/setup.css new file mode 100644 index 0000000..51eaa2d --- /dev/null +++ b/public/setup.css @@ -0,0 +1,227 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Segoe UI", Tahoma, sans-serif; + color: #17202a; + background: + linear-gradient(135deg, rgba(19, 83, 91, 0.12), rgba(238, 183, 76, 0.14)), + #f5f7f8; +} + +.setup-shell { + max-width: 1180px; + margin: 0 auto; + padding: 32px 20px 48px; +} + +.setup-header { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + margin-bottom: 24px; +} + +.setup-header h1 { + margin: 0 0 8px; + font-size: 34px; + letter-spacing: 0; +} + +.setup-header p { + margin: 0; + color: #52616b; + max-width: 680px; +} + +.status-pill { + border: 1px solid #cbd7dd; + background: #ffffff; + border-radius: 999px; + padding: 8px 14px; + white-space: nowrap; + font-weight: 600; +} + +.layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 18px; +} + +.steps, +.panel { + background: rgba(255, 255, 255, 0.92); + border: 1px solid #d8e1e6; + border-radius: 8px; + box-shadow: 0 18px 42px rgba(21, 39, 52, 0.08); +} + +.steps { + padding: 10px; + height: fit-content; +} + +.step-button { + width: 100%; + border: 0; + border-radius: 6px; + background: transparent; + color: #344955; + padding: 12px; + text-align: left; + font-weight: 700; + cursor: pointer; +} + +.step-button.active { + background: #0f4c5c; + color: #fff; +} + +.panel { + padding: 24px; + min-height: 520px; +} + +.section { + display: none; +} + +.section.active { + display: block; +} + +.section h2 { + margin: 0 0 8px; + font-size: 24px; +} + +.section p { + color: #52616b; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.field { + display: grid; + gap: 6px; +} + +.field label { + font-weight: 700; + color: #2c3f4b; +} + +.field input, +.field select { + min-height: 42px; + border: 1px solid #bdcbd2; + border-radius: 6px; + padding: 9px 11px; + font-size: 15px; + background: #fff; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +button.primary, +button.secondary { + border: 0; + border-radius: 6px; + padding: 11px 15px; + font-weight: 800; + cursor: pointer; +} + +button.primary { + background: #0f4c5c; + color: white; +} + +button.secondary { + background: #e8eef1; + color: #1f3440; +} + +.check-list, +.result-list { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.check-row, +.result-row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + align-items: start; + border: 1px solid #d8e1e6; + border-radius: 6px; + padding: 12px; + background: #fbfcfd; +} + +.badge { + display: inline-block; + width: fit-content; + border-radius: 999px; + padding: 4px 9px; + font-size: 12px; + font-weight: 800; +} + +.ok { + color: #0d5f3c; + background: #dff5ea; +} + +.warn { + color: #8a5600; + background: #fff0cf; +} + +.error { + color: #8a1f1f; + background: #ffe0df; +} + +code { + display: inline-block; + max-width: 100%; + padding: 3px 6px; + border-radius: 4px; + background: #edf2f4; + overflow-wrap: anywhere; +} + +@media (max-width: 780px) { + .setup-header, + .layout, + .grid { + display: block; + } + + .steps { + margin-bottom: 14px; + } + + .status-pill { + margin-top: 12px; + display: inline-block; + } +} diff --git a/public/setup.html b/public/setup.html new file mode 100644 index 0000000..abc6824 --- /dev/null +++ b/public/setup.html @@ -0,0 +1,118 @@ + + + + + + Scanner Map Setup + + + +
+
+
+

Scanner Map Setup

+

Configure the instance, verify dependencies, and finish first-run setup from the browser.

+
+
Checking setup...
+
+ +
+ + +
+
+

Installer Checks

+

Scanner Map verifies dependencies and shows exact commands for anything missing. The web app does not run privileged installer commands.

+
+ +
+
+
+ +
+

Admin Account

+

Create or update the local admin account used for protected setup and settings screens.

+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

Providers

+

Set the core runtime choices and write-only secrets. Existing secret values are never shown back.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+

Finish Setup

+

Complete setup after the required account, upload key, geocoding, transcription, and storage settings are configured.

+
+ + Open Map + Open Settings +
+
+
+
+
+
+ + + diff --git a/public/setup.js b/public/setup.js new file mode 100644 index 0000000..30a1540 --- /dev/null +++ b/public/setup.js @@ -0,0 +1,128 @@ +const sections = document.querySelectorAll('.section'); +const buttons = document.querySelectorAll('.step-button'); + +function showStep(id) { + sections.forEach((section) => section.classList.toggle('active', section.id === id)); + buttons.forEach((button) => button.classList.toggle('active', button.dataset.step === id)); +} + +function renderMessage(targetId, message, type = 'ok') { + const target = document.getElementById(targetId); + target.innerHTML = `
${type}
${message}
`; +} + +function labelFor(value) { + return value.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); +} + +async function jsonFetch(url, options = {}) { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...options + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || response.statusText); + return data; +} + +async function loadStatus() { + const status = await jsonFetch('/api/setup/status'); + const el = document.getElementById('setup-status'); + el.textContent = status.setupComplete ? 'Setup complete' : `Missing: ${status.missing.join(', ') || 'review'}`; + el.className = `status-pill ${status.setupComplete ? 'ok' : 'warn'}`; +} + +async function runChecks() { + const checks = await jsonFetch('/api/setup/checks'); + const rows = Object.entries(checks).map(([key, check]) => { + const command = check.installCommand ? `
Install: ${check.installCommand}
` : ''; + const detail = check.version || check.error || check.url || ''; + return `
+ ${check.ok ? 'ok' : (check.optional ? 'optional' : 'missing')} +
${labelFor(key)}
${detail}
${command}
+
`; + }).join(''); + document.getElementById('checks-list').innerHTML = rows; +} + +buttons.forEach((button) => button.addEventListener('click', () => showStep(button.dataset.step))); + +document.getElementById('run-checks').addEventListener('click', () => { + runChecks().catch((error) => renderMessage('checks-list', error.message, 'error')); +}); + +document.getElementById('save-admin').addEventListener('click', async () => { + const password = document.getElementById('admin-password').value; + const confirm = document.getElementById('confirm-password').value; + if (password !== confirm) return renderMessage('admin-result', 'Passwords do not match.', 'error'); + try { + await jsonFetch('/api/setup/admin', { + method: 'POST', + body: JSON.stringify({ username: 'admin', password }) + }); + renderMessage('admin-result', 'Admin account saved.'); + await loadStatus(); + } catch (error) { + renderMessage('admin-result', error.message, 'error'); + } +}); + +document.getElementById('save-providers').addEventListener('click', async () => { + try { + await jsonFetch('/api/setup/settings', { + method: 'POST', + body: JSON.stringify({ + storageMode: document.getElementById('storage-mode').value, + transcriptionMode: document.getElementById('transcription-mode').value, + aiProvider: document.getElementById('ai-provider').value, + timezone: document.getElementById('timezone').value + }) + }); + + const uploadKey = document.getElementById('upload-key').value; + if (uploadKey) { + await jsonFetch('/api/setup/secrets', { + method: 'POST', + body: JSON.stringify({ key: 'uploadApiKey', value: uploadKey }) + }); + } + + const geocodeKey = document.getElementById('geocode-key').value; + if (geocodeKey) { + await jsonFetch('/api/setup/secrets', { + method: 'POST', + body: JSON.stringify({ key: 'googleMapsApiKey', value: geocodeKey }) + }); + } + + renderMessage('provider-result', 'Provider settings saved. Restart may be required for some settings.'); + await loadStatus(); + } catch (error) { + renderMessage('provider-result', error.message, 'error'); + } +}); + +document.getElementById('test-providers').addEventListener('click', async () => { + try { + const checks = await Promise.all(['geocoding', 'transcription', 'ai', 'storage', 'upload'].map((provider) => + jsonFetch('/api/setup/test-provider', { method: 'POST', body: JSON.stringify({ provider }) }).then((result) => [provider, result]) + )); + document.getElementById('provider-result').innerHTML = checks.map(([provider, result]) => + `
${result.ok ? 'ok' : 'check'}
${labelFor(provider)}
${JSON.stringify(result, null, 2)}
` + ).join(''); + } catch (error) { + renderMessage('provider-result', error.message, 'error'); + } +}); + +document.getElementById('complete-setup').addEventListener('click', async () => { + try { + await jsonFetch('/api/setup/complete', { method: 'POST', body: '{}' }); + renderMessage('finish-result', 'Setup complete. You can open the map or settings.'); + await loadStatus(); + } catch (error) { + renderMessage('finish-result', error.message, 'error'); + } +}); + +loadStatus().catch(() => {}); diff --git a/src/db/migrations.js b/src/db/migrations.js index 36e519a..5583c35 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -84,11 +84,36 @@ const BASE_MIGRATIONS = [ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, completed_at DATETIME, - FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) + FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) ON DELETE SET NULL )`, `CREATE INDEX IF NOT EXISTS idx_call_jobs_status_priority ON call_jobs (status, priority DESC, created_at ASC)`, `CREATE INDEX IF NOT EXISTS idx_call_jobs_transcription_type ON call_jobs (transcription_id, job_type)` ] + }, + { + id: '004_create_app_settings', + statements: [ + `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT, + is_secret INTEGER NOT NULL DEFAULT 0, + requires_restart INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS setup_state ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS settings_audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + setting_key TEXT, + actor TEXT, + details_json TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )` + ] } ]; diff --git a/src/jobs/processingJobs.js b/src/jobs/processingJobs.js index 345fb1e..4cd7d0a 100644 --- a/src/jobs/processingJobs.js +++ b/src/jobs/processingJobs.js @@ -45,6 +45,15 @@ function get(db, sql, params = []) { }); } +function all(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + async function createProcessingJob(db, { transcriptionId, jobType, @@ -116,11 +125,69 @@ async function getJobById(db, jobId) { }; } +async function getJobSummary(db) { + const rows = await all( + db, + `SELECT job_type, status, COUNT(*) AS count + FROM call_jobs + GROUP BY job_type, status + ORDER BY job_type ASC, status ASC` + ); + + const totals = {}; + for (const row of rows) { + if (!totals[row.job_type]) totals[row.job_type] = {}; + totals[row.job_type][row.status] = row.count; + } + + return { + totals, + rows + }; +} + +async function getRecentJobs(db, { limit = 50, status, jobType } = {}) { + const safeLimit = Math.max(1, Math.min(parseInt(limit, 10) || 50, 200)); + const where = []; + const params = []; + + if (status) { + where.push('status = ?'); + params.push(status); + } + + if (jobType) { + where.push('job_type = ?'); + params.push(jobType); + } + + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const rows = await all( + db, + `SELECT id, transcription_id, job_type, status, attempts, max_attempts, priority, + run_after, payload_json, result_json, last_error, created_at, updated_at, + started_at, completed_at + FROM call_jobs + ${whereClause} + ORDER BY created_at DESC + LIMIT ?`, + [...params, safeLimit] + ); + + return rows.map((row) => ({ + ...row, + payload: parseJson(row.payload_json, {}), + result: parseJson(row.result_json, null) + })); +} + module.exports = { JOB_STATUS, JOB_TYPES, createProcessingJob, getJobById, + getJobSummary, + getRecentJobs, markJobCompleted, markJobFailed, markJobProcessing, diff --git a/src/settings/settingsService.js b/src/settings/settingsService.js new file mode 100644 index 0000000..fa37e34 --- /dev/null +++ b/src/settings/settingsService.js @@ -0,0 +1,277 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const SETTING_DEFINITIONS = { + publicDomain: { envKey: 'PUBLIC_DOMAIN', defaultValue: 'localhost', requiresRestart: true }, + timezone: { envKey: 'TIMEZONE', defaultValue: 'US/Eastern', requiresRestart: false }, + storageMode: { envKey: 'STORAGE_MODE', defaultValue: 'local', requiresRestart: true }, + transcriptionMode: { envKey: 'TRANSCRIPTION_MODE', defaultValue: 'local', requiresRestart: true }, + transcriptionDevice: { envKey: 'TRANSCRIPTION_DEVICE', defaultValue: 'cpu', requiresRestart: true }, + aiProvider: { envKey: 'AI_PROVIDER', defaultValue: 'ollama', requiresRestart: false }, + ollamaUrl: { envKey: 'OLLAMA_URL', defaultValue: 'http://localhost:11434', requiresRestart: false }, + ollamaModel: { envKey: 'OLLAMA_MODEL', defaultValue: 'llama3.1:8b', requiresRestart: false }, + openaiModel: { envKey: 'OPENAI_MODEL', defaultValue: 'gpt-4o-mini', requiresRestart: false }, + fasterWhisperServerUrl: { envKey: 'FASTER_WHISPER_SERVER_URL', defaultValue: '', requiresRestart: false }, + whisperModel: { envKey: 'WHISPER_MODEL', defaultValue: 'large-v3', requiresRestart: false }, + mappedTalkGroups: { envKey: 'MAPPED_TALK_GROUPS', defaultValue: '', requiresRestart: false }, + enableMappedTalkGroups: { envKey: 'ENABLE_MAPPED_TALK_GROUPS', defaultValue: 'true', requiresRestart: false }, + summaryLookbackHours: { envKey: 'SUMMARY_LOOKBACK_HOURS', defaultValue: '1', requiresRestart: false }, + askAiLookbackHours: { envKey: 'ASK_AI_LOOKBACK_HOURS', defaultValue: '8', requiresRestart: false }, + maxConcurrentTranscriptions: { envKey: 'MAX_CONCURRENT_TRANSCRIPTIONS', defaultValue: '3', requiresRestart: true } +}; + +const SECRET_DEFINITIONS = { + discordToken: { envKey: 'DISCORD_TOKEN', requiresRestart: true }, + googleMapsApiKey: { envKey: 'GOOGLE_MAPS_API_KEY', requiresRestart: false }, + locationIqApiKey: { envKey: 'LOCATIONIQ_API_KEY', requiresRestart: false }, + openaiApiKey: { envKey: 'OPENAI_API_KEY', requiresRestart: false }, + icadApiKey: { envKey: 'ICAD_API_KEY', requiresRestart: false }, + s3AccessKeyId: { envKey: 'S3_ACCESS_KEY_ID', requiresRestart: true }, + s3SecretAccessKey: { envKey: 'S3_SECRET_ACCESS_KEY', requiresRestart: true }, + webserverPassword: { envKey: 'WEBSERVER_PASSWORD', requiresRestart: true }, + uploadApiKey: { envKey: 'SCANNER_MAP_UPLOAD_API_KEY', requiresRestart: false } +}; + +function run(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function onRun(err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +function get(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +function all(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + +function deriveKey(secret) { + return crypto.createHash('sha256').update(secret).digest(); +} + +function encryptSecret(plainText, secret) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), iv); + const encrypted = Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return JSON.stringify({ + v: 1, + iv: iv.toString('base64'), + tag: tag.toString('base64'), + data: encrypted.toString('base64') + }); +} + +function decryptSecret(payload, secret) { + const parsed = JSON.parse(payload); + const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(secret), Buffer.from(parsed.iv, 'base64')); + decipher.setAuthTag(Buffer.from(parsed.tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(parsed.data, 'base64')), + decipher.final() + ]).toString('utf8'); +} + +function getInstanceSecret(options = {}) { + if (options.env && options.env.SETTINGS_ENCRYPTION_KEY) { + return options.env.SETTINGS_ENCRYPTION_KEY; + } + + const dataDir = options.dataDir || path.join(__dirname, '..', '..', 'data'); + const secretPath = options.secretPath || path.join(dataDir, 'instance-secret.key'); + fs.mkdirSync(dataDir, { recursive: true }); + + if (fs.existsSync(secretPath)) { + return fs.readFileSync(secretPath, 'utf8').trim(); + } + + const generated = crypto.randomBytes(32).toString('hex'); + fs.writeFileSync(secretPath, `${generated}\n`, { mode: 0o600 }); + return generated; +} + +async function audit(db, eventType, settingKey, details = {}, actor = 'system') { + await run( + db, + 'INSERT INTO settings_audit_events (event_type, setting_key, actor, details_json) VALUES (?, ?, ?, ?)', + [eventType, settingKey || null, actor, JSON.stringify(details)] + ); +} + +async function getStoredSettings(db) { + const rows = await all(db, 'SELECT key, value, is_secret, requires_restart, updated_at FROM app_settings ORDER BY key'); + const settings = {}; + const secrets = {}; + + for (const row of rows) { + if (row.is_secret) { + secrets[row.key] = { + configured: Boolean(row.value), + source: 'sqlite', + requiresRestart: Boolean(row.requires_restart), + updatedAt: row.updated_at + }; + } else { + settings[row.key] = { + value: row.value, + source: 'sqlite', + requiresRestart: Boolean(row.requires_restart), + updatedAt: row.updated_at + }; + } + } + + return { settings, secrets }; +} + +async function resolveSettings(db, env = process.env) { + const stored = await getStoredSettings(db); + const settings = {}; + + for (const [key, definition] of Object.entries(SETTING_DEFINITIONS)) { + const storedValue = stored.settings[key]; + if (storedValue) { + settings[key] = storedValue; + } else if (env[definition.envKey] !== undefined && env[definition.envKey] !== '') { + settings[key] = { + value: env[definition.envKey], + source: 'env', + requiresRestart: definition.requiresRestart + }; + } else { + settings[key] = { + value: definition.defaultValue, + source: 'default', + requiresRestart: definition.requiresRestart + }; + } + } + + const secrets = {}; + for (const [key, definition] of Object.entries(SECRET_DEFINITIONS)) { + const storedSecret = stored.secrets[key]; + secrets[key] = storedSecret || { + configured: Boolean(env[definition.envKey]), + source: env[definition.envKey] ? 'env' : 'missing', + requiresRestart: definition.requiresRestart + }; + } + + return { settings, secrets }; +} + +async function saveSettings(db, values, actor = 'admin') { + const results = {}; + + for (const [key, value] of Object.entries(values || {})) { + const definition = SETTING_DEFINITIONS[key]; + if (!definition) { + results[key] = { ok: false, error: 'Unknown setting' }; + continue; + } + + await run( + db, + `INSERT INTO app_settings (key, value, is_secret, requires_restart, updated_at) + VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, is_secret = 0, + requires_restart = excluded.requires_restart, updated_at = CURRENT_TIMESTAMP`, + [key, String(value), definition.requiresRestart ? 1 : 0] + ); + await audit(db, 'setting_updated', key, { requiresRestart: definition.requiresRestart }, actor); + results[key] = { ok: true, requiresRestart: definition.requiresRestart }; + } + + return results; +} + +async function saveSecret(db, key, value, options = {}) { + const definition = SECRET_DEFINITIONS[key]; + if (!definition) { + return { ok: false, error: 'Unknown secret' }; + } + + if (!value) { + return { ok: false, error: 'Secret value is required' }; + } + + const instanceSecret = getInstanceSecret({ env: options.env || process.env }); + await run( + db, + `INSERT INTO app_settings (key, value, is_secret, requires_restart, updated_at) + VALUES (?, ?, 1, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, is_secret = 1, + requires_restart = excluded.requires_restart, updated_at = CURRENT_TIMESTAMP`, + [key, encryptSecret(value, instanceSecret), definition.requiresRestart ? 1 : 0] + ); + await audit(db, 'secret_updated', key, { requiresRestart: definition.requiresRestart }, options.actor || 'admin'); + + return { ok: true, configured: true, requiresRestart: definition.requiresRestart }; +} + +async function getSetupStatus(db, env = process.env) { + const resolved = await resolveSettings(db, env); + const setupRow = await get(db, 'SELECT value FROM setup_state WHERE key = ?', ['setup_complete']); + const adminRow = await get(db, 'SELECT COUNT(*) AS count FROM users WHERE username = ?', ['admin']).catch(() => ({ count: 0 })); + + const hasGeocoding = resolved.secrets.googleMapsApiKey.configured || resolved.secrets.locationIqApiKey.configured; + const missing = []; + if (!adminRow || adminRow.count === 0) missing.push('adminAccount'); + if (!resolved.secrets.uploadApiKey.configured) missing.push('uploadApiKey'); + if (!hasGeocoding) missing.push('geocodingProvider'); + if (!resolved.settings.transcriptionMode.value) missing.push('transcriptionMode'); + if (!resolved.settings.storageMode.value) missing.push('storageMode'); + + return { + setupRequired: setupRow?.value !== 'true' || missing.length > 0, + setupComplete: setupRow?.value === 'true' && missing.length === 0, + missing, + checks: { + adminAccount: Boolean(adminRow && adminRow.count > 0), + uploadApiKey: resolved.secrets.uploadApiKey.configured, + geocodingProvider: hasGeocoding, + transcriptionMode: Boolean(resolved.settings.transcriptionMode.value), + storageMode: Boolean(resolved.settings.storageMode.value) + }, + settings: resolved.settings, + secrets: resolved.secrets + }; +} + +async function markSetupComplete(db, actor = 'admin') { + await run( + db, + `INSERT INTO setup_state (key, value, updated_at) + VALUES ('setup_complete', 'true', CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = CURRENT_TIMESTAMP` + ); + await audit(db, 'setup_completed', 'setup_complete', {}, actor); +} + +module.exports = { + SECRET_DEFINITIONS, + SETTING_DEFINITIONS, + decryptSecret, + encryptSecret, + getInstanceSecret, + getSetupStatus, + resolveSettings, + saveSecret, + saveSettings, + markSetupComplete +}; diff --git a/src/setup/checks.js b/src/setup/checks.js new file mode 100644 index 0000000..8beaa4b --- /dev/null +++ b/src/setup/checks.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); + +function checkCommand(command, args = ['--version']) { + return new Promise((resolve) => { + execFile(command, args, { timeout: 5000 }, (error, stdout, stderr) => { + resolve({ + ok: !error, + command, + version: (stdout || stderr || '').split(/\r?\n/)[0].trim(), + error: error ? error.message : null + }); + }); + }); +} + +function commandHint(name) { + const isWindows = process.platform === 'win32'; + const hints = { + node: isWindows ? 'winget install OpenJS.NodeJS.LTS' : 'sudo apt-get install -y nodejs npm', + python: isWindows ? 'winget install Python.Python.3.11' : 'sudo apt-get install -y python3 python3-venv python3-pip', + ffmpeg: isWindows ? 'winget install Gyan.FFmpeg' : 'sudo apt-get install -y ffmpeg', + ollama: isWindows ? 'winget install Ollama.Ollama' : 'curl -fsSL https://ollama.com/install.sh | sh' + }; + return hints[name] || ''; +} + +function checkWritableDir(dirPath) { + try { + fs.mkdirSync(dirPath, { recursive: true }); + const testFile = path.join(dirPath, `.write-test-${Date.now()}`); + fs.writeFileSync(testFile, 'ok'); + fs.unlinkSync(testFile); + return { ok: true, path: dirPath }; + } catch (error) { + return { ok: false, path: dirPath, error: error.message }; + } +} + +async function runSetupChecks(options = {}) { + const rootDir = options.rootDir || path.join(__dirname, '..', '..'); + const env = options.env || process.env; + const [node, python, ffmpeg, ollama] = await Promise.all([ + checkCommand(process.execPath, ['--version']), + checkCommand(env.PYTHON_COMMAND || (process.platform === 'win32' ? 'py' : 'python3'), ['--version']), + checkCommand('ffmpeg', ['-version']), + checkCommand('ollama', ['--version']) + ]); + + const checks = { + node: { ...node, installCommand: commandHint('node') }, + python: { ...python, installCommand: commandHint('python') }, + ffmpeg: { ...ffmpeg, installCommand: commandHint('ffmpeg') }, + ollama: { ...ollama, optional: true, installCommand: commandHint('ollama') }, + cuda: { ok: false, optional: true, command: 'nvidia-smi', installCommand: 'Install NVIDIA drivers, CUDA Toolkit, cuDNN, and compatible PyTorch wheels.' }, + dataDir: checkWritableDir(path.join(rootDir, 'data')), + audioDir: checkWritableDir(path.join(rootDir, 'audio')), + geocodingProvider: { + ok: Boolean(env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY), + configuredProviders: { + google: Boolean(env.GOOGLE_MAPS_API_KEY), + locationiq: Boolean(env.LOCATIONIQ_API_KEY) + } + }, + transcriptionProvider: { + ok: Boolean(env.TRANSCRIPTION_MODE || 'local'), + mode: env.TRANSCRIPTION_MODE || 'local' + }, + aiProvider: { + ok: Boolean(env.AI_PROVIDER || 'ollama'), + provider: env.AI_PROVIDER || 'ollama' + }, + uploadEndpoint: { + ok: true, + url: `/api/call-upload` + } + }; + + checks.cuda = await checkCommand('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader']).then((result) => ({ + ...checks.cuda, + ok: result.ok, + version: result.version, + error: result.error + })); + + return checks; +} + +module.exports = { + checkCommand, + checkWritableDir, + runSetupChecks +}; diff --git a/test/migrations.test.js b/test/migrations.test.js index 3c1fd3e..8974ef9 100644 --- a/test/migrations.test.js +++ b/test/migrations.test.js @@ -6,13 +6,13 @@ const { getMigrationPlan } = require('../src/db/migrations'); test('migration plan includes core tables by default', () => { assert.deepEqual( getMigrationPlan({ enableAuth: false }).map((migration) => migration.id), - ['001_create_core_tables', '003_create_call_jobs'] + ['001_create_core_tables', '003_create_call_jobs', '004_create_app_settings'] ); }); test('migration plan includes auth tables when auth is enabled', () => { assert.deepEqual( getMigrationPlan({ enableAuth: true }).map((migration) => migration.id), - ['001_create_core_tables', '002_create_auth_tables', '003_create_call_jobs'] + ['001_create_core_tables', '002_create_auth_tables', '003_create_call_jobs', '004_create_app_settings'] ); }); diff --git a/test/processingJobs.test.js b/test/processingJobs.test.js index 14d1984..bcc966b 100644 --- a/test/processingJobs.test.js +++ b/test/processingJobs.test.js @@ -4,6 +4,8 @@ const assert = require('node:assert/strict'); const { JOB_STATUS, JOB_TYPES, + getRecentJobs, + getJobSummary, parseJson, serializeJson } = require('../src/jobs/processingJobs'); @@ -23,3 +25,43 @@ test('serializeJson and parseJson preserve payload objects', () => { test('parseJson returns fallback for invalid JSON', () => { assert.deepEqual(parseJson('{bad json', { ok: false }), { ok: false }); }); + +test('getJobSummary groups rows by job type and status', async () => { + const rows = [ + { job_type: JOB_TYPES.TRANSCRIPTION, status: JOB_STATUS.PENDING, count: 2 }, + { job_type: JOB_TYPES.TRANSCRIPTION, status: JOB_STATUS.COMPLETED, count: 1 } + ]; + const db = { + all(sql, params, callback) { + callback(null, rows); + } + }; + + const summary = await getJobSummary(db); + + assert.equal(summary.totals.transcription.pending, 2); + assert.equal(summary.totals.transcription.completed, 1); + assert.deepEqual(summary.rows, rows); +}); + +test('getRecentJobs clamps limit and parses payload/result JSON', async () => { + const db = { + all(sql, params, callback) { + assert.equal(params.at(-1), 200); + callback(null, [{ + id: 7, + transcription_id: 42, + job_type: JOB_TYPES.TRANSCRIPTION, + status: JOB_STATUS.COMPLETED, + payload_json: '{"mode":"local"}', + result_json: '{"empty":false}' + }]); + } + }; + + const jobs = await getRecentJobs(db, { limit: 999 }); + + assert.equal(jobs[0].id, 7); + assert.deepEqual(jobs[0].payload, { mode: 'local' }); + assert.deepEqual(jobs[0].result, { empty: false }); +}); diff --git a/test/settingsService.test.js b/test/settingsService.test.js new file mode 100644 index 0000000..ce572b4 --- /dev/null +++ b/test/settingsService.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + decryptSecret, + encryptSecret, + getSetupStatus, + resolveSettings +} = require('../src/settings/settingsService'); + +function createFakeDb({ settingsRows = [], setupComplete = false, adminCount = 0 } = {}) { + return { + all(sql, params, callback) { + callback(null, settingsRows); + }, + get(sql, params, callback) { + if (sql.includes('setup_state')) { + callback(null, setupComplete ? { value: 'true' } : undefined); + return; + } + if (sql.includes('COUNT(*) AS count FROM users')) { + callback(null, { count: adminCount }); + return; + } + callback(null, undefined); + } + }; +} + +test('resolveSettings prefers SQLite settings over env and defaults', async () => { + const db = createFakeDb({ + settingsRows: [ + { key: 'timezone', value: 'America/Chicago', is_secret: 0, requires_restart: 0, updated_at: 'now' } + ] + }); + + const resolved = await resolveSettings(db, { + TIMEZONE: 'US/Eastern', + PUBLIC_DOMAIN: 'scanner.example' + }); + + assert.equal(resolved.settings.timezone.value, 'America/Chicago'); + assert.equal(resolved.settings.timezone.source, 'sqlite'); + assert.equal(resolved.settings.publicDomain.value, 'scanner.example'); + assert.equal(resolved.settings.publicDomain.source, 'env'); + assert.equal(resolved.settings.storageMode.value, 'local'); + assert.equal(resolved.settings.storageMode.source, 'default'); +}); + +test('resolveSettings redacts write-only secret values', async () => { + const db = createFakeDb({ + settingsRows: [ + { key: 'openaiApiKey', value: 'encrypted-payload', is_secret: 1, requires_restart: 0, updated_at: 'now' } + ] + }); + + const resolved = await resolveSettings(db, {}); + + assert.equal(resolved.secrets.openaiApiKey.configured, true); + assert.equal(resolved.secrets.openaiApiKey.source, 'sqlite'); + assert.equal(Object.hasOwn(resolved.secrets.openaiApiKey, 'value'), false); +}); + +test('encryptSecret and decryptSecret round trip secret values', () => { + const secret = 'local-instance-secret'; + const encrypted = encryptSecret('api-key-value', secret); + + assert.notEqual(encrypted, 'api-key-value'); + assert.equal(decryptSecret(encrypted, secret), 'api-key-value'); +}); + +test('getSetupStatus reports incomplete setup requirements', async () => { + const db = createFakeDb({ setupComplete: false, adminCount: 0 }); + const status = await getSetupStatus(db, {}); + + assert.equal(status.setupRequired, true); + assert.equal(status.setupComplete, false); + assert.ok(status.missing.includes('adminAccount')); + assert.ok(status.missing.includes('uploadApiKey')); + assert.ok(status.missing.includes('geocodingProvider')); +}); + +test('getSetupStatus accepts configured essentials', async () => { + const db = createFakeDb({ + setupComplete: true, + adminCount: 1, + settingsRows: [ + { key: 'uploadApiKey', value: 'encrypted', is_secret: 1, requires_restart: 0, updated_at: 'now' }, + { key: 'googleMapsApiKey', value: 'encrypted', is_secret: 1, requires_restart: 0, updated_at: 'now' }, + { key: 'transcriptionMode', value: 'local', is_secret: 0, requires_restart: 1, updated_at: 'now' }, + { key: 'storageMode', value: 'local', is_secret: 0, requires_restart: 1, updated_at: 'now' } + ] + }); + + const status = await getSetupStatus(db, {}); + + assert.equal(status.setupRequired, false); + assert.equal(status.setupComplete, true); + assert.deepEqual(status.missing, []); +}); diff --git a/test/setupChecks.test.js b/test/setupChecks.test.js new file mode 100644 index 0000000..d24ad02 --- /dev/null +++ b/test/setupChecks.test.js @@ -0,0 +1,18 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { checkWritableDir } = require('../src/setup/checks'); + +test('checkWritableDir creates and verifies writable directories', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-')); + const nested = path.join(tempDir, 'data'); + + const result = checkWritableDir(nested); + + assert.equal(result.ok, true); + assert.equal(fs.existsSync(nested), true); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); diff --git a/webserver.js b/webserver.js index e24e14f..587a4ed 100644 --- a/webserver.js +++ b/webserver.js @@ -2,6 +2,16 @@ require('dotenv').config(); const { loadConfig } = require('./src/config'); +const { applyMigrations } = require('./src/db/migrations'); +const { getJobSummary, getRecentJobs } = require('./src/jobs/processingJobs'); +const { + getSetupStatus, + markSetupComplete, + resolveSettings, + saveSecret, + saveSettings +} = require('./src/settings/settingsService'); +const { runSetupChecks } = require('./src/setup/checks'); const AWS = require('aws-sdk'); // Add AWS SDK const express = require('express'); @@ -45,27 +55,24 @@ const { const startupConfig = loadConfig(process.env); if (!startupConfig.isValid) { - console.error('ERROR: Invalid configuration:'); + console.warn('WARNING: Configuration has issues. Setup mode will remain available:'); for (const error of startupConfig.errors) { - console.error(`- ${error.key}: ${error.message}`); + console.warn(`- ${error.key}: ${error.message}`); } - process.exit(1); } - -// Validate required environment variables -const requiredVars = ['WEBSERVER_PORT', 'PUBLIC_DOMAIN']; -const missingVars = requiredVars.filter(varName => !process.env[varName]); - -if (missingVars.length > 0) { - console.error(`ERROR: Missing required environment variables: ${missingVars.join(', ')}`); - process.exit(1); -} - -// Check for at least one geocoding API key -if (!GOOGLE_MAPS_API_KEY && !LOCATIONIQ_API_KEY) { - console.error('ERROR: At least one geocoding API key is required (GOOGLE_MAPS_API_KEY or LOCATIONIQ_API_KEY)'); - process.exit(1); -} + +// Validate required environment variables +const requiredVars = ['WEBSERVER_PORT', 'PUBLIC_DOMAIN']; +const missingVars = requiredVars.filter(varName => !process.env[varName]); + +if (missingVars.length > 0) { + console.warn(`WARNING: Missing environment variables: ${missingVars.join(', ')}. Setup mode will remain available.`); +} + +// Check for at least one geocoding API key +if (!GOOGLE_MAPS_API_KEY && !LOCATIONIQ_API_KEY) { + console.warn('WARNING: No geocoding API key configured yet. Use /setup to configure Google Maps or LocationIQ.'); +} // Log geocoding API availability if (GOOGLE_MAPS_API_KEY) { @@ -125,23 +132,23 @@ app.get('/api/test', (req, res) => { // --- NEW: S3 Client Setup --- let s3 = null; -if (STORAGE_MODE === 's3') { - if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { - console.error('FATAL: STORAGE_MODE is s3, but required S3 environment variables are missing! Check webserver .env'); - process.exit(1); // Exit if S3 config is incomplete - } - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 - signatureVersion: 'v4' - }); - s3 = new AWS.S3(); - console.log(`[Webserver] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); -} else { - console.log('[Webserver] Storage mode set to local.'); -} +if (STORAGE_MODE === 's3') { + if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { + console.warn('WARNING: STORAGE_MODE=s3, but S3 configuration is incomplete. Audio serving from S3 will be unavailable until setup is completed.'); + } else { + AWS.config.update({ + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + endpoint: S3_ENDPOINT, + s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 + signatureVersion: 'v4' + }); + s3 = new AWS.S3(); + console.log(`[Webserver] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); + } +} else { + console.log('[Webserver] Storage mode set to local.'); +} // Authentication is enabled if ENABLE_AUTH=true const authEnabled = ENABLE_AUTH?.toLowerCase() === 'true'; @@ -156,51 +163,31 @@ const server = http.createServer(app); const io = socketIo(server); // Database setup -const db = new sqlite3.Database('./botdata.db', sqlite3.OPEN_READWRITE, (err) => { - if (err) { - console.error('Error opening database', err.message); - } else { +const db = new sqlite3.Database('./botdata.db', (err) => { + if (err) { + console.error('Error opening database', err.message); + } else { console.log('Connected to the SQLite database.'); - } -}); - -db.run(`ALTER TABLE transcriptions ADD COLUMN category TEXT`, err => { - // Ignore error if column already exists - if (!err || err.message.includes('duplicate column name')) { - console.log('Category column exists or was created successfully'); - } -}); - -// Create authentication tables if authentication is enabled -if (authEnabled) { - db.serialize(() => { - // Users table - db.run(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - salt TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Sessions table - db.run(` - CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - token TEXT UNIQUE NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL, - last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, - ip_address TEXT, - user_agent TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - }); -} + } +}); + +const dbReady = applyMigrations(db, { enableAuth: true }) + .then((applied) => { + if (applied.length > 0) { + console.log(`[Webserver] Applied migrations: ${applied.join(', ')}`); + } + return new Promise((resolve) => { + db.run(`ALTER TABLE transcriptions ADD COLUMN category TEXT`, err => { + if (!err || err.message.includes('duplicate column name')) { + console.log('Category column exists or was created successfully'); + } + resolve(); + }); + }); + }) + .catch((err) => { + console.error('[Webserver] Error initializing database schema:', err); + }); // Helper Functions for Authentication function hashPassword(password, salt) { @@ -685,7 +672,7 @@ async function serveAudioFromDb(res, transcriptionId) { } // Public Routes (No Auth Required) -app.get('/audio/:id', async (req, res) => { +app.get('/audio/:id', async (req, res) => { const transcriptionId = req.params.id; try { @@ -729,11 +716,133 @@ app.get('/audio/:id', async (req, res) => { } catch (dbErr) { console.error('[Audio Request] Database error:', dbErr); return res.status(500).send('Internal Server Error'); - } -}); - -// Apply authentication middleware to protected routes if auth is enabled -app.use(basicAuth); + } +}); + +app.get('/setup', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'setup.html')); +}); + +app.get('/settings', basicAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'settings.html')); +}); + +app.get('/api/setup/status', async (req, res) => { + await dbReady; + try { + const status = await getSetupStatus(db, process.env); + res.json(status); + } catch (err) { + console.error('Error fetching setup status:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/setup/checks', async (req, res) => { + await dbReady; + try { + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + res.json(checks); + } catch (err) { + console.error('Error running setup checks:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/admin', async (req, res) => { + await dbReady; + const { username = 'admin', password } = req.body || {}; + if (username !== 'admin') { + return res.status(400).json({ error: 'The first setup user must be admin.' }); + } + if (!password || password.length < 8) { + return res.status(400).json({ error: 'Admin password must be at least 8 characters.' }); + } + + try { + const existing = await new Promise((resolve, reject) => { + db.get('SELECT id FROM users WHERE username = ?', ['admin'], (err, row) => err ? reject(err) : resolve(row)); + }); + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = hashPassword(password, salt); + + if (existing) { + db.run('UPDATE users SET password_hash = ?, salt = ? WHERE username = ?', [passwordHash, salt, 'admin'], (err) => { + if (err) return res.status(500).json({ error: 'Failed to update admin user' }); + res.json({ ok: true, updated: true }); + }); + } else { + db.run('INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)', ['admin', passwordHash, salt], (err) => { + if (err) return res.status(500).json({ error: 'Failed to create admin user' }); + res.json({ ok: true, created: true }); + }); + } + } catch (err) { + console.error('Error creating setup admin:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/settings', async (req, res) => { + await dbReady; + try { + const result = await saveSettings(db, req.body || {}, 'setup'); + res.json({ ok: true, result }); + } catch (err) { + console.error('Error saving setup settings:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/secrets', async (req, res) => { + await dbReady; + const { key, value } = req.body || {}; + try { + const result = await saveSecret(db, key, value, { actor: 'setup', env: process.env }); + if (!result.ok) return res.status(400).json({ error: result.error }); + res.json(result); + } catch (err) { + console.error('Error saving setup secret:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/test-provider', async (req, res) => { + await dbReady; + const { provider } = req.body || {}; + try { + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + const providerMap = { + geocoding: checks.geocodingProvider, + transcription: checks.transcriptionProvider, + ai: checks.aiProvider, + storage: checks.dataDir, + upload: checks.uploadEndpoint + }; + res.json(providerMap[provider] || { ok: false, error: 'Unknown provider test' }); + } catch (err) { + console.error('Error testing provider:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/complete', async (req, res) => { + await dbReady; + try { + const status = await getSetupStatus(db, process.env); + if (status.missing.length > 0) { + return res.status(400).json({ error: 'Setup is incomplete', missing: status.missing }); + } + await markSetupComplete(db, 'setup'); + res.json({ ok: true, setupComplete: true }); + } catch (err) { + console.error('Error completing setup:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Apply authentication middleware to protected routes if auth is enabled +app.use(basicAuth); // Serve static files from the 'public' directory app.use(express.static(path.join(__dirname, 'public'))); @@ -801,7 +910,7 @@ app.delete('/api/sessions/:token', adminAuth, (req, res) => { ); }); -app.get('/api/sessions/me', (req, res) => { +app.get('/api/sessions/me', (req, res) => { if (!authEnabled) { return res.json([]); } @@ -819,11 +928,85 @@ app.get('/api/sessions/me', (req, res) => { } res.json(sessions); } - ); -}); - -// User Management Routes (Admin Only when auth is enabled) -app.post('/api/users', adminAuth, async (req, res) => { + ); +}); + +// Processing Job Diagnostics Routes (Admin Only when auth is enabled) +app.get('/api/jobs/summary', adminAuth, async (req, res) => { + try { + const summary = await getJobSummary(db); + res.json(summary); + } catch (err) { + console.error('Error fetching job summary:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/jobs/recent', adminAuth, async (req, res) => { + try { + const jobs = await getRecentJobs(db, { + limit: req.query.limit, + status: req.query.status, + jobType: req.query.jobType + }); + res.json(jobs); + } catch (err) { + console.error('Error fetching recent jobs:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/settings', adminAuth, async (req, res) => { + await dbReady; + try { + const settings = await resolveSettings(db, process.env); + res.json(settings); + } catch (err) { + console.error('Error fetching settings:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.put('/api/settings', adminAuth, async (req, res) => { + await dbReady; + try { + const result = await saveSettings(db, req.body || {}, req.user?.username || 'admin'); + const requiresRestart = Object.values(result).some((item) => item.requiresRestart); + res.json({ ok: true, result, requiresRestart }); + } catch (err) { + console.error('Error updating settings:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.put('/api/settings/secrets/:key', adminAuth, async (req, res) => { + await dbReady; + try { + const result = await saveSecret(db, req.params.key, req.body?.value, { + actor: req.user?.username || 'admin', + env: process.env + }); + if (!result.ok) return res.status(400).json({ error: result.error }); + res.json(result); + } catch (err) { + console.error('Error updating secret:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/settings/checks', adminAuth, async (req, res) => { + await dbReady; + try { + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + res.json(checks); + } catch (err) { + console.error('Error running settings checks:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// User Management Routes (Admin Only when auth is enabled) +app.post('/api/users', adminAuth, async (req, res) => { if (!authEnabled) { return res.status(400).json({ error: 'Authentication is disabled' }); } From d0d7ca67864a509988d595eece5f9649b9b7c499 Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 22:44:09 -0500 Subject: [PATCH 06/10] Resolve webserver runtime settings from SQLite --- src/settings/settingsService.js | 42 ++++++++++ src/setup/checks.js | 15 ++-- test/settingsService.test.js | 52 +++++++++++++ webserver.js | 131 +++++++++++++++++++------------- 4 files changed, 180 insertions(+), 60 deletions(-) diff --git a/src/settings/settingsService.js b/src/settings/settingsService.js index fa37e34..b61d46d 100644 --- a/src/settings/settingsService.js +++ b/src/settings/settingsService.js @@ -175,6 +175,45 @@ async function resolveSettings(db, env = process.env) { return { settings, secrets }; } +async function getRuntimeSetting(db, key, env = process.env) { + const definition = SETTING_DEFINITIONS[key]; + if (!definition) return undefined; + + const row = await get(db, 'SELECT value FROM app_settings WHERE key = ? AND is_secret = 0', [key]); + if (row && row.value !== undefined && row.value !== null) return row.value; + if (env[definition.envKey] !== undefined && env[definition.envKey] !== '') return env[definition.envKey]; + return definition.defaultValue; +} + +async function getRuntimeSecret(db, key, options = {}) { + const definition = SECRET_DEFINITIONS[key]; + if (!definition) return undefined; + + const env = options.env || process.env; + const row = await get(db, 'SELECT value FROM app_settings WHERE key = ? AND is_secret = 1', [key]); + if (row && row.value) { + const instanceSecret = getInstanceSecret({ env }); + return decryptSecret(row.value, instanceSecret); + } + + return env[definition.envKey] || ''; +} + +async function getRuntimeConfig(db, env = process.env) { + const settings = {}; + const secrets = {}; + + for (const key of Object.keys(SETTING_DEFINITIONS)) { + settings[key] = await getRuntimeSetting(db, key, env); + } + + for (const key of Object.keys(SECRET_DEFINITIONS)) { + secrets[key] = await getRuntimeSecret(db, key, { env }); + } + + return { settings, secrets }; +} + async function saveSettings(db, values, actor = 'admin') { const results = {}; @@ -269,6 +308,9 @@ module.exports = { decryptSecret, encryptSecret, getInstanceSecret, + getRuntimeConfig, + getRuntimeSecret, + getRuntimeSetting, getSetupStatus, resolveSettings, saveSecret, diff --git a/src/setup/checks.js b/src/setup/checks.js index 8beaa4b..9760a4f 100644 --- a/src/setup/checks.js +++ b/src/setup/checks.js @@ -41,6 +41,7 @@ function checkWritableDir(dirPath) { async function runSetupChecks(options = {}) { const rootDir = options.rootDir || path.join(__dirname, '..', '..'); const env = options.env || process.env; + const runtime = options.runtime || { settings: {}, secrets: {} }; const [node, python, ffmpeg, ollama] = await Promise.all([ checkCommand(process.execPath, ['--version']), checkCommand(env.PYTHON_COMMAND || (process.platform === 'win32' ? 'py' : 'python3'), ['--version']), @@ -57,19 +58,19 @@ async function runSetupChecks(options = {}) { dataDir: checkWritableDir(path.join(rootDir, 'data')), audioDir: checkWritableDir(path.join(rootDir, 'audio')), geocodingProvider: { - ok: Boolean(env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY), + ok: Boolean(runtime.secrets.googleMapsApiKey || runtime.secrets.locationIqApiKey || env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY), configuredProviders: { - google: Boolean(env.GOOGLE_MAPS_API_KEY), - locationiq: Boolean(env.LOCATIONIQ_API_KEY) + google: Boolean(runtime.secrets.googleMapsApiKey || env.GOOGLE_MAPS_API_KEY), + locationiq: Boolean(runtime.secrets.locationIqApiKey || env.LOCATIONIQ_API_KEY) } }, transcriptionProvider: { - ok: Boolean(env.TRANSCRIPTION_MODE || 'local'), - mode: env.TRANSCRIPTION_MODE || 'local' + ok: Boolean(runtime.settings.transcriptionMode || env.TRANSCRIPTION_MODE || 'local'), + mode: runtime.settings.transcriptionMode || env.TRANSCRIPTION_MODE || 'local' }, aiProvider: { - ok: Boolean(env.AI_PROVIDER || 'ollama'), - provider: env.AI_PROVIDER || 'ollama' + ok: Boolean(runtime.settings.aiProvider || env.AI_PROVIDER || 'ollama'), + provider: runtime.settings.aiProvider || env.AI_PROVIDER || 'ollama' }, uploadEndpoint: { ok: true, diff --git a/test/settingsService.test.js b/test/settingsService.test.js index ce572b4..1bd6cb2 100644 --- a/test/settingsService.test.js +++ b/test/settingsService.test.js @@ -4,6 +4,9 @@ const assert = require('node:assert/strict'); const { decryptSecret, encryptSecret, + getRuntimeConfig, + getRuntimeSecret, + getRuntimeSetting, getSetupStatus, resolveSettings } = require('../src/settings/settingsService'); @@ -14,6 +17,10 @@ function createFakeDb({ settingsRows = [], setupComplete = false, adminCount = 0 callback(null, settingsRows); }, get(sql, params, callback) { + if (sql.includes('app_settings')) { + callback(null, settingsRows.find((row) => row.key === params[0])); + return; + } if (sql.includes('setup_state')) { callback(null, setupComplete ? { value: 'true' } : undefined); return; @@ -47,6 +54,51 @@ test('resolveSettings prefers SQLite settings over env and defaults', async () = assert.equal(resolved.settings.storageMode.source, 'default'); }); +test('runtime setting reads SQLite before env before default', async () => { + const db = createFakeDb({ + settingsRows: [ + { key: 'storageMode', value: 's3', is_secret: 0, requires_restart: 1, updated_at: 'now' } + ] + }); + + assert.equal(await getRuntimeSetting(db, 'storageMode', { STORAGE_MODE: 'local' }), 's3'); + assert.equal(await getRuntimeSetting(createFakeDb(), 'storageMode', { STORAGE_MODE: 'local' }), 'local'); + assert.equal(await getRuntimeSetting(createFakeDb(), 'storageMode', {}), 'local'); +}); + +test('runtime secret decrypts SQLite values before env fallback', async () => { + const secret = 'test-instance-secret'; + const encrypted = encryptSecret('stored-openai-key', secret); + const db = createFakeDb({ + settingsRows: [ + { key: 'openaiApiKey', value: encrypted, is_secret: 1, requires_restart: 0, updated_at: 'now' } + ] + }); + + const value = await getRuntimeSecret(db, 'openaiApiKey', { + env: { + SETTINGS_ENCRYPTION_KEY: secret, + OPENAI_API_KEY: 'env-openai-key' + } + }); + + assert.equal(value, 'stored-openai-key'); + assert.equal(await getRuntimeSecret(createFakeDb(), 'openaiApiKey', { env: { OPENAI_API_KEY: 'env-key' } }), 'env-key'); +}); + +test('runtime config includes resolved settings and decrypted secrets', async () => { + const db = createFakeDb({ + settingsRows: [ + { key: 'timezone', value: 'America/Chicago', is_secret: 0, requires_restart: 0, updated_at: 'now' } + ] + }); + + const config = await getRuntimeConfig(db, { OPENAI_API_KEY: 'env-key' }); + + assert.equal(config.settings.timezone, 'America/Chicago'); + assert.equal(config.secrets.openaiApiKey, 'env-key'); +}); + test('resolveSettings redacts write-only secret values', async () => { const db = createFakeDb({ settingsRows: [ diff --git a/webserver.js b/webserver.js index 587a4ed..1631162 100644 --- a/webserver.js +++ b/webserver.js @@ -6,6 +6,7 @@ const { applyMigrations } = require('./src/db/migrations'); const { getJobSummary, getRecentJobs } = require('./src/jobs/processingJobs'); const { getSetupStatus, + getRuntimeConfig, markSetupComplete, resolveSettings, saveSecret, @@ -91,28 +92,33 @@ if (LOCATIONIQ_API_KEY) { const app = express(); app.use(express.json()); // Add this line to parse JSON bodies -app.get('/api/config/google-api-key', (req, res) => { - res.json({ apiKey: GOOGLE_MAPS_API_KEY }); -}); - -// Add endpoint to serve LocationIQ API key -app.get('/api/config/locationiq-api-key', (req, res) => { - res.json({ apiKey: LOCATIONIQ_API_KEY }); -}); - -// Add endpoint to serve all geocoding configuration -app.get('/api/config/geocoding', (req, res) => { - res.json({ - google: { - available: !!GOOGLE_MAPS_API_KEY, - apiKey: GOOGLE_MAPS_API_KEY - }, - locationiq: { - available: !!LOCATIONIQ_API_KEY, - apiKey: LOCATIONIQ_API_KEY - } - }); -}); +app.get('/api/config/google-api-key', async (req, res) => { + const runtime = await getResolvedRuntimeConfig(); + res.json({ apiKey: runtime.secrets.googleMapsApiKey }); +}); + +// Add endpoint to serve LocationIQ API key +app.get('/api/config/locationiq-api-key', async (req, res) => { + const runtime = await getResolvedRuntimeConfig(); + res.json({ apiKey: runtime.secrets.locationIqApiKey }); +}); + +// Add endpoint to serve all geocoding configuration +app.get('/api/config/geocoding', async (req, res) => { + const runtime = await getResolvedRuntimeConfig(); + const googleMapsApiKey = runtime.secrets.googleMapsApiKey; + const locationIqApiKey = runtime.secrets.locationIqApiKey; + res.json({ + google: { + available: !!googleMapsApiKey, + apiKey: googleMapsApiKey + }, + locationiq: { + available: !!locationIqApiKey, + apiKey: locationIqApiKey + } + }); +}); // Add endpoint to check if current user is admin app.get('/api/auth/is-admin', async (req, res) => { @@ -188,6 +194,11 @@ const dbReady = applyMigrations(db, { enableAuth: true }) .catch((err) => { console.error('[Webserver] Error initializing database schema:', err); }); + +async function getResolvedRuntimeConfig() { + await dbReady; + return getRuntimeConfig(db, process.env); +} // Helper Functions for Authentication function hashPassword(password, salt) { @@ -270,30 +281,36 @@ Transmission: "${transcript}" Category:`; - let category = 'OTHER'; // Default value - - const controller = new AbortController(); + let category = 'OTHER'; // Default value + const runtime = await getResolvedRuntimeConfig(); + const aiProvider = runtime.settings.aiProvider || AI_PROVIDER; + const openaiApiKey = runtime.secrets.openaiApiKey || OPENAI_API_KEY; + const openaiModel = runtime.settings.openaiModel || OPENAI_MODEL; + const ollamaUrl = runtime.settings.ollamaUrl || OLLAMA_URL; + const ollamaModel = runtime.settings.ollamaModel || OLLAMA_MODEL; + + const controller = new AbortController(); const timeoutId = setTimeout(() => { console.warn(`[Webserver] AI request timed out after 10 seconds during categorization.`); controller.abort(); - }, 10000); // 10-second timeout - - // --- AI Provider Logic --- - if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY) { - console.error('[Webserver] FATAL: AI_PROVIDER is set to openai, but OPENAI_API_KEY is not configured!'); - return 'OTHER'; // Fallback if key is missing - } - console.log(`[Webserver] Categorizing with OpenAI model: ${OPENAI_MODEL}`); - - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${OPENAI_API_KEY}` - }, - body: JSON.stringify({ - model: OPENAI_MODEL, + }, 10000); // 10-second timeout + + // --- AI Provider Logic --- + if (aiProvider.toLowerCase() === 'openai') { + if (!openaiApiKey) { + console.error('[Webserver] FATAL: AI_PROVIDER is set to openai, but OPENAI_API_KEY is not configured!'); + return 'OTHER'; // Fallback if key is missing + } + console.log(`[Webserver] Categorizing with OpenAI model: ${openaiModel}`); + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${openaiApiKey}` + }, + body: JSON.stringify({ + model: openaiModel, messages: [{ role: 'user', content: commonPrompt }], temperature: 0.2, // Lower temp for more deterministic category max_tokens: 20 // A category name is short @@ -313,15 +330,15 @@ Category:`; if (result.choices && result.choices.length > 0 && result.choices[0].message) { category = result.choices[0].message.content.trim(); } - - } else { // Default to Ollama - console.log(`[Webserver] Categorizing with Ollama model: ${OLLAMA_MODEL}`); - - const response = await fetch(`${OLLAMA_URL}/api/generate`, { + + } else { // Default to Ollama + console.log(`[Webserver] Categorizing with Ollama model: ${ollamaModel}`); + + const response = await fetch(`${ollamaUrl}/api/generate`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, prompt: commonPrompt, // The prompt is compatible stream: false, options: { @@ -741,7 +758,8 @@ app.get('/api/setup/status', async (req, res) => { app.get('/api/setup/checks', async (req, res) => { await dbReady; try { - const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + const runtime = await getResolvedRuntimeConfig(); + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env, runtime }); res.json(checks); } catch (err) { console.error('Error running setup checks:', err); @@ -811,7 +829,8 @@ app.post('/api/setup/test-provider', async (req, res) => { await dbReady; const { provider } = req.body || {}; try { - const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + const runtime = await getResolvedRuntimeConfig(); + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env, runtime }); const providerMap = { geocoding: checks.geocodingProvider, transcription: checks.transcriptionProvider, @@ -972,7 +991,12 @@ app.put('/api/settings', adminAuth, async (req, res) => { try { const result = await saveSettings(db, req.body || {}, req.user?.username || 'admin'); const requiresRestart = Object.values(result).some((item) => item.requiresRestart); - res.json({ ok: true, result, requiresRestart }); + res.json({ + ok: true, + result, + requiresRestart, + hotAppliedByWebserver: ['aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel'] + }); } catch (err) { console.error('Error updating settings:', err); res.status(500).json({ error: 'Internal server error' }); @@ -997,7 +1021,8 @@ app.put('/api/settings/secrets/:key', adminAuth, async (req, res) => { app.get('/api/settings/checks', adminAuth, async (req, res) => { await dbReady; try { - const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + const runtime = await getResolvedRuntimeConfig(); + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env, runtime }); res.json(checks); } catch (err) { console.error('Error running settings checks:', err); From 0de44cf9c4fb79f4e17ca0b7ed75275b9863e49a Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 22:59:41 -0500 Subject: [PATCH 07/10] Integrate bot runtime settings --- bot.js | 162 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 83 insertions(+), 79 deletions(-) diff --git a/bot.js b/bot.js index 987ea4f..5b73dd6 100644 --- a/bot.js +++ b/bot.js @@ -4,6 +4,7 @@ require('dotenv').config(); const { loadConfig } = require('./src/config'); const { applyMigrations } = require('./src/db/migrations'); const { normalizeIncomingCall } = require('./src/ingestion/normalizeCall'); +const { getRuntimeConfig, getSetupStatus } = require('./src/settings/settingsService'); const { JOB_TYPES, createProcessingJob, @@ -81,56 +82,28 @@ const { const startupConfig = loadConfig(process.env); if (!startupConfig.isValid) { - console.error('FATAL: Invalid configuration:'); + console.warn('WARNING: Configuration has issues. Setup mode will remain available:'); for (const error of startupConfig.errors) { - console.error(`- ${error.key}: ${error.message}`); + console.warn(`- ${error.key}: ${error.message}`); } - process.exit(1); } -// --- VALIDATE AI-RELATED ENV VARS --- -if (!AI_PROVIDER) { - console.error("FATAL: AI_PROVIDER is not set in the .env file. Please specify 'ollama' or 'openai'."); - process.exit(1); -} - -if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY || !OPENAI_MODEL) { - console.error("FATAL: AI_PROVIDER is 'openai', but OPENAI_API_KEY or OPENAI_MODEL is missing in the .env file."); - process.exit(1); - } -} else if (AI_PROVIDER.toLowerCase() === 'ollama') { - if (!OLLAMA_URL || !OLLAMA_MODEL) { - console.error("FATAL: AI_PROVIDER is 'ollama', but OLLAMA_URL or OLLAMA_MODEL is missing in the .env file."); - process.exit(1); - } -} else { - console.error(`FATAL: Invalid AI_PROVIDER specified in .env file: '${AI_PROVIDER}'. Must be 'openai' or 'ollama'.`); - process.exit(1); -} -// --- END VALIDATION --- - // --- VALIDATE TRANSCRIPTION-RELATED ENV VARS --- const effectiveTranscriptionMode = TRANSCRIPTION_MODE || 'local'; // Keep this to ensure a default if (!['local', 'remote', 'openai', 'icad'].includes(effectiveTranscriptionMode)) { - console.error(`FATAL: Invalid TRANSCRIPTION_MODE specified in .env file: '${TRANSCRIPTION_MODE}'. Must be 'local', 'remote', 'openai', or 'icad'.`); - process.exit(1); + console.warn(`WARNING: Invalid TRANSCRIPTION_MODE specified in .env file: '${TRANSCRIPTION_MODE}'. Use /setup to choose local, remote, openai, or icad.`); } if (effectiveTranscriptionMode === 'local' && !TRANSCRIPTION_DEVICE) { - console.error("FATAL: TRANSCRIPTION_MODE is 'local', but TRANSCRIPTION_DEVICE is missing in the .env file. Please set it to 'cuda' for a GPU or 'cpu' for CPU."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'local', but TRANSCRIPTION_DEVICE is missing. Use /setup to choose cpu or cuda."); } if (effectiveTranscriptionMode === 'remote' && !FASTER_WHISPER_SERVER_URL) { - console.error("FATAL: TRANSCRIPTION_MODE is 'remote', but FASTER_WHISPER_SERVER_URL is missing in the .env file."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'remote', but FASTER_WHISPER_SERVER_URL is missing. Use /setup to configure it."); } if (effectiveTranscriptionMode === 'openai' && !OPENAI_API_KEY) { - console.error("FATAL: TRANSCRIPTION_MODE is 'openai', but OPENAI_API_KEY is missing in the .env file. This is required for OpenAI transcriptions."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'openai', but OPENAI_API_KEY is missing. Use /setup to configure it."); } if (effectiveTranscriptionMode === 'icad' && !ICAD_URL) { - console.error("FATAL: TRANSCRIPTION_MODE is 'icad', but ICAD_URL is missing in the .env file. Please set it to your ICAD API endpoint URL."); - process.exit(1); + console.warn("WARNING: TRANSCRIPTION_MODE is 'icad', but ICAD_URL is missing. Use /setup to configure it."); } // --- END VALIDATION --- @@ -189,9 +162,7 @@ if (ENABLE_TWO_TONE_MODE && ENABLE_TWO_TONE_MODE.toLowerCase() === 'true') { const missingVars = requiredTwoToneVars.filter(varName => !process.env[varName]); if (missingVars.length > 0) { - console.error(`FATAL: Two-tone mode is enabled but missing required environment variables: ${missingVars.join(', ')}`); - console.error('Please add these variables to your .env file. See TWO_TONE_ENV_ADDITIONS.txt for the complete list.'); - process.exit(1); + console.warn(`WARNING: Two-tone mode is enabled but missing required environment variables: ${missingVars.join(', ')}. Use /setup or .env to complete this before enabling bot services.`); } } @@ -840,18 +811,18 @@ const AWS = require('aws-sdk'); let s3 = null; if (STORAGE_MODE === 's3') { if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { - logger.error('FATAL: STORAGE_MODE is s3, but required S3 environment variables are missing! Check bot .env'); - process.exit(1); // Exit if S3 config is incomplete + logger.warn('WARNING: STORAGE_MODE is s3, but required S3 environment variables are missing. Audio serving from S3 will be unavailable until setup is completed.'); + } else { + AWS.config.update({ + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + endpoint: S3_ENDPOINT, + s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 + signatureVersion: 'v4' + }); + s3 = new AWS.S3(); + logger.info(`[Bot] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); } - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 - signatureVersion: 'v4' - }); - s3 = new AWS.S3(); - logger.info(`[Bot] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); } else { logger.info('[Bot] Storage mode set to local.'); } @@ -976,6 +947,21 @@ async function initializeDatabase() { } } +async function getBotRuntimeConfig() { + return getRuntimeConfig(db, process.env); +} + +async function getPublicAudioUrl(audioId) { + let publicDomain = PUBLIC_DOMAIN || 'localhost'; + try { + const runtime = await getBotRuntimeConfig(); + publicDomain = runtime.settings.publicDomain || publicDomain; + } catch (error) { + logger.warn(`Could not load runtime public domain; falling back to startup config: ${error.message}`); + } + return `http://${publicDomain}/audio/${audioId}`; +} + // Function to create admin user if authentication is enabled function createAdminUser() { return new Promise((resolve, reject) => { @@ -1091,16 +1077,23 @@ async function initializeBot() { // Step 6: Create admin user for webserver if auth is enabled await createAdminUser(); - // Step 7: Start bot services (Discord and Express API) - await startBotServices(); - - // Step 8: Start webserver last - if (WEBSERVER_PORT && (GOOGLE_MAPS_API_KEY || LOCATIONIQ_API_KEY)) { + // Step 7: Start webserver before Discord so setup can run even when bot settings are incomplete + if (WEBSERVER_PORT) { await startWebserver(); } else { - logger.warn('Webserver not started: WEBSERVER_PORT or geocoding API key (GOOGLE_MAPS_API_KEY or LOCATIONIQ_API_KEY) not configured'); + logger.warn('Webserver not started: WEBSERVER_PORT is not configured'); + } + + // Step 8: In setup mode, keep the browser console available without forcing Discord login + const setupStatus = await getSetupStatus(db, process.env); + if (setupStatus.setupRequired) { + logger.warn(`Setup is incomplete (${setupStatus.missing.join(', ') || 'unknown requirements'}). Discord bot services will start after setup is completed and the app is restarted.`); + return true; } + // Step 9: Start bot services (Discord and Express API) + await startBotServices(); + logger.info('Bot initialization completed successfully!'); return true; } catch (error) { @@ -3920,7 +3913,7 @@ async function processMergedCallSegments( .trim(); // Build combined transcription lines for Discord (all segments in one message) - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${transcriptionId}`; + const audioUrl = await getPublicAudioUrl(transcriptionId); const transcriptionLines = []; for (const segment of sortedSegments) { @@ -4274,12 +4267,12 @@ function sendAlertMessage( callback ) { // Look up the audio_id from the database for this transcription - db.get('SELECT id FROM audio_files WHERE transcription_id = ?', [audioID], (err, row) => { + db.get('SELECT id FROM audio_files WHERE transcription_id = ?', [audioID], async (err, row) => { // Use transcription ID as fallback if audio ID not found const actualAudioID = (err || !row) ? audioID : row.id; // Create a URL for the audio file - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${actualAudioID}`; + const audioUrl = await getPublicAudioUrl(actualAudioID); // Log the IDs for debugging logger.info(`Alert - Transcription ID: ${audioID}, Audio ID: ${actualAudioID}, URL: ${audioUrl}`); @@ -4514,7 +4507,7 @@ function sendTranscriptionMessage( } // Get or create the channel within the category - getOrCreateChannel(channelName, category.id, (channel) => { + getOrCreateChannel(channelName, category.id, async (channel) => { if (!channel) { logger.error('Failed to get or create channel.'); if (callback) callback(); // Ensure callback is called even on error @@ -4525,7 +4518,7 @@ function sendTranscriptionMessage( // Note: We use transcription ID (`audioID` parameter) for the URL now // as audio_files might get cleaned up. // The audio server route /audio/:id expects the transcription ID. - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${audioID}`; + const audioUrl = await getPublicAudioUrl(audioID); // Log the ID and URL for debugging logger.info(`Creating link for Transcription ID: ${audioID}, Audio URL: ${audioUrl}`); @@ -5045,26 +5038,32 @@ Focus on providing insightful analysis of each transmission. The "description" f Include no other text besides this JSON.`; // Call the AI provider with a timeout + const runtime = await getBotRuntimeConfig(); + const aiProvider = (runtime.settings.aiProvider || AI_PROVIDER || 'ollama').toLowerCase(); + const openaiApiKey = runtime.secrets.openaiApiKey || OPENAI_API_KEY || ''; + const openaiModel = runtime.settings.openaiModel || OPENAI_MODEL || 'gpt-4o-mini'; + const ollamaUrl = runtime.settings.ollamaUrl || OLLAMA_URL || 'http://localhost:11434'; + const ollamaModel = runtime.settings.ollamaModel || OLLAMA_MODEL || 'llama3.1:8b'; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout let resultText = ''; try { - if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY) { + if (aiProvider === 'openai') { + if (!openaiApiKey) { logger.error("[Bot] FATAL: AI_PROVIDER is set to openai, but OPENAI_API_KEY is not configured!"); throw new Error("OpenAI API key is not configured."); } - logger.info(`[Bot] Generating summary with OpenAI model: ${OPENAI_MODEL}`); + logger.info(`[Bot] Generating summary with OpenAI model: ${openaiModel}`); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${OPENAI_API_KEY}` + 'Authorization': `Bearer ${openaiApiKey}` }, body: JSON.stringify({ - model: OPENAI_MODEL, + model: openaiModel, messages: [{ role: 'user', content: commonPrompt }], temperature: 0.3, response_format: { type: "json_object" } // Request JSON output @@ -5082,13 +5081,13 @@ Include no other text besides this JSON.`; } } else { // Default to Ollama - logger.info(`[Bot] Generating summary with Ollama model: ${OLLAMA_MODEL}`); + logger.info(`[Bot] Generating summary with Ollama model: ${ollamaModel}`); - const response = await fetch(`${OLLAMA_URL}/api/generate`, { + const response = await fetch(`${ollamaUrl}/api/generate`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, prompt: commonPrompt, stream: false }), @@ -5347,7 +5346,7 @@ async function updateSummaryEmbed() { if (summary.highlights && summary.highlights.length > 0) { for (const highlight of summary.highlights) { // Get audio URL - const audioUrl = `http://${PUBLIC_DOMAIN}/audio/${highlight.id}`; + const audioUrl = await getPublicAudioUrl(highlight.id); // Fix timestamp display - use timestamp directly from database let timestampDisplay; @@ -6226,8 +6225,13 @@ client.on('interactionCreate', async (interaction) => { const userQuestion = interaction.fields.getTextInputValue('ai_question'); try { - // --- Read lookback from .env, default to 8 hours --- - const askAiLookbackHours = parseFloat(ASK_AI_LOOKBACK_HOURS) || 8; + const runtime = await getBotRuntimeConfig(); + const askAiLookbackHours = parseFloat(runtime.settings.askAiLookbackHours || ASK_AI_LOOKBACK_HOURS) || 8; + const aiProvider = (runtime.settings.aiProvider || AI_PROVIDER || 'ollama').toLowerCase(); + const openaiApiKey = runtime.secrets.openaiApiKey || OPENAI_API_KEY || ''; + const openaiModel = runtime.settings.openaiModel || OPENAI_MODEL || 'gpt-4o-mini'; + const ollamaUrl = runtime.settings.ollamaUrl || OLLAMA_URL || 'http://localhost:11434'; + const ollamaModel = runtime.settings.ollamaModel || OLLAMA_MODEL || 'llama3.1:8b'; const now = new Date(); const queryStartDate = new Date(now.getTime() - askAiLookbackHours * 60 * 60 * 1000); // Convert start date to Unix seconds for the query @@ -6328,20 +6332,20 @@ User Question: ${userQuestion} let aiResponseText = 'Error: Could not get response from AI.'; try { - if (AI_PROVIDER.toLowerCase() === 'openai') { - if (!OPENAI_API_KEY) { + if (aiProvider === 'openai') { + if (!openaiApiKey) { throw new Error("OpenAI API key is not configured."); } - logger.info(`[Bot] Answering question with OpenAI model: ${OPENAI_MODEL}`); + logger.info(`[Bot] Answering question with OpenAI model: ${openaiModel}`); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${OPENAI_API_KEY}` + 'Authorization': `Bearer ${openaiApiKey}` }, body: JSON.stringify({ - model: OPENAI_MODEL, + model: openaiModel, messages: [{ role: 'user', content: commonPrompt }], temperature: 0.5, max_tokens: 500 @@ -6358,13 +6362,13 @@ User Question: ${userQuestion} } } else { // Default to Ollama - logger.info(`[Bot] Answering question with Ollama model: ${OLLAMA_MODEL}`); + logger.info(`[Bot] Answering question with Ollama model: ${ollamaModel}`); - const response = await fetch(`${OLLAMA_URL}/api/generate`, { + const response = await fetch(`${ollamaUrl}/api/generate`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, prompt: commonPrompt, stream: false, options: { num_ctx: 35000 } From 619ab35906c316cf9e0303d90f923dc96dc74e4c Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 23:04:40 -0500 Subject: [PATCH 08/10] Resolve transcription settings at runtime --- bot.js | 102 ++++++++++++++++++++------------ public/settings.html | 7 +++ public/settings.js | 5 +- src/settings/settingsService.js | 5 ++ 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/bot.js b/bot.js index 5b73dd6..f315194 100644 --- a/bot.js +++ b/bot.js @@ -382,8 +382,8 @@ function cleanStaleQueueEntries() { function detectTwoTone(audioFilePath, transcriptionId, talkGroupID, callback) { // For non-local modes, use a separate Python process for tone detection - if (effectiveTranscriptionMode !== 'local') { - logger.info(`Using standalone tone detection for ${effectiveTranscriptionMode} mode`); + if (activeTranscriptionMode !== 'local') { + logger.info(`Using standalone tone detection for ${activeTranscriptionMode} mode`); return detectTwoToneStandalone(audioFilePath, transcriptionId, talkGroupID, callback); } @@ -951,6 +951,25 @@ async function getBotRuntimeConfig() { return getRuntimeConfig(db, process.env); } +async function getBotTranscriptionConfig() { + const runtime = await getBotRuntimeConfig(); + const mode = (runtime.settings.transcriptionMode || TRANSCRIPTION_MODE || 'local').toLowerCase(); + + return { + mode: ['local', 'remote', 'openai', 'icad'].includes(mode) ? mode : 'local', + device: (runtime.settings.transcriptionDevice || TRANSCRIPTION_DEVICE || 'cpu').toLowerCase(), + fasterWhisperServerUrl: runtime.settings.fasterWhisperServerUrl || FASTER_WHISPER_SERVER_URL || '', + whisperModel: runtime.settings.whisperModel || WHISPER_MODEL || 'large-v3', + openaiApiKey: runtime.secrets.openaiApiKey || OPENAI_API_KEY || '', + openaiTranscriptionPrompt: runtime.settings.openaiTranscriptionPrompt || OPENAI_TRANSCRIPTION_PROMPT || '', + openaiTranscriptionModel: runtime.settings.openaiTranscriptionModel || OPENAI_TRANSCRIPTION_MODEL || 'whisper-1', + openaiTranscriptionTemperature: runtime.settings.openaiTranscriptionTemperature || OPENAI_TRANSCRIPTION_TEMPERATURE || '0.0', + icadUrl: runtime.settings.icadUrl || ICAD_URL || '', + icadProfile: runtime.settings.icadProfile || ICAD_PROFILE || 'whisper-1', + icadApiKey: runtime.secrets.icadApiKey || ICAD_API_KEY || '' + }; +} + async function getPublicAudioUrl(audioId) { let publicDomain = PUBLIC_DOMAIN || 'localhost'; try { @@ -1159,6 +1178,7 @@ let isBootComplete = false; const messageCache = new Map(); // Stores the latest message for each channel const MESSAGE_COOLDOWN = 15000; // 15 seconds in milliseconds let transcriptionProcess = null; +let activeTranscriptionMode = effectiveTranscriptionMode; let isProcessingTranscription = false; let currentTranscriptionId = null; // Track current transcription for timeout let transcriptionTimeout = null; // Timeout for current transcription @@ -1811,12 +1831,12 @@ app.get('/audio/:id', (req, res) => { // Function to start the transcription process // Function to start the transcription process async function startTranscriptionProcess() { - // *** ADD THIS CHECK AT THE TOP *** - if (effectiveTranscriptionMode !== 'local') { + const transcriptionConfig = await getBotTranscriptionConfig(); + activeTranscriptionMode = transcriptionConfig.mode; + if (transcriptionConfig.mode !== 'local') { logger.info('Transcription mode is not local, skipping Python process start.'); - return; // Don't start if mode is remote + return; } - // *** END ADDED CHECK *** // Clean up existing process if it exists if (transcriptionProcess) { @@ -2162,7 +2182,7 @@ async function startTranscriptionProcess() { transcriptionProcess.on('error', (err) => { logger.error(`Failed to start local transcription process: ${err.message}`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { logger.info('Will attempt to restart local transcription process in 10 seconds due to spawn error...'); setTimeout(startTranscriptionProcess, 10000); } @@ -2340,7 +2360,7 @@ async function startTranscriptionProcess() { cleanupTranscriptionProcess(); // Only restart if not too many recent failures - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { if (code === null) { // For null exit codes (startup crashes), wait longer and provide guidance logger.error('STARTUP CRASH DETECTED - Will NOT automatically restart to prevent loop'); @@ -2459,14 +2479,14 @@ function startProcessHealthCheck() { if (timeSinceActivity > 600000 && queueSize > 0) { // 10 minutes + queue items = real problem logger.error(`Transcription process appears stuck (no activity for 10 minutes with ${queueSize} items queued). Restarting...`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 5000); } return; } else if (timeSinceActivity > 1800000) { // 30 minutes with no activity at all (safety net) logger.warn(`Very long radio silence detected (30+ minutes). Performing health check restart as precaution...`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 5000); } return; @@ -2492,7 +2512,7 @@ function startProcessHealthCheck() { if (queueSize > 15 && !isProcessingTranscription && timeSinceActivity > 300000) { // 5 minutes + 15+ items = real stuck logger.error(`Queue definitely stuck with ${queueSize} items and no processing for 5 minutes. Force restarting transcription process...`); cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 2000); } } @@ -2559,7 +2579,7 @@ function processNextTranscription() { logger.error(`Transcription timeout for ID ${currentTranscriptionId}. Restarting process...`); // Force restart the process on timeout cleanupTranscriptionProcess(); - if (effectiveTranscriptionMode === 'local') { + if (activeTranscriptionMode === 'local') { setTimeout(startTranscriptionProcess, 5000); } }, TRANSCRIPTION_TIMEOUT_MS); @@ -2593,8 +2613,10 @@ function processNextTranscription() { // *** NEW FUNCTION for Remote Transcription *** async function transcribeAudioRemotely(filePath, callback) { + const transcriptionConfig = await getBotTranscriptionConfig(); + // Ensure URL is configured for remote mode - if (!FASTER_WHISPER_SERVER_URL) { + if (!transcriptionConfig.fasterWhisperServerUrl) { logger.error('FATAL: FASTER_WHISPER_SERVER_URL is not configured for remote mode.'); if (callback) callback(""); // Fail gracefully return; @@ -2626,14 +2648,14 @@ async function transcribeAudioRemotely(filePath, callback) { const form = new FormData(); form.append('file', fs.createReadStream(filePath)); // Append model if specified in environment - if (WHISPER_MODEL) { - form.append('model', WHISPER_MODEL); - logger.info(`Requesting remote model: ${WHISPER_MODEL}`); + if (transcriptionConfig.whisperModel) { + form.append('model', transcriptionConfig.whisperModel); + logger.info(`Requesting remote model: ${transcriptionConfig.whisperModel}`); } // Add language parameter if needed // form.append('language', 'en'); - const apiEndpoint = `${FASTER_WHISPER_SERVER_URL}/v1/audio/transcriptions`; + const apiEndpoint = `${transcriptionConfig.fasterWhisperServerUrl}/v1/audio/transcriptions`; const filenameForLog = path.basename(filePath); logger.info(`Sending remote transcription request for ${filenameForLog} to ${apiEndpoint}`); @@ -2695,8 +2717,10 @@ async function transcribeAudioRemotely(filePath, callback) { } async function transcribeWithOpenAIAPI(filePath, callback) { + const transcriptionConfig = await getBotTranscriptionConfig(); + // Check for API Key - if (!OPENAI_API_KEY) { + if (!transcriptionConfig.openaiApiKey) { logger.error('FATAL: TRANSCRIPTION_MODE is openai, but OPENAI_API_KEY is not configured.'); if (callback) callback(""); // Fail gracefully return; @@ -2714,21 +2738,21 @@ async function transcribeWithOpenAIAPI(filePath, callback) { form.append('file', fs.createReadStream(filePath)); // Use the model from environment variable, fallback to whisper-1 if not set - const modelToUse = OPENAI_TRANSCRIPTION_MODEL || 'whisper-1'; + const modelToUse = transcriptionConfig.openaiTranscriptionModel; form.append('model', modelToUse); // Force language to English for better scanner audio transcription form.append('language', 'en'); // Add temperature parameter for transcription consistency (if supported) - const temperature = OPENAI_TRANSCRIPTION_TEMPERATURE || '0.0'; + const temperature = transcriptionConfig.openaiTranscriptionTemperature; form.append('temperature', temperature); const filenameForLog = path.basename(filePath); // Add custom prompt if configured to improve scanner audio transcription - if (OPENAI_TRANSCRIPTION_PROMPT) { - form.append('prompt', OPENAI_TRANSCRIPTION_PROMPT); + if (transcriptionConfig.openaiTranscriptionPrompt) { + form.append('prompt', transcriptionConfig.openaiTranscriptionPrompt); logger.info(`Using custom OpenAI transcription prompt for ${filenameForLog}`); } @@ -2747,7 +2771,7 @@ async function transcribeWithOpenAIAPI(filePath, callback) { method: 'POST', body: form, headers: { - 'Authorization': `Bearer ${OPENAI_API_KEY}`, + 'Authorization': `Bearer ${transcriptionConfig.openaiApiKey}`, ...form.getHeaders() }, signal: controller.signal @@ -2785,8 +2809,10 @@ async function transcribeWithOpenAIAPI(filePath, callback) { } async function transcribeWithICADAPI(filePath, callback) { + const transcriptionConfig = await getBotTranscriptionConfig(); + // Check for ICAD URL - if (!ICAD_URL) { + if (!transcriptionConfig.icadUrl) { logger.error('FATAL: TRANSCRIPTION_MODE is icad, but ICAD_URL is not configured.'); if (callback) callback(""); // Fail gracefully return; @@ -2804,7 +2830,7 @@ async function transcribeWithICADAPI(filePath, callback) { form.append('file', fs.createReadStream(filePath)); // Set model based on ICAD_PROFILE if provided, otherwise use default - const modelToUse = ICAD_PROFILE || 'whisper-1'; + const modelToUse = transcriptionConfig.icadProfile; form.append('model', modelToUse); // Add standard OpenAI Whisper API parameters that ICAD should understand @@ -2814,9 +2840,9 @@ async function transcribeWithICADAPI(filePath, callback) { // Explicitly disable clip_timestamps to override any profile settings form.append('clip_timestamps', ''); - const apiEndpoint = `${ICAD_URL}/v1/audio/transcriptions`; + const apiEndpoint = `${transcriptionConfig.icadUrl}/v1/audio/transcriptions`; const filenameForLog = path.basename(filePath); - const authStatus = ICAD_API_KEY ? 'with authentication' : 'without authentication'; + const authStatus = transcriptionConfig.icadApiKey ? 'with authentication' : 'without authentication'; logger.info(`Sending ICAD transcription request for ${filenameForLog} to ${apiEndpoint} using model/profile: ${modelToUse} (${authStatus})`); const controller = new AbortController(); @@ -2830,8 +2856,8 @@ async function transcribeWithICADAPI(filePath, callback) { }; // Add authorization header if ICAD_API_KEY is provided - if (ICAD_API_KEY) { - headers['Authorization'] = `Bearer ${ICAD_API_KEY}`; + if (transcriptionConfig.icadApiKey) { + headers['Authorization'] = `Bearer ${transcriptionConfig.icadApiKey}`; } const response = await fetch(apiEndpoint, { @@ -3029,6 +3055,7 @@ function handleNewAudio(audioData) { const transcriptionId = this.lastID; // Get the ID from the database insert let transcriptionJobId = null; + const transcriptionConfig = await getBotTranscriptionConfig(); logger.info(`Created transcription record ID ${transcriptionId} using storage path: ${storagePath}`); try { @@ -3038,7 +3065,7 @@ function handleNewAudio(audioData) { payload: { filename, talkGroupID, - transcriptionMode: effectiveTranscriptionMode, + transcriptionMode: transcriptionConfig.mode, storageMode: STORAGE_MODE } }); @@ -3147,7 +3174,7 @@ function handleNewAudio(audioData) { }; // Transcribe based on mode (use the same mode as the main call) - const segmentTranscriptionMode = effectiveTranscriptionMode || 'local'; + const segmentTranscriptionMode = transcriptionConfig.mode; if (segmentTranscriptionMode === 'openai') { transcribeWithOpenAIAPI(segment.audioPath, segmentCallback); } else if (segmentTranscriptionMode === 'remote') { @@ -3275,20 +3302,20 @@ function handleNewAudio(audioData) { // --- End common callback definition --- // --- Choose transcription method based on mode --- - logger.info(`Initiating transcription for ID ${transcriptionId} using mode: ${effectiveTranscriptionMode}`); + logger.info(`Initiating transcription for ID ${transcriptionId} using mode: ${transcriptionConfig.mode}`); if (transcriptionJobId) { safelyUpdateProcessingJob('mark transcription job processing', () => markJobProcessing(db, transcriptionJobId)); } - if (effectiveTranscriptionMode === 'openai') { + if (transcriptionConfig.mode === 'openai') { // OpenAI API transcription mode const pathToUse = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; transcribeWithOpenAIAPI(pathToUse, processingCallback); - } else if (effectiveTranscriptionMode === 'remote') { + } else if (transcriptionConfig.mode === 'remote') { // Use the remote function for faster-whisper server const pathToUseForRemote = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; transcribeAudioRemotely(pathToUseForRemote, processingCallback); - } else if (effectiveTranscriptionMode === 'icad') { + } else if (transcriptionConfig.mode === 'icad') { // ICAD API transcription mode (OpenAI-compatible interface) const pathToUse = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; transcribeWithICADAPI(pathToUse, processingCallback); @@ -5976,11 +6003,12 @@ client.once('ready', async () => { startSummaryScheduler(); // Start transcription process if needed - if (effectiveTranscriptionMode === 'local') { + const transcriptionConfig = await getBotTranscriptionConfig(); + if (transcriptionConfig.mode === 'local') { logger.info('Initializing local transcription process...'); startTranscriptionProcess(); } else { - logger.info(`Transcription mode set to '${effectiveTranscriptionMode}'. Local Python process will not be started.`); + logger.info(`Transcription mode set to '${transcriptionConfig.mode}'. Local Python process will not be started.`); } isBootComplete = true; diff --git a/public/settings.html b/public/settings.html index 89224b5..e10a199 100644 --- a/public/settings.html +++ b/public/settings.html @@ -55,9 +55,16 @@

Providers

+
+
+
+
+
+
+
diff --git a/public/settings.js b/public/settings.js index 47d24ae..eafc149 100644 --- a/public/settings.js +++ b/public/settings.js @@ -2,9 +2,10 @@ const normalKeys = [ 'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours', 'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode', 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', - 'fasterWhisperServerUrl' + 'fasterWhisperServerUrl', 'whisperModel', 'openaiTranscriptionPrompt', + 'openaiTranscriptionModel', 'openaiTranscriptionTemperature', 'icadUrl', 'icadProfile' ]; -const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey']; +const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey', 'icadApiKey']; function showStep(id) { document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id)); diff --git a/src/settings/settingsService.js b/src/settings/settingsService.js index b61d46d..a5c17fa 100644 --- a/src/settings/settingsService.js +++ b/src/settings/settingsService.js @@ -14,6 +14,11 @@ const SETTING_DEFINITIONS = { openaiModel: { envKey: 'OPENAI_MODEL', defaultValue: 'gpt-4o-mini', requiresRestart: false }, fasterWhisperServerUrl: { envKey: 'FASTER_WHISPER_SERVER_URL', defaultValue: '', requiresRestart: false }, whisperModel: { envKey: 'WHISPER_MODEL', defaultValue: 'large-v3', requiresRestart: false }, + openaiTranscriptionPrompt: { envKey: 'OPENAI_TRANSCRIPTION_PROMPT', defaultValue: '', requiresRestart: false }, + openaiTranscriptionModel: { envKey: 'OPENAI_TRANSCRIPTION_MODEL', defaultValue: 'whisper-1', requiresRestart: false }, + openaiTranscriptionTemperature: { envKey: 'OPENAI_TRANSCRIPTION_TEMPERATURE', defaultValue: '0.0', requiresRestart: false }, + icadUrl: { envKey: 'ICAD_URL', defaultValue: '', requiresRestart: false }, + icadProfile: { envKey: 'ICAD_PROFILE', defaultValue: 'whisper-1', requiresRestart: false }, mappedTalkGroups: { envKey: 'MAPPED_TALK_GROUPS', defaultValue: '', requiresRestart: false }, enableMappedTalkGroups: { envKey: 'ENABLE_MAPPED_TALK_GROUPS', defaultValue: 'true', requiresRestart: false }, summaryLookbackHours: { envKey: 'SUMMARY_LOOKBACK_HOURS', defaultValue: '1', requiresRestart: false }, From 5432931b15138d12c7871c9739bddd8cb2cb0b06 Mon Sep 17 00:00:00 2001 From: Dadud Date: Sun, 17 May 2026 23:11:09 -0500 Subject: [PATCH 09/10] Harden runtime storage setup --- bot.js | 140 +++++++++++++++++++++----------- public/settings.html | 4 + public/settings.js | 4 +- public/setup.html | 16 ++++ public/setup.js | 18 ++++ src/settings/settingsService.js | 2 + src/setup/checks.js | 32 ++++++-- test/setupChecks.test.js | 56 ++++++++++++- webserver.js | 92 ++++++++++++++------- 9 files changed, 280 insertions(+), 84 deletions(-) diff --git a/bot.js b/bot.js index f315194..3b70b82 100644 --- a/bot.js +++ b/bot.js @@ -808,24 +808,7 @@ const logger = winston.createLogger({ // --- NEW: Add S3 Client Setup --- const AWS = require('aws-sdk'); -let s3 = null; -if (STORAGE_MODE === 's3') { - if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { - logger.warn('WARNING: STORAGE_MODE is s3, but required S3 environment variables are missing. Audio serving from S3 will be unavailable until setup is completed.'); - } else { - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 - signatureVersion: 'v4' - }); - s3 = new AWS.S3(); - logger.info(`[Bot] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); - } -} else { - logger.info('[Bot] Storage mode set to local.'); -} +logger.info(`[Bot] Startup storage mode from .env: ${STORAGE_MODE || 'local'}. Runtime settings may override this after database initialization.`); // --- END S3 Client Setup --- // --- INITIALIZATION FUNCTIONS --- @@ -970,6 +953,46 @@ async function getBotTranscriptionConfig() { }; } +async function getBotStorageConfig() { + const runtime = await getBotRuntimeConfig(); + const mode = (runtime.settings.storageMode || STORAGE_MODE || 'local').toLowerCase(); + + return { + mode: mode === 's3' ? 's3' : 'local', + s3Endpoint: runtime.settings.s3Endpoint || S3_ENDPOINT || '', + s3BucketName: runtime.settings.s3BucketName || S3_BUCKET_NAME || '', + s3AccessKeyId: runtime.secrets.s3AccessKeyId || S3_ACCESS_KEY_ID || '', + s3SecretAccessKey: runtime.secrets.s3SecretAccessKey || S3_SECRET_ACCESS_KEY || '' + }; +} + +function createS3Client(storageConfig) { + return new AWS.S3({ + accessKeyId: storageConfig.s3AccessKeyId, + secretAccessKey: storageConfig.s3SecretAccessKey, + endpoint: storageConfig.s3Endpoint, + s3ForcePathStyle: true, + signatureVersion: 'v4' + }); +} + +function isS3Ready(storageConfig) { + return Boolean( + storageConfig.mode === 's3' && + storageConfig.s3Endpoint && + storageConfig.s3BucketName && + storageConfig.s3AccessKeyId && + storageConfig.s3SecretAccessKey + ); +} + +function getToneAudioPath(storageConfig, audioFilePath) { + if (storageConfig.mode === 's3' && storageConfig.s3Endpoint && storageConfig.s3BucketName) { + return `https://${storageConfig.s3Endpoint.replace('https://', '').replace('http://', '')}/${storageConfig.s3BucketName}/${audioFilePath}`; + } + return path.join(__dirname, 'audio', audioFilePath); +} + async function getPublicAudioUrl(audioId) { let publicDomain = PUBLIC_DOMAIN || 'localhost'; try { @@ -3005,7 +3028,7 @@ function handleNewAudio(audioData) { } // Read file into buffer (This is needed for DB blob AND for S3->Local transcription) - fs.readFile(tempPath, (err, fileBuffer) => { + fs.readFile(tempPath, async (err, fileBuffer) => { if (err) { logger.error(`Error reading audio file ${tempPath}:`, err); // Clean up temp file if read fails @@ -3015,10 +3038,12 @@ function handleNewAudio(audioData) { return; } + const storageConfig = await getBotStorageConfig(); + // --- Start DB Operations --- Miminized changes here // Determine the storage path/key based on STORAGE_MODE let storagePath; - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // For S3, we store the filename as the key (assuming it's unique enough) // You might want a more structured path like 'audio/YYYY/MM/DD/filename' storagePath = filename; @@ -3066,7 +3091,7 @@ function handleNewAudio(audioData) { filename, talkGroupID, transcriptionMode: transcriptionConfig.mode, - storageMode: STORAGE_MODE + storageMode: storageConfig.mode } }); logger.info(`Created transcription job ${transcriptionJobId} for transcription ID ${transcriptionId}`); @@ -3075,7 +3100,7 @@ function handleNewAudio(audioData) { } // Conditionally insert audio blob for Listen Live feature (local storage only) - if (STORAGE_MODE !== 's3') { + if (storageConfig.mode !== 's3') { db.run( `INSERT INTO audio_files (transcription_id, audio_data) VALUES (?, ?)`, [transcriptionId, fileBuffer], @@ -3168,7 +3193,8 @@ function handleNewAudio(audioData) { storagePath, audioPathForSplitting, tempPath, - finalPathIfLocal + finalPathIfLocal, + storageConfig.mode ); } }; @@ -3227,9 +3253,9 @@ function handleNewAudio(audioData) { logger.info(`Checking for two-tone in talk group ${talkGroupID} (ID: ${transcriptionId}) - empty transcription`); // Use the audio file path for tone detection - const audioPathForTones = STORAGE_MODE === 's3' ? - `https://${S3_ENDPOINT.replace('https://', '').replace('http://', '')}/${S3_BUCKET_NAME}/${filename}` : - (finalPathIfLocal || path.join(__dirname, 'audio', filename)); + const audioPathForTones = storageConfig.mode === 's3' + ? getToneAudioPath(storageConfig, filename) + : (finalPathIfLocal || path.join(__dirname, 'audio', filename)); // Wait for tone detection to complete before continuing await new Promise((resolve) => { @@ -3243,7 +3269,7 @@ function handleNewAudio(audioData) { } // Clean up temp file only if storage was S3 - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // Use setImmediate to avoid file handle race conditions setImmediate(() => { fs.unlink(tempPath, (errUnlink) => { @@ -3274,11 +3300,12 @@ function handleNewAudio(audioData) { emergency, priority, encrypted, call_length, // <-- Pass call metadata freq_error, signalQuality, // <-- Pass signal quality frequency, start_time, stop_time, // <-- Pass timing/frequency - tdma_slot, phase2_tdma, color_code // <-- Pass TDMA/color code + tdma_slot, phase2_tdma, color_code, // <-- Pass TDMA/color code + storageConfig ); // Clean up temp file only if storage was S3 - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // Use setImmediate to avoid file handle race conditions setImmediate(() => { fs.unlink(tempPath, (errUnlink) => { @@ -3309,21 +3336,21 @@ function handleNewAudio(audioData) { if (transcriptionConfig.mode === 'openai') { // OpenAI API transcription mode - const pathToUse = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; + const pathToUse = (storageConfig.mode === 'local') ? finalPathIfLocal : tempPath; transcribeWithOpenAIAPI(pathToUse, processingCallback); } else if (transcriptionConfig.mode === 'remote') { // Use the remote function for faster-whisper server - const pathToUseForRemote = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; + const pathToUseForRemote = (storageConfig.mode === 'local') ? finalPathIfLocal : tempPath; transcribeAudioRemotely(pathToUseForRemote, processingCallback); } else if (transcriptionConfig.mode === 'icad') { // ICAD API transcription mode (OpenAI-compatible interface) - const pathToUse = (STORAGE_MODE === 'local') ? finalPathIfLocal : tempPath; + const pathToUse = (storageConfig.mode === 'local') ? finalPathIfLocal : tempPath; transcribeWithICADAPI(pathToUse, processingCallback); } else { // 'local' transcription mode const localRequestId = uuidv4(); let payload; - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { // S3 Storage + Local Transcription: Send buffer logger.info(`Queueing local transcription (ID: ${localRequestId}) for DB ID ${transcriptionId} using BASE64 BUFFER`); @@ -3417,22 +3444,35 @@ function handleNewAudio(audioData) { // --- End afterStorageComplete function definition --- // --- Handle Audio Storage based on Mode --- - if (STORAGE_MODE === 's3') { + if (storageConfig.mode === 's3') { + if (!isS3Ready(storageConfig)) { + const error = new Error('S3 storage mode is selected, but S3 endpoint, bucket, or credentials are incomplete.'); + logger.error(error.message); + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job failed', () => markJobFailed(db, transcriptionJobId, error)); + } + db.run('DELETE FROM transcriptions WHERE id = ?', [transcriptionId], () => {}); + fs.unlink(tempPath, (errUnlink) => { + if (errUnlink) logger.error(`Error deleting temp file after incomplete S3 config ${tempPath}:`, errUnlink); + }); + return; + } + const s3Client = createS3Client(storageConfig); // Upload the buffer to S3 const s3Params = { - Bucket: S3_BUCKET_NAME, + Bucket: storageConfig.s3BucketName, Key: storagePath, // Use the determined S3 key Body: fileBuffer, // ContentType: 'audio/mpeg', // Or determine dynamically }; - s3.upload(s3Params, (s3Err, data) => { + s3Client.upload(s3Params, (s3Err, data) => { if (s3Err) { // Check for specific MinIO storage threshold error const errorMessage = s3Err.message || s3Err.toString() || ''; if (errorMessage.includes('minimum free drive threshold') || errorMessage.includes('free drive threshold')) { logger.error(`[MINIO STORAGE FULL] MinIO server has reached its minimum free drive threshold.`); logger.error(`[MINIO STORAGE FULL] Transcription ID ${transcriptionId} could not be uploaded.`); - logger.error(`[MINIO STORAGE FULL] Action required: Free up disk space on MinIO server or delete old objects from bucket: ${S3_BUCKET_NAME}`); + logger.error(`[MINIO STORAGE FULL] Action required: Free up disk space on MinIO server or delete old objects from bucket: ${storageConfig.s3BucketName}`); logger.error(`[MINIO STORAGE FULL] Full error: ${errorMessage}`); } else { logger.error(`Error uploading audio to S3 for transcription ID ${transcriptionId} (key: ${storagePath}):`, s3Err); @@ -3924,7 +3964,8 @@ async function processMergedCallSegments( storagePath, audioPathForSplitting, tempPath, - finalPathIfLocal + finalPathIfLocal, + storageMode = STORAGE_MODE ) { logger.info(`Processing ${segmentTranscriptions.length} segments for merged call ID ${transcriptionId}`); @@ -3990,7 +4031,7 @@ async function processMergedCallSegments( ); // Clean up temp file only if storage was S3 - if (STORAGE_MODE === 's3') { + if (storageMode === 's3') { setImmediate(() => { fs.unlink(tempPath, (errUnlink) => { if (errUnlink && errUnlink.code !== 'ENOENT') { @@ -4029,11 +4070,13 @@ async function handleNewTranscription( stop_time, tdma_slot, phase2_tdma, - color_code + color_code, + storageConfig = null ) { logger.info(`Starting handleNewTranscription for ID ${id}`); logger.info(`Transcription text length: ${transcriptionText.length} characters`); logger.info(`Talk Group: ${talkGroupID} - ${talkGroupName}`); + const resolvedStorageConfig = storageConfig || await getBotStorageConfig(); // Auto-queue calls after two-tone detection (if in two-tone mode) if (IS_TWO_TONE_MODE_ENABLED && lastTwoToneTime > 0 && lastDetectedToneGroup) { @@ -4081,9 +4124,7 @@ async function handleNewTranscription( logger.info(`Checking for two-tone in talk group ${talkGroupID} (ID: ${id})`); // Construct the proper audio path for tone detection - const audioPathForTones = STORAGE_MODE === 's3' ? - `https://${S3_ENDPOINT.replace('https://', '').replace('http://', '')}/${S3_BUCKET_NAME}/${audioFilePath}` : - path.join(__dirname, 'audio', audioFilePath); // Construct full local path + const audioPathForTones = getToneAudioPath(resolvedStorageConfig, audioFilePath); // Wait for tone detection to complete before continuing await new Promise((resolve) => { @@ -5602,7 +5643,7 @@ function playAudioForTalkGroup(talkGroupID, transcriptionId) { } } -function processAudioQueue(talkGroupID) { +async function processAudioQueue(talkGroupID) { talkGroupID = talkGroupID.toString(); const talkGroupData = activeVoiceChannels.get(talkGroupID); if (!talkGroupData || !talkGroupData.player || !talkGroupData.queue) { @@ -5648,14 +5689,21 @@ function processAudioQueue(talkGroupID) { }); }; - if (STORAGE_MODE === 's3') { + const storageConfig = await getBotStorageConfig(); + if (storageConfig.mode === 's3') { + if (!isS3Ready(storageConfig)) { + logger.error(`S3 Mode: storage configuration incomplete for Discord playback (ID ${transcriptionId})`); + processAudioQueue(talkGroupID); + return; + } + const s3Client = createS3Client(storageConfig); db.get('SELECT audio_file_path FROM transcriptions WHERE id = ?', [transcriptionId], (err, row) => { if (err || !row || !row.audio_file_path) { logger.error(`S3 Mode: Could not find audio_file_path for transcription ID ${transcriptionId}`, err); processAudioQueue(talkGroupID); return; } - const s3Stream = s3.getObject({ Bucket: S3_BUCKET_NAME, Key: row.audio_file_path }).createReadStream(); + const s3Stream = s3Client.getObject({ Bucket: storageConfig.s3BucketName, Key: row.audio_file_path }).createReadStream(); s3Stream.on('error', s3Err => { logger.error(`Error streaming from S3 for Discord playback (ID ${transcriptionId}):`, s3Err); processAudioQueue(talkGroupID); diff --git a/public/settings.html b/public/settings.html index e10a199..ac2c6f0 100644 --- a/public/settings.html +++ b/public/settings.html @@ -48,6 +48,10 @@

Ingestion

Providers

+
+
+
+
diff --git a/public/settings.js b/public/settings.js index eafc149..df9b4d9 100644 --- a/public/settings.js +++ b/public/settings.js @@ -1,11 +1,11 @@ const normalKeys = [ 'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours', 'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode', - 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', + 's3Endpoint', 's3BucketName', 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', 'fasterWhisperServerUrl', 'whisperModel', 'openaiTranscriptionPrompt', 'openaiTranscriptionModel', 'openaiTranscriptionTemperature', 'icadUrl', 'icadProfile' ]; -const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey', 'icadApiKey']; +const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey', 'icadApiKey', 's3AccessKeyId', 's3SecretAccessKey']; function showStep(id) { document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id)); diff --git a/public/setup.html b/public/setup.html index abc6824..4098efe 100644 --- a/public/setup.html +++ b/public/setup.html @@ -84,6 +84,22 @@

Providers

+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/public/setup.js b/public/setup.js index 30a1540..55a1874 100644 --- a/public/setup.js +++ b/public/setup.js @@ -73,6 +73,8 @@ document.getElementById('save-providers').addEventListener('click', async () => method: 'POST', body: JSON.stringify({ storageMode: document.getElementById('storage-mode').value, + s3Endpoint: document.getElementById('s3-endpoint').value, + s3BucketName: document.getElementById('s3-bucket').value, transcriptionMode: document.getElementById('transcription-mode').value, aiProvider: document.getElementById('ai-provider').value, timezone: document.getElementById('timezone').value @@ -95,6 +97,22 @@ document.getElementById('save-providers').addEventListener('click', async () => }); } + const s3AccessKey = document.getElementById('s3-access-key').value; + if (s3AccessKey) { + await jsonFetch('/api/setup/secrets', { + method: 'POST', + body: JSON.stringify({ key: 's3AccessKeyId', value: s3AccessKey }) + }); + } + + const s3SecretKey = document.getElementById('s3-secret-key').value; + if (s3SecretKey) { + await jsonFetch('/api/setup/secrets', { + method: 'POST', + body: JSON.stringify({ key: 's3SecretAccessKey', value: s3SecretKey }) + }); + } + renderMessage('provider-result', 'Provider settings saved. Restart may be required for some settings.'); await loadStatus(); } catch (error) { diff --git a/src/settings/settingsService.js b/src/settings/settingsService.js index a5c17fa..7fdb2f7 100644 --- a/src/settings/settingsService.js +++ b/src/settings/settingsService.js @@ -6,6 +6,8 @@ const SETTING_DEFINITIONS = { publicDomain: { envKey: 'PUBLIC_DOMAIN', defaultValue: 'localhost', requiresRestart: true }, timezone: { envKey: 'TIMEZONE', defaultValue: 'US/Eastern', requiresRestart: false }, storageMode: { envKey: 'STORAGE_MODE', defaultValue: 'local', requiresRestart: true }, + s3Endpoint: { envKey: 'S3_ENDPOINT', defaultValue: '', requiresRestart: true }, + s3BucketName: { envKey: 'S3_BUCKET_NAME', defaultValue: '', requiresRestart: true }, transcriptionMode: { envKey: 'TRANSCRIPTION_MODE', defaultValue: 'local', requiresRestart: true }, transcriptionDevice: { envKey: 'TRANSCRIPTION_DEVICE', defaultValue: 'cpu', requiresRestart: true }, aiProvider: { envKey: 'AI_PROVIDER', defaultValue: 'ollama', requiresRestart: false }, diff --git a/src/setup/checks.js b/src/setup/checks.js index 9760a4f..900cfb5 100644 --- a/src/setup/checks.js +++ b/src/setup/checks.js @@ -42,6 +42,24 @@ async function runSetupChecks(options = {}) { const rootDir = options.rootDir || path.join(__dirname, '..', '..'); const env = options.env || process.env; const runtime = options.runtime || { settings: {}, secrets: {} }; + const storageMode = (runtime.settings.storageMode || env.STORAGE_MODE || 'local').toLowerCase(); + const transcriptionMode = (runtime.settings.transcriptionMode || env.TRANSCRIPTION_MODE || 'local').toLowerCase(); + const aiProvider = (runtime.settings.aiProvider || env.AI_PROVIDER || 'ollama').toLowerCase(); + const hasS3Config = Boolean( + runtime.settings.s3Endpoint || env.S3_ENDPOINT + ) && Boolean( + runtime.settings.s3BucketName || env.S3_BUCKET_NAME + ) && Boolean( + runtime.secrets.s3AccessKeyId || env.S3_ACCESS_KEY_ID + ) && Boolean( + runtime.secrets.s3SecretAccessKey || env.S3_SECRET_ACCESS_KEY + ); + const transcriptionReady = + transcriptionMode === 'local' || + (transcriptionMode === 'remote' && Boolean(runtime.settings.fasterWhisperServerUrl || env.FASTER_WHISPER_SERVER_URL)) || + (transcriptionMode === 'openai' && Boolean(runtime.secrets.openaiApiKey || env.OPENAI_API_KEY)) || + (transcriptionMode === 'icad' && Boolean(runtime.settings.icadUrl || env.ICAD_URL)); + const aiReady = aiProvider !== 'openai' || Boolean(runtime.secrets.openaiApiKey || env.OPENAI_API_KEY); const [node, python, ffmpeg, ollama] = await Promise.all([ checkCommand(process.execPath, ['--version']), checkCommand(env.PYTHON_COMMAND || (process.platform === 'win32' ? 'py' : 'python3'), ['--version']), @@ -65,15 +83,19 @@ async function runSetupChecks(options = {}) { } }, transcriptionProvider: { - ok: Boolean(runtime.settings.transcriptionMode || env.TRANSCRIPTION_MODE || 'local'), - mode: runtime.settings.transcriptionMode || env.TRANSCRIPTION_MODE || 'local' + ok: transcriptionReady, + mode: transcriptionMode }, aiProvider: { - ok: Boolean(runtime.settings.aiProvider || env.AI_PROVIDER || 'ollama'), - provider: runtime.settings.aiProvider || env.AI_PROVIDER || 'ollama' + ok: aiReady, + provider: aiProvider + }, + storageProvider: { + ok: storageMode === 'local' || hasS3Config, + mode: storageMode }, uploadEndpoint: { - ok: true, + ok: Boolean(runtime.secrets.uploadApiKey || env.SCANNER_MAP_UPLOAD_API_KEY), url: `/api/call-upload` } }; diff --git a/test/setupChecks.test.js b/test/setupChecks.test.js index d24ad02..a514567 100644 --- a/test/setupChecks.test.js +++ b/test/setupChecks.test.js @@ -4,7 +4,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { checkWritableDir } = require('../src/setup/checks'); +const { checkWritableDir, runSetupChecks } = require('../src/setup/checks'); test('checkWritableDir creates and verifies writable directories', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-')); @@ -16,3 +16,57 @@ test('checkWritableDir creates and verifies writable directories', () => { assert.equal(fs.existsSync(nested), true); fs.rmSync(tempDir, { recursive: true, force: true }); }); + +test('runSetupChecks validates provider-specific readiness from runtime config', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-')); + + const checks = await runSetupChecks({ + rootDir: tempDir, + env: {}, + runtime: { + settings: { + storageMode: 's3', + transcriptionMode: 'remote', + aiProvider: 'openai' + }, + secrets: {} + } + }); + + assert.equal(checks.storageProvider.ok, false); + assert.equal(checks.transcriptionProvider.ok, false); + assert.equal(checks.aiProvider.ok, false); + assert.equal(checks.uploadEndpoint.ok, false); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test('runSetupChecks accepts configured S3 and provider secrets', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-')); + + const checks = await runSetupChecks({ + rootDir: tempDir, + env: {}, + runtime: { + settings: { + storageMode: 's3', + s3Endpoint: 'http://localhost:9000', + s3BucketName: 'scanner-audio', + transcriptionMode: 'remote', + fasterWhisperServerUrl: 'http://localhost:8000', + aiProvider: 'openai' + }, + secrets: { + s3AccessKeyId: 'key', + s3SecretAccessKey: 'secret', + openaiApiKey: 'openai', + uploadApiKey: 'upload' + } + } + }); + + assert.equal(checks.storageProvider.ok, true); + assert.equal(checks.transcriptionProvider.ok, true); + assert.equal(checks.aiProvider.ok, true); + assert.equal(checks.uploadEndpoint.ok, true); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); diff --git a/webserver.js b/webserver.js index 1631162..03c9c9f 100644 --- a/webserver.js +++ b/webserver.js @@ -136,25 +136,7 @@ app.get('/api/test', (req, res) => { res.json({ message: 'Server is working', timestamp: Date.now() }); }); -// --- NEW: S3 Client Setup --- -let s3 = null; -if (STORAGE_MODE === 's3') { - if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { - console.warn('WARNING: STORAGE_MODE=s3, but S3 configuration is incomplete. Audio serving from S3 will be unavailable until setup is completed.'); - } else { - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 - signatureVersion: 'v4' - }); - s3 = new AWS.S3(); - console.log(`[Webserver] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); - } -} else { - console.log('[Webserver] Storage mode set to local.'); -} +console.log(`[Webserver] Startup storage mode from .env: ${STORAGE_MODE || 'local'}. Runtime settings may override this after database initialization.`); // Authentication is enabled if ENABLE_AUTH=true const authEnabled = ENABLE_AUTH?.toLowerCase() === 'true'; @@ -199,8 +181,40 @@ async function getResolvedRuntimeConfig() { await dbReady; return getRuntimeConfig(db, process.env); } - -// Helper Functions for Authentication + +async function getWebserverStorageConfig() { + const runtime = await getResolvedRuntimeConfig(); + const mode = (runtime.settings.storageMode || STORAGE_MODE || 'local').toLowerCase(); + return { + mode: mode === 's3' ? 's3' : 'local', + s3Endpoint: runtime.settings.s3Endpoint || S3_ENDPOINT || '', + s3BucketName: runtime.settings.s3BucketName || S3_BUCKET_NAME || '', + s3AccessKeyId: runtime.secrets.s3AccessKeyId || S3_ACCESS_KEY_ID || '', + s3SecretAccessKey: runtime.secrets.s3SecretAccessKey || S3_SECRET_ACCESS_KEY || '' + }; +} + +function isS3Ready(storageConfig) { + return Boolean( + storageConfig.mode === 's3' && + storageConfig.s3Endpoint && + storageConfig.s3BucketName && + storageConfig.s3AccessKeyId && + storageConfig.s3SecretAccessKey + ); +} + +function createS3Client(storageConfig) { + return new AWS.S3({ + accessKeyId: storageConfig.s3AccessKeyId, + secretAccessKey: storageConfig.s3SecretAccessKey, + endpoint: storageConfig.s3Endpoint, + s3ForcePathStyle: true, + signatureVersion: 'v4' + }); +} + +// Helper Functions for Authentication function hashPassword(password, salt) { return crypto .pbkdf2Sync(password, salt, 10000, 64, 'sha512') @@ -700,14 +714,21 @@ app.get('/audio/:id', async (req, res) => { }); }); - if (transcriptionRow && transcriptionRow.audio_file_path) { - const audioStoragePath = transcriptionRow.audio_file_path; - const extension = path.extname(audioStoragePath).toLowerCase(); - const contentType = extension === '.m4a' ? 'audio/mp4' : 'audio/mpeg'; - - if (STORAGE_MODE === 's3') { - const params = { Bucket: S3_BUCKET_NAME, Key: audioStoragePath }; - const s3Stream = s3.getObject(params).createReadStream(); + if (transcriptionRow && transcriptionRow.audio_file_path) { + const audioStoragePath = transcriptionRow.audio_file_path; + const extension = path.extname(audioStoragePath).toLowerCase(); + const contentType = extension === '.m4a' ? 'audio/mp4' : 'audio/mpeg'; + const storageConfig = await getWebserverStorageConfig(); + + if (storageConfig.mode === 's3') { + if (!isS3Ready(storageConfig)) { + console.warn(`[Audio S3] S3 runtime settings are incomplete. Falling back to DB for transcription ${transcriptionId}.`); + serveAudioFromDb(res, transcriptionId); + return; + } + const s3Client = createS3Client(storageConfig); + const params = { Bucket: storageConfig.s3BucketName, Key: audioStoragePath }; + const s3Stream = s3Client.getObject(params).createReadStream(); s3Stream.on('error', (s3Err) => { console.warn(`[Audio S3] S3 stream error for key ${audioStoragePath}: ${s3Err.code}. Falling back to DB.`); serveAudioFromDb(res, transcriptionId); @@ -835,7 +856,7 @@ app.post('/api/setup/test-provider', async (req, res) => { geocoding: checks.geocodingProvider, transcription: checks.transcriptionProvider, ai: checks.aiProvider, - storage: checks.dataDir, + storage: checks.storageProvider, upload: checks.uploadEndpoint }; res.json(providerMap[provider] || { ok: false, error: 'Unknown provider test' }); @@ -852,6 +873,17 @@ app.post('/api/setup/complete', async (req, res) => { if (status.missing.length > 0) { return res.status(400).json({ error: 'Setup is incomplete', missing: status.missing }); } + const runtime = await getResolvedRuntimeConfig(); + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env, runtime }); + const requiredChecks = ['node', 'python', 'ffmpeg', 'dataDir', 'audioDir', 'geocodingProvider', 'transcriptionProvider', 'aiProvider', 'storageProvider', 'uploadEndpoint']; + const failedChecks = requiredChecks.filter((key) => !checks[key] || !checks[key].ok); + if (failedChecks.length > 0) { + return res.status(400).json({ + error: 'Setup readiness checks failed', + failedChecks, + checks + }); + } await markSetupComplete(db, 'setup'); res.json({ ok: true, setupComplete: true }); } catch (err) { From 2a7b22c062eb0d9f49fa9661a3977b743c40d005 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 03:44:54 +0000 Subject: [PATCH 10/10] Implement full Scanner Map plan: upstream stack, Docker, security, settings UI Merge upstream architecture/runtime-settings-integration and port fork hardening overlays: API key fingerprint validation, geocode proxy, rate limits, unified polling, transcription queue facade, and WaveSurfer cleanup. Add Docker-first deployment with core/whisper/qwen/tone images, Compose profiles, GHCR publish workflow, and Unraid CA templates. Introduce pluggable local transcription backends (faster-whisper, Qwen3-ASR) with profile-based Python requirements. Expand /settings and /setup with terminal green console theme, extract shared settings CSS, and add npm run setup/doctor for native dev installs. Co-authored-by: Kyle B --- .github/workflows/docker-publish.yml | 64 ++ README.md | 213 ++--- bot.js | 67 +- docker/.env.example | 21 + docker/Dockerfile | 30 + docker/Dockerfile.qwen | 27 + docker/Dockerfile.tone | 26 + docker/Dockerfile.whisper | 26 + docker/docker-compose.gpu.yml | 25 + docker/docker-compose.yml | 42 + docker/entrypoint.sh | 16 + package-lock.json | 28 +- package.json | 5 +- public/app.js | 44 +- public/config.js | 16 +- public/css/console.css | 285 ++++++ public/css/settings.css | 435 ++++++++++ public/index.html | 444 +--------- public/js/admin-settings.js | 183 ++++ public/settings.html | 117 ++- public/settings.js | 78 -- public/setup.html | 16 +- requirements-base.txt | 4 + requirements-local-qwen.txt | 5 + requirements-local-whisper.txt | 4 + requirements-tone.txt | 2 + requirements.txt | 10 +- scripts/doctor.js | 57 ++ scripts/install-python-deps.js | 60 ++ scripts/setup.js | 50 ++ src/auth/apiKeyValidation.js | 46 + src/db/migrations.js | 10 + src/polling/callPoller.js | 117 +++ src/routes/geocodeProxy.js | 118 +++ src/settings/settingsService.js | 6 + src/transcription/queue.js | 68 ++ test/apiKeyValidation.test.js | 29 + test/migrations.test.js | 4 +- test/transcriptionQueue.test.js | 25 + test/unraidTemplates.test.js | 22 + test/up.js | 26 +- transcribe.py | 462 ++-------- transcription/__init__.py | 1 + transcription/backends/__init__.py | 35 + transcription/backends/faster_whisper.py | 73 ++ transcription/backends/qwen3_asr.py | 63 ++ transcription/router.py | 46 + unraid/README-unraid.md | 40 + unraid/icon.png | Bin 0 -> 70 bytes unraid/scanner-map-gpu.xml | 36 + unraid/scanner-map.xml | 37 + webserver.js | 1013 +++++++++------------- 52 files changed, 2908 insertions(+), 1769 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 docker/.env.example create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.qwen create mode 100644 docker/Dockerfile.tone create mode 100644 docker/Dockerfile.whisper create mode 100644 docker/docker-compose.gpu.yml create mode 100644 docker/docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 public/css/console.css create mode 100644 public/css/settings.css create mode 100644 public/js/admin-settings.js delete mode 100644 public/settings.js create mode 100644 requirements-base.txt create mode 100644 requirements-local-qwen.txt create mode 100644 requirements-local-whisper.txt create mode 100644 requirements-tone.txt create mode 100644 scripts/doctor.js create mode 100644 scripts/install-python-deps.js create mode 100644 scripts/setup.js create mode 100644 src/auth/apiKeyValidation.js create mode 100644 src/polling/callPoller.js create mode 100644 src/routes/geocodeProxy.js create mode 100644 src/transcription/queue.js create mode 100644 test/apiKeyValidation.test.js create mode 100644 test/transcriptionQueue.test.js create mode 100644 test/unraidTemplates.test.js create mode 100644 transcription/__init__.py create mode 100644 transcription/backends/__init__.py create mode 100644 transcription/backends/faster_whisper.py create mode 100644 transcription/backends/qwen3_asr.py create mode 100644 transcription/router.py create mode 100644 unraid/README-unraid.md create mode 100644 unraid/icon.png create mode 100644 unraid/scanner-map-gpu.xml create mode 100644 unraid/scanner-map.xml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d15d947 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,64 @@ +name: Docker Publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + include: + - variant: core + dockerfile: docker/Dockerfile + tag_suffix: core + - variant: whisper + dockerfile: docker/Dockerfile.whisper + tag_suffix: whisper + - variant: qwen + dockerfile: docker/Dockerfile.qwen + tag_suffix: qwen + - variant: tone + dockerfile: docker/Dockerfile.tone + tag_suffix: tone + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},suffix=-${{ matrix.tag_suffix }} + type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.tag_suffix }} + type=raw,value=${{ matrix.tag_suffix }},enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: ${{ github.event_name != 'workflow_dispatch' || github.ref_type == 'tag' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/README.md b/README.md index 4a19530..7ceee98 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,100 @@ -# Scanner Map [![Discord](https://img.shields.io/badge/Discord-Join%20Now-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/X7vej75zZy) - - -A **real-time mapping system** for radio calls. -Ingests calls from SDRTrunk, TrunkRecorder, or any **rdio-scanner compatible endpoint**, then: - -- Transcribes audio (local or cloud AI) -- Extracts and geocodes locations -- Displays calls on an interactive map with **playback** and **Discord integration** - -434934279-4f51548f-e33f-4807-a11d-d91f3a6b4db1(1) - ---- - -## 🔥 Recent Updates - -- **Admin-restricted marker editing** — Map marker editing now locked behind admin user when authentication is enabled -- **Purge calls from map** — New admin-only feature to remove calls by talkgroup category and time range, includes undo button to restore accidentally purged calls -- Full **one-command integration** (no multiple terminals) -- Auto-generated API keys & admin users -- Improved **AI summaries & Ask AI** features -- New **S3 audio storage option** -- **OpenAI transcription prompting** — configure custom prompts in `.env` to fine‑tune transcription behavior -- **Two-tone detection** — powered by [icad-tone-detection](https://github.com/TheGreatCodeholio/icad-tone-detection). - - Detects fire/EMS tones in radio calls - - Optionally restrict address extraction to toned calls only, or combine tone + address detection for greater accuracy -- **ICAD Transcribe integration** — thanks to [TheGreatCodeholio/icad_transcribe](https://github.com/TheGreatCodeholio/icad_transcribe) for providing advanced radio-optimized transcription support - ---- - -## ✨ Features - -### 🚀 Core -- **One-command startup:** `node bot.js` -- **Automatic setup:** database, API keys, talkgroups, admin accounts -- **Integrated services:** Discord bot + webserver run together - -### 🗺️ Mapping -- Real-time calls displayed on a Leaflet map -- Marker clustering, heatmaps, day/night/satellite views -- Call details with transcript + audio playback -- Call filtering and marker editing (admin-restricted when auth enabled) -- **Call purging:** Admin-only bulk removal with undo functionality - -### 🎤 Transcription -- **Local:** `faster-whisper` (CPU or NVIDIA GPU) -- **Remote:** via [speaches](https://github.com/speaches-ai/speaches) or custom servers -- **OpenAI Whisper API** with support for custom prompts -- **ICAD Transcribe** for radio-optimized results - -### 🤖 AI Enhancements -- Address extraction + geocoding (Google Maps or LocationIQ) -- AI summaries of recent transmissions -- "Ask AI" chat about call history -- Optional two‑tone detection for toned call filtering - -### 🎮 Discord Integration -- Auto-post transcriptions by talkgroup -- Keyword alerts -- AI summaries with refresh buttons -- Optional: live audio in voice channels - -### 🔒 Security -- Optional user authentication -- Auto-generated API keys -- Secure session management -- Admin-only controls for sensitive operations - ---- - -## 📦 Installation - -Supports **Windows 10/11** and **Debian/Ubuntu Linux**. -Installation scripts handle dependencies, configuration, and setup. - -### Prerequisites -- SDRTrunk, TrunkRecorder, or rdio-scanner configured -- Talkgroup export from RadioReference (Premium subscription recommended) -- API key for **Google Maps** or **LocationIQ** -- (Optional) NVIDIA GPU for local transcription -- (Optional) Discord Bot application -- (Optional) Remote transcription server (e.g., [speaches](https://github.com/speaches-ai/speaches) or ICAD) - -### Quick Start -```bash -# Linux -sudo bash linux_install_scanner_map.sh +# Scanner Map + +Real-time mapping of radio calls: ingest from TrunkRecorder, SDRTrunk, icad, rdio-scanner, or Discord; transcribe with OpenAI, iCAD, or local Whisper/Qwen3-ASR; display on an interactive map with optional Discord/TalkGroup notifications. -# Windows (PowerShell as Admin) -.\install_scanner_map.ps1 +## Quick start (Docker — recommended) + +```bash +git clone https://github.com/Dadud/Scanner-map.git +cd Scanner-map/docker +cp .env.example .env +# Edit .env: DISCORD_TOKEN, API_KEY, at least one geocoding key, etc. +docker compose up -d ``` -Then: +Open **http://localhost:3000** (map) and **http://localhost:3000/settings** (admin console). + +### Compose profiles + +| Profile | Use case | +|---------|----------| +| *(default)* | Core app only — use OpenAI or remote iCAD for transcription | +| `local-whisper` | Add Faster-Whisper sidecar (`LOCAL_TRANSCRIPTION=true`) | +| `local-qwen` | Add Qwen3-ASR sidecar (`LOCAL_TRANSCRIPTION_BACKEND=qwen3-asr`) | +| `gpu` | NVIDIA runtime for local models (see `docker-compose.gpu.yml`) | + ```bash -cd scanner-map -source .venv/bin/activate # Linux -node bot.js +docker compose --profile local-whisper up -d +docker compose -f docker-compose.yml -f docker-compose.gpu.yml --profile gpu --profile local-qwen up -d ``` -### Manual contributor setup -If you're developing locally without the installer scripts: +### Unraid + +Import templates from [`unraid/`](unraid/) — see [unraid/README-unraid.md](unraid/README-unraid.md). + +## Native install (development) ```bash npm install -python -m venv .venv -source .venv/bin/activate # Linux -# .venv\Scripts\Activate.ps1 # Windows PowerShell -pip install -r requirements.txt -cp .env.example .env -npm start +npm run setup # guided wizard → .env +npm run doctor # verify dependencies +npm start # bot + webserver ``` -Use `.env.example` as the committed template and keep real secrets only in your local `.env`. +Python deps for local transcription: + +```bash +npm run install:python-deps -- --backend whisper # or qwen, tone, all +``` ---- +## Configuration -## ⚙️ Configuration +- **First run:** `/setup` wizard when `ENABLE_SETUP=true` (default). +- **Ongoing:** `/settings` admin console (terminal theme) — General, Discord, Ingestion, Transcription, Storage & AI, Diagnostics. +- **Environment:** see `docker/.env.example` and [Configuration Reference](#configuration-reference). -Copy `.env.example` to `.env` and fill in your environment-specific values. Do not commit real `.env` files, API keys, or storage credentials. +Key transcription settings: -All main settings are in `.env`. Key options: +| Setting | Description | +|---------|-------------| +| `TRANSCRIPTION_MODE` | `local`, `remote`, `openai`, or `icad` | +| `LOCAL_TRANSCRIPTION_BACKEND` | `faster-whisper` (default) or `qwen3-asr` | +| `QWEN_ASR_MODEL` | e.g. `Qwen/Qwen3-ASR-0.6B` | +| `ENABLE_TONE_DETECTION` | Two-tone / pager detection (optional Python deps) | -- `DISCORD_TOKEN` — your bot token -- `Maps_API_KEY` / `LOCATIONIQ_API_KEY` — geocoding provider -- `MAPPED_TALK_GROUPS` — talkgroups to monitor -- `TRANSCRIPTION_MODE` — `local`, `remote`, `openai`, or `icad` -- `STORAGE_MODE` — `local` or `s3` -- `OPENAI_PROMPT` — (if using OpenAI) provide a custom transcription prompt -- `ENABLE_TONE_DETECTION` — enable/disable two‑tone detection +## Architecture -Other files to edit: -- `public/config.js` ← map defaults (center, zoom, icons, etc.) -- `data/apikeys.json` ← auto-generated on first run +- **`bot.js`** — Discord bot, audio ingestion, transcription orchestration. +- **`webserver.js`** — Map UI, REST API, geocode proxy (API keys never sent to browser). +- **`src/`** — Config, DB migrations, settings service, job persistence, ingestion adapters. +- **`transcription/`** — Python router (`transcribe.py`) with pluggable backends. ---- +Upstream modular stack: [poisonednumber/Scanner-map PRs #9–#14](https://github.com/poisonednumber/Scanner-map/pulls). -## 📡 Connecting Your Radio Software +## Security -- **SDRTrunk:** Configure Streaming → Rdio Scanner endpoint -- **TrunkRecorder:** Add an `uploadServer` entry pointing to `http://:/api/call-upload` -- **rdio-scanner downstream:** Add server + API key +- API keys stored as HMAC fingerprints (fast validation path). +- Geocoding keys proxied server-side when configured. +- Optional `ENABLE_AUTH` for `/audio/:id` and admin routes. +- Rate limits on upload and audio endpoints. ---- +## Development -## 💻 System Requirements -- OS: Windows 10/11 or Debian/Ubuntu -- CPU: Modern multi-core -- RAM: 16GB+ recommended -- GPU: (Optional) NVIDIA CUDA (8GB+ VRAM recommended) -- Storage: SSD (5—10GB for models + audio) +```bash +npm test +npm run check:syntax +npm run migrate +``` ---- +## Configuration reference -## 🛠 Troubleshooting -- Logs: `combined.log` and `error.log` -- Check `.env` values (especially API keys and modes) -- Verify dependencies: Node, Python, FFmpeg, CUDA (if using GPU) -- Ensure correct geocoding.js (Google vs LocationIQ) +See `docker/.env.example` for the full list. Required for most deployments: ---- +- `DISCORD_TOKEN`, `CLIENT_ID` — Discord bot +- `API_KEY` — upload authentication +- `GOOGLE_MAPS_API_KEY` or `LOCATIONIQ_API_KEY` — geocoding +- `OPENAI_API_KEY` — if using OpenAI transcription/summary -## 🤝 Contributing -Pull requests and issue reports are welcome. +## License -## 📬 Support -- Open a GitHub Issue -- Contact **poisonednumber** on Discord +See repository license file. diff --git a/bot.js b/bot.js index 3b70b82..406dda9 100644 --- a/bot.js +++ b/bot.js @@ -12,6 +12,12 @@ const { markJobFailed, markJobProcessing } = require('./src/jobs/processingJobs'); +const { + buildFingerprintIndex, + validateApiKeyFast, + attachFingerprintToNewKey, +} = require('./src/auth/apiKeyValidation'); +const { createTranscriptionQueue } = require('./src/transcription/queue'); // Get environment variables first, before any usage const { @@ -401,6 +407,15 @@ function detectTwoTone(audioFilePath, transcriptionId, talkGroupID, callback) { callback, startTime: Date.now() }); + + setTimeout(() => { + const pending = pendingToneDetections.get(requestId); + if (pending) { + pendingToneDetections.delete(requestId); + logger.warn(`Tone detection TTL expired for request ${requestId}`); + if (pending.callback) pending.callback(false, new Error('Tone detection timeout')); + } + }, 60000); logger.info(`Starting tone detection for ID ${transcriptionId} (request: ${requestId})`); @@ -891,19 +906,19 @@ function ensureApiKey() { // Create a default API key const defaultKey = uuidv4(); const hashedKey = bcrypt.hashSync(defaultKey, 10); - const initialApiKeys = [{ - key: hashedKey, - name: 'Default', + const initialApiKeys = [attachFingerprintToNewKey({ + key: hashedKey, + name: 'Default', disabled: false, created_at: new Date().toISOString(), - description: 'Auto-generated API key for first boot' - }]; + description: 'Auto-generated API key for first boot', + }, defaultKey)]; fs.writeFileSync(API_KEY_FILE, JSON.stringify(initialApiKeys, null, 2)); - logger.info(`Created default API key: ${defaultKey}`); + logger.info('Created default API key (ID: Default)'); logger.info(`API key saved to: ${API_KEY_FILE}`); - logger.warn('IMPORTANT: Save this API key as it won\'t be shown again!'); + logger.warn('IMPORTANT: Save the generated API key from setup — it will not be logged again.'); resolve(defaultKey); } else { logger.info('API key file already exists.'); @@ -1180,6 +1195,15 @@ const { extractAddress, geocodeAddress, hyperlinkAddress, loadTalkGroups } = req // Express app setup const app = express(); +const rateLimit = require('express-rate-limit'); +const uploadRateLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many uploads, please try again later.', +}); +app.use('/api/call-upload', uploadRateLimiter); const PORT_NUM = parseInt(PORT, 10); // Discord client setup @@ -1195,7 +1219,8 @@ const client = new Client({ // Global variables let alertChannel; const UPLOAD_DIR = path.join(__dirname, 'audio'); -let transcriptionQueue = []; +let transcriptionQueue = createTranscriptionQueue(); +let apiKeyFingerprintIndex = new Map(); let activeTranscriptions = 0; let isBootComplete = false; const messageCache = new Map(); // Stores the latest message for each channel @@ -1225,6 +1250,8 @@ const db = new sqlite3.Database('./botdata.db', (err) => { process.exit(1); } else { logger.info('Connected to SQLite database.'); + db.run('PRAGMA journal_mode = WAL;'); + db.run('PRAGMA busy_timeout = 5000;'); // Trigger initialization after database connection initializeBot().catch((error) => { logger.error('Fatal error during bot initialization:', error); @@ -1240,31 +1267,33 @@ const loadApiKeys = () => { if (fs.existsSync(API_KEY_FILE)) { const data = fs.readFileSync(API_KEY_FILE, 'utf8'); apiKeys = JSON.parse(data); + apiKeyFingerprintIndex = buildFingerprintIndex(apiKeys); logger.info(`Loaded ${apiKeys.length} API keys.`); } else { logger.warn('API key file not found. This should have been created during initialization.'); apiKeys = []; + apiKeyFingerprintIndex = new Map(); } } catch (err) { logger.error('Error loading API keys:', err); apiKeys = []; + apiKeyFingerprintIndex = new Map(); } }; // Helper Functions const validateApiKey = async (key) => { - //logger.info(`Validating API key: ${key.substring(0, 3)}...`); - for (let apiKey of apiKeys) { - if (!apiKey.disabled) { - const match = await bcrypt.compare(key, apiKey.key); - if (match) { - //logger.info('API key validation successful'); - return apiKey; - } + const match = await validateApiKeyFast(key, apiKeys, apiKeyFingerprintIndex); + if (match && match.fingerprint && !match._fingerprintPersisted) { + match._fingerprintPersisted = true; + try { + fs.writeFileSync(API_KEY_FILE, JSON.stringify(apiKeys, null, 2)); + } catch (err) { + logger.warn('Could not persist API key fingerprint cache:', err.message); } } - logger.error('API key validation failed'); - return null; + if (!match) logger.error('API key validation failed'); + return match; }; const generateCustomFilename = (fields, originalFilename) => { @@ -2455,7 +2484,7 @@ function cleanupTranscriptionProcess() { } } } - transcriptionQueue = []; // Clear the queue + transcriptionQueue.clear(); } // Reset all state variables diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..0251760 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,21 @@ +# Copy to docker/.env before: docker compose --profile core up -d + +PROFILE_TAG=core +WEB_PORT=3000 +BOT_PORT=3306 + +TRANSCRIPTION_MODE=remote +FASTER_WHISPER_SERVER_URL=http://localhost:8000 + +AI_PROVIDER=ollama +OLLAMA_URL=http://ollama:11434 +OLLAMA_MODEL=llama3.1:8b + +PUBLIC_DOMAIN=localhost +WEBSERVER_PORT=3000 +BOT_PORT=3306 +ENABLE_AUTH=false + +# Geocoding (at least one required for full operation) +# Maps_API_KEY= +# LOCATIONIQ_API_KEY= diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..3ab5ed4 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,30 @@ +# Scanner Map — core image (Node + base Python, no ML stack) +FROM node:20-bookworm-slim AS core + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg python3 python3-pip python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +COPY requirements-base.txt ./ +RUN pip3 install --no-cache-dir --break-system-packages -r requirements-base.txt \ + || pip3 install --no-cache-dir -r requirements-base.txt + +COPY . . + +RUN chmod +x docker/entrypoint.sh + +ENV NODE_ENV=production \ + WEBSERVER_PORT=3000 \ + BOT_PORT=3306 + +EXPOSE 3000 3306 + +VOLUME ["/app/data", "/app/audio", "/app/models"] + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["node", "bot.js"] diff --git a/docker/Dockerfile.qwen b/docker/Dockerfile.qwen new file mode 100644 index 0000000..5db4375 --- /dev/null +++ b/docker/Dockerfile.qwen @@ -0,0 +1,27 @@ +FROM node:20-bookworm-slim AS qwen + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg python3 python3-pip python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +COPY requirements-base.txt requirements-local-qwen.txt ./ +RUN pip3 install --no-cache-dir --break-system-packages -r requirements-local-qwen.txt \ + || pip3 install --no-cache-dir -r requirements-local-qwen.txt + +COPY . . +RUN chmod +x docker/entrypoint.sh + +ENV NODE_ENV=production \ + TRANSCRIPTION_MODE=local \ + LOCAL_TRANSCRIPTION_BACKEND=qwen3-asr \ + QWEN_ASR_MODEL=Qwen/Qwen3-ASR-0.6B + +EXPOSE 3000 3306 +VOLUME ["/app/data", "/app/audio", "/app/models"] +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["node", "bot.js"] diff --git a/docker/Dockerfile.tone b/docker/Dockerfile.tone new file mode 100644 index 0000000..faad140 --- /dev/null +++ b/docker/Dockerfile.tone @@ -0,0 +1,26 @@ +# Scanner Map — tone detection image (extends core) +FROM node:20-bookworm-slim AS tone + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg python3 python3-pip python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +COPY requirements-base.txt requirements-tone.txt ./ +RUN pip3 install --no-cache-dir --break-system-packages -r requirements-tone.txt \ + || pip3 install --no-cache-dir -r requirements-tone.txt + +COPY . . +RUN chmod +x docker/entrypoint.sh + +ENV NODE_ENV=production \ + ENABLE_TONE_DETECTION=true + +EXPOSE 3000 3306 +VOLUME ["/app/data", "/app/audio", "/app/models"] +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["node", "bot.js"] diff --git a/docker/Dockerfile.whisper b/docker/Dockerfile.whisper new file mode 100644 index 0000000..1562481 --- /dev/null +++ b/docker/Dockerfile.whisper @@ -0,0 +1,26 @@ +FROM node:20-bookworm-slim AS whisper + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg python3 python3-pip python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +COPY requirements-base.txt requirements-local-whisper.txt ./ +RUN pip3 install --no-cache-dir --break-system-packages -r requirements-local-whisper.txt \ + || pip3 install --no-cache-dir -r requirements-local-whisper.txt + +COPY . . +RUN chmod +x docker/entrypoint.sh + +ENV NODE_ENV=production \ + TRANSCRIPTION_MODE=local \ + LOCAL_TRANSCRIPTION_BACKEND=faster-whisper + +EXPOSE 3000 3306 +VOLUME ["/app/data", "/app/audio", "/app/models"] +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["node", "bot.js"] diff --git a/docker/docker-compose.gpu.yml b/docker/docker-compose.gpu.yml new file mode 100644 index 0000000..9f0ad47 --- /dev/null +++ b/docker/docker-compose.gpu.yml @@ -0,0 +1,25 @@ +# GPU override: docker compose -f docker/docker-compose.yml -f docker/docker-compose.gpu.yml --profile local-whisper up -d + +services: + scanner-map-whisper: + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + environment: + TRANSCRIPTION_DEVICE: cuda + + scanner-map-qwen: + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + environment: + TRANSCRIPTION_DEVICE: cuda + QWEN_ASR_BACKEND: transformers diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4d2ac96 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,42 @@ +name: scanner-map + +services: + scanner-map: + image: ${SCANNER_MAP_IMAGE:-ghcr.io/dadud/scanner-map:${PROFILE_TAG:-core}} + build: + context: .. + dockerfile: docker/Dockerfile + args: + PROFILE: ${PROFILE_TAG:-core} + profiles: [core, local-whisper, local-qwen, tone-detect, ollama, full] + env_file: + - path: .env + required: false + environment: + TRANSCRIPTION_MODE: ${TRANSCRIPTION_MODE:-remote} + LOCAL_TRANSCRIPTION_BACKEND: ${LOCAL_TRANSCRIPTION_BACKEND:-faster-whisper} + WEBSERVER_PORT: ${WEBSERVER_PORT:-3000} + BOT_PORT: ${BOT_PORT:-3306} + ports: + - "${WEB_PORT:-3000}:${WEBSERVER_PORT:-3000}" + - "${BOT_PORT:-3306}:${BOT_PORT:-3306}" + volumes: + - scanner-appdata:/app/data + - scanner-audio:/app/audio + - scanner-models:/app/models + restart: unless-stopped + + ollama: + image: ollama/ollama:latest + profiles: [ollama, full] + volumes: + - ollama-data:/root/.ollama + ports: + - "11434:11434" + restart: unless-stopped + +volumes: + scanner-appdata: + scanner-audio: + scanner-models: + ollama-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..e0e7ede --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +cd /app + +mkdir -p /app/data /app/audio /app/models /app/logs + +if [ ! -f /app/data/.env-linked ] && [ -f /app/.env ]; then + : # use mounted or baked .env +fi + +export WHISPER_MODEL="${WHISPER_MODEL:-large-v3}" +export TRANSCRIPTION_DEVICE="${TRANSCRIPTION_DEVICE:-cpu}" +export LOCAL_TRANSCRIPTION_BACKEND="${LOCAL_TRANSCRIPTION_BACKEND:-faster-whisper}" + +exec "$@" diff --git a/package-lock.json b/package-lock.json index 39256e3..9422d52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "discord.js": "^14.20.0", "dotenv": "^16.6.1", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "form-data": "^4.0.4", "moment-timezone": "^0.6.0", "node-cache": "^5.1.2", @@ -533,9 +534,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -552,9 +550,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -571,9 +566,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -590,9 +582,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1844,6 +1833,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 13b2119..aa86c42 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "scripts": { "start": "node bot.js", "web": "node webserver.js", + "setup": "node scripts/setup.js", + "doctor": "node scripts/doctor.js", "import-talkgroups": "node import_csv.js", "check:config": "node scripts/check-config.js", - "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js && node --check public/setup.js && node --check public/settings.js", + "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js && node --check public/setup.js && node --check public/js/admin-settings.js && node --check scripts/setup.js && node --check scripts/doctor.js", "demo:data": "node scripts/generate-demo-data.js", "test": "node --test test/*.test.js" }, @@ -26,6 +28,7 @@ "discord.js": "^14.20.0", "dotenv": "^16.6.1", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "form-data": "^4.0.4", "moment-timezone": "^0.6.0", "node-cache": "^5.1.2", diff --git a/public/app.js b/public/app.js index 11021d5..ae21256 100644 --- a/public/app.js +++ b/public/app.js @@ -617,7 +617,7 @@ function updateCategoryCounts() { categoryItem.className = 'category-item'; categoryItem.dataset.category = catInfo.name; categoryItem.innerHTML = ` -
${catInfo.name}
+
${escapeHtml(catInfo.name)}
${catInfo.count}
`; @@ -1464,6 +1464,23 @@ function createLocationIQDropdown(inputElement, scope) { // Search LocationIQ API async function searchLocationIQ(query, dropdown, inputElement, scope) { try { + if (appConfig.geocoding.useProxy) { + const data = await safeFetchJson(`/api/geocode/autocomplete?q=${encodeURIComponent(query)}`); + const results = data.results || []; + dropdown.innerHTML = ''; + results.forEach((item) => { + const el = document.createElement('div'); + el.className = 'locationiq-item'; + el.textContent = item.label; + el.dataset.lat = item.lat || ''; + el.dataset.lon = item.lon || ''; + el.addEventListener('click', () => selectLocationIQItem(el, inputElement, dropdown, scope)); + dropdown.appendChild(el); + }); + dropdown.style.display = results.length ? 'block' : 'none'; + return; + } + // Get current map center for dynamic bias const mapCenter = map.getCenter(); const biasLat = mapCenter.lat; @@ -1719,8 +1736,8 @@ async function startAddressSearch(callId, originalMarker, modal) { let autocompleteDropdown = null; // Check which providers are available - const googleAvailable = appConfig.geocoding.googleApiKey && typeof google !== 'undefined' && google.maps && google.maps.places; - const locationiqAvailable = appConfig.geocoding.locationiqApiKey; + const googleAvailable = !appConfig.geocoding.useProxy && appConfig.geocoding.googleApiKey && typeof google !== 'undefined' && google.maps && google.maps.places; + const locationiqAvailable = appConfig.geocoding.locationiqAvailable || appConfig.geocoding.locationiqApiKey || appConfig.geocoding.useProxy; if (googleAvailable) { console.log('[Address Search] Using Google Places Autocomplete'); @@ -1798,6 +1815,15 @@ async function startAddressSearch(callId, originalMarker, modal) { } function getOriginalAddress(lat, lng) { + if (appConfig.geocoding.useProxy) { + return safeFetchJson(`/api/geocode/reverse?lat=${lat}&lon=${lng}`) + .then(data => { + if (data.results && data.results[0]) return data.results[0].formatted_address; + if (data.display_name) return data.display_name; + return `Unknown (${lat}, ${lng})`; + }) + .catch(() => `Unknown (${lat}, ${lng})`); + } // Try Google first if available, then LocationIQ as fallback if (appConfig.geocoding.googleApiKey) { return fetch(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${appConfig.geocoding.googleApiKey}`) @@ -1823,6 +1849,11 @@ async function startAddressSearch(callId, originalMarker, modal) { } function getLocationIQReverseGeocode(lat, lng) { + if (appConfig.geocoding.useProxy) { + return safeFetchJson(`/api/geocode/reverse?lat=${lat}&lon=${lng}`) + .then(data => data.display_name || (data.results && data.results[0] && data.results[0].formatted_address) || `Unknown (${lat}, ${lng})`) + .catch(() => `Unknown (${lat}, ${lng})`); + } const params = new URLSearchParams({ key: appConfig.geocoding.locationiqApiKey, lat: lat, @@ -2528,6 +2559,13 @@ function clearMarkers() { console.log(`[DEBUG] clearMarkers started`); console.log(`[DEBUG] Current markers count: ${Object.keys(markers).length}`); console.log(`[DEBUG] Current allMarkers count: ${Object.keys(allMarkers).length}`); + + Object.keys(wavesurfers).forEach(callId => { + if (wavesurfers[callId]) { + try { wavesurfers[callId].destroy(); } catch (e) { /* ignore */ } + delete wavesurfers[callId]; + } + }); // First, remove all pulse markers Object.keys(markers).forEach(callId => { diff --git a/public/config.js b/public/config.js index 2cb57f7..c144bbb 100644 --- a/public/config.js +++ b/public/config.js @@ -168,13 +168,21 @@ async function fetchGeocodingConfig() { const data = await response.json(); if (data.google.available) { - config.geocoding.googleApiKey = data.google.apiKey; - console.log('[Config] Google Places API available'); + config.geocoding.googleAvailable = true; + config.geocoding.useProxy = !!data.google.useProxy; + if (!data.google.useProxy && data.google.apiKey) { + config.geocoding.googleApiKey = data.google.apiKey; + } + console.log('[Config] Google geocoding available' + (data.google.useProxy ? ' (server proxy)' : '')); } if (data.locationiq.available) { - config.geocoding.locationiqApiKey = data.locationiq.apiKey; - console.log('[Config] LocationIQ API available'); + config.geocoding.locationiqAvailable = true; + config.geocoding.useProxy = config.geocoding.useProxy || !!data.locationiq.useProxy; + if (!data.locationiq.useProxy && data.locationiq.apiKey) { + config.geocoding.locationiqApiKey = data.locationiq.apiKey; + } + console.log('[Config] LocationIQ geocoding available' + (data.locationiq.useProxy ? ' (server proxy)' : '')); } // Log available providers diff --git a/public/css/console.css b/public/css/console.css new file mode 100644 index 0000000..0676999 --- /dev/null +++ b/public/css/console.css @@ -0,0 +1,285 @@ +@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap'); + +:root { + --primary-color: #00ff00; + --background-color: #000000; + --text-color: #00ff00; + --border-color: #00ff00; + --hover-color: #003300; + --panel-bg: rgba(0, 30, 0, 0.85); + --muted-text: rgba(0, 255, 0, 0.55); +} + +* { + box-sizing: border-box; +} + +body.console-page { + margin: 0; + min-height: 100vh; + font-family: 'Share Tech Mono', monospace; + color: var(--text-color); + background: var(--background-color); + overflow-x: hidden; +} + +.console-shell { + max-width: 1180px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.console-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 20px; + padding: 16px; + background: rgba(0, 0, 0, 0.7); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 0 10px rgba(0, 255, 0, 0.35); +} + +.console-header h1 { + margin: 0 0 8px; + font-size: 28px; + color: var(--primary-color); +} + +.console-header p { + margin: 0; + color: var(--muted-text); + max-width: 680px; +} + +.console-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; +} + +.console-nav, +.console-panel { + background: var(--panel-bg); + border: 1px solid rgba(0, 255, 0, 0.25); + border-radius: 6px; + box-shadow: 0 0 12px rgba(0, 255, 0, 0.15); +} + +.console-nav { + padding: 8px; + height: fit-content; +} + +.console-tab, +.step-button { + width: 100%; + border: 1px solid rgba(0, 255, 0, 0.25); + border-radius: 4px; + background: rgba(0, 50, 0, 0.35); + color: var(--text-color); + cursor: pointer; + font-family: inherit; + font-size: 13px; + padding: 10px 12px; + margin-bottom: 6px; + text-align: left; + transition: all 0.2s; +} + +.console-tab:hover, +.step-button:hover, +.console-btn:hover { + background: rgba(0, 100, 0, 0.45); + border-color: var(--primary-color); + box-shadow: 0 0 8px rgba(0, 255, 0, 0.25); +} + +.console-tab.active, +.step-button.active { + background: rgba(0, 255, 0, 0.15); + border-color: var(--primary-color); +} + +.console-panel { + padding: 20px; +} + +.section { + display: none; +} + +.section.active { + display: block; +} + +.section h2 { + margin: 0 0 16px; + color: var(--primary-color); + font-size: 18px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.settings-group { + background: rgba(0, 20, 0, 0.5); + border: 1px solid rgba(0, 255, 0, 0.15); + border-radius: 6px; + padding: 14px; + margin-bottom: 14px; +} + +.settings-group-title { + font-size: 13px; + font-weight: bold; + color: var(--primary-color); + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.field label { + display: block; + font-size: 12px; + margin-bottom: 6px; + color: var(--muted-text); +} + +.field input, +.field select, +.settings-input, +.settings-select { + width: 100%; + padding: 8px 10px; + background: var(--background-color); + color: var(--text-color); + border: 1px solid rgba(0, 255, 0, 0.35); + border-radius: 4px; + font-family: inherit; + font-size: 13px; +} + +.field-hint { + font-size: 11px; + color: var(--muted-text); + margin-top: 4px; +} + +.panel-hidden { + display: none !important; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-top: 16px; +} + +.console-btn, +.primary, +.secondary { + padding: 8px 16px; + background: var(--background-color); + color: var(--text-color); + border: 1px solid var(--border-color); + border-radius: 4px; + font-family: inherit; + cursor: pointer; + transition: all 0.2s; +} + +.console-btn.primary, +.primary { + background: rgba(0, 255, 0, 0.12); +} + +.console-link, +.status-pill { + display: inline-block; + padding: 8px 14px; + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-color); + text-decoration: none; + white-space: nowrap; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + border: 1px solid rgba(0, 255, 0, 0.35); +} + +.badge-warn { + color: #ffff99; + border-color: #ffff99; +} + +.badge-ok { + color: var(--primary-color); +} + +.check-list { + list-style: none; + padding: 0; + margin: 12px 0 0; +} + +.check-item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid rgba(0, 255, 0, 0.1); + font-size: 13px; +} + +.check-item:last-child { + border-bottom: none; +} + +.check-status-pass { color: var(--primary-color); } +.check-status-warn { color: #ffff99; } +.check-status-fail { color: #ff6666; } + +.jobs-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + margin-top: 12px; +} + +.jobs-table th, +.jobs-table td { + border: 1px solid rgba(0, 255, 0, 0.2); + padding: 8px; + text-align: left; +} + +.jobs-table th { + color: var(--primary-color); +} + +#save-result, +.save-result { + color: var(--muted-text); + font-size: 12px; +} + +@media (max-width: 768px) { + .console-layout { + grid-template-columns: 1fr; + } +} diff --git a/public/css/settings.css b/public/css/settings.css new file mode 100644 index 0000000..1a0b87c --- /dev/null +++ b/public/css/settings.css @@ -0,0 +1,435 @@ +/* --- Settings Modal Styles --- */ +.settings-modal-content { + width: 90%; + max-width: 700px; + max-height: 85vh; + display: flex; + flex-direction: column; +} + +.settings-tabs { + display: flex; + gap: 5px; + margin-bottom: 15px; + border-bottom: 1px solid rgba(0, 255, 0, 0.2); + padding-bottom: 10px; + flex-wrap: wrap; +} + +.settings-tab { + padding: 8px 16px; + background: rgba(0, 50, 0, 0.3); + border: 1px solid rgba(0, 255, 0, 0.3); + border-radius: 4px; + color: var(--text-color); + cursor: pointer; + font-family: 'Share Tech Mono', monospace; + font-size: 13px; + transition: all 0.2s; +} + +.settings-tab:hover { + background: rgba(0, 100, 0, 0.4); + border-color: var(--primary-color); +} + +.settings-tab.active { + background: rgba(0, 255, 0, 0.15); + border-color: var(--primary-color); + box-shadow: 0 0 8px rgba(0, 255, 0, 0.3); +} + +.settings-section { + display: none; + flex-direction: column; + gap: 15px; + overflow-y: auto; + padding-right: 10px; + max-height: 55vh; +} + +.settings-section.active { + display: flex; +} + +.settings-group { + background: rgba(0, 30, 0, 0.4); + border: 1px solid rgba(0, 255, 0, 0.15); + border-radius: 6px; + padding: 15px; +} + +.settings-group-title { + font-size: 14px; + font-weight: bold; + color: var(--primary-color); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.setting-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid rgba(0, 255, 0, 0.08); +} + +.setting-row:last-child { + border-bottom: none; +} + +.setting-label { + display: flex; + flex-direction: column; + gap: 4px; +} + +.setting-label span:first-child { + font-size: 13px; + color: var(--text-color); +} + +.setting-label span:last-child { + font-size: 11px; + color: rgba(0, 255, 0, 0.5); +} + +.setting-control { + display: flex; + align-items: center; + gap: 10px; +} + +.settings-toggle { + position: relative; + width: 48px; + height: 24px; +} + +.settings-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.settings-toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 100, 0, 0.4); + border: 1px solid rgba(0, 255, 0, 0.3); + border-radius: 12px; + transition: 0.3s; +} + +.settings-toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 2px; + bottom: 2px; + background-color: #666; + border-radius: 50%; + transition: 0.3s; +} + +.settings-toggle input:checked + .settings-toggle-slider { + background-color: rgba(0, 255, 0, 0.3); + border-color: var(--primary-color); +} + +.settings-toggle input:checked + .settings-toggle-slider:before { + transform: translateX(24px); + background-color: var(--primary-color); +} + +.settings-select { + background: #000; + color: #00ff00; + border: 1px solid rgba(0, 255, 0, 0.4); + border-radius: 4px; + padding: 6px 10px; + font-family: 'Share Tech Mono', monospace; + font-size: 12px; + min-width: 120px; +} + +.settings-input { + background: #000; + color: #00ff00; + border: 1px solid rgba(0, 255, 0, 0.4); + border-radius: 4px; + padding: 6px 10px; + font-family: 'Share Tech Mono', monospace; + font-size: 12px; + width: 80px; + text-align: center; +} + +.settings-range { + width: 120px; + accent-color: var(--primary-color); +} + +.settings-actions { + display: flex; + justify-content: space-between; + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid rgba(0, 255, 0, 0.2); +} + +/* --- Onboarding Styles --- */ +.onboarding-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.95); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-container { + width: 90%; + max-width: 600px; + background: #0a0a0a; + border: 2px solid var(--primary-color); + border-radius: 8px; + box-shadow: 0 0 40px rgba(0, 255, 0, 0.3); + padding: 30px; + max-height: 90vh; + overflow-y: auto; +} + +.onboarding-header { + text-align: center; + margin-bottom: 25px; +} + +.onboarding-header h1 { + font-size: 24px; + color: var(--primary-color); + margin-bottom: 10px; + text-shadow: 0 0 10px rgba(0, 255, 0, 0.5); +} + +.onboarding-header p { + color: rgba(0, 255, 0, 0.7); + font-size: 14px; +} + +.onboarding-step { + display: none; +} + +.onboarding-step.active { + display: block; +} + +.onboarding-step h2 { + font-size: 18px; + color: #00ff00; + margin-bottom: 15px; +} + +.onboarding-step p { + font-size: 13px; + color: rgba(0, 255, 0, 0.8); + margin-bottom: 20px; + line-height: 1.5; +} + +.onboarding-options { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.onboarding-option { + display: flex; + align-items: center; + padding: 12px 15px; + background: rgba(0, 50, 0, 0.3); + border: 1px solid rgba(0, 255, 0, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.onboarding-option:hover { + background: rgba(0, 100, 0, 0.4); + border-color: var(--primary-color); +} + +.onboarding-option.selected { + background: rgba(0, 255, 0, 0.15); + border-color: var(--primary-color); +} + +.onboarding-option input { + margin-right: 12px; + accent-color: var(--primary-color); + width: 18px; + height: 18px; +} + +.onboarding-option label { + cursor: pointer; + flex: 1; +} + +.onboarding-option .option-title { + font-size: 14px; + color: #00ff00; + display: block; +} + +.onboarding-option .option-desc { + font-size: 11px; + color: rgba(0, 255, 0, 0.6); + display: block; + margin-top: 4px; +} + +.onboarding-progress { + display: flex; + justify-content: center; + gap: 8px; + margin-bottom: 25px; +} + +.onboarding-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(0, 255, 0, 0.2); + border: 1px solid rgba(0, 255, 0, 0.4); + transition: all 0.3s; +} + +.onboarding-dot.active { + background: var(--primary-color); + box-shadow: 0 0 8px var(--primary-color); +} + +.onboarding-dot.completed { + background: #00aa00; +} + +.onboarding-nav { + display: flex; + justify-content: space-between; + margin-top: 20px; +} + +.onboarding-btn { + padding: 10px 25px; + border: 1px solid var(--primary-color); + border-radius: 4px; + font-family: 'Share Tech Mono', monospace; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.onboarding-btn.primary { + background: rgba(0, 255, 0, 0.2); + color: #00ff00; +} + +.onboarding-btn.primary:hover { + background: rgba(0, 255, 0, 0.3); + box-shadow: 0 0 15px rgba(0, 255, 0, 0.4); +} + +.onboarding-btn.secondary { + background: transparent; + color: rgba(0, 255, 0, 0.6); + border-color: rgba(0, 255, 0, 0.3); +} + +.onboarding-btn.secondary:hover { + background: rgba(0, 255, 0, 0.1); + color: #00ff00; +} + +.onboarding-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.onboarding-features { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 15px; + margin: 20px 0; +} + +.onboarding-feature { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px; + background: rgba(0, 30, 0, 0.3); + border-radius: 4px; +} + +.onboarding-feature-icon { + font-size: 20px; +} + +.onboarding-feature-text h4 { + font-size: 12px; + color: #00ff00; + margin-bottom: 4px; +} + +.onboarding-feature-text p { + font-size: 11px; + color: rgba(0, 255, 0, 0.6); + margin: 0; +} + +.onboarding-input-group { + margin-bottom: 15px; +} + +.onboarding-input-group label { + display: block; + font-size: 12px; + color: rgba(0, 255, 0, 0.8); + margin-bottom: 6px; +} + +.onboarding-input { + width: 100%; + padding: 10px 12px; + background: #000; + color: #00ff00; + border: 1px solid rgba(0, 255, 0, 0.4); + border-radius: 4px; + font-family: 'Share Tech Mono', monospace; + font-size: 14px; + box-sizing: border-box; +} + +.onboarding-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 8px rgba(0, 255, 0, 0.3); +} + +.onboarding-input::placeholder { + color: rgba(0, 255, 0, 0.3); +} diff --git a/public/index.html b/public/index.html index 837e3c3..dd95904 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,7 @@ + @@ -415,441 +416,6 @@ filter: invert(1) hue-rotate(120deg) brightness(1.5) !important; } - /* --- Settings Modal Styles --- */ - .settings-modal-content { - width: 90%; - max-width: 700px; - max-height: 85vh; - display: flex; - flex-direction: column; - } - - .settings-tabs { - display: flex; - gap: 5px; - margin-bottom: 15px; - border-bottom: 1px solid rgba(0, 255, 0, 0.2); - padding-bottom: 10px; - flex-wrap: wrap; - } - - .settings-tab { - padding: 8px 16px; - background: rgba(0, 50, 0, 0.3); - border: 1px solid rgba(0, 255, 0, 0.3); - border-radius: 4px; - color: var(--text-color); - cursor: pointer; - font-family: 'Share Tech Mono', monospace; - font-size: 13px; - transition: all 0.2s; - } - - .settings-tab:hover { - background: rgba(0, 100, 0, 0.4); - border-color: var(--primary-color); - } - - .settings-tab.active { - background: rgba(0, 255, 0, 0.15); - border-color: var(--primary-color); - box-shadow: 0 0 8px rgba(0, 255, 0, 0.3); - } - - .settings-section { - display: none; - flex-direction: column; - gap: 15px; - overflow-y: auto; - padding-right: 10px; - max-height: 55vh; - } - - .settings-section.active { - display: flex; - } - - .settings-group { - background: rgba(0, 30, 0, 0.4); - border: 1px solid rgba(0, 255, 0, 0.15); - border-radius: 6px; - padding: 15px; - } - - .settings-group-title { - font-size: 14px; - font-weight: bold; - color: var(--primary-color); - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 1px; - } - - .setting-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 0; - border-bottom: 1px solid rgba(0, 255, 0, 0.08); - } - - .setting-row:last-child { - border-bottom: none; - } - - .setting-label { - display: flex; - flex-direction: column; - gap: 4px; - } - - .setting-label span:first-child { - font-size: 13px; - color: var(--text-color); - } - - .setting-label span:last-child { - font-size: 11px; - color: rgba(0, 255, 0, 0.5); - } - - .setting-control { - display: flex; - align-items: center; - gap: 10px; - } - - .settings-toggle { - position: relative; - width: 48px; - height: 24px; - } - - .settings-toggle input { - opacity: 0; - width: 0; - height: 0; - } - - .settings-toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 100, 0, 0.4); - border: 1px solid rgba(0, 255, 0, 0.3); - border-radius: 12px; - transition: 0.3s; - } - - .settings-toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 2px; - bottom: 2px; - background-color: #666; - border-radius: 50%; - transition: 0.3s; - } - - .settings-toggle input:checked + .settings-toggle-slider { - background-color: rgba(0, 255, 0, 0.3); - border-color: var(--primary-color); - } - - .settings-toggle input:checked + .settings-toggle-slider:before { - transform: translateX(24px); - background-color: var(--primary-color); - } - - .settings-select { - background: #000; - color: #00ff00; - border: 1px solid rgba(0, 255, 0, 0.4); - border-radius: 4px; - padding: 6px 10px; - font-family: 'Share Tech Mono', monospace; - font-size: 12px; - min-width: 120px; - } - - .settings-input { - background: #000; - color: #00ff00; - border: 1px solid rgba(0, 255, 0, 0.4); - border-radius: 4px; - padding: 6px 10px; - font-family: 'Share Tech Mono', monospace; - font-size: 12px; - width: 80px; - text-align: center; - } - - .settings-range { - width: 120px; - accent-color: var(--primary-color); - } - - .settings-actions { - display: flex; - justify-content: space-between; - margin-top: 15px; - padding-top: 15px; - border-top: 1px solid rgba(0, 255, 0, 0.2); - } - - /* --- Onboarding Styles --- */ - .onboarding-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.95); - z-index: 10000; - display: flex; - align-items: center; - justify-content: center; - } - - .onboarding-container { - width: 90%; - max-width: 600px; - background: #0a0a0a; - border: 2px solid var(--primary-color); - border-radius: 8px; - box-shadow: 0 0 40px rgba(0, 255, 0, 0.3); - padding: 30px; - max-height: 90vh; - overflow-y: auto; - } - - .onboarding-header { - text-align: center; - margin-bottom: 25px; - } - - .onboarding-header h1 { - font-size: 24px; - color: var(--primary-color); - margin-bottom: 10px; - text-shadow: 0 0 10px rgba(0, 255, 0, 0.5); - } - - .onboarding-header p { - color: rgba(0, 255, 0, 0.7); - font-size: 14px; - } - - .onboarding-step { - display: none; - } - - .onboarding-step.active { - display: block; - } - - .onboarding-step h2 { - font-size: 18px; - color: #00ff00; - margin-bottom: 15px; - } - - .onboarding-step p { - font-size: 13px; - color: rgba(0, 255, 0, 0.8); - margin-bottom: 20px; - line-height: 1.5; - } - - .onboarding-options { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 20px; - } - - .onboarding-option { - display: flex; - align-items: center; - padding: 12px 15px; - background: rgba(0, 50, 0, 0.3); - border: 1px solid rgba(0, 255, 0, 0.2); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - } - - .onboarding-option:hover { - background: rgba(0, 100, 0, 0.4); - border-color: var(--primary-color); - } - - .onboarding-option.selected { - background: rgba(0, 255, 0, 0.15); - border-color: var(--primary-color); - } - - .onboarding-option input { - margin-right: 12px; - accent-color: var(--primary-color); - width: 18px; - height: 18px; - } - - .onboarding-option label { - cursor: pointer; - flex: 1; - } - - .onboarding-option .option-title { - font-size: 14px; - color: #00ff00; - display: block; - } - - .onboarding-option .option-desc { - font-size: 11px; - color: rgba(0, 255, 0, 0.6); - display: block; - margin-top: 4px; - } - - .onboarding-progress { - display: flex; - justify-content: center; - gap: 8px; - margin-bottom: 25px; - } - - .onboarding-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: rgba(0, 255, 0, 0.2); - border: 1px solid rgba(0, 255, 0, 0.4); - transition: all 0.3s; - } - - .onboarding-dot.active { - background: var(--primary-color); - box-shadow: 0 0 8px var(--primary-color); - } - - .onboarding-dot.completed { - background: #00aa00; - } - - .onboarding-nav { - display: flex; - justify-content: space-between; - margin-top: 20px; - } - - .onboarding-btn { - padding: 10px 25px; - border: 1px solid var(--primary-color); - border-radius: 4px; - font-family: 'Share Tech Mono', monospace; - font-size: 14px; - cursor: pointer; - transition: all 0.2s; - } - - .onboarding-btn.primary { - background: rgba(0, 255, 0, 0.2); - color: #00ff00; - } - - .onboarding-btn.primary:hover { - background: rgba(0, 255, 0, 0.3); - box-shadow: 0 0 15px rgba(0, 255, 0, 0.4); - } - - .onboarding-btn.secondary { - background: transparent; - color: rgba(0, 255, 0, 0.6); - border-color: rgba(0, 255, 0, 0.3); - } - - .onboarding-btn.secondary:hover { - background: rgba(0, 255, 0, 0.1); - color: #00ff00; - } - - .onboarding-btn:disabled { - opacity: 0.4; - cursor: not-allowed; - } - - .onboarding-features { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 15px; - margin: 20px 0; - } - - .onboarding-feature { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px; - background: rgba(0, 30, 0, 0.3); - border-radius: 4px; - } - - .onboarding-feature-icon { - font-size: 20px; - } - - .onboarding-feature-text h4 { - font-size: 12px; - color: #00ff00; - margin-bottom: 4px; - } - - .onboarding-feature-text p { - font-size: 11px; - color: rgba(0, 255, 0, 0.6); - margin: 0; - } - - .onboarding-input-group { - margin-bottom: 15px; - } - - .onboarding-input-group label { - display: block; - font-size: 12px; - color: rgba(0, 255, 0, 0.8); - margin-bottom: 6px; - } - - .onboarding-input { - width: 100%; - padding: 10px 12px; - background: #000; - color: #00ff00; - border: 1px solid rgba(0, 255, 0, 0.4); - border-radius: 4px; - font-family: 'Share Tech Mono', monospace; - font-size: 14px; - box-sizing: border-box; - } - - .onboarding-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 8px rgba(0, 255, 0, 0.3); - } - - .onboarding-input::placeholder { - color: rgba(0, 255, 0, 0.3); - } @@ -1370,6 +936,7 @@

Settings

@@ -1572,7 +1139,7 @@

Audio Settings

async function initGoogleMaps() { await new Promise((resolve) => { const checkKey = () => { - if (window?.appConfig?.geocoding?.googleApiKey) { + if (window?.appConfig?.geocoding?.googleApiKey || window?.appConfig?.geocoding?.useProxy) { resolve(); } else { setTimeout(checkKey, 100); @@ -1581,6 +1148,11 @@

Audio Settings

checkKey(); }); + if (window.appConfig.geocoding.useProxy && !window.appConfig.geocoding.googleApiKey) { + console.log('Geocoding uses server proxy; skipping Google Maps JS loader'); + return Promise.resolve(); + } + const gKey = window.appConfig.geocoding.googleApiKey; if (gKey) { return new Promise((resolve) => { diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js new file mode 100644 index 0000000..cece29a --- /dev/null +++ b/public/js/admin-settings.js @@ -0,0 +1,183 @@ +const normalKeys = [ + 'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours', + 'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode', + 'localTranscriptionBackend', 's3Endpoint', 's3BucketName', 'transcriptionDevice', + 'whisperModel', 'qwenAsrModel', 'qwenAsrBackend', 'qwenAsrLanguage', + 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', + 'fasterWhisperServerUrl', 'openaiTranscriptionPrompt', + 'openaiTranscriptionModel', 'openaiTranscriptionTemperature', 'icadUrl', 'icadProfile', + 'enableToneDetection', 'enableAuth', +]; +const secretKeys = [ + 'uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey', + 'icadApiKey', 's3AccessKeyId', 's3SecretAccessKey', 'discordToken', +]; + +function showStep(id) { + document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id)); + document.querySelectorAll('.console-tab, .step-button').forEach((button) => { + button.classList.toggle('active', button.dataset.step === id); + }); +} + +document.querySelectorAll('.console-tab, .step-button').forEach((button) => { + button.addEventListener('click', () => showStep(button.dataset.step)); +}); + +async function jsonFetch(url, options = {}) { + const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || response.statusText); + return data; +} + +function updateConditionalPanels() { + const mode = document.getElementById('transcriptionMode')?.value || 'remote'; + const backend = document.getElementById('localTranscriptionBackend')?.value || 'faster-whisper'; + const storage = document.getElementById('storageMode')?.value || 'local'; + + document.querySelectorAll('[data-panel="local"]').forEach((el) => { + el.classList.toggle('panel-hidden', mode !== 'local'); + }); + document.querySelectorAll('[data-panel="remote"]').forEach((el) => { + el.classList.toggle('panel-hidden', mode !== 'remote'); + }); + document.querySelectorAll('[data-panel="openai-tx"]').forEach((el) => { + el.classList.toggle('panel-hidden', mode !== 'openai'); + }); + document.querySelectorAll('[data-panel="icad"]').forEach((el) => { + el.classList.toggle('panel-hidden', mode !== 'icad'); + }); + document.querySelectorAll('[data-panel="whisper"]').forEach((el) => { + el.classList.toggle('panel-hidden', mode !== 'local' || backend.startsWith('qwen')); + }); + document.querySelectorAll('[data-panel="qwen"]').forEach((el) => { + el.classList.toggle('panel-hidden', mode !== 'local' || !backend.startsWith('qwen')); + }); + document.querySelectorAll('[data-panel="s3"]').forEach((el) => { + el.classList.toggle('panel-hidden', storage !== 's3'); + }); +} + +['transcriptionMode', 'localTranscriptionBackend', 'storageMode'].forEach((id) => { + document.getElementById(id)?.addEventListener('change', updateConditionalPanels); +}); + +function renderChecks(checks) { + const output = document.getElementById('diagnostic-output'); + output.innerHTML = ''; + const list = document.createElement('ul'); + list.className = 'check-list'; + for (const check of checks.checks || checks.results || []) { + const item = document.createElement('li'); + item.className = 'check-item'; + const status = (check.status || check.level || 'info').toLowerCase(); + item.innerHTML = `${check.name || check.id || 'Check'}${status.toUpperCase()}`; + if (check.message) { + const msg = document.createElement('div'); + msg.className = 'field-hint'; + msg.textContent = check.message; + item.appendChild(msg); + } + list.appendChild(item); + } + output.appendChild(list); +} + +function renderJobs(summary, recent) { + const output = document.getElementById('diagnostic-output'); + output.innerHTML = ''; + + const cards = document.createElement('div'); + cards.className = 'settings-group'; + cards.innerHTML = `
Job Summary
`; + const pre = document.createElement('div'); + pre.className = 'field-hint'; + pre.textContent = JSON.stringify(summary.summary || summary, null, 2); + cards.appendChild(pre); + output.appendChild(cards); + + const rows = recent.jobs || recent.recent || []; + if (rows.length) { + const table = document.createElement('table'); + table.className = 'jobs-table'; + table.innerHTML = 'IDTypeStatusError'; + const tbody = document.createElement('tbody'); + rows.forEach((job) => { + const tr = document.createElement('tr'); + tr.innerHTML = `${job.id}${job.job_type || job.type || ''}${job.status}${job.last_error || ''}`; + tbody.appendChild(tr); + }); + table.appendChild(tbody); + output.appendChild(table); + } +} + +async function loadSettings() { + const data = await jsonFetch('/api/settings'); + for (const key of normalKeys) { + const input = document.getElementById(key); + if (input && data.settings[key]) input.value = data.settings[key].value; + } + for (const key of secretKeys) { + const input = document.getElementById(key); + if (input && data.secrets[key]?.configured) { + input.placeholder = 'Configured — enter a new value to replace'; + } + } + updateConditionalPanels(); +} + +document.getElementById('save-settings')?.addEventListener('click', async () => { + const result = document.getElementById('save-result'); + try { + const payload = {}; + for (const key of normalKeys) { + const input = document.getElementById(key); + if (input) payload[key] = input.value; + } + const saved = await jsonFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }); + + for (const key of secretKeys) { + const input = document.getElementById(key); + if (input && input.value) { + await jsonFetch(`/api/settings/secrets/${key}`, { method: 'PUT', body: JSON.stringify({ value: input.value }) }); + input.value = ''; + input.placeholder = 'Configured — enter a new value to replace'; + } + } + + result.innerHTML = saved.requiresRestart + ? 'Saved. Restart required' + : 'Saved. OK'; + updateConditionalPanels(); + } catch (error) { + result.textContent = error.message; + } +}); + +document.getElementById('run-diagnostics')?.addEventListener('click', async () => { + try { + const checks = await jsonFetch('/api/settings/checks'); + renderChecks(checks); + } catch (error) { + document.getElementById('diagnostic-output').textContent = error.message; + } +}); + +document.getElementById('load-jobs')?.addEventListener('click', async () => { + try { + const [summary, recent] = await Promise.all([ + jsonFetch('/api/jobs/summary'), + jsonFetch('/api/jobs/recent?limit=10'), + ]); + renderJobs(summary, recent); + } catch (error) { + document.getElementById('diagnostic-output').textContent = error.message; + } +}); + +loadSettings().catch((error) => { + const el = document.getElementById('save-result'); + if (el) el.textContent = error.message; +}); diff --git a/public/settings.html b/public/settings.html index ac2c6f0..eb31135 100644 --- a/public/settings.html +++ b/public/settings.html @@ -4,90 +4,111 @@ Scanner Map Settings - + - -
-
+ +
+

Scanner Map Settings

-

Manage runtime settings, write-only secrets, and setup diagnostics.

+

Runtime configuration, secrets, transcription backends, and diagnostics.

- Back to Map + Back to Map
-
-
- + diff --git a/public/settings.js b/public/settings.js deleted file mode 100644 index df9b4d9..0000000 --- a/public/settings.js +++ /dev/null @@ -1,78 +0,0 @@ -const normalKeys = [ - 'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours', - 'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode', - 's3Endpoint', 's3BucketName', 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', - 'fasterWhisperServerUrl', 'whisperModel', 'openaiTranscriptionPrompt', - 'openaiTranscriptionModel', 'openaiTranscriptionTemperature', 'icadUrl', 'icadProfile' -]; -const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey', 'icadApiKey', 's3AccessKeyId', 's3SecretAccessKey']; - -function showStep(id) { - document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id)); - document.querySelectorAll('.step-button').forEach((button) => button.classList.toggle('active', button.dataset.step === id)); -} - -document.querySelectorAll('.step-button').forEach((button) => button.addEventListener('click', () => showStep(button.dataset.step))); - -async function jsonFetch(url, options = {}) { - const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); - const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || response.statusText); - return data; -} - -async function loadSettings() { - const data = await jsonFetch('/api/settings'); - for (const key of normalKeys) { - const input = document.getElementById(key); - if (input && data.settings[key]) input.value = data.settings[key].value; - } - for (const key of secretKeys) { - const input = document.getElementById(key); - if (input && data.secrets[key]?.configured) input.placeholder = 'Configured - enter a new value to replace'; - } -} - -document.getElementById('save-settings').addEventListener('click', async () => { - const result = document.getElementById('save-result'); - try { - const payload = {}; - for (const key of normalKeys) { - const input = document.getElementById(key); - if (input) payload[key] = input.value; - } - const saved = await jsonFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }); - - for (const key of secretKeys) { - const input = document.getElementById(key); - if (input && input.value) { - await jsonFetch(`/api/settings/secrets/${key}`, { method: 'PUT', body: JSON.stringify({ value: input.value }) }); - input.value = ''; - input.placeholder = 'Configured - enter a new value to replace'; - } - } - - result.textContent = saved.requiresRestart ? 'Saved. Restart required for some changes.' : 'Saved.'; - } catch (error) { - result.textContent = error.message; - } -}); - -document.getElementById('run-diagnostics').addEventListener('click', async () => { - const output = document.getElementById('diagnostic-output'); - const checks = await jsonFetch('/api/settings/checks'); - output.innerHTML = `
${JSON.stringify(checks, null, 2)}
`; -}); - -document.getElementById('load-jobs').addEventListener('click', async () => { - const output = document.getElementById('diagnostic-output'); - const [summary, recent] = await Promise.all([ - jsonFetch('/api/jobs/summary'), - jsonFetch('/api/jobs/recent?limit=10') - ]); - output.innerHTML = `
${JSON.stringify({ summary, recent }, null, 2)}
`; -}); - -loadSettings().catch((error) => { - document.getElementById('save-result').textContent = error.message; -}); diff --git a/public/setup.html b/public/setup.html index 4098efe..519a7b1 100644 --- a/public/setup.html +++ b/public/setup.html @@ -4,27 +4,27 @@ Scanner Map Setup - + - -
-
+ +
+

Scanner Map Setup

Configure the instance, verify dependencies, and finish first-run setup from the browser.

-
Checking setup...
+
-
-