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/.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/.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/.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..7ceee98 100644 --- a/README.md +++ b/README.md @@ -1,150 +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 +# 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. + +## 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 +``` + +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 -# Linux -sudo bash linux_install_scanner_map.sh +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 +``` + +### Unraid + +Import templates from [`unraid/`](unraid/) โ€” see [unraid/README-unraid.md](unraid/README-unraid.md). -# Windows (PowerShell as Admin) -.\install_scanner_map.ps1 +## Native install (development) + +```bash +npm install +npm run setup # guided wizard โ†’ .env +npm run doctor # verify dependencies +npm start # bot + webserver ``` -Then: +Python deps for local transcription: + ```bash -cd scanner-map -source .venv/bin/activate # Linux -node bot.js +npm run install:python-deps -- --backend whisper # or qwen, tone, all ``` ---- +## 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). -## โš™๏ธ Configuration +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 28df574..406dda9 100644 --- a/bot.js +++ b/bot.js @@ -1,6 +1,23 @@ // 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'); +const { getRuntimeConfig, getSetupStatus } = require('./src/settings/settingsService'); +const { + JOB_TYPES, + createProcessingJob, + markJobCompleted, + 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 { @@ -69,49 +86,30 @@ const { TONE_TIME_RESOLUTION_MS } = process.env; -// --- 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); +const startupConfig = loadConfig(process.env); +if (!startupConfig.isValid) { + console.warn('WARNING: Configuration has issues. Setup mode will remain available:'); + for (const error of startupConfig.errors) { + console.warn(`- ${error.key}: ${error.message}`); } -} 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 --- @@ -170,9 +168,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.`); } } @@ -392,8 +388,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); } @@ -411,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})`); @@ -818,24 +823,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.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 - } - 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 --- @@ -918,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 (ID: ${initialApiKeys[0].id})`); + 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.'); @@ -944,111 +932,93 @@ 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(); - } - }; +async function initializeDatabase() { + logger.info('Initializing database tables...'); + const applied = await applyMigrations(db, { + enableAuth: ENABLE_AUTH?.toLowerCase() === 'true' + }); - 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')); - } + if (applied.length > 0) { + logger.info(`Applied database migrations: ${applied.join(', ')}`); + } else { + logger.info('Database schema already up to date.'); + } +} - // Create indexes for commonly queried columns - db.run(`CREATE INDEX IF NOT EXISTS idx_transcriptions_timestamp ON transcriptions(timestamp)`, (err) => { - if (err) logger.warn('Error creating timestamp index:', err.message); - }); - db.run(`CREATE INDEX IF NOT EXISTS idx_transcriptions_coords ON transcriptions(lat, lon) WHERE lat IS NOT NULL`, (err) => { - if (err) logger.warn('Error creating coords index:', err.message); - }); - db.run(`CREATE INDEX IF NOT EXISTS idx_transcriptions_talkgroup ON transcriptions(talk_group_id)`, (err) => { - if (err) logger.warn('Error creating talkgroup index:', err.message); - }); - db.run(`CREATE INDEX IF NOT EXISTS idx_transcriptions_category ON transcriptions(category)`, (err) => { - if (err) logger.warn('Error creating category index:', err.message); - }); - db.run(`CREATE INDEX IF NOT EXISTS idx_audio_transcription ON audio_files(transcription_id)`, (err) => { - if (err) logger.warn('Error creating audio index:', err.message); - }); - }); +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 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 { + 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) => { @@ -1093,7 +1063,7 @@ function createAdminUser() { reject(err); } else { logger.info('Created admin user for webserver authentication.'); - logger.info('Created admin user for webserver authentication.'); + logger.info(`Admin credentials: username=admin, password=${WEBSERVER_PASSWORD}`); resolve(); } } @@ -1138,7 +1108,8 @@ async function initializeBot() { if (newApiKey) { // Log the new API key one more time for visibility console.log('='.repeat(60)); - console.log('NEW API KEY GENERATED'); + console.log('NEW API KEY GENERATED:'); + console.log(newApiKey); console.log('Please save this key - it will not be shown again!'); console.log('='.repeat(60)); } @@ -1163,16 +1134,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) { @@ -1217,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 @@ -1232,10 +1219,14 @@ 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 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 @@ -1259,7 +1250,6 @@ const db = new sqlite3.Database('./botdata.db', (err) => { process.exit(1); } else { logger.info('Connected to SQLite database.'); - // Enable WAL mode for safer concurrent access from webserver.js db.run('PRAGMA journal_mode = WAL;'); db.run('PRAGMA busy_timeout = 5000;'); // Trigger initialization after database connection @@ -1277,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) => { @@ -1473,18 +1465,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.'); @@ -1798,17 +1796,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() !== '[]') || @@ -1876,12 +1883,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) { @@ -2227,7 +2234,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); } @@ -2405,7 +2412,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'); @@ -2477,7 +2484,7 @@ function cleanupTranscriptionProcess() { } } } - transcriptionQueue = []; // Clear the queue + transcriptionQueue.clear(); } // Reset all state variables @@ -2524,14 +2531,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; @@ -2557,7 +2564,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); } } @@ -2624,7 +2631,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); @@ -2658,8 +2665,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; @@ -2691,14 +2700,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}`); @@ -2760,8 +2769,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; @@ -2779,21 +2790,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}`); } @@ -2812,7 +2823,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 @@ -2850,8 +2861,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; @@ -2869,7 +2882,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 @@ -2879,9 +2892,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(); @@ -2895,8 +2908,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, { @@ -2966,6 +2979,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}`); @@ -3038,7 +3057,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 @@ -3048,10 +3067,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; @@ -3076,7 +3097,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 @@ -3087,10 +3108,28 @@ 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 { + transcriptionJobId = await createProcessingJob(db, { + transcriptionId, + jobType: JOB_TYPES.TRANSCRIPTION, + payload: { + filename, + talkGroupID, + transcriptionMode: transcriptionConfig.mode, + storageMode: storageConfig.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') { + if (storageConfig.mode !== 's3') { db.run( `INSERT INTO audio_files (transcription_id, audio_data) VALUES (?, ?)`, [transcriptionId, fileBuffer], @@ -3183,13 +3222,14 @@ function handleNewAudio(audioData) { storagePath, audioPathForSplitting, tempPath, - finalPathIfLocal + finalPathIfLocal, + storageConfig.mode ); } }; // 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') { @@ -3229,6 +3269,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 @@ -3236,9 +3282,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) => { @@ -3252,7 +3298,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) => { @@ -3283,11 +3329,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) => { @@ -3299,31 +3346,40 @@ function handleNewAudio(audioData) { }); }); } + if (transcriptionJobId) { + safelyUpdateProcessingJob('mark transcription job completed', () => markJobCompleted(db, transcriptionJobId, { + empty: false, + transcriptionLength: transcriptionText.length + })); + } logger.info(`Successfully processed: ${filename}`); }); }; // --- 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; + const pathToUse = (storageConfig.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; + const pathToUseForRemote = (storageConfig.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; + 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,27 +3473,43 @@ 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); } // 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); @@ -3455,6 +3527,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); @@ -3594,7 +3669,8 @@ function transcribeAudio(filePath, callback) { id: requestId, path: filePath, callback: processCallback, // Use the wrapper callback - // retry tracking removed (was unused) + retries: 0, // Track retry attempts + maxRetries: 2 // Maximum number of retries }); // Try to process @@ -3917,7 +3993,8 @@ async function processMergedCallSegments( storagePath, audioPathForSplitting, tempPath, - finalPathIfLocal + finalPathIfLocal, + storageMode = STORAGE_MODE ) { logger.info(`Processing ${segmentTranscriptions.length} segments for merged call ID ${transcriptionId}`); @@ -3933,7 +4010,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) { @@ -3983,7 +4060,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') { @@ -4022,11 +4099,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) { @@ -4074,9 +4153,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) => { @@ -4287,12 +4364,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}`); @@ -4527,7 +4604,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 @@ -4538,7 +4615,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}`); @@ -5058,26 +5135,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 @@ -5095,13 +5178,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 }), @@ -5360,7 +5443,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; @@ -5566,6 +5649,8 @@ function getOrCreateChannel(channelName, categoryId, callback) { // Voice channel and audio playback management const activeVoiceChannels = new Map(); +const audioFilesInUse = new Set(); + function playAudioForTalkGroup(talkGroupID, transcriptionId) { talkGroupID = talkGroupID.toString(); const talkGroupData = activeVoiceChannels.get(talkGroupID); @@ -5587,7 +5672,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) { @@ -5633,14 +5718,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); @@ -5675,6 +5767,13 @@ function deleteAudioFile(transcriptionId) { }); } +function markAudioFileAsNotNeeded(transcriptionId) { + audioFilesInUse.delete(transcriptionId); + if (!audioFilesInUse.has(transcriptionId)) { + deleteAudioFile(transcriptionId); + } +} + async function handleListenLive(interaction, talkGroupID) { talkGroupID = talkGroupID.toString(); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); @@ -5866,6 +5965,12 @@ function cleanupVoiceChannel(talkGroupID) { } } + if (queue && queue.length > 0) { + queue.forEach((transcriptionId) => { + markAudioFileAsNotNeeded(transcriptionId); + }); + } + if (voiceChannel) { voiceChannel .delete() @@ -5975,13 +6080,15 @@ 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; }); client.on('interactionCreate', async (interaction) => { @@ -6223,8 +6330,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 @@ -6325,20 +6437,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 @@ -6355,13 +6467,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 } @@ -6497,4 +6609,4 @@ process.on('SIGINT', () => { process.exit(0); }); }); -}); \ No newline at end of file +}); 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/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/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-lock.json b/package-lock.json new file mode 100644 index 0000000..9422d52 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4467 @@ +{ + "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", + "express-rate-limit": "^7.5.0", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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/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", + "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..aa86c42 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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" + }, + "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", + "express-rate-limit": "^7.5.0", + "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/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 bc737ee..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); - } @@ -919,6 +485,7 @@ Add User View Users Manage Sessions + Settings Console Call Purge @@ -1369,6 +936,7 @@

Settings

@@ -1571,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); @@ -1580,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 new file mode 100644 index 0000000..eb31135 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,114 @@ + + + + + + Scanner Map Settings + + + +
+
+
+

Scanner Map Settings

+

Runtime configuration, secrets, transcription backends, and diagnostics.

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

General

+
+
+
+
+
+
+
+
+ +
+

Discord

+
+
+
+
+
+
+ +
+

Ingestion

+
+
+
+
+ +
+

Transcription

+
+
+
+
+

VRAM: large-v3 ~3GB+, turbo lower

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

Storage & AI

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

Diagnostics

+
+ + +
+
+
+ +
+ + +
+
+
+
+ + + 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..519a7b1 --- /dev/null +++ b/public/setup.html @@ -0,0 +1,134 @@ + + + + + + Scanner Map Setup + + + +
+
+
+

Scanner Map Setup

+

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

+
+ +
+ +
+ + +
+
+

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..55a1874 --- /dev/null +++ b/public/setup.js @@ -0,0 +1,146 @@ +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, + 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 + }) + }); + + 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 }) + }); + } + + 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) { + 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/requirements-base.txt b/requirements-base.txt new file mode 100644 index 0000000..d0f8f7b --- /dev/null +++ b/requirements-base.txt @@ -0,0 +1,4 @@ +boto3 +numpy +pydub +python-dotenv diff --git a/requirements-local-qwen.txt b/requirements-local-qwen.txt new file mode 100644 index 0000000..0c41dac --- /dev/null +++ b/requirements-local-qwen.txt @@ -0,0 +1,5 @@ +-r requirements-base.txt +qwen-asr +transformers +torch +torchaudio diff --git a/requirements-local-whisper.txt b/requirements-local-whisper.txt new file mode 100644 index 0000000..3b48785 --- /dev/null +++ b/requirements-local-whisper.txt @@ -0,0 +1,4 @@ +-r requirements-base.txt +faster-whisper +torch +torchaudio diff --git a/requirements-tone.txt b/requirements-tone.txt new file mode 100644 index 0000000..58c77b0 --- /dev/null +++ b/requirements-tone.txt @@ -0,0 +1,2 @@ +-r requirements-base.txt +icad-tone-detection diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eee6463 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +-r requirements-base.txt 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/doctor.js b/scripts/doctor.js new file mode 100644 index 0000000..fb360fe --- /dev/null +++ b/scripts/doctor.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); + +function check(name, ok, detail = '') { + const status = ok ? 'OK' : 'MISSING'; + console.log(`${status.padEnd(8)} ${name}${detail ? ` โ€” ${detail}` : ''}`); + return ok; +} + +function main() { + console.log('Scanner Map doctor\n'); + + const nodeOk = check('Node.js', process.version.startsWith('v'), process.version); + const ffmpegOk = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; + check('ffmpeg', ffmpegOk); + + const venv = path.join(ROOT, '.venv'); + check('.venv', fs.existsSync(venv)); + + const pkg = path.join(ROOT, 'package.json'); + check('package.json', fs.existsSync(pkg)); + check('node_modules', fs.existsSync(path.join(ROOT, 'node_modules'))); + + const py = spawnSync('python3', ['-c', 'import pydub'], { encoding: 'utf8' }); + check('python pydub', py.status === 0); + + const envPath = path.join(ROOT, '.env'); + if (fs.existsSync(envPath)) { + const env = fs.readFileSync(envPath, 'utf8'); + const mode = (env.match(/^TRANSCRIPTION_MODE=(.*)$/m) || [])[1] || 'remote'; + check('TRANSCRIPTION_MODE', true, mode.trim()); + if (mode.trim() === 'local') { + const fw = spawnSync('python3', ['-c', 'import faster_whisper'], { encoding: 'utf8' }); + const qwen = spawnSync('python3', ['-c', 'import qwen_asr'], { encoding: 'utf8' }); + check('faster-whisper', fw.status === 0); + check('qwen-asr', qwen.status === 0); + } + } else { + check('.env', false, 'copy .env.example'); + } + + const modelsDir = path.join(ROOT, 'models'); + if (fs.existsSync(modelsDir)) { + const files = fs.readdirSync(modelsDir); + check('models cache', true, `${files.length} entries`); + } + + if (!nodeOk || !ffmpegOk) process.exit(1); +} + +main(); 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/scripts/install-python-deps.js b/scripts/install-python-deps.js new file mode 100644 index 0000000..dd8e3ad --- /dev/null +++ b/scripts/install-python-deps.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); + +function readEnvFile() { + const envPath = path.join(ROOT, '.env'); + if (!fs.existsSync(envPath)) return {}; + const out = {}; + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim(); + } + return out; +} + +function detectProfiles(env) { + const profiles = new Set(['base']); + const mode = (env.TRANSCRIPTION_MODE || 'remote').toLowerCase(); + if (mode === 'local') { + const backend = (env.LOCAL_TRANSCRIPTION_BACKEND || 'faster-whisper').toLowerCase(); + if (backend.startsWith('qwen')) profiles.add('local-qwen'); + else profiles.add('local-whisper'); + } + if ((env.ENABLE_TONE_DETECTION || 'false').toLowerCase() === 'true') { + profiles.add('tone'); + } + return profiles; +} + +function pipInstall(requirementFile) { + const req = path.join(ROOT, requirementFile); + if (!fs.existsSync(req)) { + console.warn(`Skipping missing ${requirementFile}`); + return; + } + console.log(`Installing ${requirementFile}...`); + const result = spawnSync('pip3', ['install', '-r', req], { stdio: 'inherit', cwd: ROOT }); + if (result.status !== 0) process.exit(result.status || 1); +} + +function main() { + const env = { ...readEnvFile(), ...process.env }; + const profiles = detectProfiles(env); + console.log('Python profiles:', [...profiles].join(', ')); + + pipInstall('requirements-base.txt'); + if (profiles.has('local-whisper')) pipInstall('requirements-local-whisper.txt'); + if (profiles.has('local-qwen')) pipInstall('requirements-local-qwen.txt'); + if (profiles.has('tone')) pipInstall('requirements-tone.txt'); +} + +main(); diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..cecc177 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); + +function run(cmd, args, opts = {}) { + const result = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts }); + if (result.status !== 0) process.exit(result.status || 1); +} + +function main() { + console.log('Scanner Map setup\n'); + + const envExample = path.join(ROOT, '.env.example'); + const envFile = path.join(ROOT, '.env'); + if (!fs.existsSync(envFile) && fs.existsSync(envExample)) { + fs.copyFileSync(envExample, envFile); + console.log('Created .env from .env.example'); + } + + console.log('Installing Node dependencies...'); + if (fs.existsSync(path.join(ROOT, 'package-lock.json'))) { + run('npm', ['ci']); + } else { + run('npm', ['install']); + } + + const venv = path.join(ROOT, '.venv'); + if (!fs.existsSync(venv)) { + console.log('Creating Python venv...'); + run('python3', ['-m', 'venv', '.venv']); + } + + const pip = path.join(venv, 'bin', 'pip'); + const pipCmd = fs.existsSync(pip) ? pip : 'pip3'; + + console.log('Installing Python dependencies for configured profile...'); + run('node', ['scripts/install-python-deps.js'], { + env: { ...process.env, PATH: `${path.join(venv, 'bin')}:${process.env.PATH}` } + }); + + console.log('\nSetup complete. Run: npm start'); + console.log('Or Docker: cp docker/.env.example docker/.env && docker compose -f docker/docker-compose.yml --profile core up -d'); +} + +main(); diff --git a/src/auth/apiKeyValidation.js b/src/auth/apiKeyValidation.js new file mode 100644 index 0000000..693346b --- /dev/null +++ b/src/auth/apiKeyValidation.js @@ -0,0 +1,46 @@ +'use strict'; + +const crypto = require('crypto'); + +function fingerprintKey(plainKey) { + return crypto.createHash('sha256').update(String(plainKey)).digest('hex'); +} + +function buildFingerprintIndex(apiKeys) { + const index = new Map(); + for (const entry of apiKeys) { + if (entry.disabled || !entry.fingerprint) continue; + index.set(entry.fingerprint, entry); + } + return index; +} + +async function validateApiKeyFast(plainKey, apiKeys, fingerprintIndex) { + if (!plainKey) return null; + const fp = fingerprintKey(plainKey); + const hit = fingerprintIndex.get(fp); + if (hit) return hit; + + const bcrypt = require('bcrypt'); + for (const entry of apiKeys) { + if (entry.disabled || !entry.key) continue; + const match = await bcrypt.compare(plainKey, entry.key); + if (match) { + entry.fingerprint = fp; + return entry; + } + } + return null; +} + +function attachFingerprintToNewKey(entry, plainKey) { + entry.fingerprint = fingerprintKey(plainKey); + return entry; +} + +module.exports = { + fingerprintKey, + buildFingerprintIndex, + validateApiKeyFast, + attachFingerprintToNewKey, +}; 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..1972e07 --- /dev/null +++ b/src/db/migrations.js @@ -0,0 +1,183 @@ +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) ON DELETE SET NULL + )` + ] + }, + { + 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 + )` + ] + }, + { + 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) 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 + )` + ] + }, + { + id: '005_create_transcription_indexes', + statements: [ + `CREATE INDEX IF NOT EXISTS idx_transcriptions_timestamp ON transcriptions(timestamp)`, + `CREATE INDEX IF NOT EXISTS idx_transcriptions_talkgroup ON transcriptions(talk_group_id)`, + `CREATE INDEX IF NOT EXISTS idx_transcriptions_category ON transcriptions(category)`, + `CREATE INDEX IF NOT EXISTS idx_transcriptions_coords ON transcriptions(lat, lon)`, + `CREATE INDEX IF NOT EXISTS idx_audio_transcription ON audio_files(transcription_id)` + ] + } +]; + +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..c77d97d --- /dev/null +++ b/src/ingestion/normalizeCall.js @@ -0,0 +1,121 @@ +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 = {}, options = {}) { + const enriched = enrichTrunkRecorderFields(fields); + const provider = options.provider || 'trunk-recorder'; + + return { + provider, + 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: provider === 'trunk-recorder' + }; +} + +function normalizeIncomingCall({ source, fields = {}, fileInfo = {} } = {}) { + if (source === 'sdrtrunk') return normalizeSdrTrunkCall(fields, fileInfo); + if (source === 'trunk-recorder') { + return normalizeTrunkRecorderCall(fields, fileInfo); + } + + if (source === 'rdio-scanner') { + return normalizeTrunkRecorderCall(fields, fileInfo, { provider: 'rdio-scanner' }); + } + + return normalizeTrunkRecorderCall(fields, fileInfo); +} + +module.exports = { + enrichTrunkRecorderFields, + extractSourceFromFilename, + normalizeIncomingCall, + normalizeSdrTrunkCall, + normalizeTrunkRecorderCall, + parseJsonField +}; diff --git a/src/jobs/processingJobs.js b/src/jobs/processingJobs.js new file mode 100644 index 0000000..4cd7d0a --- /dev/null +++ b/src/jobs/processingJobs.js @@ -0,0 +1,196 @@ +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); + }); + }); +} + +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, + 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) + }; +} + +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, + parseJson, + serializeJson +}; 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/src/polling/callPoller.js b/src/polling/callPoller.js new file mode 100644 index 0000000..b951ca5 --- /dev/null +++ b/src/polling/callPoller.js @@ -0,0 +1,117 @@ +'use strict'; + +function createCallPoller({ db, io, generateShortSummary }) { + let lastPollId = 0; + let pollTimer = null; + let polling = false; + + function initializeLastPollId() { + db.get('SELECT MAX(id) AS maxId FROM transcriptions', (err, row) => { + if (err) { + console.error('Error initializing poll cursor:', err.message); + } else { + lastPollId = row.maxId || 0; + console.log(`Initialized poll cursor to ${lastPollId}`); + } + }); + } + + async function categorizeCallAsync(row) { + try { + const category = await generateShortSummary(row.transcription); + if (!category) return; + await new Promise((resolve, reject) => { + db.run( + 'UPDATE transcriptions SET category = ? WHERE id = ?', + [category, row.id], + (dbErr) => (dbErr ? reject(dbErr) : resolve()) + ); + }); + io.emit('callUpdated', { id: row.id, category }); + } catch (err) { + console.error(`Error categorizing call ${row.id}:`, err); + } + } + + function shouldEmitWithPlaceholder(row) { + if (row.transcription) return row; + const callAgeMs = Date.now() - (row.timestamp * 1000); + if (callAgeMs > 10000) { + return { ...row, transcription: '[Transcription Pending...]' }; + } + return null; + } + + function fetchAndEmitNewCalls() { + if (polling) return; + polling = true; + + db.all( + ` + SELECT t.*, tg.alpha_tag AS talk_group_name, tg.tag AS talk_group_tag + FROM transcriptions t + LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id + WHERE t.id > ? + ORDER BY t.id ASC + LIMIT 20 + `, + [lastPollId], + (err, rows) => { + polling = false; + if (err) { + console.error('Error polling transcriptions:', err.message); + scheduleNextPoll(); + return; + } + + let updatedLastId = lastPollId; + for (const row of rows || []) { + if (row.id > updatedLastId) updatedLastId = row.id; + + const feedRow = shouldEmitWithPlaceholder(row); + if (feedRow) { + io.emit('liveFeedUpdate', feedRow); + } + + const hasValidCoords = + row.lat != null && + row.lon != null && + row.lat >= -90 && + row.lat <= 90 && + row.lon >= -180 && + row.lon <= 180; + + if (hasValidCoords) { + if (!row.category && row.transcription) { + categorizeCallAsync(row); + } + const mapRow = shouldEmitWithPlaceholder(row); + if (mapRow) io.emit('newCall', mapRow); + } + } + + if (updatedLastId > lastPollId) lastPollId = updatedLastId; + scheduleNextPoll(); + } + ); + } + + function scheduleNextPoll() { + if (pollTimer) clearTimeout(pollTimer); + pollTimer = setTimeout(fetchAndEmitNewCalls, 2000); + } + + function start() { + initializeLastPollId(); + scheduleNextPoll(); + } + + function stop() { + if (pollTimer) clearTimeout(pollTimer); + pollTimer = null; + } + + return { start, stop }; +} + +module.exports = { createCallPoller }; diff --git a/src/routes/geocodeProxy.js b/src/routes/geocodeProxy.js new file mode 100644 index 0000000..554d530 --- /dev/null +++ b/src/routes/geocodeProxy.js @@ -0,0 +1,118 @@ +'use strict'; + +const fetch = require('node-fetch'); + +async function proxyAutocomplete(query, runtime) { + const q = String(query || '').trim(); + if (q.length < 2) return []; + + const googleKey = runtime.secrets.googleMapsApiKey; + const locationIqKey = runtime.secrets.locationIqApiKey; + + if (googleKey) { + const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(q)}&key=${googleKey}`; + const response = await fetch(url); + const data = await response.json(); + return (data.predictions || []).map((p) => ({ + provider: 'google', + label: p.description, + placeId: p.place_id, + })); + } + + if (locationIqKey) { + const params = new URLSearchParams({ + key: locationIqKey, + q, + limit: '5', + countrycodes: 'us', + }); + const response = await fetch(`https://us1.locationiq.com/v1/autocomplete?${params}`); + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((item) => ({ + provider: 'locationiq', + label: item.display_name, + lat: item.lat, + lon: item.lon, + })); + } + + return []; +} + +async function proxyReverseGeocode(lat, lon, runtime) { + const googleKey = runtime.secrets.googleMapsApiKey; + const locationIqKey = runtime.secrets.locationIqApiKey; + + if (googleKey) { + const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lon}&key=${googleKey}`; + const response = await fetch(url); + return response.json(); + } + + if (locationIqKey) { + const params = new URLSearchParams({ + key: locationIqKey, + lat: String(lat), + lon: String(lon), + format: 'json', + }); + const response = await fetch(`https://us1.locationiq.com/v1/reverse?${params}`); + return response.json(); + } + + return { error: 'No geocoding provider configured' }; +} + +function registerGeocodeRoutes(app, { getResolvedRuntimeConfig, basicAuth }) { + app.get('/api/geocode/autocomplete', basicAuth, async (req, res) => { + try { + const runtime = await getResolvedRuntimeConfig(); + const results = await proxyAutocomplete(req.query.q, runtime); + res.json({ results }); + } catch (err) { + console.error('[Geocode proxy]', err); + res.status(500).json({ error: 'Geocode autocomplete failed' }); + } + }); + + app.get('/api/geocode/reverse', basicAuth, async (req, res) => { + try { + const runtime = await getResolvedRuntimeConfig(); + const lat = parseFloat(req.query.lat); + const lon = parseFloat(req.query.lon); + if (Number.isNaN(lat) || Number.isNaN(lon)) { + return res.status(400).json({ error: 'Invalid lat/lon' }); + } + const data = await proxyReverseGeocode(lat, lon, runtime); + res.json(data); + } catch (err) { + console.error('[Geocode proxy]', err); + res.status(500).json({ error: 'Reverse geocode failed' }); + } + }); + + // Legacy endpoints โ€” no longer expose raw keys + app.get('/api/config/google-api-key', basicAuth, (_req, res) => { + res.json({ apiKey: null, useProxy: true }); + }); + + app.get('/api/config/locationiq-api-key', basicAuth, (_req, res) => { + res.json({ apiKey: null, useProxy: true }); + }); + + app.get('/api/config/geocoding', basicAuth, async (_req, res) => { + const runtime = await getResolvedRuntimeConfig(); + res.json({ + google: { available: !!runtime.secrets.googleMapsApiKey, useProxy: true }, + locationiq: { available: !!runtime.secrets.locationIqApiKey, useProxy: true }, + }); + }); +} + +module.exports = { + registerGeocodeRoutes, + proxyAutocomplete, + proxyReverseGeocode, +}; diff --git a/src/settings/settingsService.js b/src/settings/settingsService.js new file mode 100644 index 0000000..1f4dc19 --- /dev/null +++ b/src/settings/settingsService.js @@ -0,0 +1,332 @@ +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 }, + 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 }, + 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 }, + localTranscriptionBackend: { envKey: 'LOCAL_TRANSCRIPTION_BACKEND', defaultValue: 'faster-whisper', requiresRestart: true }, + qwenAsrModel: { envKey: 'QWEN_ASR_MODEL', defaultValue: 'Qwen/Qwen3-ASR-0.6B', requiresRestart: true }, + qwenAsrBackend: { envKey: 'QWEN_ASR_BACKEND', defaultValue: 'transformers', requiresRestart: true }, + qwenAsrLanguage: { envKey: 'QWEN_ASR_LANGUAGE', defaultValue: 'auto', requiresRestart: false }, + enableToneDetection: { envKey: 'ENABLE_TONE_DETECTION', defaultValue: 'false', requiresRestart: true }, + enableAuth: { envKey: 'ENABLE_AUTH', defaultValue: 'false', requiresRestart: true }, + 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 }, + 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 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 = {}; + + 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, + getRuntimeConfig, + getRuntimeSecret, + getRuntimeSetting, + getSetupStatus, + resolveSettings, + saveSecret, + saveSettings, + markSetupComplete +}; diff --git a/src/setup/checks.js b/src/setup/checks.js new file mode 100644 index 0000000..900cfb5 --- /dev/null +++ b/src/setup/checks.js @@ -0,0 +1,117 @@ +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 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']), + 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(runtime.secrets.googleMapsApiKey || runtime.secrets.locationIqApiKey || env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY), + configuredProviders: { + google: Boolean(runtime.secrets.googleMapsApiKey || env.GOOGLE_MAPS_API_KEY), + locationiq: Boolean(runtime.secrets.locationIqApiKey || env.LOCATIONIQ_API_KEY) + } + }, + transcriptionProvider: { + ok: transcriptionReady, + mode: transcriptionMode + }, + aiProvider: { + ok: aiReady, + provider: aiProvider + }, + storageProvider: { + ok: storageMode === 'local' || hasS3Config, + mode: storageMode + }, + uploadEndpoint: { + ok: Boolean(runtime.secrets.uploadApiKey || env.SCANNER_MAP_UPLOAD_API_KEY), + 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/src/transcription/queue.js b/src/transcription/queue.js new file mode 100644 index 0000000..c21d2e1 --- /dev/null +++ b/src/transcription/queue.js @@ -0,0 +1,68 @@ +'use strict'; + +/** Array-compatible queue keyed by transcription id for safe concurrent access. */ +function createTranscriptionQueue() { + const map = new Map(); + const order = []; + + const queue = { + get length() { + return order.length; + }, + push(item) { + if (!item || item.id == null) return; + if (!map.has(item.id)) order.push(item.id); + map.set(item.id, item); + return order.length; + }, + unshift(item) { + if (!item || item.id == null) return; + if (map.has(item.id)) { + const idx = order.indexOf(item.id); + if (idx >= 0) order.splice(idx, 1); + } + order.unshift(item.id); + map.set(item.id, item); + return order.length; + }, + shift() { + const id = order.shift(); + if (id == null) return undefined; + const item = map.get(id); + map.delete(id); + return item; + }, + findIndex(fn) { + return order.findIndex((id) => fn(map.get(id))); + }, + splice(start, deleteCount) { + const removed = []; + const count = deleteCount ?? order.length - start; + for (let i = 0; i < count && start < order.length; i++) { + const id = order.splice(start, 1)[0]; + const item = map.get(id); + map.delete(id); + if (item) removed.push(item); + } + return removed; + }, + get(index) { + const id = order[index]; + return id == null ? undefined : map.get(id); + }, + find(id) { + return map.get(id); + }, + clear() { + map.clear(); + order.length = 0; + }, + [Symbol.iterator]() { + return order.map((id) => map.get(id))[Symbol.iterator](); + }, + }; + + return queue; +} + +module.exports = { createTranscriptionQueue }; diff --git a/test/apiKeyValidation.test.js b/test/apiKeyValidation.test.js new file mode 100644 index 0000000..ae39938 --- /dev/null +++ b/test/apiKeyValidation.test.js @@ -0,0 +1,29 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + fingerprintKey, + buildFingerprintIndex, + attachFingerprintToNewKey, +} = require('../src/auth/apiKeyValidation'); + +describe('apiKeyValidation', () => { + it('fingerprintKey is stable for the same input', () => { + const a = fingerprintKey('test-key-123'); + const b = fingerprintKey('test-key-123'); + assert.equal(a, b); + assert.notEqual(a, fingerprintKey('other')); + }); + + it('buildFingerprintIndex maps configured fingerprints', () => { + const keys = [{ id: 1, fingerprint: fingerprintKey('abc'), disabled: false }]; + const index = buildFingerprintIndex(keys); + assert.equal(index.get(fingerprintKey('abc')).id, 1); + }); + + it('attachFingerprintToNewKey stores fingerprint on entry', () => { + const entry = attachFingerprintToNewKey({ key: 'hash' }, 'plain'); + assert.ok(entry.fingerprint); + }); +}); 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..04e094b --- /dev/null +++ b/test/ingestion.test.js @@ -0,0 +1,62 @@ +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); +}); + +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/test/migrations.test.js b/test/migrations.test.js new file mode 100644 index 0000000..41f068c --- /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', '003_create_call_jobs', '004_create_app_settings', '005_create_transcription_indexes'] + ); +}); + +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', '004_create_app_settings', '005_create_transcription_indexes'] + ); +}); 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']); +}); diff --git a/test/processingJobs.test.js b/test/processingJobs.test.js new file mode 100644 index 0000000..bcc966b --- /dev/null +++ b/test/processingJobs.test.js @@ -0,0 +1,67 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + JOB_STATUS, + JOB_TYPES, + getRecentJobs, + getJobSummary, + 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 }); +}); + +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..1bd6cb2 --- /dev/null +++ b/test/settingsService.test.js @@ -0,0 +1,152 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + decryptSecret, + encryptSecret, + getRuntimeConfig, + getRuntimeSecret, + getRuntimeSetting, + 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('app_settings')) { + callback(null, settingsRows.find((row) => row.key === params[0])); + return; + } + 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('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: [ + { 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..a514567 --- /dev/null +++ b/test/setupChecks.test.js @@ -0,0 +1,72 @@ +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, runSetupChecks } = 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 }); +}); + +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/test/transcriptionQueue.test.js b/test/transcriptionQueue.test.js new file mode 100644 index 0000000..4ab3d81 --- /dev/null +++ b/test/transcriptionQueue.test.js @@ -0,0 +1,25 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createTranscriptionQueue } = require('../src/transcription/queue'); + +describe('transcription queue facade', () => { + it('supports push shift and findIndex like an array', () => { + const q = createTranscriptionQueue(); + q.push({ id: 1, data: 'a' }); + q.push({ id: 2, data: 'b' }); + assert.equal(q.length, 2); + assert.equal(q.findIndex((item) => item.id === 2), 1); + const first = q.shift(); + assert.equal(first.id, 1); + assert.equal(q.length, 1); + }); + + it('unshift adds to front', () => { + const q = createTranscriptionQueue(); + q.push({ id: 1 }); + q.unshift({ id: 9 }); + assert.equal(q.shift().id, 9); + }); +}); diff --git a/test/unraidTemplates.test.js b/test/unraidTemplates.test.js new file mode 100644 index 0000000..d03adaf --- /dev/null +++ b/test/unraidTemplates.test.js @@ -0,0 +1,22 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +describe('unraid templates', () => { + it('scanner-map.xml is well-formed and has required fields', () => { + const xml = fs.readFileSync(path.join(__dirname, '..', 'unraid', 'scanner-map.xml'), 'utf8'); + assert.ok(xml.includes('')); + assert.ok(xml.includes('')); + assert.ok(xml.includes('')); + assert.ok(xml.includes('TRANSCRIPTION_MODE')); + }); + + it('scanner-map-gpu.xml references GPU settings', () => { + const xml = fs.readFileSync(path.join(__dirname, '..', 'unraid', 'scanner-map-gpu.xml'), 'utf8'); + assert.ok(xml.includes('nvidia')); + assert.ok(xml.includes('WHISPER_MODEL')); + }); +}); diff --git a/test/up.js b/test/up.js index 764cf4e..909426d 100644 --- a/test/up.js +++ b/test/up.js @@ -3,10 +3,14 @@ const path = require('path'); const FormData = require('form-data'); const axios = require('axios'); -const API_KEY = 'c4c5f9e2-1698-4ebe-98f0-33656e313cb3'; // Replace with your actual API key -const UPLOAD_URL = 'http://localhost:3306/api/call-upload'; +const API_KEY = process.env.API_KEY || process.env.SCANNER_MAP_API_KEY; +const UPLOAD_URL = process.env.UPLOAD_URL || 'http://localhost:3306/api/call-upload'; + +if (!API_KEY) { + console.error('Set API_KEY (or SCANNER_MAP_API_KEY) before running this upload helper.'); + process.exit(1); +} -// Function to upload a single file async function uploadFile(filePath) { const form = new FormData(); const stats = fs.statSync(filePath); @@ -16,14 +20,13 @@ async function uploadFile(filePath) { form.append('file', fileStream, { knownLength: fileSizeInBytes }); form.append('key', API_KEY); - form.append('talkgroup', '4005'); // Replace with an appropriate talkgroup ID + form.append('talkgroup', process.env.TEST_TALKGROUP || '4005'); form.append('dateTime', Math.floor(Date.now() / 1000).toString()); form.append('systemLabel', 'Test System'); form.append('talkgroupLabel', 'Test Talkgroup'); - - // Add source information - use different identifier for M4A files if needed + if (fileExtension === '.m4a') { - form.append('source', 'TR-1234'); // You can customize the source ID for M4A files + form.append('source', 'TR-1234'); } else { form.append('source', 'Manual Upload'); } @@ -47,11 +50,9 @@ async function uploadFile(filePath) { } } -// Function to process all audio files in the current directory async function processDirectory() { const files = fs.readdirSync(__dirname); - // Filter for both MP3 and M4A files - const audioFiles = files.filter(file => { + const audioFiles = files.filter((file) => { const ext = path.extname(file).toLowerCase(); return ext === '.mp3' || ext === '.m4a'; }); @@ -62,14 +63,13 @@ async function processDirectory() { } console.log(`Found ${audioFiles.length} audio files to process.`); - + for (const file of audioFiles) { console.log(`Processing: ${file}`); await uploadFile(path.join(__dirname, file)); } } -// Run the script processDirectory() .then(() => console.log('All files processed')) - .catch(err => console.error('Error processing files:', err)); \ No newline at end of file + .catch((err) => console.error('Error processing files:', err)); diff --git a/transcribe.py b/transcribe.py index 70c22b7..9f83c19 100644 --- a/transcribe.py +++ b/transcribe.py @@ -1,188 +1,39 @@ -# persistent_transcribe.py -# Enhanced with OpenAI-style prompting support for better scanner audio transcription -import sys -import io -import torch -import warnings -import os +#!/usr/bin/env python3 +"""Local transcription worker โ€” pluggable backends (faster-whisper, Qwen3-ASR).""" import json import logging -import base64 -import numpy as np -from pydub import AudioSegment -from dotenv import load_dotenv - -# Import tone detection module -try: - from tone_detect import ToneDetector - TONE_DETECTION_AVAILABLE = True - logger_temp = logging.getLogger(__name__) - logger_temp.info("โœ“ Tone detection module loaded successfully") -except ImportError as e: - TONE_DETECTION_AVAILABLE = False - print(f"WARNING: Tone detection not available: {e}", file=sys.stderr) +import os +import sys +import time +import warnings -# Suppress specific CUDA compatibility warnings for newer GPUs -warnings.filterwarnings("ignore", message=".*CUDA capability.*not compatible.*") -warnings.filterwarnings("ignore", message=".*with CUDA capability.*") +from dotenv import load_dotenv -# Load environment variables from .env load_dotenv() -# Get environment variables - strict loading, no defaults -WHISPER_MODEL = os.getenv('WHISPER_MODEL') -TRANSCRIPTION_DEVICE = os.getenv('TRANSCRIPTION_DEVICE') -OPENAI_TRANSCRIPTION_PROMPT = os.getenv('OPENAI_TRANSCRIPTION_PROMPT') - -# Validate required environment variables -required_vars = ['WHISPER_MODEL', 'TRANSCRIPTION_DEVICE'] -missing_vars = [var for var in required_vars if os.getenv(var) is None] +warnings.filterwarnings('ignore', message='.*CUDA capability.*not compatible.*') +warnings.filterwarnings('ignore', message='.*with CUDA capability.*') -if missing_vars: - error_msg = f"FATAL ERROR: Missing required environment variables: {', '.join(missing_vars)}" - print(error_msg, file=sys.stderr) - print("Please check your .env file and ensure these variables are set:", file=sys.stderr) - for var in missing_vars: - print(f" {var}=", file=sys.stderr) - sys.exit(1) - -# Startup validation function -def validate_startup_environment(): - """Validate that all required dependencies are available before starting""" - try: - print("Validating Python environment...", file=sys.stderr) - - # Check Python version - python_version = sys.version_info - if python_version.major < 3 or (python_version.major == 3 and python_version.minor < 8): - print(f"ERROR: Python 3.8+ required, found {python_version.major}.{python_version.minor}", file=sys.stderr) - return False - - # Check critical imports - try: - import torch - print(f"โœ“ PyTorch {torch.__version__} available", file=sys.stderr) - except ImportError as e: - print(f"ERROR: PyTorch not available: {e}", file=sys.stderr) - return False - - try: - from faster_whisper import WhisperModel - print("โœ“ faster-whisper available", file=sys.stderr) - except ImportError as e: - print(f"ERROR: faster-whisper not available: {e}", file=sys.stderr) - return False - - try: - from pydub import AudioSegment - print("โœ“ pydub available", file=sys.stderr) - except ImportError as e: - print(f"ERROR: pydub not available: {e}", file=sys.stderr) - return False - - # Check device availability - if TRANSCRIPTION_DEVICE == 'cuda': - if not torch.cuda.is_available(): - print("ERROR: CUDA requested but not available", file=sys.stderr) - print("Available devices:", file=sys.stderr) - print(f" CPU: Available", file=sys.stderr) - print(f" CUDA: {torch.cuda.is_available()}", file=sys.stderr) - if hasattr(torch.backends, 'mps'): - print(f" MPS: {torch.backends.mps.is_available()}", file=sys.stderr) - return False - else: - print(f"โœ“ CUDA available: {torch.cuda.get_device_name()}", file=sys.stderr) - - # Log prompt configuration if available - if OPENAI_TRANSCRIPTION_PROMPT: - print("โœ“ Custom transcription prompt configured", file=sys.stderr) - - print("โœ“ Environment validation passed", file=sys.stderr) - return True - - except Exception as e: - print(f"ERROR during environment validation: {e}", file=sys.stderr) - return False - -# Run startup validation -if not validate_startup_environment(): - print("FATAL: Environment validation failed", file=sys.stderr) - sys.exit(1) - -# Configure logging to send INFO and above to stderr logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', - handlers=[ - logging.StreamHandler(sys.stderr) # Send INFO and above to stderr - ] + handlers=[logging.StreamHandler(sys.stderr)], ) - logger = logging.getLogger(__name__) -warnings.filterwarnings("ignore") -# Import faster_whisper here try: - from faster_whisper import WhisperModel + from tone_detect import ToneDetector + TONE_DETECTION_AVAILABLE = True except ImportError: - error_msg = "faster_whisper not installed. Run: pip install faster-whisper" - print(error_msg, file=sys.stderr) - sys.exit(1) - -# Check device availability -device = TRANSCRIPTION_DEVICE -# Check for MPS availability on macOS ARM -if device == "mps" and not torch.backends.mps.is_available(): - logger.warning("MPS requested but not available. Checking for CUDA...") - if torch.cuda.is_available(): - logger.warning("CUDA is available, falling back to CUDA.") - device = "cuda" - else: - logger.warning("CUDA not available, falling back to CPU.") - device = "cpu" -elif device == "cuda" and not torch.cuda.is_available(): - logger.warning("CUDA requested but not available. Falling back to CPU.") - device = "cpu" - -logger.info(f"Using device: {device}") - -# Determine computation type -# Use float32 for MPS, float16 for CUDA, int8 for CPU -if device == "mps": - compute_type = "float32" - logger.info("Using float32 compute type for MPS device.") -elif device == "cuda": - compute_type = "float16" - logger.info("Using float16 compute type for CUDA device.") -else: - compute_type = "int8" - logger.info("Using int8 compute type for CPU device.") + TONE_DETECTION_AVAILABLE = False + logger.warning('Tone detection not available') -# Load the Faster Whisper model with optimizations for high-volume systems -try: - model = WhisperModel( - WHISPER_MODEL, - device=device, - compute_type=compute_type, - download_root="./models", # Cache models locally - num_workers=1, # Single worker to avoid memory issues under high load - cpu_threads=0 # Use default CPU threads (auto-detect) - ) - logger.info(f"Loaded model: {WHISPER_MODEL} on {device} with compute_type: {compute_type}") - - # Log prompt configuration if available - if OPENAI_TRANSCRIPTION_PROMPT: - logger.info("Custom transcription prompt configured for scanner audio context") -except Exception as e: - error_msg = f"Error loading model: {str(e)}" - print(error_msg, file=sys.stderr) - sys.exit(1) +from transcription.backends import create_backend +from transcription.router import load_audio_from_command -# Initialize tone detector if available tone_detector = None -if TONE_DETECTION_AVAILABLE: +if TONE_DETECTION_AVAILABLE and (os.getenv('ENABLE_TONE_DETECTION', 'false').lower() == 'true'): try: - # Get tone detection configuration from environment or use defaults tone_config = { 'tone_a_min_length': float(os.getenv('TWO_TONE_MIN_TONE_LENGTH', '0.85')), 'tone_b_min_length': float(os.getenv('TWO_TONE_MAX_TONE_LENGTH', '5.0')), @@ -190,260 +41,81 @@ def validate_startup_environment(): 'fe_freq_band': os.getenv('TWO_TONE_FREQUENCY_BAND', '200,3000'), 'two_tone_bw_hz': int(os.getenv('TWO_TONE_BANDWIDTH_HZ', '25')), 'two_tone_min_pair_separation_hz': int(os.getenv('TWO_TONE_MIN_PAIR_SEPARATION_HZ', '40')), - 'time_resolution_ms': int(os.getenv('TWO_TONE_TIME_RESOLUTION_MS', '50')) + 'time_resolution_ms': int(os.getenv('TWO_TONE_TIME_RESOLUTION_MS', '50')), } tone_detector = ToneDetector(tone_config) - logger.info("โœ“ Tone detector initialized with configuration") - except Exception as e: - logger.warning(f"Failed to initialize tone detector: {e}") - tone_detector = None + logger.info('Tone detector initialized') + except Exception as exc: + logger.warning('Failed to initialize tone detector: %s', exc) -# Signal that the model is loaded and ready -print(json.dumps({"ready": True})) +backend = create_backend() +print(json.dumps({'ready': True, 'backend': backend.name})) sys.stdout.flush() -# Track last heartbeat time -import time last_heartbeat = time.time() -# Main loop to process commands while True: try: - # Send periodic heartbeat to show process is alive during quiet periods - current_time = time.time() - if current_time - last_heartbeat > 300: # 5 minutes - print(json.dumps({"heartbeat": True, "timestamp": current_time})) + now = time.time() + if now - last_heartbeat > 300: + print(json.dumps({'heartbeat': True, 'timestamp': now})) sys.stdout.flush() - last_heartbeat = current_time - - # Read command from stdin with timeout handling + last_heartbeat = now + line = sys.stdin.readline().strip() if not line: continue command = json.loads(line) request_id = command.get('id') - if not request_id: - logger.error(f"Command missing 'id': {line}") + logger.error("Command missing 'id': %s", line) continue - - # Log that we're starting to process this request - logger.info(f"Processing transcription request ID: {request_id}") - # Determine input type: path or base64 data - audio_input = None - input_type = None - error_detail = None - - if command.get('command') == 'transcribe': - if 'path' in command: - audio_file_path = command['path'] - if not os.path.isfile(audio_file_path): - error_detail = f"Audio file does not exist: {audio_file_path}" - else: - audio_input = audio_file_path - input_type = 'path' - elif 'audio_data_base64' in command: - try: - base64_data = command['audio_data_base64'] - audio_bytes = base64.b64decode(base64_data) - if not audio_bytes: - error_detail = "Decoded audio data is empty." - else: - # Load audio from bytes using pydub - audio_segment = AudioSegment.from_file(io.BytesIO(audio_bytes)) - # Convert to mono and set frame rate for Whisper (16kHz) - audio_segment = audio_segment.set_frame_rate(16000).set_channels(1) - # Convert to numpy array of floats - samples = np.array(audio_segment.get_array_of_samples()).astype(np.float32) / 32768.0 - audio_input = samples - input_type = 'buffer' - except base64.binascii.Error: - error_detail = "Invalid Base64 data received." - except Exception as e: - error_detail = f"Error processing audio buffer: {str(e)}" - else: - error_detail = "Invalid command format: missing 'path' or 'audio_data_base64'." - elif command.get('command') == 'detect_tones': - # Handle tone detection command - if not TONE_DETECTION_AVAILABLE or not tone_detector: - error_detail = "Tone detection is not available. Please install icad-tone-detection." - elif 'path' in command: - audio_file_path = command['path'] - if not os.path.isfile(audio_file_path): - error_detail = f"Audio file does not exist: {audio_file_path}" - else: - # Process tone detection immediately and return result - logger.info(f"Processing tone detection request ID: {request_id} for file: {audio_file_path}") - try: - detection_result = tone_detector.detect_tones_in_file(audio_file_path) - detected_tones = tone_detector.get_detected_tones(detection_result) - - response = { - "id": request_id, - "has_two_tone": detection_result.get('has_two_tone', False), - "detected_tones": detected_tones, - "file_path": audio_file_path - } - - if 'error' in detection_result: - response['error'] = detection_result['error'] - - logger.info(f"Tone detection completed for ID {request_id}: {response['has_two_tone']}") - print(json.dumps(response)) - sys.stdout.flush() - continue # Skip the transcription processing section - - except Exception as e: - error_detail = f"Error during tone detection: {str(e)}" - else: - error_detail = "Tone detection requires 'path' parameter." - else: - error_detail = f"Invalid command: {command.get('command')}" + if command.get('command') == 'detect_tones': + if not tone_detector: + print(json.dumps({'id': request_id, 'error': 'Tone detection not available'})) + sys.stdout.flush() + continue + path = command.get('path') + if not path or not os.path.isfile(path): + print(json.dumps({'id': request_id, 'error': 'Tone detection requires valid path'})) + sys.stdout.flush() + continue + try: + detection_result = tone_detector.detect_tones_in_file(path) + detected_tones = tone_detector.get_detected_tones(detection_result) + response = { + 'id': request_id, + 'has_two_tone': detection_result.get('has_two_tone', False), + 'detected_tones': detected_tones, + 'file_path': path, + } + if 'error' in detection_result: + response['error'] = detection_result['error'] + print(json.dumps(response)) + sys.stdout.flush() + except Exception as exc: + print(json.dumps({'id': request_id, 'error': str(exc)})) + sys.stdout.flush() + continue + audio_input, input_type, error_detail = load_audio_from_command(command) if error_detail: - error_response = {"id": request_id, "error": error_detail} - print(json.dumps(error_response)) + print(json.dumps({'id': request_id, 'error': error_detail})) sys.stdout.flush() continue - # --- Start Transcription --- - logger.info(f"Starting transcription for ID: {request_id} (type: {input_type})") - - # File integrity check ONLY if input is a path - if input_type == 'path': - try: - import subprocess - # First check if file exists and is readable - if not os.path.isfile(audio_input): - error_response = {"id": request_id, "error": f"Audio file not found: {audio_input}"} - print(json.dumps(error_response)) - sys.stdout.flush() - continue - - # Check file size (basic validation) - file_size = os.path.getsize(audio_input) - if file_size < 1000: # Less than 1KB - error_response = {"id": request_id, "error": f"Audio file too small: {file_size} bytes"} - print(json.dumps(error_response)) - sys.stdout.flush() - continue - elif file_size > 100 * 1024 * 1024: # More than 100MB - error_response = {"id": request_id, "error": f"Audio file too large: {file_size} bytes"} - print(json.dumps(error_response)) - sys.stdout.flush() - continue - - # Quick ffprobe check for file integrity - result = subprocess.run( - ['ffprobe', '-v', 'quiet', '-show_format', audio_input], - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - text=True, - timeout=15 # Increased timeout - ) - if result.returncode != 0: - error_response = {"id": request_id, "error": f"Corrupt audio file (ffprobe check): {audio_input}"} - print(json.dumps(error_response)) - sys.stdout.flush() - continue - except subprocess.TimeoutExpired: - logger.warning(f"FFprobe timeout for file: {audio_input}") - error_response = {"id": request_id, "error": f"File validation timeout: {audio_input}"} - print(json.dumps(error_response)) - sys.stdout.flush() - continue - except Exception as probe_err: - logger.warning(f"FFprobe check failed for {audio_input}: {str(probe_err)}") - # Continue with transcription attempt anyway - - # Transcribe the audio (from path or buffer) with English as the specified language try: - # Add memory cleanup before transcription for large buffers - if input_type == 'buffer': - import gc - gc.collect() # Force garbage collection before processing large audio - - # Prepare transcription parameters - transcription_params = { - 'audio': audio_input, # Can be path string or numpy array - 'language': 'en', - 'beam_size': 3, # Reduced from 5 to 3 for faster processing - 'vad_filter': True, - 'vad_parameters': {"min_silence_duration_ms": 750}, # Increased threshold for busy systems - 'word_timestamps': False, # Disable word timestamps for speed - 'condition_on_previous_text': False # Disable for better performance - } - - # Add prompt if available (helps with scanner audio context) - if OPENAI_TRANSCRIPTION_PROMPT: - transcription_params['initial_prompt'] = OPENAI_TRANSCRIPTION_PROMPT - logger.info(f"Using custom transcription prompt for ID {request_id}") - - # Try with VAD filtering first - optimized for high-volume systems - segments, info = model.transcribe(**transcription_params) - - segments_text = [segment.text for segment in segments] - transcription = " ".join(segments_text).strip() - - # If transcription is empty after VAD filtering, try without VAD - if not transcription: - logger.info(f"Retrying transcription for ID {request_id} without VAD filter.") - - # Retry without VAD but keep other parameters including prompt - retry_params = transcription_params.copy() - retry_params['vad_filter'] = False - - segments, info = model.transcribe(**retry_params) - segments_text = [segment.text for segment in segments] - transcription = " ".join(segments_text).strip() - - logger.info(f"Transcription successful for ID: {request_id} (length: {len(transcription)} chars)") - success_response = {"id": request_id, "transcription": transcription} - print(json.dumps(success_response)) + transcription = backend.transcribe(audio_input, input_type, request_id) + print(json.dumps({'id': request_id, 'transcription': transcription or ''})) sys.stdout.flush() - - # Clean up memory for buffer-based transcriptions - if input_type == 'buffer': - del audio_input # Free the numpy array - gc.collect() - - except Exception as e: - error_str = str(e) - # Detect specific FFmpeg/audio processing errors - if "[Errno 1094995529]" in error_str or "Invalid data found" in error_str or "corrupt" in error_str.lower(): - error_response = {"id": request_id, "error": f"Corrupt audio data for ID {request_id}."} - elif "out of memory" in error_str.lower() or "memory" in error_str.lower(): - error_response = {"id": request_id, "error": f"Out of memory during transcription for ID {request_id}."} - else: - error_response = {"id": request_id, "error": f"Error during transcription for ID {request_id}: {error_str}"} - logger.error(f"Transcription failed for ID {request_id}: {error_response['error']}") - print(json.dumps(error_response)) + except Exception as exc: + logger.exception('Transcription failed for %s', request_id) + print(json.dumps({'id': request_id, 'error': str(exc)})) sys.stdout.flush() - - # Clean up memory on error for buffer-based transcriptions - if input_type == 'buffer' and 'audio_input' in locals(): - try: - del audio_input - import gc - gc.collect() - except: - pass - # --- End Transcription --- - except json.JSONDecodeError as json_err: - logger.error(f"Failed to decode JSON command: {line} - Error: {json_err}") - continue # Skip this invalid command - except Exception as e: - # Catch broader exceptions in the loop to prevent crashing - logger.error(f"Unexpected error in main loop: {str(e)}", exc_info=True) - # Optionally send an error back if we can identify the ID - if 'request_id' in locals() and request_id: - error_response = {"id": request_id, "error": f"Unexpected server error: {str(e)}"} - try: - print(json.dumps(error_response)) - sys.stdout.flush() - except Exception: - pass # Ignore errors trying to report errors - continue \ No newline at end of file + except json.JSONDecodeError as exc: + logger.error('Invalid JSON: %s (%s)', line, exc) + except Exception as exc: + logger.exception('Unexpected worker error: %s', exc) diff --git a/transcription/__init__.py b/transcription/__init__.py new file mode 100644 index 0000000..184c425 --- /dev/null +++ b/transcription/__init__.py @@ -0,0 +1 @@ +"""Transcription backend package.""" diff --git a/transcription/backends/__init__.py b/transcription/backends/__init__.py new file mode 100644 index 0000000..b0b105b --- /dev/null +++ b/transcription/backends/__init__.py @@ -0,0 +1,35 @@ +import logging +import os +import sys + +from transcription.backends.faster_whisper import FasterWhisperBackend +from transcription.backends.qwen3_asr import Qwen3AsrBackend + +logger = logging.getLogger(__name__) + +BACKENDS = { + 'faster-whisper': FasterWhisperBackend, + 'qwen3-asr': Qwen3AsrBackend, + 'qwen3-asr-vllm': Qwen3AsrBackend, +} + + +def resolve_backend_name(): + backend = (os.getenv('LOCAL_TRANSCRIPTION_BACKEND') or 'faster-whisper').lower() + if backend == 'qwen3-asr-vllm': + os.environ.setdefault('QWEN_ASR_BACKEND', 'vllm') + return 'qwen3-asr' + return backend + + +def create_backend(): + name = resolve_backend_name() + cls = BACKENDS.get(name) + if cls is None: + print(f"ERROR: Unknown LOCAL_TRANSCRIPTION_BACKEND: {name}", file=sys.stderr) + print(f"Supported: {', '.join(BACKENDS.keys())}", file=sys.stderr) + sys.exit(1) + backend = cls() + if not backend.validate_environment(): + sys.exit(1) + return backend diff --git a/transcription/backends/faster_whisper.py b/transcription/backends/faster_whisper.py new file mode 100644 index 0000000..bbbb162 --- /dev/null +++ b/transcription/backends/faster_whisper.py @@ -0,0 +1,73 @@ +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +class FasterWhisperBackend: + name = 'faster-whisper' + + def __init__(self): + self.model = None + self.device = None + + def validate_environment(self): + try: + import torch + from faster_whisper import WhisperModel + except ImportError as exc: + print(f"ERROR: faster-whisper not available: {exc}", file=sys.stderr) + return False + + device = os.getenv('TRANSCRIPTION_DEVICE', 'cpu') + if device == 'cuda' and not torch.cuda.is_available(): + logger.warning('CUDA requested but unavailable; using CPU') + device = 'cpu' + elif device == 'mps' and hasattr(torch.backends, 'mps') and not torch.backends.mps.is_available(): + device = 'cpu' + + whisper_model = os.getenv('WHISPER_MODEL', 'large-v3') + if device == 'cuda': + compute_type = 'float16' + elif device == 'mps': + compute_type = 'float32' + else: + compute_type = 'int8' + + from faster_whisper import WhisperModel + + self.model = WhisperModel( + whisper_model, + device=device, + compute_type=compute_type, + download_root=os.getenv('WHISPER_DOWNLOAD_ROOT', './models'), + num_workers=1, + cpu_threads=0, + ) + self.device = device + logger.info('Loaded faster-whisper model %s on %s', whisper_model, device) + return True + + def transcribe(self, audio_input, input_type, request_id): + prompt = os.getenv('OPENAI_TRANSCRIPTION_PROMPT') + params = { + 'audio': audio_input, + 'language': 'en', + 'beam_size': 3, + 'vad_filter': True, + 'vad_parameters': {'min_silence_duration_ms': 750}, + 'word_timestamps': False, + 'condition_on_previous_text': False, + } + if prompt: + params['initial_prompt'] = prompt + + segments, _info = self.model.transcribe(**params) + text = ' '.join(segment.text for segment in segments).strip() + if not text: + retry = dict(params) + retry['vad_filter'] = False + segments, _info = self.model.transcribe(**retry) + text = ' '.join(segment.text for segment in segments).strip() + return text diff --git a/transcription/backends/qwen3_asr.py b/transcription/backends/qwen3_asr.py new file mode 100644 index 0000000..d4bd4e2 --- /dev/null +++ b/transcription/backends/qwen3_asr.py @@ -0,0 +1,63 @@ +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +class Qwen3AsrBackend: + name = 'qwen3-asr' + + def __init__(self): + self.model = None + + def validate_environment(self): + try: + from qwen_asr import Qwen3ASRModel + except ImportError as exc: + print(f"ERROR: qwen-asr not available: {exc}", file=sys.stderr) + print('Install with: pip install -r requirements-local-qwen.txt', file=sys.stderr) + return False + return True + + def _load_model(self): + if self.model is not None: + return + from qwen_asr import Qwen3ASRModel + + model_id = os.getenv('QWEN_ASR_MODEL', 'Qwen/Qwen3-ASR-0.6B') + backend = (os.getenv('QWEN_ASR_BACKEND') or 'transformers').lower() + cache_dir = os.getenv('HF_HOME') or os.getenv('TRANSFORMERS_CACHE') or './models' + + logger.info('Loading Qwen3-ASR model %s (backend=%s)', model_id, backend) + if backend == 'vllm': + self.model = Qwen3ASRModel.LLM(model=model_id) + else: + self.model = Qwen3ASRModel.from_pretrained(model_id, cache_dir=cache_dir) + + def transcribe(self, audio_input, input_type, request_id): + import numpy as np + + self._load_model() + language = os.getenv('QWEN_ASR_LANGUAGE') or None + if language == 'auto': + language = None + + if input_type == 'path': + audio_arg = audio_input + else: + sr = 16000 + audio_arg = (np.asarray(audio_input, dtype=np.float32), sr) + + context = os.getenv('OPENAI_TRANSCRIPTION_PROMPT') or '' + kwargs = {'audio': audio_arg, 'language': language, 'return_time_stamps': False} + if context: + kwargs['context'] = [context] + + results = self.model.transcribe(**kwargs) + if isinstance(results, list): + if not results: + return '' + item = results[0] + return getattr(item, 'text', None) or getattr(item, 'transcription', None) or str(item) + return getattr(results, 'text', None) or str(results) diff --git a/transcription/router.py b/transcription/router.py new file mode 100644 index 0000000..b9ae730 --- /dev/null +++ b/transcription/router.py @@ -0,0 +1,46 @@ +import io +import logging +import os + +import numpy as np +from pydub import AudioSegment + +logger = logging.getLogger(__name__) + + +def load_audio_from_command(command): + """Return (audio_input, input_type, error_detail).""" + if command.get('command') != 'transcribe': + return None, None, f"Invalid command: {command.get('command')}" + + if 'path' in command: + path = command['path'] + if not os.path.isfile(path): + return None, None, f"Audio file does not exist: {path}" + return path, 'path', None + + if 'audio_data_base64' in command: + import base64 + + try: + audio_bytes = base64.b64decode(command['audio_data_base64']) + if not audio_bytes: + return None, None, 'Decoded audio data is empty.' + segment = AudioSegment.from_file(io.BytesIO(audio_bytes)) + segment = segment.set_frame_rate(16000).set_channels(1) + samples = np.array(segment.get_array_of_samples()).astype(np.float32) / 32768.0 + return samples, 'buffer', None + except Exception as exc: + return None, None, f"Error processing audio buffer: {exc}" + + return None, None, "Invalid command format: missing 'path' or 'audio_data_base64'." + + +class TranscriptionBackend: + name = 'base' + + def validate_environment(self): + return True + + def transcribe(self, audio_input, input_type, request_id): + raise NotImplementedError diff --git a/unraid/README-unraid.md b/unraid/README-unraid.md new file mode 100644 index 0000000..c39be61 --- /dev/null +++ b/unraid/README-unraid.md @@ -0,0 +1,40 @@ +# Unraid Docker Templates for Scanner Map + +## Add template repository + +1. Open **Docker** tab in Unraid. +2. Scroll to **Template Repositories** at the bottom. +3. Add: + + `https://github.com/Dadud/Scanner-map/tree/main/unraid` + +4. Click **Save**, then **Add Container** and select a Scanner Map template. + +## Templates + +| Template | Image | Use when | +|----------|-------|----------| +| **scanner-map.xml** | `:core` | Remote/OpenAI/iCAD transcription โ€” no GPU | +| **scanner-map-gpu.xml** | `:whisper` or `:qwen` | Local faster-whisper or Qwen3-ASR with NVIDIA GPU | + +## Prerequisites + +- **Core template:** Map API key (Google Maps or LocationIQ) configured after first launch via `/setup`. +- **GPU template:** [NVIDIA Driver plugin](https://forums.unraid.net/topic/98978-plugin-nvidia-driver/) installed; GPU assigned in container settings. + +## Paths + +| Host (default) | Container | Purpose | +|----------------|-----------|---------| +| `/mnt/user/appdata/scanner-map` | `/app/data` | SQLite DB, API keys, settings | +| `/mnt/user/appdata/scanner-map/audio` | `/app/audio` | Recorded call audio | +| `/mnt/user/appdata/scanner-map/models` | `/app/models` | Whisper / HuggingFace model cache | + +## Ports + +- **3000** โ€” Web map UI +- **3306** โ€” Upload API for TrunkRecorder / SDRTrunk / rdio-scanner + +## First run + +Open `http://:3000/setup` to complete configuration. diff --git a/unraid/icon.png b/unraid/icon.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/unraid/icon.png differ diff --git a/unraid/scanner-map-gpu.xml b/unraid/scanner-map-gpu.xml new file mode 100644 index 0000000..c641831 --- /dev/null +++ b/unraid/scanner-map-gpu.xml @@ -0,0 +1,36 @@ + + + Scanner-Map-GPU + ghcr.io/dadud/scanner-map:whisper + https://ghcr.io/ + bridge + + bash + false + https://github.com/poisonednumber/Scanner-map/issues + https://github.com/poisonednumber/Scanner-map + Scanner Map with local faster-whisper transcription. Requires NVIDIA GPU and Unraid NVIDIA Driver plugin. + Tools:Utilities + http://[IP]:[PORT:3000]/ + https://raw.githubusercontent.com/Dadud/Scanner-map/main/unraid/scanner-map-gpu.xml + https://raw.githubusercontent.com/Dadud/Scanner-map/main/unraid/icon.png + --runtime=nvidia + + + + + + NVIDIA Driver plugin + /mnt/user/appdata/scanner-map + /mnt/user/appdata/scanner-map/audio + /mnt/user/appdata/scanner-map/models + 3000 + 3306 + cuda + large-v3 + faster-whisper + Qwen/Qwen3-ASR-0.6B + localhost + + all + diff --git a/unraid/scanner-map.xml b/unraid/scanner-map.xml new file mode 100644 index 0000000..a426375 --- /dev/null +++ b/unraid/scanner-map.xml @@ -0,0 +1,37 @@ + + + Scanner-Map + ghcr.io/dadud/scanner-map:core + https://ghcr.io/ + bridge + + bash + false + https://github.com/poisonednumber/Scanner-map/issues + https://github.com/poisonednumber/Scanner-map + Real-time police/fire/EMS scanner call mapper. Core image for remote, OpenAI, or iCAD transcription (no local ML stack). + Tools:Utilities + http://[IP]:[PORT:3000]/ + https://raw.githubusercontent.com/Dadud/Scanner-map/main/unraid/scanner-map.xml + https://raw.githubusercontent.com/Dadud/Scanner-map/main/unraid/icon.png + + + + + + + + /mnt/user/appdata/scanner-map + /mnt/user/appdata/scanner-map/audio + /mnt/user/appdata/scanner-map/models + 3000 + 3306 + 99 + 100 + remote + localhost + + + + + diff --git a/webserver.js b/webserver.js index fa91f0c..f09306a 100644 --- a/webserver.js +++ b/webserver.js @@ -1,1952 +1,1868 @@ -// webserver.js - Web interface for viewing and managing calls with optional authentication - -require('dotenv').config(); -const AWS = require('aws-sdk'); // Add AWS SDK - -const express = require('express'); -const sqlite3 = require('sqlite3').verbose(); -const path = require('path'); -const http = require('http'); -const socketIo = require('socket.io'); -const crypto = require('crypto'); -const fetch = require('node-fetch'); -const fs = require('fs'); -const logsDir = path.join(__dirname, 'logs'); -if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); -} - -// Environment variables -const { - WEBSERVER_PORT, - WEBSERVER_PASSWORD, - PUBLIC_DOMAIN, - TIMEZONE, - ENABLE_AUTH, // New environment variable for toggling authentication - SESSION_DURATION_DAYS = "7", // Default 7 days if not specified - MAX_SESSIONS_PER_USER = "5", // Default 5 sessions if not specified - GOOGLE_MAPS_API_KEY = null, - // --- NEW: Geocoding API Keys --- - LOCATIONIQ_API_KEY = null, - // --- NEW: Storage Env Vars --- - STORAGE_MODE = 'local', // Default to local if not set - S3_ENDPOINT, - S3_BUCKET_NAME, - S3_ACCESS_KEY_ID, - S3_SECRET_ACCESS_KEY, - // --- NEW: AI Provider Env Vars --- - AI_PROVIDER = 'ollama', // Can be 'ollama' or 'openai' - OPENAI_API_KEY, - 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; - -// 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); -} - -// Log geocoding API availability -if (GOOGLE_MAPS_API_KEY) { - console.log('[Webserver] Google Maps API key found - Google Places autocomplete will be available'); -} else { - console.log('[Webserver] Google Maps API key not found - Google Places autocomplete will be disabled'); -} - -if (LOCATIONIQ_API_KEY) { - console.log('[Webserver] LocationIQ API key found - LocationIQ autocomplete will be available'); -} else { - console.log('[Webserver] LocationIQ API key not found - LocationIQ autocomplete will be disabled'); -} - -// Add endpoint to serve Google 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 - } - }); -}); - -// Add endpoint to check if current user is admin -app.get('/api/auth/is-admin', async (req, res) => { - if (!authEnabled) { - return res.json({ isAdmin: false, authEnabled: false }); - } - - const authHeader = req.headers['authorization']; - const adminStatus = await isAdminUser(authHeader); - res.json({ isAdmin: adminStatus, authEnabled: true }); -}); - -// Test endpoint to verify server is working -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.error('FATAL: STORAGE_MODE is s3, but required S3 environment variables are missing! Check webserver .env'); - process.exit(1); - } - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, - 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'; - -// --- USER SETTINGS API --- -const DEFAULT_USER_SETTINGS = { - mapStyle: 'day', - defaultTimeRange: 12, - notificationsEnabled: true, - notificationSound: true, - trackNewCalls: true, - muteNewCalls: false, - globalVolume: 0.5, - heatmapEnabled: false, - heatmapIntensity: 5, - liveFeedTalkgroups: [], - autoPlay: true, - onboardingComplete: false -}; - -function getUserIdFromReq(req) { - if (!authEnabled) { - return null; - } - return req.user?.id || null; -} - -async function getUserSettings(userId) { - return new Promise((resolve, reject) => { - if (userId === null) { - return resolve(DEFAULT_USER_SETTINGS); - } - db.get('SELECT settings_json FROM user_settings WHERE user_id = ?', [userId], (err, row) => { - if (err) return reject(err); - if (!row) { - return resolve(DEFAULT_USER_SETTINGS); - } - try { - const settings = JSON.parse(row.settings_json); - resolve({ ...DEFAULT_USER_SETTINGS, ...settings }); - } catch (e) { - resolve(DEFAULT_USER_SETTINGS); - } - }); - }); -} - -async function saveUserSettings(userId, settings) { - return new Promise((resolve, reject) => { - if (userId === null) { - return resolve({ success: true }); - } - const settingsJson = JSON.stringify(settings); - db.run( - `INSERT INTO user_settings (user_id, settings_json, updated_at) - VALUES (?, ?, datetime('now')) - ON CONFLICT(user_id) DO UPDATE SET - settings_json = excluded.settings_json, - updated_at = datetime('now')`, - [userId, settingsJson], - function(err) { - if (err) return reject(err); - resolve({ success: true }); - } - ); - }); -} - -app.get('/api/settings', async (req, res) => { - try { - const userId = getUserIdFromReq(req); - const settings = await getUserSettings(userId); - res.json({ success: true, settings }); - } catch (error) { - console.error('Error fetching settings:', error); - res.status(500).json({ success: false, error: 'Failed to fetch settings' }); - } -}); - -app.post('/api/settings', async (req, res) => { - try { - const userId = getUserIdFromReq(req); - if (userId === null) { - return res.status(401).json({ success: false, error: 'Authentication required', anonymous: true }); - } - const updates = req.body; - if (!updates || typeof updates !== 'object') { - return res.status(400).json({ success: false, error: 'Invalid settings data' }); - } - const currentSettings = await getUserSettings(userId); - const mergedSettings = { ...currentSettings, ...updates }; - await saveUserSettings(userId, mergedSettings); - res.json({ success: true, settings: mergedSettings }); - } catch (error) { - console.error('Error saving settings:', error); - res.status(500).json({ success: false, error: 'Failed to save settings' }); - } -}); - -app.post('/api/settings/reset', async (req, res) => { - try { - const userId = getUserIdFromReq(req); - if (userId === null) { - return res.status(401).json({ success: false, error: 'Authentication required' }); - } - await saveUserSettings(userId, DEFAULT_USER_SETTINGS); - res.json({ success: true, settings: DEFAULT_USER_SETTINGS }); - } catch (error) { - console.error('Error resetting settings:', error); - res.status(500).json({ success: false, error: 'Failed to reset settings' }); - } -}); - -app.get('/api/onboarding/status', async (req, res) => { - try { - const userId = getUserIdFromReq(req); - if (userId === null) { - return res.json({ needsOnboarding: false, authEnabled: false }); - } - const settings = await getUserSettings(userId); - res.json({ - needsOnboarding: !settings.onboardingComplete, - authEnabled: true, - settings - }); - } catch (error) { - console.error('Error checking onboarding status:', error); - res.status(500).json({ success: false, error: 'Failed to check onboarding status' }); - } -}); - -app.post('/api/onboarding/complete', async (req, res) => { - try { - const userId = getUserIdFromReq(req); - if (userId === null) { - return res.status(401).json({ success: false, error: 'Authentication required' }); - } - const currentSettings = await getUserSettings(userId); - const updatedSettings = { ...currentSettings, onboardingComplete: true, ...req.body }; - await saveUserSettings(userId, updatedSettings); - res.json({ success: true, settings: updatedSettings }); - } catch (error) { - console.error('Error completing onboarding:', error); - res.status(500).json({ success: false, error: 'Failed to complete onboarding' }); - } -}); - -// --- END USER SETTINGS API --- - -// Session configuration (used only if auth is enabled) -const SESSION_DURATION = parseInt(SESSION_DURATION_DAYS, 10) * 24 * 60 * 60 * 1000; // Convert days to milliseconds -const MAX_SESSIONS = parseInt(MAX_SESSIONS_PER_USER, 10); -const SESSION_CLEANUP_INTERVAL = 60 * 60 * 1000; // Cleanup every hour - -// Express app setup -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 { - console.log('Connected to the SQLite database.'); - // Enable WAL mode for safer concurrent access from bot.js - db.run('PRAGMA journal_mode = WAL;'); - db.run('PRAGMA busy_timeout = 5000;'); - } -}); - -db.run(`ALTER TABLE transcriptions ADD COLUMN category TEXT`, err => { - if (!err) { - console.log('Category column added successfully'); - } else if (err.message.includes('duplicate column name')) { - console.log('Category column already exists'); - } else { - console.error('Error adding category column:', err.message); - } -}); - -// 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 - ) - `); - - // User settings table (stores UI preferences per user) - db.run(` - CREATE TABLE IF NOT EXISTS user_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER UNIQUE NOT NULL, - settings_json TEXT NOT NULL DEFAULT '{}', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - }); -} - -// Helper Functions for Authentication -function hashPassword(password, salt) { - return crypto - .pbkdf2Sync(password, salt, 10000, 64, 'sha512') - .toString('hex'); -} - -function generateSessionToken() { - return crypto.randomBytes(32).toString('hex'); -} - -// Session Management Functions -async function createSession(userId, req) { - const token = generateSessionToken(); - const expiresAt = new Date(Date.now() + SESSION_DURATION); - const ipAddress = req.ip; - const userAgent = req.get('user-agent'); - - return new Promise((resolve, reject) => { - db.run( - `INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent) - VALUES (?, ?, datetime(?), ?, ?)`, - [userId, token, expiresAt.toISOString(), ipAddress, userAgent], - function(err) { - if (err) reject(err); - else resolve({ token, expiresAt }); - } - ); - }); -} - -async function validateSession(token) { - return new Promise((resolve, reject) => { - db.get( - `SELECT * FROM sessions - WHERE token = ? AND expires_at > datetime('now')`, - [token], - (err, session) => { - if (err) reject(err); - else resolve(session); - } - ); - }); -} - -async function generateShortSummary(transcript) { - try { - // Original list of categories for the AI - const categories = [ - 'Medical Emergency', 'Injured Person', 'Disturbance', 'Vehicle Collision', - 'Burglary', 'Assault', 'Structure Fire', 'Missing Person', 'Medical Call', - 'Building Fire', 'Stolen Vehicle', 'Service Call', 'Vehicle Stop', - 'Unconscious Person', 'Reckless Driver', 'Person With A Gun', - 'Altered Level of Consciousness', 'Breathing Problems', 'Fight', - 'Carbon Monoxide', 'Abduction', 'Passed Out Person', 'Hazmat', - 'Fire Alarm', 'Traffic Hazard', 'Intoxicated Person', 'Mvc', // Note: Mvc is often redundant with Vehicle Collision - 'Animal Bite', - 'Assist' - ]; - - // This prompt works well for both Ollama and OpenAI's chat models - const commonPrompt = ` -You are an expert emergency service dispatcher categorizing radio transmissions. -Analyze the following first responder radio transmission and categorize it into EXACTLY ONE of the categories listed below. -Choose the category that best fits the main subject of the transmission. -Focus on the primary reason for the dispatch if multiple events are mentioned. - -**PRIORITIZATION:** -- If a clear event type (like Vehicle Collision, Fire, Assault, Medical Emergency, etc.) is mentioned, **use that category even if the dispatcher says "no details"** or the information is minimal. do not add stars around your output such as "**GAS LEAK**". -- Use the 'Other' category ONLY if the transmission primarily contains just location/unit information OR if no specific event type from the list is mentioned at all. - -It is CRUCIAL that your response is ONLY one of the category names from this list and nothing else. - -Categories: -${categories.map(cat => `- ${cat}`).join('\n')} -- Other - -Transmission: "${transcript}" - -Category:`; - - let category = 'OTHER'; // Default value - - 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, - messages: [{ role: 'user', content: commonPrompt }], - temperature: 0.2, // Lower temp for more deterministic category - max_tokens: 20 // A category name is short - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`[Webserver] OpenAI API error! status: ${response.status}, transcript: ${transcript}, details: ${errorText}`); - throw new Error(`OpenAI API error! status: ${response.status}`); - } - - const result = await response.json(); - 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`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - model: OLLAMA_MODEL, - prompt: commonPrompt, // The prompt is compatible - stream: false, - options: { - temperature: 0.3 - } - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - console.error(`[Webserver] Ollama API error! status: ${response.status} for transcript: ${transcript}`); - throw new Error(`Ollama API error! status: ${response.status}`); - } - - const result = await response.json(); - category = result.response.trim(); - } - // --- End AI Provider Logic --- - - - // The existing post-processing logic is generic enough to work for both - const thinkBlockRegex = /[\s\S]*?<\/think>\s*/; - category = category.replace(thinkBlockRegex, '').trim().toUpperCase(); - - // Validate the AI's response against the known categories (including OTHER) - const validCategoriesUppercase = categories.map(cat => cat.toUpperCase()); - validCategoriesUppercase.push('OTHER'); - - if (!validCategoriesUppercase.includes(category)) { - console.warn(`[Webserver] AI returned an unexpected or invalid category: "${category}". Defaulting to OTHER for transcript: "${transcript}"`); - category = 'OTHER'; - } - - return category; - - } catch (error) { - console.error(`[Webserver] Error categorizing call: "${transcript}". Error: ${error.message}`); - if (error.name === 'AbortError') { - console.error(`[Webserver] AI request timed out during categorization: ${error.message}`); - } - return 'OTHER'; // Fallback to 'OTHER' in case of any errors - } -} - -function cleanupExpiredSessions() { - if (authEnabled) { - db.run('DELETE FROM sessions WHERE expires_at <= datetime("now")', [], (err) => { - if (err) { - console.error('Error cleaning up expired sessions:', err); - } else { - console.log('Expired sessions cleaned up'); - } - }); - } -} - -// Start session cleanup interval if auth enabled -if (authEnabled) { - setInterval(cleanupExpiredSessions, SESSION_CLEANUP_INTERVAL); -} - -// Authentication Middleware - only applied when authentication is enabled -const basicAuth = async (req, res, next) => { - // Skip authentication if disabled in .env - if (!authEnabled) { - return next(); - } - - try { - const authHeader = req.headers['authorization']; - if (!authHeader) { - res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); - return res.status(401).send('Authentication required.'); - } - - // Check if it's a Bearer token (session-based auth) - if (authHeader.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1]; - if (!token) { - return res.status(401).send('Invalid Bearer token format.'); - } - - // Validate the session token - const session = await validateSession(token); - if (!session) { - return res.status(401).send('Invalid or expired session token.'); - } - - // Get the user from the session - const user = await new Promise((resolve, reject) => { - db.get('SELECT id, username FROM users WHERE id = ?', [session.user_id], (err, row) => { - if (err) reject(err); - else resolve(row); - }); - }); - - if (!user) { - return res.status(401).send('User not found for session.'); - } - - // Set user info in request for downstream use - req.user = { id: user.id, username: user.username }; - req.session = session; - return next(); - } - - // Check if it's Basic auth (username:password) - if (authHeader.startsWith('Basic ')) { - const base64Credentials = authHeader.split(' ')[1]; - if (!base64Credentials) { - res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); - return res.status(401).send('Invalid authentication format.'); - } - - const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); - const [username, password] = credentials.split(':'); - - // Check credentials against database - db.get( - 'SELECT id, password_hash, salt FROM users WHERE username = ?', - [username], - async (err, user) => { - if (err) { - console.error('Database error during authentication:', err); - return res.status(500).send('Internal server error.'); - } - - if (!user) { - res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); - return res.status(401).send('Invalid credentials.'); - } - - const hashedPassword = hashPassword(password, user.salt); - if (hashedPassword === user.password_hash) { - // Get all active sessions for user, ordered by creation date - db.all( - `SELECT id, created_at, expires_at - FROM sessions - WHERE user_id = ? AND expires_at > datetime('now') - ORDER BY created_at ASC`, - [user.id], - async (err, sessions) => { - if (err) { - return res.status(500).send('Internal server error.'); - } - - // If at session limit, remove oldest session - if (sessions.length >= MAX_SESSIONS) { - db.run( - 'DELETE FROM sessions WHERE id = ?', - [sessions[0].id], - async (err) => { - if (err) { - console.error('Error removing oldest session:', err); - return res.status(500).send('Internal server error.'); - } - console.log(`Removed oldest session for user ${username}`); - try { - const session = await createSession(user.id, req); - req.user = { id: user.id, username }; - req.session = session; - next(); - } catch (err) { - console.error('Error creating session:', err); - return res.status(500).send('Internal server error.'); - } - } - ); - } else { - try { - const session = await createSession(user.id, req); - req.user = { id: user.id, username }; - req.session = session; - next(); - } catch (err) { - console.error('Error creating session:', err); - return res.status(500).send('Internal server error.'); - } - } - } - ); - } else { - res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); - return res.status(401).send('Invalid credentials.'); - } - } - ); - } else { - return res.status(401).send('Unsupported authentication method. Use Basic or Bearer.'); - } - } catch (err) { - console.error('Authentication error:', err); - return res.status(500).send('Internal server error.'); - } -}; - -// Admin Authentication Middleware -const adminAuth = async (req, res, next) => { - // Skip authentication if disabled in .env - if (!authEnabled) { - return next(); - } - - const authHeader = req.headers['authorization']; - if (!authHeader) { - return res.status(401).send('Admin authentication required.'); - } - - try { - const isAdmin = await isAdminUser(authHeader); - if (isAdmin) { - next(); - } else { - return res.status(401).send('Invalid admin credentials.'); - } - } catch (error) { - console.error('Error in adminAuth:', error); - return res.status(500).send('Authentication error.'); - } -}; - -// Helper function to check if user is admin -async function isAdminUser(authHeader) { - if (!authEnabled || !authHeader) { - return false; - } - - try { - // Check if it's a Bearer token (session-based auth) - if (authHeader.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1]; - if (!token) { - return false; - } - - // Validate the session token - const session = await validateSession(token); - if (!session) { - return false; - } - - // Get the user from the session - const user = await new Promise((resolve, reject) => { - db.get('SELECT username FROM users WHERE id = ?', [session.user_id], (err, row) => { - if (err) reject(err); - else resolve(row); - }); - }); - - // Check if the user is admin - return user && user.username === 'admin'; - } - - // Check if it's Basic auth (username:password) - if (authHeader.startsWith('Basic ')) { - const base64Credentials = authHeader.split(' ')[1]; - if (!base64Credentials) { - return false; - } - - const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); - const [username, password] = credentials.split(':'); - - return username === 'admin' && password === WEBSERVER_PASSWORD; - } - - return false; - } catch (error) { - console.error('Error in isAdminUser:', error); - return false; - } -} - -// --- NEW HELPER FUNCTION --- -// Store the last purge operation details for undo functionality -let lastPurgeDetails = null; - -// Function to store original coordinates before purging -async function storeOriginalCoordinates(talkgroupIds, categories, timeRangeStart, timeRangeEnd) { - return new Promise((resolve, reject) => { - // Build the WHERE clause to get calls that will be purged - let whereConditions = ['lat IS NOT NULL AND lon IS NOT NULL']; - let params = []; - - // If no talkgroups selected, it means "all talkgroups" (no filter applied) - if (talkgroupIds && talkgroupIds.length > 0) { - whereConditions.push(`talk_group_id IN (${talkgroupIds.map(() => '?').join(',')})`); - params.push(...talkgroupIds); - } - // If no talkgroups selected, don't add any filter - this means "all talkgroups" - - if (categories && categories.length > 0) { - whereConditions.push(`UPPER(category) IN (${categories.map(() => 'UPPER(?)').join(',')})`); - params.push(...categories); - } - - whereConditions.push('timestamp BETWEEN ? AND ?'); - params.push(timeRangeStart, timeRangeEnd); - - const whereClause = whereConditions.join(' AND '); - const selectQuery = `SELECT id, lat, lon FROM transcriptions WHERE ${whereClause}`; - - db.all(selectQuery, params, (err, rows) => { - if (err) { - reject(err); - } else { - resolve(rows); - } - }); - }); -} - -async function serveAudioFromDb(res, transcriptionId) { - console.log(`[Audio DB] Serving audio for ID ${transcriptionId} from database blob.`); - try { - const audioRow = await new Promise((resolve, reject) => { - db.get('SELECT audio_data FROM audio_files WHERE transcription_id = ?', [transcriptionId], (err, row) => { - if (err) reject(err); - else resolve(row); - }); - }); - - if (audioRow && audioRow.audio_data) { - const pathRow = await new Promise((resolve, reject) => { - db.get('SELECT audio_file_path FROM transcriptions WHERE id = ?', [transcriptionId], (err, row) => { - if (err) reject(err); else resolve(row); - }); - }); - - const filePath = pathRow ? pathRow.audio_file_path : ''; - const extension = path.extname(filePath).toLowerCase(); - const contentType = extension === '.m4a' ? 'audio/mp4' : 'audio/mpeg'; - - res.setHeader('Content-Type', contentType); - res.send(audioRow.audio_data); - } else { - console.error(`[Audio DB] Audio data not found in DB for ID: ${transcriptionId}`); - if (!res.headersSent) { - res.status(404).send('Audio not found in any storage location.'); - } - } - } catch (dbErr) { - console.error(`[Audio DB] DB error for ID ${transcriptionId}:`, dbErr); - if (!res.headersSent) { - res.status(500).send('Internal Server Error during DB fallback.'); - } - } -} - -// Public Routes (No Auth Required) -app.get('/audio/:id', async (req, res) => { - const transcriptionId = req.params.id; - - try { - const transcriptionRow = await new Promise((resolve, reject) => { - db.get('SELECT audio_file_path FROM transcriptions WHERE id = ?', [transcriptionId], (err, row) => { - if (err) reject(err); - else resolve(row); - }); - }); - - 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(); - s3Stream.on('error', (s3Err) => { - console.warn(`[Audio S3] S3 stream error for key ${audioStoragePath}: ${s3Err.code}. Falling back to DB.`); - serveAudioFromDb(res, transcriptionId); - }); - res.setHeader('Content-Type', contentType); - s3Stream.pipe(res); - return; - } else { // Local storage - const localPath = path.join(__dirname, 'audio', audioStoragePath); - if (fs.existsSync(localPath)) { - res.setHeader('Content-Type', contentType); - fs.createReadStream(localPath).pipe(res); - return; - } else { - console.warn(`[Audio Local] File not found at ${localPath}. Falling back to DB.`); - } - } - } - - // Fallback to serving from the database blob if file not found or path missing. - serveAudioFromDb(res, transcriptionId); - - } 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); - -// Serve static files from the 'public' directory -app.use(express.static(path.join(__dirname, 'public'))); - -// Session Management Routes (Only relevant when auth is enabled) -app.get('/api/sessions/current', (req, res) => { - if (authEnabled) { - res.json({ - session: req.session || null, - user: req.user || null - }); - } else { - res.json({ - session: { token: 'anonymous-session' }, - user: { username: 'anonymous' } - }); - } -}); - -app.get('/api/sessions', adminAuth, (req, res) => { - if (!authEnabled) { - return res.json([]); - } - - const userId = req.query.userId; - let query = ` - SELECT s.*, u.username, s.ip_address, s.user_agent - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.expires_at > datetime('now') - `; - const params = []; - - if (userId && userId !== 'all') { - query += ' AND s.user_id = ?'; - params.push(userId); - } - - query += ' ORDER BY s.created_at DESC'; - - db.all(query, params, (err, sessions) => { - if (err) { - console.error('Error fetching sessions:', err); - return res.status(500).json({ error: 'Internal server error' }); - } - res.json(sessions); - }); -}); - -app.delete('/api/sessions/:token', adminAuth, (req, res) => { - if (!authEnabled) { - return res.json({ message: 'Authentication is disabled' }); - } - - db.run( - 'DELETE FROM sessions WHERE token = ?', - [req.params.token], - function(err) { - if (err) { - console.error('Error deleting session:', err); - return res.status(500).json({ error: 'Internal server error' }); - } - res.json({ message: 'Session terminated successfully' }); - } - ); -}); - -app.get('/api/sessions/me', (req, res) => { - if (!authEnabled) { - return res.json([]); - } - - db.all( - `SELECT id, created_at, expires_at, ip_address, user_agent - FROM sessions - WHERE user_id = ? AND expires_at > datetime('now') - ORDER BY created_at DESC`, - [req.user.id], - (err, sessions) => { - if (err) { - console.error('Error fetching user sessions:', err); - return res.status(500).json({ error: 'Internal server error' }); - } - res.json(sessions); - } - ); -}); - -// 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' }); - } - - const { username, password } = req.body; - - if (!username || !password) { - return res.status(400).json({ error: 'Username and password required.' }); - } - - const salt = crypto.randomBytes(16).toString('hex'); - const passwordHash = hashPassword(password, salt); - - try { - const result = await new Promise((resolve, reject) => { - db.run( - 'INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)', - [username, passwordHash, salt], - function(err) { - if (err) reject(err); - else resolve(this.lastID); - } - ); - }); - - res.status(201).json({ - message: 'User created successfully', - userId: result - }); - } catch (err) { - if (err.message.includes('UNIQUE constraint failed')) { - res.status(409).json({ error: 'Username already exists.' }); - } else { - console.error('Error creating user:', err); - res.status(500).json({ error: 'Internal server error.' }); - } - } -}); - -app.get('/api/users', adminAuth, (req, res) => { - if (!authEnabled) { - return res.json([]); - } - - db.all( - `SELECT u.id, u.username, u.created_at, - COUNT(s.id) as active_sessions - FROM users u - LEFT JOIN sessions s ON u.id = s.user_id - AND s.expires_at > datetime('now') - GROUP BY u.id - ORDER BY u.created_at DESC`, - [], - (err, users) => { - if (err) { - console.error('Error fetching users:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - res.json(users); - } - ); -}); - -app.delete('/api/users/:id', adminAuth, (req, res) => { - if (!authEnabled) { - return res.status(400).json({ error: 'Authentication is disabled' }); - } - - const userId = parseInt(req.params.id, 10); - - if (isNaN(userId)) { - return res.status(400).json({ error: 'Invalid user ID.' }); - } - - db.run('DELETE FROM users WHERE id = ?', [userId], function(err) { - if (err) { - console.error('Error deleting user:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - res.json({ message: 'User deleted successfully.' }); - }); -}); - -// API Routes for call data -app.get('/api/calls', (req, res) => { - const hours = parseInt(req.query.hours) || 12; - // Convert hours to a Unix timestamp (seconds) for the WHERE clause - const sinceTimestampUnix = Math.floor((Date.now() - hours * 60 * 60 * 1000) / 1000); - - console.log(`Fetching calls since Unix timestamp: ${sinceTimestampUnix} (${hours} hours ago)`); - - db.all( - ` - SELECT t.*, tg.alpha_tag AS talk_group_name, tg.tag AS talk_group_tag - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.timestamp >= ? AND t.lat IS NOT NULL AND t.lon IS NOT NULL - ORDER BY t.timestamp DESC - `, - [sinceTimestampUnix], // Use Unix timestamp for the query - (err, rows) => { - if (err) { - console.error('Error fetching calls:', err); - res.status(500).json({ error: err.message }); - return; - } - - console.log(`Returning ${rows.length} calls`); - // Timestamps are now already Unix seconds from the DB - if (rows.length > 0) { - console.log(`Oldest call in result (Unix ts): ${rows[rows.length - 1].timestamp}`); - console.log(`Newest call in result (Unix ts): ${rows[0].timestamp}`); - } - res.json(rows); // Send rows directly as timestamps are already numeric - } - ); -}); - -app.delete('/api/markers/:id', (req, res) => { - const markerId = parseInt(req.params.id, 10); - - if (isNaN(markerId)) { - return res.status(400).json({ error: 'Invalid marker ID' }); - } - - db.run( - 'DELETE FROM transcriptions WHERE id = ?', - [markerId], - function(err) { - if (err) { - console.error('Error deleting marker:', err); - return res.status(500).json({ error: 'Internal server error' }); - } - - res.json({ message: 'Marker deleted successfully' }); - } - ); -}); - -app.put('/api/markers/:id/location', (req, res) => { - const markerId = parseInt(req.params.id); - const { lat, lon } = req.body; - - if (isNaN(markerId) || typeof lat !== 'number' || typeof lon !== 'number') { - return res.status(400).json({ error: 'Invalid parameters' }); - } - - // Validate coordinate ranges - if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { - return res.status(400).json({ error: 'Coordinates out of valid range' }); - } - - db.run( - 'UPDATE transcriptions SET lat = ?, lon = ? WHERE id = ?', - [lat, lon, markerId], - function(err) { - if (err) { - console.error('Error updating marker location:', err); - return res.status(500).json({ error: 'Internal server error' }); - } - res.json({ success: true }); - } - ); -}); - -app.get('/api/additional-transcriptions/:callId', (req, res) => { - const callId = parseInt(req.params.callId, 10); - const skip = parseInt(req.query.skip, 10) || 0; - - if (isNaN(callId)) { - return res.status(400).send('Invalid call ID.'); - } - - db.get( - 'SELECT talk_group_id FROM transcriptions WHERE id = ?', - [callId], - (err, row) => { - if (err) { - console.error('Error fetching talk group ID:', err); - return res.status(500).json({ error: 'Internal Server Error' }); - } - - if (!row) { - return res.status(404).json({ error: 'Call not found' }); - } - - const talkGroupId = row.talk_group_id; - - db.all( - ` - SELECT t.id, t.transcription, t.audio_file_path, t.timestamp, tg.alpha_tag AS talk_group_name - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.talk_group_id = ? AND t.id > ? - ORDER BY t.id ASC - LIMIT 3 OFFSET ? - `, - [talkGroupId, callId, skip], - (err, rows) => { - if (err) { - console.error('Error fetching additional transcriptions:', err); - return res.status(500).json({ error: 'Internal Server Error' }); - } - // ADD LOGGING HERE - console.log(`[/api/additional-transcriptions] Responding with ${rows.length} rows. First row ID: ${rows.length > 0 ? rows[0].id : 'N/A'}`); - res.json(rows); - } - ); - } - ); -}); - -// NEW Endpoint for Talkgroup History -app.get('/api/talkgroup/:talkgroupId/calls', (req, res) => { - const talkgroupId = parseInt(req.params.talkgroupId, 10); - const sinceId = parseInt(req.query.sinceId, 10) || 0; // For polling - const limit = parseInt(req.query.limit, 10) || 30; // Default limit 30 - const offset = parseInt(req.query.offset, 10) || 0; // Default offset 0 - - if (isNaN(talkgroupId)) { - return res.status(400).json({ error: 'Invalid talkgroup ID' }); - } - - let query; - const params = []; - - if (sinceId > 0) { - // Polling request: Get calls strictly newer than the last known ID (limit doesn't apply here) - console.log(`Polling calls for talkgroup ${talkgroupId} since ID: ${sinceId}`); - query = ` - SELECT t.id, t.transcription, t.timestamp, tg.alpha_tag AS talk_group_name - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.talk_group_id = ? AND t.id > ? - AND t.transcription IS NOT NULL - ORDER BY t.id ASC -- Fetch oldest first when polling since ID - `; - params.push(talkgroupId, sinceId); - } else { - // Initial load or subsequent page request: Use LIMIT and OFFSET - console.log(`Fetching calls for talkgroup ${talkgroupId} with limit: ${limit}, offset: ${offset}`); - query = ` - SELECT t.id, t.transcription, t.timestamp, tg.alpha_tag AS talk_group_name - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.talk_group_id = ? - AND t.transcription IS NOT NULL - ORDER BY t.timestamp DESC -- Show newest first overall - LIMIT ? OFFSET ? - `; - params.push(talkgroupId, limit, offset); - } - - db.all(query, params, (err, rows) => { - if (err) { - console.error(`Error fetching calls for talkgroup ${talkgroupId}:`, err); - return res.status(500).json({ error: 'Internal server error' }); - } - - if (sinceId > 0) { - console.log(`Poll returned ${rows.length} calls for talkgroup ${talkgroupId} since ID ${sinceId}`); - } else { - console.log(`Paginated load returned ${rows.length} calls for talkgroup ${talkgroupId}`); - } - res.json(rows); - }); -}); -// END NEW Endpoint - -// NEW Endpoint to get details for a single call (for live feed retries) -app.get('/api/call/:id/details', (req, res) => { - const callId = parseInt(req.params.id, 10); - - if (isNaN(callId)) { - return res.status(400).json({ error: 'Invalid call ID' }); - } - - db.get( - ` - SELECT t.id, t.transcription, t.timestamp, t.talk_group_id, tg.alpha_tag AS talk_group_name - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.id = ? - `, - [callId], - (err, row) => { - if (err) { - console.error(`Error fetching details for call ${callId}:`, err); - return res.status(500).json({ error: 'Internal server error' }); - } - if (!row) { - return res.status(404).json({ error: 'Call not found' }); - } - // console.log(`[API Call Details] Returning details for ID: ${callId}`); // Optional: verbose log - res.json(row); - } - ); -}); - -// Socket.IO Setup -io.on('connection', (socket) => { - console.log(`Client connected: ${socket.id}`); - socket.on('disconnect', () => { - console.log(`Client disconnected: ${socket.id}`); - }); -}); - -// --- Start Polling Logic --- - -// State variables for polling -let lastCallId = 0; // For map updates -let lastLiveFeedCallId = 0; // For live feed updates - -// Initialization functions -function initializeLastCallId() { - db.get('SELECT MAX(id) AS maxId FROM transcriptions', (err, row) => { - if (err) { - console.error('Error initializing lastCallId:', err.message); - } else { - lastCallId = row.maxId || 0; - console.log(`Initialized lastCallId (for map) to ${lastCallId}`); - } - }); -} - -function initializeLastLiveFeedCallId() { - db.get('SELECT MAX(id) AS maxId FROM transcriptions', (err, row) => { - if (err) { - console.error('Error initializing lastLiveFeedCallId:', err.message); - } else { - lastLiveFeedCallId = row.maxId || 0; - console.log(`Initialized lastLiveFeedCallId (for feed) to ${lastLiveFeedCallId}`); - } - }); -} - - -// Async background categorization โ€” does not block polling -async function categorizeCallAsync(row) { - try { - const category = await generateShortSummary(row.transcription); - if (category) { - await new Promise((resolve, reject) => { - db.run( - `UPDATE transcriptions SET category = ? WHERE id = ?`, - [category, row.id], - function(dbErr) { - if (dbErr) reject(dbErr); - else resolve(); - } - ); - }); - io.emit('callUpdated', { id: row.id, category }); - } - } catch (categoryError) { - console.error(`Error generating category for map call ID ${row.id}:`, categoryError); - } -} - -// Polling function for MAP updates (requires lat/lon) -function checkForNewCalls() { - db.all( - ` - SELECT t.*, tg.alpha_tag AS talk_group_name, tg.tag AS talk_group_tag - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.id > ? - AND t.lat IS NOT NULL - AND t.lon IS NOT NULL - AND t.lat BETWEEN -90 AND 90 - AND t.lon BETWEEN -180 AND 180 - ORDER BY t.id ASC - LIMIT 10 - `, - [lastCallId], - (err, rows) => { - if (err) { - console.error('Error checking for new map calls:', err.message); - return; - } - - let updatedLastId = lastCallId; - if (rows && rows.length > 0) { - for (const row of rows) { - if (row.id > updatedLastId) { - updatedLastId = row.id; - } - - // Decoupled: categorize asynchronously without blocking emission - if (!row.category && row.transcription) { - categorizeCallAsync(row); - } - - // Emission Logic with Timeout - if (row.transcription) { - io.emit('newCall', row); - } else { - const callAgeMs = Date.now() - (row.timestamp * 1000); - if (callAgeMs > 10000) { - const rowWithPlaceholder = { ...row, transcription: "[Transcription Pending...]" }; - io.emit('newCall', rowWithPlaceholder); - } - } - } - if (updatedLastId > lastCallId) { - lastCallId = updatedLastId; - } - } - } - ); -} - -// Polling function specifically for the LIVE FEED (no location check) -function checkForLiveFeedCalls() { - db.all( - ` - SELECT t.id, t.talk_group_id, t.transcription, t.timestamp, - t.audio_file_path, - tg.alpha_tag AS talk_group_name - FROM transcriptions t - LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id - WHERE t.id > ? - ORDER BY t.id ASC - LIMIT 10 - `, - [lastLiveFeedCallId], - (err, rows) => { - if (err) { - console.error('Error checking for live feed calls:', err.message); - return; - } - - let highestEmittedId = lastLiveFeedCallId; - - if (rows && rows.length > 0) { - rows.forEach(row => { - let shouldEmit = false; - if (row.transcription) { - shouldEmit = true; - } else { - const callAgeMs = Date.now() - (row.timestamp * 1000); - if (callAgeMs > 10000) { - row.transcription = "[Transcription Pending...]"; - shouldEmit = true; - } - } - - if (shouldEmit) { - io.emit('liveFeedUpdate', row); - if (row.id > highestEmittedId) { - highestEmittedId = row.id; - } - } - }); - - if (highestEmittedId > lastLiveFeedCallId) { - lastLiveFeedCallId = highestEmittedId; - } - } - } - ); -} - -// Recursive scheduling to prevent overlapping poll executions -let mapPollTimer = null; -let feedPollTimer = null; - -function scheduleMapPoll() { - mapPollTimer = setTimeout(() => { - checkForNewCalls(); - scheduleMapPoll(); - }, 2000); -} - -function scheduleFeedPoll() { - feedPollTimer = setTimeout(() => { - checkForLiveFeedCalls(); - scheduleFeedPoll(); - }, 2500); -} - -// Initialize last IDs and start polling -initializeLastCallId(); -initializeLastLiveFeedCallId(); -scheduleMapPoll(); -scheduleFeedPoll(); - -// --- End Polling Logic --- - -// Server Startup -server.listen(WEBSERVER_PORT, () => { - console.log(`Web server running on port ${WEBSERVER_PORT}`); - console.log(`Audio URL base: http://${PUBLIC_DOMAIN}:${WEBSERVER_PORT}/audio/`); - - if (authEnabled) { - console.log('Authentication: ENABLED'); - console.log(`Session duration: ${SESSION_DURATION / (24 * 60 * 60 * 1000)} days`); - console.log(`Max sessions per user: ${MAX_SESSIONS}`); - } else { - console.log('Authentication: DISABLED'); - } -}); - -// Add correction logging endpoint -app.post('/api/log/correction', (req, res) => { - const { callId, originalAddress, newAddress } = req.body; - - if (!callId || !originalAddress || !newAddress) { - return res.status(400).json({ error: 'Missing required fields' }); - } - - const logData = { - timestamp: new Date().toISOString(), - callId, - originalAddress, - newAddress - }; - - const logFilePath = path.join(logsDir, `corrections_${new Date().toISOString().split('T')[0]}.json`); - - // Read existing logs - let existingLogs = []; - if (fs.existsSync(logFilePath)) { - try { - const fileContent = fs.readFileSync(logFilePath, 'utf8'); - existingLogs = JSON.parse(fileContent); - } catch (err) { - console.error('Error reading log file:', err); - } - } - - // Add new log entry - existingLogs.push(logData); - - // Write back to file - fs.writeFile(logFilePath, JSON.stringify(existingLogs, null, 2), (err) => { - if (err) { - console.error('Error writing to log file:', err); - return res.status(500).json({ error: 'Failed to write to log' }); - } - res.json({ success: true }); - }); -}); - -// NEW Endpoint for logging deletions -app.post('/api/log/deletion', (req, res) => { - const { callId, category, transcription, location, address, action } = req.body; - - // Basic validation - check for essential fields - if (!callId || action !== 'marker_deletion') { - return res.status(400).json({ error: 'Missing required fields for deletion log' }); - } - - const logData = { - timestamp: new Date().toISOString(), - callId, - category: category || 'UNKNOWN', - transcription: transcription || 'N/A', - location: location || null, - address: address || 'N/A', - action - }; - - const logFilePath = path.join(logsDir, `deletions_${new Date().toISOString().split('T')[0]}.json`); - - // Read existing logs for deletions - let existingLogs = []; - if (fs.existsSync(logFilePath)) { - try { - const fileContent = fs.readFileSync(logFilePath, 'utf8'); - if (fileContent) { // Check if file is not empty - existingLogs = JSON.parse(fileContent); - if (!Array.isArray(existingLogs)) { // Ensure it's an array - console.warn('Deletion log file was not an array, resetting.'); - existingLogs = []; - } - } else { - existingLogs = []; - } - } catch (err) { - console.error('Error reading deletion log file:', err); - existingLogs = []; // Reset if reading fails - } - } - - // Add new log entry - existingLogs.push(logData); - - // Write back to file - fs.writeFile(logFilePath, JSON.stringify(existingLogs, null, 2), (err) => { - if (err) { - console.error('Error writing to deletion log file:', err); - // Still return success to client, as the main operation (deletion) likely succeeded - // but log the server-side error. - return res.status(500).json({ error: 'Failed to write to deletion log' }); - } - console.log(`Deletion logged successfully for callId: ${callId}`); - res.json({ success: true, message: 'Deletion logged.' }); - }); -}); - -// Get all talkgroups for selection UI -app.get('/api/talkgroups', (req, res) => { - db.all( - `SELECT id, alpha_tag, tag - FROM talk_groups - ORDER BY alpha_tag ASC`, // Order alphabetically for easier browsing - [], - (err, rows) => { - if (err) { - console.error('Error fetching talkgroups:', err); - return res.status(500).json({ error: 'Internal server error' }); - } - // Combine alpha_tag and tag for display if alpha_tag exists - const talkgroups = rows.map(tg => ({ - id: tg.id, - name: tg.alpha_tag ? `${tg.alpha_tag} (${tg.tag || tg.id})` : (tg.tag || `ID: ${tg.id}`) - })); - res.json(talkgroups); - } - ); -}); - -// Get all available categories for selection UI -app.get('/api/categories', (req, res) => { - const categories = [ - 'Medical Emergency', 'Injured Person', 'Disturbance', 'Vehicle Collision', - 'Burglary', 'Assault', 'Structure Fire', 'Missing Person', 'Medical Call', - 'Building Fire', 'Stolen Vehicle', 'Service Call', 'Vehicle Stop', - 'Unconscious Person', 'Reckless Driver', 'Person With A Gun', - 'Altered Level of Consciousness', 'Breathing Problems', 'Fight', - 'Carbon Monoxide', 'Abduction', 'Passed Out Person', 'Hazmat', - 'Fire Alarm', 'Traffic Hazard', 'Intoxicated Person', 'Mvc', - 'Animal Bite', 'Assist', 'Other' - ]; - - res.json(categories); -}); - -// Get count of calls that would be purged (Admin Only when auth is enabled) -app.get('/api/calls/purge-count', async (req, res) => { - // Check authentication if enabled - if (ENABLE_AUTH?.toLowerCase() === 'true') { - const authHeader = req.headers.authorization; - if (!authHeader || !(await isAdminUser(authHeader))) { - return res.status(403).json({ error: 'Admin access required' }); - } - } - - // Parse query parameters - handle both array and single values - const talkgroupIds = req.query.talkgroupIds ? - (Array.isArray(req.query.talkgroupIds) ? req.query.talkgroupIds : [req.query.talkgroupIds]) : []; - const categories = req.query.categories ? - (Array.isArray(req.query.categories) ? req.query.categories : [req.query.categories]) : []; - - // Handle timeRange parameters from query string - const timeRangeStart = req.query.timeRangeStart; - const timeRangeEnd = req.query.timeRangeEnd; - - // Validate input - if (!timeRangeStart || !timeRangeEnd) { - return res.status(400).json({ error: 'Time range is required' }); - } - - // Build the WHERE clause dynamically - let whereConditions = ['lat IS NOT NULL AND lon IS NOT NULL']; // Only count calls that have coordinates - let params = []; - - // Add talkgroup filter if specified - if (talkgroupIds.length > 0) { - whereConditions.push(`talk_group_id IN (${talkgroupIds.map(() => '?').join(',')})`); - params.push(...talkgroupIds); - } - - // Add category filter if specified - if (categories.length > 0) { - // Use UPPER() to make case-insensitive comparison - whereConditions.push(`UPPER(category) IN (${categories.map(() => 'UPPER(?)').join(',')})`); - params.push(...categories); - } - - // Add time range filter - whereConditions.push('timestamp BETWEEN ? AND ?'); - const startTime = parseInt(timeRangeStart); - const endTime = parseInt(timeRangeEnd); - - // Validate parsed timestamps - if (isNaN(startTime) || isNaN(endTime)) { - console.error(`[Purge Count] Invalid timestamps: start=${timeRangeStart} (parsed: ${startTime}), end=${timeRangeEnd} (parsed: ${endTime})`); - return res.status(400).json({ error: 'Invalid timestamp format' }); - } - - params.push(startTime, endTime); - - const whereClause = whereConditions.join(' AND '); - - // Execute the count query - const countQuery = `SELECT COUNT(*) as count FROM transcriptions WHERE ${whereClause}`; - - // Check if database is available - if (!db) { - console.error('[Purge Count] Database not available'); - return res.status(500).json({ error: 'Database not available' }); - } - - db.get(countQuery, params, (err, row) => { - if (err) { - console.error('Error counting calls:', err); - return res.status(500).json({ error: 'Failed to count calls' }); - } - - if (!row) { - console.error('[Purge Count] No result from count query'); - return res.status(500).json({ error: 'Failed to count calls - no result' }); - } - - res.json({ - success: true, - count: row.count - }); - }); -}); - -// Purge calls by setting coordinates to NULL (Admin Only when auth is enabled) -app.post('/api/calls/purge', async (req, res) => { - try { - // Check authentication if enabled - if (ENABLE_AUTH?.toLowerCase() === 'true') { - const authHeader = req.headers.authorization; - if (!authHeader || !(await isAdminUser(authHeader))) { - return res.status(403).json({ error: 'Admin access required' }); - } - } - - const { talkgroupIds, categories, timeRangeStart, timeRangeEnd } = req.body; - - // Validate input - if (!timeRangeStart || !timeRangeEnd) { - return res.status(400).json({ error: 'Time range is required' }); - } - - // Build the WHERE clause dynamically - let whereConditions = ['lat IS NOT NULL AND lon IS NOT NULL']; // Only purge calls that have coordinates - let params = []; - - // Add talkgroup filter if specified - // If no talkgroups selected, it means "all talkgroups" (no filter applied) - if (talkgroupIds && talkgroupIds.length > 0) { - whereConditions.push(`talk_group_id IN (${talkgroupIds.map(() => '?').join(',')})`); - params.push(...talkgroupIds); - } - // If no talkgroups selected, don't add any filter - this means "all talkgroups" - - // Add category filter if specified - if (categories && categories.length > 0) { - // Use UPPER() to make case-insensitive comparison - whereConditions.push(`UPPER(category) IN (${categories.map(() => 'UPPER(?)').join(',')})`); - params.push(...categories); - } - - // Add time range filter - whereConditions.push('timestamp BETWEEN ? AND ?'); - const startTime = parseInt(timeRangeStart); - const endTime = parseInt(timeRangeEnd); - - // Validate parsed timestamps - if (isNaN(startTime) || isNaN(endTime)) { - console.error(`[Purge] Invalid timestamps: start=${timeRangeStart} (parsed: ${startTime}), end=${timeRangeEnd} (parsed: ${endTime})`); - return res.status(400).json({ error: 'Invalid timestamp format' }); - } - - params.push(startTime, endTime); - - const whereClause = whereConditions.join(' AND '); - - // Check if database is available - if (!db) { - console.error('[Purge] Database not available'); - return res.status(500).json({ error: 'Database not available' }); - } - - // Store original coordinates before purging - try { - const originalCoords = await storeOriginalCoordinates(talkgroupIds, categories, startTime, endTime); - - // Execute the purge query - const purgeQuery = `UPDATE transcriptions SET lat = NULL, lon = NULL WHERE ${whereClause}`; - - db.run(purgeQuery, params, function(err) { - if (err) { - console.error('Error purging calls:', err); - return res.status(500).json({ error: 'Failed to purge calls' }); - } - - // Store the last purge details for undo functionality - lastPurgeDetails = { - talkgroupIds: talkgroupIds || [], - categories: categories || [], - timeRangeStart: startTime, - timeRangeEnd: endTime, - purgedCount: this.changes, - timestamp: Date.now(), - originalCoordinates: originalCoords - }; - - res.json({ - success: true, - purgedCount: this.changes, - message: `Successfully purged ${this.changes} calls from the map` - }); - }); - } catch (coordError) { - console.error('Error storing original coordinates:', coordError); - return res.status(500).json({ error: 'Failed to store original coordinates for undo' }); - } - } catch (error) { - console.error('Unexpected error in purge endpoint:', error); - res.status(500).json({ error: 'Internal server error during purge operation' }); - } -}); - -// Check if there's a purge operation that can be undone -app.get('/api/calls/can-undo-purge', async (req, res) => { - // Check authentication if enabled - if (ENABLE_AUTH?.toLowerCase() === 'true') { - const authHeader = req.headers.authorization; - if (!authHeader || !(await isAdminUser(authHeader))) { - return res.status(403).json({ error: 'Admin access required' }); - } - } - - if (!lastPurgeDetails) { - return res.json({ canUndo: false, message: 'No purge operation to undo' }); - } - - // No time limit for undo operations - - res.json({ - canUndo: true, - message: `Can undo purge of ${lastPurgeDetails.purgedCount} calls`, - purgeDetails: { - categories: lastPurgeDetails.categories, - talkgroups: lastPurgeDetails.talkgroupIds, - timeRange: { - start: new Date(lastPurgeDetails.timeRangeStart * 1000).toLocaleString(), - end: new Date(lastPurgeDetails.timeRangeEnd * 1000).toLocaleString() - }, - timestamp: new Date(lastPurgeDetails.timestamp).toLocaleString() - } - }); -}); - -// Undo last purge operation (Admin Only when auth is enabled) -app.post('/api/calls/undo-last-purge', async (req, res) => { - try { - // Check authentication if enabled - if (ENABLE_AUTH?.toLowerCase() === 'true') { - const authHeader = req.headers.authorization; - if (!authHeader || !(await isAdminUser(authHeader))) { - return res.status(403).json({ error: 'Admin access required' }); - } - } - - // Check if there's a last purge to undo - if (!lastPurgeDetails) { - return res.status(400).json({ error: 'No purge operation to undo' }); - } - - // No time limit for undo operations - - // Build the WHERE clause to restore coordinates - let whereConditions = ['lat IS NULL AND lon IS NULL']; // Only restore calls that have no coordinates - let params = []; - - // Add talkgroup filter if specified - if (lastPurgeDetails.talkgroupIds && lastPurgeDetails.talkgroupIds.length > 0) { - whereConditions.push(`talk_group_id IN (${lastPurgeDetails.talkgroupIds.map(() => '?').join(',')})`); - params.push(...lastPurgeDetails.talkgroupIds); - } - - // Add category filter if specified - if (lastPurgeDetails.categories && lastPurgeDetails.categories.length > 0) { - // Use UPPER() to make case-insensitive comparison - whereConditions.push(`UPPER(category) IN (${lastPurgeDetails.categories.map(() => 'UPPER(?)').join(',')})`); - params.push(...lastPurgeDetails.categories); - } - - // Add time range filter - whereConditions.push('timestamp BETWEEN ? AND ?'); - params.push(lastPurgeDetails.timeRangeStart, lastPurgeDetails.timeRangeEnd); - - const whereClause = whereConditions.join(' AND '); - - // Check if database is available - if (!db) { - console.error('[Undo Purge] Database not available'); - return res.status(500).json({ error: 'Database not available' }); - } - - // Check if we have the original coordinates stored - if (!lastPurgeDetails.originalCoordinates || lastPurgeDetails.originalCoordinates.length === 0) { - return res.status(400).json({ error: 'No original coordinates available for restoration' }); - } - - // Restore the original coordinates for each call - let restoredCount = 0; - let hasError = false; - - for (const coord of lastPurgeDetails.originalCoordinates) { - const restoreQuery = `UPDATE transcriptions SET lat = ?, lon = ? WHERE id = ?`; - - db.run(restoreQuery, [coord.lat, coord.lon, coord.id], function(err) { - if (err) { - console.error(`Error restoring coordinates for call ${coord.id}:`, err); - hasError = true; - } else { - restoredCount++; - } - }); - } - - // Wait a bit for all updates to complete, then respond - setTimeout(() => { - if (hasError) { - return res.status(500).json({ error: 'Some calls could not be restored' }); - } - - // Clear the last purge details after successful undo - const undonePurgeDetails = { ...lastPurgeDetails }; - lastPurgeDetails = null; - - res.json({ - success: true, - restoredCount: restoredCount, - message: `Successfully restored ${restoredCount} calls to the map`, - undonePurge: undonePurgeDetails - }); - }, 100); - - } catch (error) { - console.error('Unexpected error in undo purge endpoint:', error); - res.status(500).json({ error: 'Internal server error during undo operation' }); - } -}); - -// General error handling middleware -app.use((err, req, res, next) => { - console.error('Unhandled error:', err); - res.status(500).json({ error: 'Internal server error' }); -}); - -// 404 handler for unmatched routes -app.use((req, res) => { - res.status(404).json({ error: 'Route not found' }); -}); - -// Graceful Shutdown -process.on('SIGINT', () => { - console.log('Shutting down web server gracefully...'); - server.close(() => { - console.log('Express server closed.'); - db.close((err) => { - if (err) { - console.error('Error closing database connection:', err); - } else { - console.log('Database connection closed.'); - } - process.exit(0); - }); - }); -}); \ No newline at end of file +// webserver.js - Web interface for viewing and managing calls with optional authentication + +require('dotenv').config(); +const { loadConfig } = require('./src/config'); +const { applyMigrations } = require('./src/db/migrations'); +const { getJobSummary, getRecentJobs } = require('./src/jobs/processingJobs'); +const { + getSetupStatus, + getRuntimeConfig, + markSetupComplete, + resolveSettings, + saveSecret, + saveSettings +} = require('./src/settings/settingsService'); +const { runSetupChecks } = require('./src/setup/checks'); +const { registerGeocodeRoutes } = require('./src/routes/geocodeProxy'); +const { createCallPoller } = require('./src/polling/callPoller'); +const rateLimit = require('express-rate-limit'); +const AWS = require('aws-sdk'); // Add AWS SDK + +const express = require('express'); +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const http = require('http'); +const socketIo = require('socket.io'); +const crypto = require('crypto'); +const fetch = require('node-fetch'); +const fs = require('fs'); +const logsDir = path.join(__dirname, 'logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +// Environment variables +const { + WEBSERVER_PORT, + WEBSERVER_PASSWORD, + PUBLIC_DOMAIN, + TIMEZONE, + ENABLE_AUTH, // New environment variable for toggling authentication + SESSION_DURATION_DAYS = "7", // Default 7 days if not specified + MAX_SESSIONS_PER_USER = "5", // Default 5 sessions if not specified + GOOGLE_MAPS_API_KEY = null, + // --- NEW: Geocoding API Keys --- + LOCATIONIQ_API_KEY = null, + // --- NEW: Storage Env Vars --- + STORAGE_MODE = 'local', // Default to local if not set + S3_ENDPOINT, + S3_BUCKET_NAME, + S3_ACCESS_KEY_ID, + S3_SECRET_ACCESS_KEY, + // --- NEW: AI Provider Env Vars --- + AI_PROVIDER = 'ollama', // Can be 'ollama' or 'openai' + OPENAI_API_KEY, + 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; + +const startupConfig = loadConfig(process.env); +if (!startupConfig.isValid) { + console.warn('WARNING: Configuration has issues. Setup mode will remain available:'); + for (const error of startupConfig.errors) { + console.warn(`- ${error.key}: ${error.message}`); + } +} + +// 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) { + console.log('[Webserver] Google Maps API key found - Google Places autocomplete will be available'); +} else { + console.log('[Webserver] Google Maps API key not found - Google Places autocomplete will be disabled'); +} + +if (LOCATIONIQ_API_KEY) { + console.log('[Webserver] LocationIQ API key found - LocationIQ autocomplete will be available'); +} else { + console.log('[Webserver] LocationIQ API key not found - LocationIQ autocomplete will be disabled'); +} + +// Geocoding proxy routes registered after basicAuth is defined (see below) + +// Add endpoint to serve Google API key +const app = express(); +app.use(express.json()); // Add this line to parse JSON bodies + +const audioRateLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, +}); + +// Add endpoint to check if current user is admin +app.get('/api/auth/is-admin', async (req, res) => { + if (!authEnabled) { + return res.json({ isAdmin: false, authEnabled: false }); + } + + const authHeader = req.headers['authorization']; + const adminStatus = await isAdminUser(authHeader); + res.json({ isAdmin: adminStatus, authEnabled: true }); +}); + +// Test endpoint to verify server is working +app.get('/api/test', (req, res) => { + res.json({ message: 'Server is working', timestamp: Date.now() }); +}); + +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'; + +// Session configuration (used only if auth is enabled) +const SESSION_DURATION = parseInt(SESSION_DURATION_DAYS, 10) * 24 * 60 * 60 * 1000; // Convert days to milliseconds +const MAX_SESSIONS = parseInt(MAX_SESSIONS_PER_USER, 10); +const SESSION_CLEANUP_INTERVAL = 60 * 60 * 1000; // Cleanup every hour + +// Express app setup +const server = http.createServer(app); +const io = socketIo(server); + +// Database setup +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('PRAGMA journal_mode = WAL;'); + db.run('PRAGMA busy_timeout = 5000;'); + } +}); + +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); + }); + +async function getResolvedRuntimeConfig() { + await dbReady; + return getRuntimeConfig(db, process.env); +} + +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') + .toString('hex'); +} + +function generateSessionToken() { + return crypto.randomBytes(32).toString('hex'); +} + +// Session Management Functions +async function createSession(userId, req) { + const token = generateSessionToken(); + const expiresAt = new Date(Date.now() + SESSION_DURATION); + const ipAddress = req.ip; + const userAgent = req.get('user-agent'); + + return new Promise((resolve, reject) => { + db.run( + `INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent) + VALUES (?, ?, datetime(?), ?, ?)`, + [userId, token, expiresAt.toISOString(), ipAddress, userAgent], + function(err) { + if (err) reject(err); + else resolve({ token, expiresAt }); + } + ); + }); +} + +async function validateSession(token) { + return new Promise((resolve, reject) => { + db.get( + `SELECT * FROM sessions + WHERE token = ? AND expires_at > datetime('now')`, + [token], + (err, session) => { + if (err) reject(err); + else resolve(session); + } + ); + }); +} + +async function generateShortSummary(transcript) { + try { + // Original list of categories for the AI + const categories = [ + 'Medical Emergency', 'Injured Person', 'Disturbance', 'Vehicle Collision', + 'Burglary', 'Assault', 'Structure Fire', 'Missing Person', 'Medical Call', + 'Building Fire', 'Stolen Vehicle', 'Service Call', 'Vehicle Stop', + 'Unconscious Person', 'Reckless Driver', 'Person With A Gun', + 'Altered Level of Consciousness', 'Breathing Problems', 'Fight', + 'Carbon Monoxide', 'Abduction', 'Passed Out Person', 'Hazmat', + 'Fire Alarm', 'Traffic Hazard', 'Intoxicated Person', 'Mvc', // Note: Mvc is often redundant with Vehicle Collision + 'Animal Bite', + 'Assist' + ]; + + // This prompt works well for both Ollama and OpenAI's chat models + const commonPrompt = ` +You are an expert emergency service dispatcher categorizing radio transmissions. +Analyze the following first responder radio transmission and categorize it into EXACTLY ONE of the categories listed below. +Choose the category that best fits the main subject of the transmission. +Focus on the primary reason for the dispatch if multiple events are mentioned. + +**PRIORITIZATION:** +- If a clear event type (like Vehicle Collision, Fire, Assault, Medical Emergency, etc.) is mentioned, **use that category even if the dispatcher says "no details"** or the information is minimal. do not add stars around your output such as "**GAS LEAK**". +- Use the 'Other' category ONLY if the transmission primarily contains just location/unit information OR if no specific event type from the list is mentioned at all. + +It is CRUCIAL that your response is ONLY one of the category names from this list and nothing else. + +Categories: +${categories.map(cat => `- ${cat}`).join('\n')} +- Other + +Transmission: "${transcript}" + +Category:`; + + 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 (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 + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[Webserver] OpenAI API error! status: ${response.status}, transcript: ${transcript}, details: ${errorText}`); + throw new Error(`OpenAI API error! status: ${response.status}`); + } + + const result = await response.json(); + 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: ${ollamaModel}`); + + const response = await fetch(`${ollamaUrl}/api/generate`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + model: ollamaModel, + prompt: commonPrompt, // The prompt is compatible + stream: false, + options: { + temperature: 0.3 + } + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error(`[Webserver] Ollama API error! status: ${response.status} for transcript: ${transcript}`); + throw new Error(`Ollama API error! status: ${response.status}`); + } + + const result = await response.json(); + category = result.response.trim(); + } + // --- End AI Provider Logic --- + + + // The existing post-processing logic is generic enough to work for both + const thinkBlockRegex = /[\s\S]*?<\/think>\s*/; + category = category.replace(thinkBlockRegex, '').trim().toUpperCase(); + + // Validate the AI's response against the known categories (including OTHER) + const validCategoriesUppercase = categories.map(cat => cat.toUpperCase()); + validCategoriesUppercase.push('OTHER'); + + if (!validCategoriesUppercase.includes(category)) { + console.warn(`[Webserver] AI returned an unexpected or invalid category: "${category}". Defaulting to OTHER for transcript: "${transcript}"`); + category = 'OTHER'; + } + + return category; + + } catch (error) { + console.error(`[Webserver] Error categorizing call: "${transcript}". Error: ${error.message}`); + if (error.name === 'AbortError') { + console.error(`[Webserver] AI request timed out during categorization: ${error.message}`); + } + return 'OTHER'; // Fallback to 'OTHER' in case of any errors + } +} + +function cleanupExpiredSessions() { + if (authEnabled) { + db.run('DELETE FROM sessions WHERE expires_at <= datetime("now")', [], (err) => { + if (err) { + console.error('Error cleaning up expired sessions:', err); + } else { + console.log('Expired sessions cleaned up'); + } + }); + } +} + +// Start session cleanup interval if auth enabled +if (authEnabled) { + setInterval(cleanupExpiredSessions, SESSION_CLEANUP_INTERVAL); +} + +// Authentication Middleware - only applied when authentication is enabled +const basicAuth = async (req, res, next) => { + // Skip authentication if disabled in .env + if (!authEnabled) { + return next(); + } + + try { + const authHeader = req.headers['authorization']; + if (!authHeader) { + res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); + return res.status(401).send('Authentication required.'); + } + + // Check if it's a Bearer token (session-based auth) + if (authHeader.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1]; + if (!token) { + return res.status(401).send('Invalid Bearer token format.'); + } + + // Validate the session token + const session = await validateSession(token); + if (!session) { + return res.status(401).send('Invalid or expired session token.'); + } + + // Get the user from the session + const user = await new Promise((resolve, reject) => { + db.get('SELECT id, username FROM users WHERE id = ?', [session.user_id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (!user) { + return res.status(401).send('User not found for session.'); + } + + // Set user info in request for downstream use + req.user = { id: user.id, username: user.username }; + req.session = session; + return next(); + } + + // Check if it's Basic auth (username:password) + if (authHeader.startsWith('Basic ')) { + const base64Credentials = authHeader.split(' ')[1]; + if (!base64Credentials) { + res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); + return res.status(401).send('Invalid authentication format.'); + } + + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [username, password] = credentials.split(':'); + + // Check credentials against database + db.get( + 'SELECT id, password_hash, salt FROM users WHERE username = ?', + [username], + async (err, user) => { + if (err) { + console.error('Database error during authentication:', err); + return res.status(500).send('Internal server error.'); + } + + if (!user) { + res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); + return res.status(401).send('Invalid credentials.'); + } + + const hashedPassword = hashPassword(password, user.salt); + if (hashedPassword === user.password_hash) { + // Get all active sessions for user, ordered by creation date + db.all( + `SELECT id, created_at, expires_at + FROM sessions + WHERE user_id = ? AND expires_at > datetime('now') + ORDER BY created_at ASC`, + [user.id], + async (err, sessions) => { + if (err) { + return res.status(500).send('Internal server error.'); + } + + // If at session limit, remove oldest session + if (sessions.length >= MAX_SESSIONS) { + db.run( + 'DELETE FROM sessions WHERE id = ?', + [sessions[0].id], + async (err) => { + if (err) { + console.error('Error removing oldest session:', err); + return res.status(500).send('Internal server error.'); + } + console.log(`Removed oldest session for user ${username}`); + try { + const session = await createSession(user.id, req); + req.user = { id: user.id, username }; + req.session = session; + next(); + } catch (err) { + console.error('Error creating session:', err); + return res.status(500).send('Internal server error.'); + } + } + ); + } else { + try { + const session = await createSession(user.id, req); + req.user = { id: user.id, username }; + req.session = session; + next(); + } catch (err) { + console.error('Error creating session:', err); + return res.status(500).send('Internal server error.'); + } + } + } + ); + } else { + res.set('WWW-Authenticate', 'Basic realm="Protected Area"'); + return res.status(401).send('Invalid credentials.'); + } + } + ); + } else { + return res.status(401).send('Unsupported authentication method. Use Basic or Bearer.'); + } + } catch (err) { + console.error('Authentication error:', err); + return res.status(500).send('Internal server error.'); + } +}; + +registerGeocodeRoutes(app, { getResolvedRuntimeConfig, basicAuth }); + +// Admin Authentication Middleware +const adminAuth = (req, res, next) => { + // Skip authentication if disabled in .env + if (!authEnabled) { + return next(); + } + + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Basic ')) { + return res.status(401).send('Admin authentication required.'); + } + + const base64Credentials = authHeader.split(' ')[1]; + if (!base64Credentials) { + return res.status(401).send('Admin authentication required.'); + } + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [username, password] = credentials.split(':'); + + if (username === 'admin' && password === WEBSERVER_PASSWORD) { + next(); + } else { + return res.status(401).send('Invalid admin credentials.'); + } +}; + +// Helper function to check if user is admin +async function isAdminUser(authHeader) { + if (!authEnabled || !authHeader) { + return false; + } + + try { + // Check if it's a Bearer token (session-based auth) + if (authHeader.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1]; + if (!token) { + return false; + } + + // Validate the session token + const session = await validateSession(token); + if (!session) { + return false; + } + + // Get the user from the session + const user = await new Promise((resolve, reject) => { + db.get('SELECT username FROM users WHERE id = ?', [session.user_id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + // Check if the user is admin + return user && user.username === 'admin'; + } + + // Check if it's Basic auth (username:password) + if (authHeader.startsWith('Basic ')) { + const base64Credentials = authHeader.split(' ')[1]; + if (!base64Credentials) { + return false; + } + + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [username, password] = credentials.split(':'); + + return username === 'admin' && password === WEBSERVER_PASSWORD; + } + + return false; + } catch (error) { + console.error('Error in isAdminUser:', error); + return false; + } +} + +// --- NEW HELPER FUNCTION --- +// Store the last purge operation details for undo functionality +let lastPurgeDetails = null; + +// Function to store original coordinates before purging +async function storeOriginalCoordinates(talkgroupIds, categories, timeRangeStart, timeRangeEnd) { + return new Promise((resolve, reject) => { + // Build the WHERE clause to get calls that will be purged + let whereConditions = ['lat IS NOT NULL AND lon IS NOT NULL']; + let params = []; + + // If no talkgroups selected, it means "all talkgroups" (no filter applied) + if (talkgroupIds && talkgroupIds.length > 0) { + whereConditions.push(`talk_group_id IN (${talkgroupIds.map(() => '?').join(',')})`); + params.push(...talkgroupIds); + } + // If no talkgroups selected, don't add any filter - this means "all talkgroups" + + if (categories && categories.length > 0) { + whereConditions.push(`UPPER(category) IN (${categories.map(() => 'UPPER(?)').join(',')})`); + params.push(...categories); + } + + whereConditions.push('timestamp BETWEEN ? AND ?'); + params.push(timeRangeStart, timeRangeEnd); + + const whereClause = whereConditions.join(' AND '); + const selectQuery = `SELECT id, lat, lon FROM transcriptions WHERE ${whereClause}`; + + db.all(selectQuery, params, (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); +} + +async function serveAudioFromDb(res, transcriptionId) { + console.log(`[Audio DB] Serving audio for ID ${transcriptionId} from database blob.`); + try { + const audioRow = await new Promise((resolve, reject) => { + db.get('SELECT audio_data FROM audio_files WHERE transcription_id = ?', [transcriptionId], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (audioRow && audioRow.audio_data) { + const pathRow = await new Promise((resolve, reject) => { + db.get('SELECT audio_file_path FROM transcriptions WHERE id = ?', [transcriptionId], (err, row) => { + if (err) reject(err); else resolve(row); + }); + }); + + const filePath = pathRow ? pathRow.audio_file_path : ''; + const extension = path.extname(filePath).toLowerCase(); + const contentType = extension === '.m4a' ? 'audio/mp4' : 'audio/mpeg'; + + res.setHeader('Content-Type', contentType); + res.send(audioRow.audio_data); + } else { + console.error(`[Audio DB] Audio data not found in DB for ID: ${transcriptionId}`); + if (!res.headersSent) { + res.status(404).send('Audio not found in any storage location.'); + } + } + } catch (dbErr) { + console.error(`[Audio DB] DB error for ID ${transcriptionId}:`, dbErr); + if (!res.headersSent) { + res.status(500).send('Internal Server Error during DB fallback.'); + } + } +} + +// Public Routes โ€” audio rate-limited; requires auth when ENABLE_AUTH=true +app.get('/audio/:id', audioRateLimiter, async (req, res, next) => { + if (authEnabled) { + return basicAuth(req, res, async () => { + await serveAudioRequest(req, res); + }); + } + return serveAudioRequest(req, res); +}); + +async function serveAudioRequest(req, res) { + const transcriptionId = req.params.id; + + try { + const transcriptionRow = await new Promise((resolve, reject) => { + db.get('SELECT audio_file_path FROM transcriptions WHERE id = ?', [transcriptionId], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + 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); + }); + res.setHeader('Content-Type', contentType); + s3Stream.pipe(res); + return; + } else { // Local storage + const localPath = path.join(__dirname, 'audio', audioStoragePath); + if (fs.existsSync(localPath)) { + res.setHeader('Content-Type', contentType); + fs.createReadStream(localPath).pipe(res); + return; + } else { + console.warn(`[Audio Local] File not found at ${localPath}. Falling back to DB.`); + } + } + } + + // Fallback to serving from the database blob if file not found or path missing. + serveAudioFromDb(res, transcriptionId); + + } catch (dbErr) { + console.error('[Audio Request] Database error:', dbErr); + return res.status(500).send('Internal Server Error'); + } +} + +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 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); + 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 runtime = await getResolvedRuntimeConfig(); + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env, runtime }); + const providerMap = { + geocoding: checks.geocodingProvider, + transcription: checks.transcriptionProvider, + ai: checks.aiProvider, + storage: checks.storageProvider, + 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 }); + } + 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) { + 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'))); + +// Session Management Routes (Only relevant when auth is enabled) +app.get('/api/sessions/current', (req, res) => { + if (authEnabled) { + res.json({ + session: req.session || null, + user: req.user || null + }); + } else { + res.json({ + session: { token: 'anonymous-session' }, + user: { username: 'anonymous' } + }); + } +}); + +app.get('/api/sessions', adminAuth, (req, res) => { + if (!authEnabled) { + return res.json([]); + } + + const userId = req.query.userId; + let query = ` + SELECT s.*, u.username, s.ip_address, s.user_agent + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.expires_at > datetime('now') + `; + const params = []; + + if (userId && userId !== 'all') { + query += ' AND s.user_id = ?'; + params.push(userId); + } + + query += ' ORDER BY s.created_at DESC'; + + db.all(query, params, (err, sessions) => { + if (err) { + console.error('Error fetching sessions:', err); + return res.status(500).json({ error: 'Internal server error' }); + } + res.json(sessions); + }); +}); + +app.delete('/api/sessions/:token', adminAuth, (req, res) => { + if (!authEnabled) { + return res.json({ message: 'Authentication is disabled' }); + } + + db.run( + 'DELETE FROM sessions WHERE token = ?', + [req.params.token], + function(err) { + if (err) { + console.error('Error deleting session:', err); + return res.status(500).json({ error: 'Internal server error' }); + } + res.json({ message: 'Session terminated successfully' }); + } + ); +}); + +app.get('/api/sessions/me', (req, res) => { + if (!authEnabled) { + return res.json([]); + } + + db.all( + `SELECT id, created_at, expires_at, ip_address, user_agent + FROM sessions + WHERE user_id = ? AND expires_at > datetime('now') + ORDER BY created_at DESC`, + [req.user.id], + (err, sessions) => { + if (err) { + console.error('Error fetching user sessions:', err); + return res.status(500).json({ error: 'Internal server error' }); + } + res.json(sessions); + } + ); +}); + +// 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, + hotAppliedByWebserver: ['aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel'] + }); + } 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 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); + 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' }); + } + + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required.' }); + } + + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = hashPassword(password, salt); + + try { + const result = await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)', + [username, passwordHash, salt], + function(err) { + if (err) reject(err); + else resolve(this.lastID); + } + ); + }); + + res.status(201).json({ + message: 'User created successfully', + userId: result + }); + } catch (err) { + if (err.message.includes('UNIQUE constraint failed')) { + res.status(409).json({ error: 'Username already exists.' }); + } else { + console.error('Error creating user:', err); + res.status(500).json({ error: 'Internal server error.' }); + } + } +}); + +app.get('/api/users', adminAuth, (req, res) => { + if (!authEnabled) { + return res.json([]); + } + + db.all( + `SELECT u.id, u.username, u.created_at, + COUNT(s.id) as active_sessions + FROM users u + LEFT JOIN sessions s ON u.id = s.user_id + AND s.expires_at > datetime('now') + GROUP BY u.id + ORDER BY u.created_at DESC`, + [], + (err, users) => { + if (err) { + console.error('Error fetching users:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json(users); + } + ); +}); + +app.delete('/api/users/:id', adminAuth, (req, res) => { + if (!authEnabled) { + return res.status(400).json({ error: 'Authentication is disabled' }); + } + + const userId = parseInt(req.params.id, 10); + + if (isNaN(userId)) { + return res.status(400).json({ error: 'Invalid user ID.' }); + } + + db.run('DELETE FROM users WHERE id = ?', [userId], function(err) { + if (err) { + console.error('Error deleting user:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json({ message: 'User deleted successfully.' }); + }); +}); + +// API Routes for call data +app.get('/api/calls', (req, res) => { + const hours = parseInt(req.query.hours) || 12; + // Convert hours to a Unix timestamp (seconds) for the WHERE clause + const sinceTimestampUnix = Math.floor((Date.now() - hours * 60 * 60 * 1000) / 1000); + + console.log(`Fetching calls since Unix timestamp: ${sinceTimestampUnix} (${hours} hours ago)`); + + db.all( + ` + SELECT t.*, tg.alpha_tag AS talk_group_name, tg.tag AS talk_group_tag + FROM transcriptions t + LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id + WHERE t.timestamp >= ? AND t.lat IS NOT NULL AND t.lon IS NOT NULL + ORDER BY t.timestamp DESC + `, + [sinceTimestampUnix], // Use Unix timestamp for the query + (err, rows) => { + if (err) { + console.error('Error fetching calls:', err); + res.status(500).json({ error: err.message }); + return; + } + + console.log(`Returning ${rows.length} calls`); + // Timestamps are now already Unix seconds from the DB + if (rows.length > 0) { + console.log(`Oldest call in result (Unix ts): ${rows[rows.length - 1].timestamp}`); + console.log(`Newest call in result (Unix ts): ${rows[0].timestamp}`); + } + res.json(rows); // Send rows directly as timestamps are already numeric + } + ); +}); + +app.delete('/api/markers/:id', (req, res) => { + const markerId = parseInt(req.params.id, 10); + + if (isNaN(markerId)) { + return res.status(400).json({ error: 'Invalid marker ID' }); + } + + db.run( + 'DELETE FROM transcriptions WHERE id = ?', + [markerId], + function(err) { + if (err) { + console.error('Error deleting marker:', err); + return res.status(500).json({ error: 'Internal server error' }); + } + + res.json({ message: 'Marker deleted successfully' }); + } + ); +}); + +app.put('/api/markers/:id/location', (req, res) => { + const markerId = parseInt(req.params.id); + const { lat, lon } = req.body; + + if (isNaN(markerId) || typeof lat !== 'number' || typeof lon !== 'number') { + return res.status(400).json({ error: 'Invalid parameters' }); + } + + db.run( + 'UPDATE transcriptions SET lat = ?, lon = ? WHERE id = ?', + [lat, lon, markerId], + function(err) { + if (err) { + console.error('Error updating marker location:', err); + return res.status(500).json({ error: 'Internal server error' }); + } + res.json({ success: true }); + } + ); +}); + +app.get('/api/additional-transcriptions/:callId', (req, res) => { + const callId = parseInt(req.params.callId, 10); + const skip = parseInt(req.query.skip, 10) || 0; + + if (isNaN(callId)) { + return res.status(400).send('Invalid call ID.'); + } + + db.get( + 'SELECT talk_group_id FROM transcriptions WHERE id = ?', + [callId], + (err, row) => { + if (err) { + console.error('Error fetching talk group ID:', err); + return res.status(500).json({ error: 'Internal Server Error' }); + } + + if (!row) { + return res.status(404).json({ error: 'Call not found' }); + } + + const talkGroupId = row.talk_group_id; + + db.all( + ` + SELECT t.id, t.transcription, t.audio_file_path, t.timestamp, tg.alpha_tag AS talk_group_name + FROM transcriptions t + LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id + WHERE t.talk_group_id = ? AND t.id > ? + ORDER BY t.id ASC + LIMIT 3 OFFSET ? + `, + [talkGroupId, callId, skip], + (err, rows) => { + if (err) { + console.error('Error fetching additional transcriptions:', err); + return res.status(500).json({ error: 'Internal Server Error' }); + } + // ADD LOGGING HERE + console.log(`[/api/additional-transcriptions] Responding with ${rows.length} rows. First row ID: ${rows.length > 0 ? rows[0].id : 'N/A'}`); + res.json(rows); + } + ); + } + ); +}); + +// NEW Endpoint for Talkgroup History +app.get('/api/talkgroup/:talkgroupId/calls', (req, res) => { + const talkgroupId = parseInt(req.params.talkgroupId, 10); + const sinceId = parseInt(req.query.sinceId, 10) || 0; // For polling + const limit = parseInt(req.query.limit, 10) || 30; // Default limit 30 + const offset = parseInt(req.query.offset, 10) || 0; // Default offset 0 + + if (isNaN(talkgroupId)) { + return res.status(400).json({ error: 'Invalid talkgroup ID' }); + } + + let query; + const params = []; + + if (sinceId > 0) { + // Polling request: Get calls strictly newer than the last known ID (limit doesn't apply here) + console.log(`Polling calls for talkgroup ${talkgroupId} since ID: ${sinceId}`); + query = ` + SELECT t.id, t.transcription, t.timestamp, tg.alpha_tag AS talk_group_name + FROM transcriptions t + LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id + WHERE t.talk_group_id = ? AND t.id > ? + AND t.transcription IS NOT NULL + ORDER BY t.id ASC -- Fetch oldest first when polling since ID + `; + params.push(talkgroupId, sinceId); + } else { + // Initial load or subsequent page request: Use LIMIT and OFFSET + console.log(`Fetching calls for talkgroup ${talkgroupId} with limit: ${limit}, offset: ${offset}`); + query = ` + SELECT t.id, t.transcription, t.timestamp, tg.alpha_tag AS talk_group_name + FROM transcriptions t + LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id + WHERE t.talk_group_id = ? + AND t.transcription IS NOT NULL + ORDER BY t.timestamp DESC -- Show newest first overall + LIMIT ? OFFSET ? + `; + params.push(talkgroupId, limit, offset); + } + + db.all(query, params, (err, rows) => { + if (err) { + console.error(`Error fetching calls for talkgroup ${talkgroupId}:`, err); + return res.status(500).json({ error: 'Internal server error' }); + } + + if (sinceId > 0) { + console.log(`Poll returned ${rows.length} calls for talkgroup ${talkgroupId} since ID ${sinceId}`); + } else { + console.log(`Paginated load returned ${rows.length} calls for talkgroup ${talkgroupId}`); + } + res.json(rows); + }); +}); +// END NEW Endpoint + +// NEW Endpoint to get details for a single call (for live feed retries) +app.get('/api/call/:id/details', (req, res) => { + const callId = parseInt(req.params.id, 10); + + if (isNaN(callId)) { + return res.status(400).json({ error: 'Invalid call ID' }); + } + + db.get( + ` + SELECT t.id, t.transcription, t.timestamp, t.talk_group_id, tg.alpha_tag AS talk_group_name + FROM transcriptions t + LEFT JOIN talk_groups tg ON t.talk_group_id = tg.id + WHERE t.id = ? + `, + [callId], + (err, row) => { + if (err) { + console.error(`Error fetching details for call ${callId}:`, err); + return res.status(500).json({ error: 'Internal server error' }); + } + if (!row) { + return res.status(404).json({ error: 'Call not found' }); + } + // console.log(`[API Call Details] Returning details for ID: ${callId}`); // Optional: verbose log + res.json(row); + } + ); +}); + +// Socket.IO Setup +io.on('connection', (socket) => { + console.log(`Client connected: ${socket.id}`); + socket.on('disconnect', () => { + console.log(`Client disconnected: ${socket.id}`); + }); +}); + +// --- Start Polling Logic --- + +const callPoller = createCallPoller({ + db, + io, + generateShortSummary, +}); +callPoller.start(); + +// --- End Polling Logic --- + +// Server Startup +server.listen(WEBSERVER_PORT, () => { + console.log(`Web server running on port ${WEBSERVER_PORT}`); + console.log(`Audio URL base: http://${PUBLIC_DOMAIN}:${WEBSERVER_PORT}/audio/`); + + if (authEnabled) { + console.log('Authentication: ENABLED'); + console.log(`Session duration: ${SESSION_DURATION / (24 * 60 * 60 * 1000)} days`); + console.log(`Max sessions per user: ${MAX_SESSIONS}`); + } else { + console.log('Authentication: DISABLED'); + } +}); + +// Add correction logging endpoint +app.post('/api/log/correction', (req, res) => { + const { callId, originalAddress, newAddress } = req.body; + + if (!callId || !originalAddress || !newAddress) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const logData = { + timestamp: new Date().toISOString(), + callId, + originalAddress, + newAddress + }; + + const logFilePath = path.join(logsDir, `corrections_${new Date().toISOString().split('T')[0]}.json`); + + // Read existing logs + let existingLogs = []; + if (fs.existsSync(logFilePath)) { + try { + const fileContent = fs.readFileSync(logFilePath, 'utf8'); + existingLogs = JSON.parse(fileContent); + } catch (err) { + console.error('Error reading log file:', err); + } + } + + // Add new log entry + existingLogs.push(logData); + + // Write back to file + fs.writeFile(logFilePath, JSON.stringify(existingLogs, null, 2), (err) => { + if (err) { + console.error('Error writing to log file:', err); + return res.status(500).json({ error: 'Failed to write to log' }); + } + res.json({ success: true }); + }); +}); + +// NEW Endpoint for logging deletions +app.post('/api/log/deletion', (req, res) => { + const { callId, category, transcription, location, address, action } = req.body; + + // Basic validation - check for essential fields + if (!callId || action !== 'marker_deletion') { + return res.status(400).json({ error: 'Missing required fields for deletion log' }); + } + + const logData = { + timestamp: new Date().toISOString(), + callId, + category: category || 'UNKNOWN', + transcription: transcription || 'N/A', + location: location || null, + address: address || 'N/A', + action + }; + + const logFilePath = path.join(logsDir, `deletions_${new Date().toISOString().split('T')[0]}.json`); + + // Read existing logs for deletions + let existingLogs = []; + if (fs.existsSync(logFilePath)) { + try { + const fileContent = fs.readFileSync(logFilePath, 'utf8'); + if (fileContent) { // Check if file is not empty + existingLogs = JSON.parse(fileContent); + if (!Array.isArray(existingLogs)) { // Ensure it's an array + console.warn('Deletion log file was not an array, resetting.'); + existingLogs = []; + } + } else { + existingLogs = []; + } + } catch (err) { + console.error('Error reading deletion log file:', err); + existingLogs = []; // Reset if reading fails + } + } + + // Add new log entry + existingLogs.push(logData); + + // Write back to file + fs.writeFile(logFilePath, JSON.stringify(existingLogs, null, 2), (err) => { + if (err) { + console.error('Error writing to deletion log file:', err); + // Still return success to client, as the main operation (deletion) likely succeeded + // but log the server-side error. + return res.status(500).json({ error: 'Failed to write to deletion log' }); + } + console.log(`Deletion logged successfully for callId: ${callId}`); + res.json({ success: true, message: 'Deletion logged.' }); + }); +}); + +// Get all talkgroups for selection UI +app.get('/api/talkgroups', (req, res) => { + db.all( + `SELECT id, alpha_tag, tag + FROM talk_groups + ORDER BY alpha_tag ASC`, // Order alphabetically for easier browsing + [], + (err, rows) => { + if (err) { + console.error('Error fetching talkgroups:', err); + return res.status(500).json({ error: 'Internal server error' }); + } + // Combine alpha_tag and tag for display if alpha_tag exists + const talkgroups = rows.map(tg => ({ + id: tg.id, + name: tg.alpha_tag ? `${tg.alpha_tag} (${tg.tag || tg.id})` : (tg.tag || `ID: ${tg.id}`) + })); + res.json(talkgroups); + } + ); +}); + +// Get all available categories for selection UI +app.get('/api/categories', (req, res) => { + const categories = [ + 'Medical Emergency', 'Injured Person', 'Disturbance', 'Vehicle Collision', + 'Burglary', 'Assault', 'Structure Fire', 'Missing Person', 'Medical Call', + 'Building Fire', 'Stolen Vehicle', 'Service Call', 'Vehicle Stop', + 'Unconscious Person', 'Reckless Driver', 'Person With A Gun', + 'Altered Level of Consciousness', 'Breathing Problems', 'Fight', + 'Carbon Monoxide', 'Abduction', 'Passed Out Person', 'Hazmat', + 'Fire Alarm', 'Traffic Hazard', 'Intoxicated Person', 'Mvc', + 'Animal Bite', 'Assist', 'Other' + ]; + + res.json(categories); +}); + +// Get count of calls that would be purged (Admin Only when auth is enabled) +app.get('/api/calls/purge-count', async (req, res) => { + // Check authentication if enabled + if (ENABLE_AUTH?.toLowerCase() === 'true') { + const authHeader = req.headers.authorization; + if (!authHeader || !(await isAdminUser(authHeader))) { + return res.status(403).json({ error: 'Admin access required' }); + } + } + + // Parse query parameters - handle both array and single values + const talkgroupIds = req.query.talkgroupIds ? + (Array.isArray(req.query.talkgroupIds) ? req.query.talkgroupIds : [req.query.talkgroupIds]) : []; + const categories = req.query.categories ? + (Array.isArray(req.query.categories) ? req.query.categories : [req.query.categories]) : []; + + // Handle timeRange parameters from query string + const timeRangeStart = req.query.timeRangeStart; + const timeRangeEnd = req.query.timeRangeEnd; + + // Validate input + if (!timeRangeStart || !timeRangeEnd) { + return res.status(400).json({ error: 'Time range is required' }); + } + + // Build the WHERE clause dynamically + let whereConditions = ['lat IS NOT NULL AND lon IS NOT NULL']; // Only count calls that have coordinates + let params = []; + + // Add talkgroup filter if specified + if (talkgroupIds.length > 0) { + whereConditions.push(`talk_group_id IN (${talkgroupIds.map(() => '?').join(',')})`); + params.push(...talkgroupIds); + } + + // Add category filter if specified + if (categories.length > 0) { + // Use UPPER() to make case-insensitive comparison + whereConditions.push(`UPPER(category) IN (${categories.map(() => 'UPPER(?)').join(',')})`); + params.push(...categories); + } + + // Add time range filter + whereConditions.push('timestamp BETWEEN ? AND ?'); + const startTime = parseInt(timeRangeStart); + const endTime = parseInt(timeRangeEnd); + + // Validate parsed timestamps + if (isNaN(startTime) || isNaN(endTime)) { + console.error(`[Purge Count] Invalid timestamps: start=${timeRangeStart} (parsed: ${startTime}), end=${timeRangeEnd} (parsed: ${endTime})`); + return res.status(400).json({ error: 'Invalid timestamp format' }); + } + + params.push(startTime, endTime); + + const whereClause = whereConditions.join(' AND '); + + // Execute the count query + const countQuery = `SELECT COUNT(*) as count FROM transcriptions WHERE ${whereClause}`; + + // Check if database is available + if (!db) { + console.error('[Purge Count] Database not available'); + return res.status(500).json({ error: 'Database not available' }); + } + + db.get(countQuery, params, (err, row) => { + if (err) { + console.error('Error counting calls:', err); + return res.status(500).json({ error: 'Failed to count calls' }); + } + + if (!row) { + console.error('[Purge Count] No result from count query'); + return res.status(500).json({ error: 'Failed to count calls - no result' }); + } + + res.json({ + success: true, + count: row.count + }); + }); +}); + +// Purge calls by setting coordinates to NULL (Admin Only when auth is enabled) +app.post('/api/calls/purge', async (req, res) => { + try { + // Check authentication if enabled + if (ENABLE_AUTH?.toLowerCase() === 'true') { + const authHeader = req.headers.authorization; + if (!authHeader || !(await isAdminUser(authHeader))) { + return res.status(403).json({ error: 'Admin access required' }); + } + } + + const { talkgroupIds, categories, timeRangeStart, timeRangeEnd } = req.body; + + // Validate input + if (!timeRangeStart || !timeRangeEnd) { + return res.status(400).json({ error: 'Time range is required' }); + } + + // Build the WHERE clause dynamically + let whereConditions = ['lat IS NOT NULL AND lon IS NOT NULL']; // Only purge calls that have coordinates + let params = []; + + // Add talkgroup filter if specified + // If no talkgroups selected, it means "all talkgroups" (no filter applied) + if (talkgroupIds && talkgroupIds.length > 0) { + whereConditions.push(`talk_group_id IN (${talkgroupIds.map(() => '?').join(',')})`); + params.push(...talkgroupIds); + } + // If no talkgroups selected, don't add any filter - this means "all talkgroups" + + // Add category filter if specified + if (categories && categories.length > 0) { + // Use UPPER() to make case-insensitive comparison + whereConditions.push(`UPPER(category) IN (${categories.map(() => 'UPPER(?)').join(',')})`); + params.push(...categories); + } + + // Add time range filter + whereConditions.push('timestamp BETWEEN ? AND ?'); + const startTime = parseInt(timeRangeStart); + const endTime = parseInt(timeRangeEnd); + + // Validate parsed timestamps + if (isNaN(startTime) || isNaN(endTime)) { + console.error(`[Purge] Invalid timestamps: start=${timeRangeStart} (parsed: ${startTime}), end=${timeRangeEnd} (parsed: ${endTime})`); + return res.status(400).json({ error: 'Invalid timestamp format' }); + } + + params.push(startTime, endTime); + + const whereClause = whereConditions.join(' AND '); + + // Check if database is available + if (!db) { + console.error('[Purge] Database not available'); + return res.status(500).json({ error: 'Database not available' }); + } + + // Store original coordinates before purging + try { + const originalCoords = await storeOriginalCoordinates(talkgroupIds, categories, startTime, endTime); + + // Execute the purge query + const purgeQuery = `UPDATE transcriptions SET lat = NULL, lon = NULL WHERE ${whereClause}`; + + db.run(purgeQuery, params, function(err) { + if (err) { + console.error('Error purging calls:', err); + return res.status(500).json({ error: 'Failed to purge calls' }); + } + + // Store the last purge details for undo functionality + lastPurgeDetails = { + talkgroupIds: talkgroupIds || [], + categories: categories || [], + timeRangeStart: startTime, + timeRangeEnd: endTime, + purgedCount: this.changes, + timestamp: Date.now(), + originalCoordinates: originalCoords + }; + + res.json({ + success: true, + purgedCount: this.changes, + message: `Successfully purged ${this.changes} calls from the map` + }); + }); + } catch (coordError) { + console.error('Error storing original coordinates:', coordError); + return res.status(500).json({ error: 'Failed to store original coordinates for undo' }); + } + } catch (error) { + console.error('Unexpected error in purge endpoint:', error); + res.status(500).json({ error: 'Internal server error during purge operation' }); + } +}); + +// Check if there's a purge operation that can be undone +app.get('/api/calls/can-undo-purge', async (req, res) => { + // Check authentication if enabled + if (ENABLE_AUTH?.toLowerCase() === 'true') { + const authHeader = req.headers.authorization; + if (!authHeader || !(await isAdminUser(authHeader))) { + return res.status(403).json({ error: 'Admin access required' }); + } + } + + if (!lastPurgeDetails) { + return res.json({ canUndo: false, message: 'No purge operation to undo' }); + } + + // No time limit for undo operations + + res.json({ + canUndo: true, + message: `Can undo purge of ${lastPurgeDetails.purgedCount} calls`, + purgeDetails: { + categories: lastPurgeDetails.categories, + talkgroups: lastPurgeDetails.talkgroupIds, + timeRange: { + start: new Date(lastPurgeDetails.timeRangeStart * 1000).toLocaleString(), + end: new Date(lastPurgeDetails.timeRangeEnd * 1000).toLocaleString() + }, + timestamp: new Date(lastPurgeDetails.timestamp).toLocaleString() + } + }); +}); + +// Undo last purge operation (Admin Only when auth is enabled) +app.post('/api/calls/undo-last-purge', async (req, res) => { + try { + // Check authentication if enabled + if (ENABLE_AUTH?.toLowerCase() === 'true') { + const authHeader = req.headers.authorization; + if (!authHeader || !(await isAdminUser(authHeader))) { + return res.status(403).json({ error: 'Admin access required' }); + } + } + + // Check if there's a last purge to undo + if (!lastPurgeDetails) { + return res.status(400).json({ error: 'No purge operation to undo' }); + } + + // No time limit for undo operations + + // Build the WHERE clause to restore coordinates + let whereConditions = ['lat IS NULL AND lon IS NULL']; // Only restore calls that have no coordinates + let params = []; + + // Add talkgroup filter if specified + if (lastPurgeDetails.talkgroupIds && lastPurgeDetails.talkgroupIds.length > 0) { + whereConditions.push(`talk_group_id IN (${lastPurgeDetails.talkgroupIds.map(() => '?').join(',')})`); + params.push(...lastPurgeDetails.talkgroupIds); + } + + // Add category filter if specified + if (lastPurgeDetails.categories && lastPurgeDetails.categories.length > 0) { + // Use UPPER() to make case-insensitive comparison + whereConditions.push(`UPPER(category) IN (${lastPurgeDetails.categories.map(() => 'UPPER(?)').join(',')})`); + params.push(...lastPurgeDetails.categories); + } + + // Add time range filter + whereConditions.push('timestamp BETWEEN ? AND ?'); + params.push(lastPurgeDetails.timeRangeStart, lastPurgeDetails.timeRangeEnd); + + const whereClause = whereConditions.join(' AND '); + + // Execute the restore query + const restoreQuery = `UPDATE transcriptions SET lat = (SELECT lat FROM transcriptions_backup WHERE id = transcriptions.id), lon = (SELECT lon FROM transcriptions_backup WHERE id = transcriptions.id) WHERE ${whereClause}`; + + // Since we don't have a backup table, we'll need to restore from the original coordinates + // For now, we'll use a different approach - restore based on the original query + const restoreQuery2 = `UPDATE transcriptions SET lat = (SELECT lat FROM transcriptions WHERE id = transcriptions.id), lon = (SELECT lon FROM transcriptions WHERE id = transcriptions.id) WHERE ${whereClause}`; + + // Check if database is available + if (!db) { + console.error('[Undo Purge] Database not available'); + return res.status(500).json({ error: 'Database not available' }); + } + + // Check if we have the original coordinates stored + if (!lastPurgeDetails.originalCoordinates || lastPurgeDetails.originalCoordinates.length === 0) { + return res.status(400).json({ error: 'No original coordinates available for restoration' }); + } + + // Restore the original coordinates for each call + let restoredCount = 0; + let hasError = false; + + for (const coord of lastPurgeDetails.originalCoordinates) { + const restoreQuery = `UPDATE transcriptions SET lat = ?, lon = ? WHERE id = ?`; + + db.run(restoreQuery, [coord.lat, coord.lon, coord.id], function(err) { + if (err) { + console.error(`Error restoring coordinates for call ${coord.id}:`, err); + hasError = true; + } else { + restoredCount++; + } + }); + } + + // Wait a bit for all updates to complete, then respond + setTimeout(() => { + if (hasError) { + return res.status(500).json({ error: 'Some calls could not be restored' }); + } + + // Clear the last purge details after successful undo + const undonePurgeDetails = { ...lastPurgeDetails }; + lastPurgeDetails = null; + + res.json({ + success: true, + restoredCount: restoredCount, + message: `Successfully restored ${restoredCount} calls to the map`, + undonePurge: undonePurgeDetails + }); + }, 100); + + } catch (error) { + console.error('Unexpected error in undo purge endpoint:', error); + res.status(500).json({ error: 'Internal server error during undo operation' }); + } +}); + +// General error handling middleware +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// 404 handler for unmatched routes +app.use((req, res) => { + res.status(404).json({ error: 'Route not found' }); +}); + +// Graceful Shutdown +process.on('SIGINT', () => { + console.log('Shutting down web server gracefully...'); + server.close(() => { + console.log('Express server closed.'); + db.close((err) => { + if (err) { + console.error('Error closing database connection:', err); + } else { + console.log('Database connection closed.'); + } + process.exit(0); + }); + }); +});