diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8a3026 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +DATABASE_URL=postgresql://user:password@localhost:5432/scanner +REDIS_URL=redis://localhost:6379 +PORT=3000 +CORS_ORIGIN=http://localhost:5173 + +DISCORD_TOKEN=your_discord_bot_token +DISCORD_ALERT_CHANNEL_ID=your_alert_channel_id +DISCORD_SUMMARY_CHANNEL_ID=your_summary_channel_id + +GEOCODING_PROVIDER=locationiq +LOCATIONIQ_API_KEY=your_locationiq_api_key +GOOGLE_MAPS_API_KEY=your_google_maps_api_key +GEOCODING_STATE=State +GEOCODING_COUNTRY=USA +GEOCODING_CITY=City +GEOCODING_TARGET_COUNTIES=County1,County2 + +TRANSCRIPTION_MODE=local +TRANSCRIPTION_DEVICE=cpu +WHISPER_MODEL=base + +AI_PROVIDER=ollama +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=llama3 +OPENAI_API_KEY=your_openai_api_key +OPENAI_MODEL=gpt-4o-mini + +ENABLE_AUTH=false +WEBSERVER_PASSWORD=change_this_password + +STORAGE_MODE=local +S3_BUCKET= +S3_REGION= +S3_ACCESS_KEY= +S3_SECRET_KEY= \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..94ee51a --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,92 @@ +name: Build and Push Images + +on: + push: + branches: [ main, refactor ] + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: docker-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./scanner-api + push: true + tags: | + ghcr.io/dadud/scanner-map-api:${{ github.ref_name }} + ghcr.io/dadud/scanner-map-api:${{ github.sha }} + platforms: linux/amd64 + + transcribe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./scanner-transcribe + push: true + tags: | + ghcr.io/dadud/scanner-map-transcribe:${{ github.ref_name }} + ghcr.io/dadud/scanner-map-transcribe:${{ github.sha }} + platforms: linux/amd64 + + discord: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./scanner-discord + push: true + tags: | + ghcr.io/dadud/scanner-map-discord:${{ github.ref_name }} + ghcr.io/dadud/scanner-map-discord:${{ github.sha }} + platforms: linux/amd64 + + ui: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./scanner-ui + push: true + tags: | + ghcr.io/dadud/scanner-map-ui:${{ github.ref_name }} + ghcr.io/dadud/scanner-map-ui:${{ github.sha }} + platforms: linux/amd64 diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..5c6c0bd --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,116 @@ +name: Runtime Smoke + +on: + push: + branches: [ main, refactor ] + workflow_run: + workflows: [Build and Push Images] + types: [completed] + workflow_dispatch: + inputs: + image_tag: + description: Image tag to test + required: false + default: refactor + +concurrency: + group: smoke-${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: read + +jobs: + smoke: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + env: + IMAGE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + DOCKER_ORG: dadud + DOCKER_REGISTRY: ghcr.io + POSTGRES_USER: scanner + POSTGRES_PASSWORD: scanner + POSTGRES_DB: scanner + REDIS_URL: redis://localhost:6379/0 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install smoke test dependencies + run: pip install requests websocket-client redis + + - name: Wait for built images + run: | + for image in scanner-map-api scanner-map-ui scanner-map-transcribe scanner-map-discord; do + for attempt in $(seq 1 30); do + if docker pull ghcr.io/dadud/${image}:${IMAGE_TAG}; then + break + fi + if [ "$attempt" -eq 30 ]; then + echo "Timed out waiting for ghcr.io/dadud/${image}:${IMAGE_TAG}" + exit 1 + fi + sleep 10 + done + done + + - name: Pull and start stack + run: | + docker compose -f docker-compose.prebuilt.yml up -d scanner-postgres scanner-redis scanner-api scanner-transcribe scanner-ui + + - name: Wait for HTTP services + run: | + python - <<'PY' + import time + import requests + + urls = [ + 'http://localhost:3000/api/health', + 'http://localhost:8001/health', + 'http://localhost' + ] + + for url in urls: + deadline = time.time() + 180 + while time.time() < deadline: + try: + response = requests.get(url, timeout=5) + if response.ok: + print(f'ready: {url}') + break + except Exception: + pass + time.sleep(2) + else: + raise SystemExit(f'timed out waiting for {url}') + PY + + - name: Initialize database schema + run: docker compose -f docker-compose.prebuilt.yml exec -T scanner-api npx prisma db push --accept-data-loss + + - name: Seed smoke talkgroup + run: | + docker compose -f docker-compose.prebuilt.yml exec -T scanner-postgres psql -U scanner -d scanner -c "INSERT INTO \"Talkgroup\" (id, \"alphaTag\") VALUES ('smoke', 'Smoke Test') ON CONFLICT (id) DO NOTHING;" + + - name: Run runtime smoke + run: python scripts/smoke_runtime.py + + - name: Print compose logs on failure + if: failure() + run: docker compose -f docker-compose.prebuilt.yml logs --no-color + + - name: Tear down stack + if: always() + run: docker compose -f docker-compose.prebuilt.yml down -v diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..97d1fcc --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,91 @@ +name: Verify Refactor + +on: + push: + branches: [ main, refactor ] + pull_request: + branches: [ main ] + workflow_dispatch: + +concurrency: + group: verify-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: scanner-api/package.json + - name: Install API dependencies + run: npm install --package-lock=false + working-directory: scanner-api + - name: Generate Prisma client + run: npm exec -- prisma generate + working-directory: scanner-api + - name: Build API + run: npm run build + working-directory: scanner-api + + ui: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: scanner-ui/package.json + - name: Install UI dependencies + run: npm install --package-lock=false + working-directory: scanner-ui + - name: Build UI + run: npm run build + working-directory: scanner-ui + + discord: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: scanner-discord/package.json + - name: Install Discord dependencies + run: npm install --package-lock=false + working-directory: scanner-discord + - name: Build Discord service + run: npm run build + working-directory: scanner-discord + + transcribe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install transcription dependencies + run: pip install -r requirements.txt + working-directory: scanner-transcribe + - name: Compile Python sources + run: python -m compileall src + working-directory: scanner-transcribe + + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate source compose file + run: docker compose -f docker-compose.yml config > /dev/null + - name: Validate prebuilt compose file + env: + DOCKER_ORG: dadud + DOCKER_REGISTRY: ghcr.io + IMAGE_TAG: refactor + run: docker compose -f docker-compose.prebuilt.yml config > /dev/null diff --git a/README.md b/README.md index 63fd3c5..11fde27 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,87 @@ -# 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 emergency scanner mapping system with modern microservices architecture. + +## Quick Start + ```bash -# Linux -sudo bash linux_install_scanner_map.sh +# Linux/macOS +curl -fsSL https://raw.githubusercontent.com/Dadud/Scanner-map/refactor/scripts/install.sh | bash -# Windows (PowerShell as Admin) -.\install_scanner_map.ps1 +# Windows +irm https://raw.githubusercontent.com/Dadud/Scanner-map/refactor/scripts/install.ps1 | iex ``` -Then: -```bash -cd scanner-map -source .venv/bin/activate # Linux -node bot.js +## Architecture + +``` +React UI (Port 80) โ†’ Fastify API (Port 3000) โ†’ PostgreSQL + โ†“ โ†“ + WebSocket Redis Pub/Sub + โ†“ โ†“ + scanner-ui scanner-transcribe (Python) + โ†“ + faster-whisper ``` ---- +## Services + +| Service | Image | Description | +|---------|-------|-------------| +| scanner-api | `ghcr.io/Dadud/scanner-map-api` | Fastify REST API + WebSocket | +| scanner-transcribe | `ghcr.io/Dadud/scanner-map-transcribe` | Python transcription | +| scanner-ui | `ghcr.io/Dadud/scanner-map-ui` | React frontend | +| scanner-discord | `ghcr.io/Dadud/scanner-map-discord` | Discord bot (optional) | -## โš™๏ธ Configuration +## Usage -All main settings are in `.env`. Key options: +```bash +# Start all services +docker-compose up -d + +# With Discord bot +docker-compose --profile discord up -d -- `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 +# View logs +docker-compose logs -f +``` -Other files to edit: -- `public/config.js` โ† map defaults (center, zoom, icons, etc.) -- `data/apikeys.json` โ† auto-generated on first run +## Verification ---- +Run the verification script from the repo root: -## ๐Ÿ“ก Connecting Your Radio Software +```bash +# Linux/macOS +./scripts/verify.sh + +# Windows PowerShell +./scripts/verify.ps1 + +# Allow running while you still have local edits +./scripts/verify.ps1 -AllowDirtyWorktree +``` -- **SDRTrunk:** Configure Streaming โ†’ Rdio Scanner endpoint -- **TrunkRecorder:** Add an `uploadServer` entry pointing to `http://:/api/call-upload` -- **rdio-scanner downstream:** Add server + API key +What it checks: ---- +1. clean git worktree +2. latest successful `Build and Push Images` workflow for the current commit, when `gh` is available +3. local service builds, when Node and Python are installed +4. compose file validation, when Docker is installed -## ๐Ÿ’ป 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) +GitHub Actions also runs `Verify Refactor` automatically on pushes to `main` and `refactor`, plus pull requests to `main`. ---- +## Configuration -## ๐Ÿ›  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) +| Variable | Default | Description | +|----------|---------|-------------| +| `DISCORD_TOKEN` | - | Discord bot token | +| `LOCATIONIQ_API_KEY` | - | Geocoding | +| `OPENAI_API_KEY` | - | AI features | +| `POSTGRES_PASSWORD` | scanner | Database password | +| `JWT_SECRET` | - | JWT signing secret | ---- +## GitHub Container Registry -## ๐Ÿค Contributing -Pull requests and issue reports are welcome. +Images are automatically built and pushed to ghcr.io on every push to main/refactor branches. -## ๐Ÿ“ฌ Support -- Open a GitHub Issue -- Contact **poisonednumber** on Discord +Tags: `latest`, `main`, `refactor`, `v1.0.0` diff --git a/docker-compose.prebuilt.yml b/docker-compose.prebuilt.yml new file mode 100644 index 0000000..7124d7a --- /dev/null +++ b/docker-compose.prebuilt.yml @@ -0,0 +1,141 @@ +version: '3.8' + +x-common-env: &common-env + POSTGRES_USER: ${POSTGRES_USER:-scanner} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scanner} + POSTGRES_DB: ${POSTGRES_DB:-scanner} + REDIS_URL: redis://scanner-redis:6379 + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + +services: + scanner-api: + image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_ORG:-dadud}/scanner-map-api:${IMAGE_TAG:-latest} + container_name: scanner-api + ports: + - "3000:3000" + environment: + <<: *common-env + NODE_ENV: production + PORT: 3000 + DATABASE_URL: postgresql://${POSTGRES_USER:-scanner}:${POSTGRES_PASSWORD:-scanner}@scanner-postgres:5432/${POSTGRES_DB:-scanner} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost} + DISCORD_TOKEN: ${DISCORD_TOKEN} + GEOCODING_PROVIDER: ${GEOCODING_PROVIDER:-locationiq} + LOCATIONIQ_API_KEY: ${LOCATIONIQ_API_KEY} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY} + TRANSCRIPTION_MODE: ${TRANSCRIPTION_MODE:-local} + FASTER_WHISPER_URL: ${FASTER_WHISPER_URL:-http://scanner-transcribe:8001} + OPENAI_API_KEY: ${OPENAI_API_KEY} + AI_PROVIDER: ${AI_PROVIDER:-ollama} + OLLAMA_URL: ${OLLAMA_URL:-http://localhost:11434} + ENABLE_AUTH: ${ENABLE_AUTH:-false} + depends_on: + scanner-postgres: + condition: service_healthy + scanner-redis: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - scanner-network + + scanner-transcribe: + image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_ORG:-dadud}/scanner-map-transcribe:${IMAGE_TAG:-latest} + container_name: scanner-transcribe + ports: + - "8001:8001" + environment: + TRANSCRIPTION_MODE: ${TRANSCRIPTION_MODE:-local} + TRANSCRIPTION_DEVICE: ${TRANSCRIPTION_DEVICE:-cpu} + WHISPER_MODEL: ${WHISPER_MODEL:-base} + OPENAI_API_KEY: ${OPENAI_API_KEY} + REDIS_URL: redis://scanner-redis:6379 + ENABLE_TONE_DETECTION: ${ENABLE_TONE_DETECTION:-false} + depends_on: + scanner-redis: + condition: service_healthy + restart: unless-stopped + networks: + - scanner-network + deploy: + resources: + limits: + memory: 4G + + scanner-discord: + image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_ORG:-dadud}/scanner-map-discord:${IMAGE_TAG:-latest} + container_name: scanner-discord + environment: + DISCORD_TOKEN: ${DISCORD_TOKEN} + REDIS_URL: redis://scanner-redis:6379 + API_URL: http://scanner-api:3000 + DISCORD_ALERT_CHANNEL_ID: ${DISCORD_ALERT_CHANNEL_ID} + DISCORD_SUMMARY_CHANNEL_ID: ${DISCORD_SUMMARY_CHANNEL_ID} + depends_on: + scanner-api: + condition: service_healthy + scanner-redis: + condition: service_healthy + restart: unless-stopped + networks: + - scanner-network + profiles: + - discord + + scanner-ui: + image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_ORG:-dadud}/scanner-map-ui:${IMAGE_TAG:-latest} + container_name: scanner-ui + ports: + - "80:80" + depends_on: + - scanner-api + restart: unless-stopped + networks: + - scanner-network + + scanner-postgres: + image: postgres:16-alpine + container_name: scanner-postgres + environment: + <<: *common-env + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scanner}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - scanner-network + + scanner-redis: + image: redis:7-alpine + container_name: scanner-redis + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + ports: + - "6379:6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - scanner-network + +volumes: + postgres_data: + redis_data: + +networks: + scanner-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e0084b3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,141 @@ +version: '3.8' + +x-common-env: &common-env + POSTGRES_USER: ${POSTGRES_USER:-scanner} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scanner} + POSTGRES_DB: ${POSTGRES_DB:-scanner} + REDIS_URL: redis://scanner-redis:6379 + +services: + scanner-api: + image: ghcr.io/dadud/scanner-map-api:${IMAGE_TAG:-latest} + container_name: scanner-api + ports: + - "3000:3000" + environment: + <<: *common-env + DATABASE_URL: postgresql://${POSTGRES_USER:-scanner}:${POSTGRES_PASSWORD:-scanner}@scanner-postgres:5432/${POSTGRES_DB:-scanner} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost} + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + DISCORD_TOKEN: ${DISCORD_TOKEN} + GEOCODING_PROVIDER: ${GEOCODING_PROVIDER:-locationiq} + LOCATIONIQ_API_KEY: ${LOCATIONIQ_API_KEY} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY} + TRANSCRIPTION_MODE: ${TRANSCRIPTION_MODE:-local} + FASTER_WHISPER_URL: ${FASTER_WHISPER_URL:-http://scanner-transcribe:8001} + OPENAI_API_KEY: ${OPENAI_API_KEY} + AI_PROVIDER: ${AI_PROVIDER:-ollama} + OLLAMA_URL: ${OLLAMA_URL:-http://localhost:11434} + ENABLE_AUTH: ${ENABLE_AUTH:-false} + ENABLE_TONE_DETECTION: ${ENABLE_TONE_DETECTION:-false} + depends_on: + scanner-postgres: + condition: service_healthy + scanner-redis: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - scanner-network + + scanner-transcribe: + image: ghcr.io/dadud/scanner-map-transcribe:${IMAGE_TAG:-latest} + container_name: scanner-transcribe + ports: + - "8001:8001" + environment: + TRANSCRIPTION_MODE: ${TRANSCRIPTION_MODE:-local} + TRANSCRIPTION_DEVICE: ${TRANSCRIPTION_DEVICE:-cpu} + WHISPER_MODEL: ${WHISPER_MODEL:-base} + OPENAI_API_KEY: ${OPENAI_API_KEY} + REDIS_URL: redis://scanner-redis:6379 + ENABLE_TONE_DETECTION: ${ENABLE_TONE_DETECTION:-false} + TONE_DETECTION_TYPE: ${TONE_DETECTION_TYPE:-auto} + depends_on: + scanner-redis: + condition: service_healthy + restart: unless-stopped + networks: + - scanner-network + deploy: + resources: + limits: + memory: 4G + + scanner-discord: + image: ghcr.io/dadud/scanner-map-discord:${IMAGE_TAG:-latest} + container_name: scanner-discord + environment: + DISCORD_TOKEN: ${DISCORD_TOKEN} + REDIS_URL: redis://scanner-redis:6379 + API_URL: http://scanner-api:3000 + DISCORD_ALERT_CHANNEL_ID: ${DISCORD_ALERT_CHANNEL_ID} + DISCORD_SUMMARY_CHANNEL_ID: ${DISCORD_SUMMARY_CHANNEL_ID} + depends_on: + scanner-api: + condition: service_healthy + scanner-redis: + condition: service_healthy + restart: unless-stopped + networks: + - scanner-network + profiles: + - discord + + scanner-ui: + image: ghcr.io/dadud/scanner-map-ui:${IMAGE_TAG:-latest} + container_name: scanner-ui + ports: + - "80:80" + depends_on: + - scanner-api + restart: unless-stopped + networks: + - scanner-network + + scanner-postgres: + image: postgres:16-alpine + container_name: scanner-postgres + environment: + <<: *common-env + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scanner}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - scanner-network + + scanner-redis: + image: redis:7-alpine + container_name: scanner-redis + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + ports: + - "6379:6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - scanner-network + +volumes: + postgres_data: + redis_data: + +networks: + scanner-network: + driver: bridge diff --git a/scanner-api/Dockerfile b/scanner-api/Dockerfile new file mode 100644 index 0000000..7e8eca6 --- /dev/null +++ b/scanner-api/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-bookworm-slim AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY prisma ./prisma/ +RUN npx prisma generate +COPY src ./src/ +RUN npm run build + +FROM node:20-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends dumb-init openssl ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/prisma ./prisma +ENV NODE_ENV=production +EXPOSE 3000 +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/index.js"] diff --git a/scanner-api/package.json b/scanner-api/package.json new file mode 100644 index 0000000..e40c5df --- /dev/null +++ b/scanner-api/package.json @@ -0,0 +1,40 @@ +{ + "name": "scanner-api", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/formbody": "^7.4.0", + "@fastify/jwt": "^8.0.1", + "@fastify/multipart": "^8.3.0", + "@fastify/static": "^7.0.4", + "@fastify/websocket": "^10.0.1", + "@prisma/client": "^5.15.0", + "axios": "^1.7.2", + "bcrypt": "^5.1.1", + "fastify": "^4.28.0", + "fastify-plugin": "^4.5.1", + "ioredis": "^5.4.1", + "pino": "^9.2.0", + "pino-pretty": "^11.2.0", + "socket.io": "^4.7.5", + "uuid": "^10.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/node": "^20.14.2", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.10", + "prisma": "^5.15.0", + "tsx": "^4.15.2", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "engines": { "node": ">=20.0.0" } +} diff --git a/scanner-api/prisma/schema.prisma b/scanner-api/prisma/schema.prisma new file mode 100644 index 0000000..3a8373c --- /dev/null +++ b/scanner-api/prisma/schema.prisma @@ -0,0 +1,64 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] +} + +model Call { + id String @id @default(uuid()) + talkgroupId String + timestamp DateTime + transcription String? + audioUrl String? + address String? + lat Float? + lon Float? + category String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + talkgroup Talkgroup @relation(fields: [talkgroupId], references: [id]) + @@index([talkgroupId, timestamp]) +} + +model Talkgroup { + id String @id + hex String? + alphaTag String? + mode String? + description String? + tag String? + county String? + calls Call[] +} + +model User { + id String @id @default(uuid()) + username String @unique + passwordHash String + salt String + isAdmin Boolean @default(false) + createdAt DateTime @default(now()) + sessions Session[] +} + +model Session { + id String @id @default(uuid()) + userId String + token String @unique + expiresAt DateTime + lastActivity DateTime @default(now()) + ipAddress String? + userAgent String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([token]) +} + +model GlobalKeyword { + id String @id @default(uuid()) + keyword String @unique + talkgroupId String? +} diff --git a/scanner-api/src/index.ts b/scanner-api/src/index.ts new file mode 100644 index 0000000..ae939fe --- /dev/null +++ b/scanner-api/src/index.ts @@ -0,0 +1,54 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import formbody from '@fastify/formbody'; +import multipart from '@fastify/multipart'; +import websocket from '@fastify/websocket'; +import { CallsRouter } from './routes/calls.js'; +import { TalkgroupsRouter } from './routes/talkgroups.js'; +import { UsersRouter } from './routes/users.js'; +import { AdminRouter } from './routes/admin.js'; +import { WebhookRouter } from './routes/webhook.js'; +import { ConfigRouter } from './routes/config.js'; +import { setupWebSocketRelay, websocketPlugin } from './websocket/handler.js'; +import { prismaPlugin } from './plugins/database.js'; +import { redisPlugin } from './plugins/redis.js'; +import { jwtPlugin } from './plugins/jwt.js'; +import { getEnv } from './plugins/env.js'; + +const PORT = parseInt(process.env.PORT || '3000', 10); + +export async function buildServer() { + const app = Fastify({ + logger: { + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' + } + }); + + await app.register(cors, { origin: true, credentials: true }); + await app.register(formbody); + await app.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } }); + await app.register(websocket); + + await app.register(prismaPlugin); + await app.register(redisPlugin); + await app.register(jwtPlugin); + setupWebSocketRelay(app); + + await app.register(CallsRouter, { prefix: '/api/calls' }); + await app.register(TalkgroupsRouter, { prefix: '/api/talkgroups' }); + await app.register(UsersRouter, { prefix: '/api/users' }); + await app.register(AdminRouter, { prefix: '/api/admin' }); + await app.register(ConfigRouter, { prefix: '/api/config' }); + await app.register(WebhookRouter, { prefix: '/api/webhook' }); + await app.register(websocketPlugin); + + app.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); + + return app; +} + +const env = getEnv(); +const app = await buildServer(); + +await app.listen({ port: PORT, host: '0.0.0.0' }); +app.log.info(`Scanner API running on port ${PORT}`); diff --git a/scanner-api/src/plugins/auth.ts b/scanner-api/src/plugins/auth.ts new file mode 100644 index 0000000..98de4d0 --- /dev/null +++ b/scanner-api/src/plugins/auth.ts @@ -0,0 +1,61 @@ +import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; +import { getEnv } from './env.js'; + +export interface AuthUser { + id: string; + username: string; + isAdmin: boolean; +} + +declare module 'fastify' { + interface FastifyRequest { + user: AuthUser; + } + interface FastifyInstance { + authenticate(request: FastifyRequest, reply: FastifyReply): Promise; + requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: AuthUser; + user: AuthUser; + } +} + +export const authPlugin: FastifyPluginAsync = async (fastify) => { + const env = getEnv(); + + if (env.ENABLE_AUTH) { + fastify.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => { + try { + await request.jwtVerify(); + } catch (err) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }); + + fastify.decorate('requireAdmin', async (request: FastifyRequest, reply: FastifyReply) => { + try { + await request.jwtVerify(); + if (!request.user?.isAdmin) { + return reply.status(403).send({ error: 'Forbidden - Admin required' }); + } + } catch (err) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }); + } else { + fastify.decorate('authenticate', async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.status(200).send(); + }); + fastify.decorate('requireAdmin', async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.status(200).send(); + }); + } +}; + +export function isAuthenticated(fastify: any): boolean { + return getEnv().ENABLE_AUTH; +} diff --git a/scanner-api/src/plugins/database.ts b/scanner-api/src/plugins/database.ts new file mode 100644 index 0000000..a26fc8a --- /dev/null +++ b/scanner-api/src/plugins/database.ts @@ -0,0 +1,18 @@ +import { FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { PrismaClient } from '@prisma/client'; + +declare module 'fastify' { + interface FastifyInstance { + prisma: PrismaClient; + } +} + +const prismaPluginImpl: FastifyPluginAsync = async (fastify) => { + const prisma = new PrismaClient(); + await prisma.$connect(); + fastify.decorate('prisma', prisma); + fastify.addHook('onClose', async () => { await prisma.$disconnect(); }); +}; + +export const prismaPlugin = fp(prismaPluginImpl, { name: 'prisma-plugin' }); diff --git a/scanner-api/src/plugins/env.ts b/scanner-api/src/plugins/env.ts new file mode 100644 index 0000000..5f685a5 --- /dev/null +++ b/scanner-api/src/plugins/env.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.string().default('3000').transform(Number), + DATABASE_URL: z.string(), + REDIS_URL: z.string(), + CORS_ORIGIN: z.string().default('*'), + JWT_SECRET: z.string().default('change-me-in-production'), + DISCORD_TOKEN: z.string().optional(), + GEOCODING_PROVIDER: z.enum(['google', 'locationiq']).default('locationiq'), + LOCATIONIQ_API_KEY: z.string().optional(), + GOOGLE_MAPS_API_KEY: z.string().optional(), + TRANSCRIPTION_MODE: z.enum(['local', 'remote', 'openai', 'icad']).default('local'), + TRANSCRIPTION_DEVICE: z.enum(['cpu', 'cuda']).default('cpu'), + WHISPER_MODEL: z.string().default('base'), + FASTER_WHISPER_URL: z.string().optional(), + OPENAI_API_KEY: z.string().optional(), + AI_PROVIDER: z.enum(['ollama', 'openai']).default('ollama'), + OLLAMA_URL: z.string().default('http://localhost:11434'), + ENABLE_AUTH: z.enum(['true', 'false']).transform(v => v === 'true').default('false'), + ENABLE_TONE_DETECTION: z.enum(['true', 'false']).transform(v => v === 'true').default('false'), + TONE_DETECTION_TYPE: z.enum(['auto', 'two_tone', 'pulsed', 'long', 'both']).default('auto'), +}); + +export function getEnv() { + const result = envSchema.safeParse(process.env); + if (!result.success) { + throw new Error(`Env validation failed: ${result.error.errors.map(e => `${e.path}: ${e.message}`).join(', ')}`); + } + return result.data; +} \ No newline at end of file diff --git a/scanner-api/src/plugins/jwt.ts b/scanner-api/src/plugins/jwt.ts new file mode 100644 index 0000000..97c114b --- /dev/null +++ b/scanner-api/src/plugins/jwt.ts @@ -0,0 +1,22 @@ +import { FastifyPluginAsync } from 'fastify'; +import jwt from '@fastify/jwt'; +import fp from 'fastify-plugin'; + +const jwtPluginImpl: FastifyPluginAsync = async (fastify) => { + await fastify.register(jwt, { secret: process.env.JWT_SECRET! }); + + fastify.decorate('authenticate', async (request: any, reply: any) => { + try { await request.jwtVerify(); } + catch { reply.status(401).send({ error: 'Unauthorized' }); } + }); + + fastify.decorate('requireAdmin', async (request: any, reply: any) => { + try { + await request.jwtVerify(); + if (!request.user?.isAdmin) reply.status(403).send({ error: 'Forbidden' }); + } + catch { reply.status(401).send({ error: 'Unauthorized' }); } + }); +}; + +export const jwtPlugin = fp(jwtPluginImpl, { name: 'jwt-plugin' }); diff --git a/scanner-api/src/plugins/redis.ts b/scanner-api/src/plugins/redis.ts new file mode 100644 index 0000000..ae60302 --- /dev/null +++ b/scanner-api/src/plugins/redis.ts @@ -0,0 +1,30 @@ +import { FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import Redis from 'ioredis'; + +declare module 'fastify' { + interface FastifyInstance { + redis: Redis; + redisPub: Redis; + redisSub: Redis; + } +} + +const redisPluginImpl: FastifyPluginAsync = async (fastify) => { + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + const redis = new Redis(redisUrl); + const redisPub = new Redis(redisUrl); + const redisSub = new Redis(redisUrl); + + fastify.decorate('redis', redis); + fastify.decorate('redisPub', redisPub); + fastify.decorate('redisSub', redisSub); + + fastify.addHook('onClose', async () => { + await redis.quit(); + await redisPub.quit(); + await redisSub.quit(); + }); +}; + +export const redisPlugin = fp(redisPluginImpl, { name: 'redis-plugin' }); diff --git a/scanner-api/src/routes/admin.ts b/scanner-api/src/routes/admin.ts new file mode 100644 index 0000000..36070fa --- /dev/null +++ b/scanner-api/src/routes/admin.ts @@ -0,0 +1,49 @@ +import { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; + +interface IdParams { id: string } + +export const AdminRouter: FastifyPluginAsync = async (fastify) => { + fastify.put<{ Params: IdParams }>('/markers/:id/location', async (request, reply) => { + const { lat, lon, address } = z.object({ + lat: z.number(), lon: z.number(), address: z.string().optional() + }).parse(request.body); + + const call = await fastify.prisma.call.update({ + where: { id: request.params.id }, data: { lat, lon, address }, + include: { talkgroup: true } + }); + await fastify.redisPub.publish('calls:updated', JSON.stringify(call)); + return call; + }); + + fastify.delete<{ Params: IdParams }>('/markers/:id', async (request, reply) => { + await fastify.prisma.call.delete({ where: { id: request.params.id } }); + await fastify.redisPub.publish('calls:deleted', JSON.stringify({ id: request.params.id })); + return reply.status(204).send(); + }); + + fastify.post('/calls/purge', async (request, reply) => { + const { talkgroupId, olderThan } = z.object({ + talkgroupId: z.string().optional(), olderThan: z.string() + }).parse(request.body); + + const where: any = { timestamp: { lt: new Date(olderThan) } }; + if (talkgroupId) where.talkgroupId = talkgroupId; + + const result = await fastify.prisma.call.deleteMany({ where }); + await fastify.redisPub.publish('calls:purged', JSON.stringify({ count: result.count })); + return { deleted: result.count }; + }); + + fastify.get('/keywords', async () => fastify.prisma.globalKeyword.findMany()); + + fastify.post('/keywords', async (request, reply) => { + const { keyword, talkgroupId } = z.object({ keyword: z.string(), talkgroupId: z.string().optional() }).parse(request.body); + return fastify.prisma.globalKeyword.upsert({ where: { keyword }, update: { talkgroupId }, create: { keyword, talkgroupId } }); + }); + + fastify.delete<{ Params: IdParams }>('/keywords/:id', async (request) => { + await fastify.prisma.globalKeyword.delete({ where: { id: request.params.id } }); + }); +}; diff --git a/scanner-api/src/routes/calls.ts b/scanner-api/src/routes/calls.ts new file mode 100644 index 0000000..37ee92a --- /dev/null +++ b/scanner-api/src/routes/calls.ts @@ -0,0 +1,54 @@ +import { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; + +const QuerySchema = z.object({ + limit: z.string().optional().default('100'), + offset: z.string().optional().default('0'), + talkgroupId: z.string().optional(), + since: z.string().optional(), +}); + +interface IdParams { id: string } + +export const CallsRouter: FastifyPluginAsync = async (fastify) => { + fastify.get('/', async (request) => { + const q = QuerySchema.parse(request.query); + const where: any = {}; + if (q.talkgroupId) where.talkgroupId = q.talkgroupId; + if (q.since) where.timestamp = { gte: new Date(q.since) }; + + return fastify.prisma.call.findMany({ + where, take: parseInt(q.limit), skip: parseInt(q.offset), + orderBy: { timestamp: 'desc' }, include: { talkgroup: true } + }); + }); + + fastify.get<{ Params: IdParams }>('/:id', async (request, reply) => { + const call = await fastify.prisma.call.findUnique({ + where: { id: request.params.id }, + include: { talkgroup: true } + }); + if (!call) return reply.status(404).send({ error: 'Not found' }); + return call; + }); + + fastify.post('/', async (request, reply) => { + const data = z.object({ + talkgroupId: z.string(), timestamp: z.string().optional(), transcription: z.string().optional(), + audioUrl: z.string().optional(), address: z.string().optional(), lat: z.number().optional(), + lon: z.number().optional(), category: z.string().optional() + }).parse(request.body); + + const call = await fastify.prisma.call.create({ + data: { ...data, timestamp: data.timestamp ? new Date(data.timestamp) : new Date() }, + include: { talkgroup: true } + }); + await fastify.redisPub.publish('calls:new', JSON.stringify(call)); + return reply.status(201).send(call); + }); + + fastify.delete<{ Params: IdParams }>('/:id', async (request, reply) => { + await fastify.prisma.call.delete({ where: { id: request.params.id } }); + return reply.status(204).send(); + }); +}; diff --git a/scanner-api/src/routes/config.ts b/scanner-api/src/routes/config.ts new file mode 100644 index 0000000..f613ab8 --- /dev/null +++ b/scanner-api/src/routes/config.ts @@ -0,0 +1,15 @@ +import { FastifyPluginAsync } from 'fastify'; + +export const ConfigRouter: FastifyPluginAsync = async (fastify) => { + fastify.get('/geocoding', () => ({ + provider: process.env.GEOCODING_PROVIDER, + state: process.env.GEOCODING_STATE, + country: process.env.GEOCODING_COUNTRY + })); + + fastify.get('/transcription', () => ({ + mode: process.env.TRANSCRIPTION_MODE, + device: process.env.TRANSCRIPTION_DEVICE, + whisperModel: process.env.WHISPER_MODEL + })); +}; \ No newline at end of file diff --git a/scanner-api/src/routes/talkgroups.ts b/scanner-api/src/routes/talkgroups.ts new file mode 100644 index 0000000..a6089ce --- /dev/null +++ b/scanner-api/src/routes/talkgroups.ts @@ -0,0 +1,33 @@ +import { Prisma } from '@prisma/client'; +import { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; + +interface IdParams { id: string } + +export const TalkgroupsRouter: FastifyPluginAsync = async (fastify) => { + fastify.get('/', async (request) => { + const q = z.object({ + limit: z.string().optional().default('1000'), + offset: z.string().optional().default('0'), + search: z.string().optional() + }).parse(request.query); + + const where: Prisma.TalkgroupWhereInput | undefined = q.search ? { + OR: [ + { alphaTag: { contains: q.search, mode: Prisma.QueryMode.insensitive } }, + { id: { contains: q.search } } + ] + } : undefined; + + return fastify.prisma.talkgroup.findMany({ where, take: parseInt(q.limit), skip: parseInt(q.offset) }); + }); + + fastify.get<{ Params: IdParams }>('/:id', async (request, reply) => { + const tg = await fastify.prisma.talkgroup.findUnique({ + where: { id: request.params.id }, + include: { calls: { take: 50, orderBy: { timestamp: 'desc' } } } + }); + if (!tg) return reply.status(404).send({ error: 'Not found' }); + return tg; + }); +}; diff --git a/scanner-api/src/routes/users.ts b/scanner-api/src/routes/users.ts new file mode 100644 index 0000000..0d1b003 --- /dev/null +++ b/scanner-api/src/routes/users.ts @@ -0,0 +1,48 @@ +import { FastifyPluginAsync } from 'fastify'; +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { z } from 'zod'; + +export const UsersRouter: FastifyPluginAsync = async (fastify) => { + fastify.post('/register', async (request, reply) => { + const { username, password, isAdmin } = z.object({ + username: z.string().min(3), password: z.string().min(8), isAdmin: z.boolean().optional() + }).parse(request.body); + + if (await fastify.prisma.user.findUnique({ where: { username } })) { + return reply.status(409).send({ error: 'Username exists' }); + } + + const salt = await bcrypt.genSalt(10); + const user = await fastify.prisma.user.create({ + data: { username, passwordHash: await bcrypt.hash(password, salt), salt, isAdmin: isAdmin || false } + }); + + return reply.status(201).send({ id: user.id, username: user.username, isAdmin: user.isAdmin }); + }); + + fastify.post('/login', async (request, reply) => { + const { username, password } = z.object({ username: z.string(), password: z.string() }).parse(request.body); + + const user = await fastify.prisma.user.findUnique({ where: { username } }); + if (!user || !(await bcrypt.compare(password, user.passwordHash))) { + return reply.status(401).send({ error: 'Invalid credentials' }); + } + + const token = uuidv4(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + await fastify.prisma.session.create({ + data: { userId: user.id, token, expiresAt, ipAddress: request.ip, userAgent: request.headers['user-agent'] } + }); + + return { token, user: { id: user.id, username: user.username, isAdmin: user.isAdmin } }; + }); + + fastify.post('/logout', async (request, reply) => { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (token) await fastify.prisma.session.deleteMany({ where: { token } }); + return { success: true }; + }); +}; \ No newline at end of file diff --git a/scanner-api/src/routes/webhook.ts b/scanner-api/src/routes/webhook.ts new file mode 100644 index 0000000..b5b580d --- /dev/null +++ b/scanner-api/src/routes/webhook.ts @@ -0,0 +1,25 @@ +import { FastifyPluginAsync } from 'fastify'; + +export const WebhookRouter: FastifyPluginAsync = async (fastify) => { + fastify.post('/call-upload', async (request, reply) => { + const { talkgroupId, timestamp, audioUrl, category, apiKey } = request.body as any; + + if (!audioUrl) return reply.status(400).send({ error: 'Audio URL required' }); + + const call = await fastify.prisma.call.create({ + data: { + talkgroupId: talkgroupId || 'unknown', + timestamp: timestamp ? new Date(timestamp) : new Date(), + audioUrl, category: category || 'unknown' + }, + include: { talkgroup: true } + }); + + await fastify.redisPub.publish('calls:new', JSON.stringify(call)); + await fastify.redisPub.publish('transcription:request', JSON.stringify({ + callId: call.id, audioUrl, talkgroupId + })); + + return reply.status(201).send({ success: true, callId: call.id }); + }); +}; \ No newline at end of file diff --git a/scanner-api/src/services/geocoding.ts b/scanner-api/src/services/geocoding.ts new file mode 100644 index 0000000..527b1d2 --- /dev/null +++ b/scanner-api/src/services/geocoding.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; + +interface GeocodingResult { + address: string; + lat: number; + lon: number; +} + +export class GeocodingService { + private provider: 'google' | 'locationiq'; + private apiKey: string; + private state: string; + private country: string; + private city: string; + private targetCounties: string[]; + + constructor() { + this.provider = (process.env.GEOCODING_PROVIDER as 'google' | 'locationiq') || 'locationiq'; + this.apiKey = this.provider === 'google' + ? process.env.GOOGLE_MAPS_API_KEY || '' + : process.env.LOCATIONIQ_API_KEY || ''; + this.state = process.env.GEOCODING_STATE || ''; + this.country = process.env.GEOCODING_COUNTRY || ''; + this.city = process.env.GEOCODING_CITY || ''; + this.targetCounties = (process.env.GEOCODING_TARGET_COUNTIES || '').split(',').filter(Boolean); + } + + async geocode(address: string): Promise { + if (this.provider === 'google') { + return this.geocodeGoogle(address); + } + return this.geocodeLocationIQ(address); + } + + private async geocodeGoogle(address: string): Promise { + try { + const fullAddress = `${address}, ${this.city}, ${this.state} ${this.country}`; + const response = await axios.get('https://maps.googleapis.com/maps/api/geocode/json', { + params: { + address: fullAddress, + key: this.apiKey + } + }); + + if (response.data.results.length > 0) { + const result = response.data.results[0]; + return { + address: result.formatted_address, + lat: result.geometry.location.lat, + lon: result.geometry.location.lng + }; + } + } catch (error) { + console.error('Google geocoding error:', error); + } + return null; + } + + private async geocodeLocationIQ(address: string): Promise { + try { + const fullAddress = `${address}, ${this.city}, ${this.state}, ${this.country}`; + const response = await axios.get('https://us1.locationiq.org/v1/search.php', { + params: { + key: this.apiKey, + q: fullAddress, + format: 'json', + addressdetails: 1, + limit: 1 + } + }); + + if (response.data.length > 0) { + const result = response.data[0]; + return { + address: result.display_name, + lat: parseFloat(result.lat), + lon: parseFloat(result.lon) + }; + } + } catch (error) { + console.error('LocationIQ geocoding error:', error); + } + return null; + } + + async extractAddressFromTranscript(transcript: string): Promise { + const patterns = [ + /(?:at|on|in|address is|located at)\s+(\d+\s+[\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd|way|court|ct|place|pl)[\w\s,]*)/i, + /(\d{3,5}\s+[\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd|way|court|ct|place|pl)[\w\s,]*)/i, + /(?:crossing|intersection of)\s+([\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr)\s+(?:and|at|with)\s+[\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr))/i + ]; + + for (const pattern of patterns) { + const match = transcript.match(pattern); + if (match) { + return match[1].trim(); + } + } + + return null; + } +} + +export const geocodingService = new GeocodingService(); \ No newline at end of file diff --git a/scanner-api/src/tests/auth.test.ts b/scanner-api/src/tests/auth.test.ts new file mode 100644 index 0000000..92e6dae --- /dev/null +++ b/scanner-api/src/tests/auth.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +describe('User Authentication', () => { + it('should validate username requirements', () => { + const validUsername = 'admin123'; + const invalidUsername = 'ab'; + + expect(validUsername.length).toBeGreaterThanOrEqual(3); + expect(invalidUsername.length).toBeLessThan(3); + }); + + it('should validate password requirements', () => { + const validPassword = 'securePassword123'; + const invalidPassword = 'short'; + + expect(validPassword.length).toBeGreaterThanOrEqual(8); + expect(invalidPassword.length).toBeLessThan(8); + }); + + it('should validate session token format', () => { + const token = '550e8400-e29b-41d4-a716-446655440000'; + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + expect(uuidRegex.test(token)).toBe(true); + }); + + it('should validate bcrypt hash format', () => { + const bcryptHash = '$2b$10$abcdefghijklmnopqrstuv.KLmNoPqRsTuVwX'; + + expect(bcryptHash.startsWith('$2b$')).toBe(true); + expect(bcryptHash.length).toBeGreaterThan(50); + }); +}); + +describe('Authorization', () => { + it('should identify admin users', () => { + const adminUser = { id: '1', username: 'admin', isAdmin: true }; + const regularUser = { id: '2', username: 'user', isAdmin: false }; + + expect(adminUser.isAdmin).toBe(true); + expect(regularUser.isAdmin).toBe(false); + }); + + it('should validate session expiration', () => { + const now = new Date(); + const expiredDate = new Date(now.getTime() - 1000); + const validDate = new Date(now.getTime() + 86400000); + + expect(expiredDate < now).toBe(true); + expect(validDate > now).toBe(true); + }); +}); \ No newline at end of file diff --git a/scanner-api/src/tests/calls.test.ts b/scanner-api/src/tests/calls.test.ts new file mode 100644 index 0000000..5891b08 --- /dev/null +++ b/scanner-api/src/tests/calls.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { CallsRouter } from '../routes/calls.js'; + +describe('Calls API', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = Fastify(); + await app.register(CallsRouter, { prefix: '/api/calls' }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /api/calls', () => { + it('should return an array', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/calls' + }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(JSON.parse(response.body))).toBe(true); + }); + + it('should support pagination', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/calls?limit=10&offset=0' + }); + + expect(response.statusCode).toBe(200); + }); + }); +}); + +describe('Calls Schema Validation', () => { + it('should validate create call schema', async () => { + const validData = { + talkgroupId: '1234', + timestamp: '2024-01-01T00:00:00Z', + transcription: 'Test transcription', + address: '123 Main St', + lat: 40.7128, + lon: -74.0060, + category: 'fire' + }; + + expect(validData.talkgroupId).toBeDefined(); + expect(validData.lat).toBeLessThan(90); + expect(validData.lat).toBeGreaterThan(-90); + expect(validData.lon).toBeLessThan(180); + expect(validData.lon).toBeGreaterThan(-180); + }); + + it('should reject invalid coordinates', () => { + const invalidLat = 100; + const invalidLon = 200; + + expect(invalidLat).toBeGreaterThan(90); + expect(invalidLon).toBeGreaterThan(180); + }); +}); \ No newline at end of file diff --git a/scanner-api/src/tests/env.test.ts b/scanner-api/src/tests/env.test.ts new file mode 100644 index 0000000..c3b5016 --- /dev/null +++ b/scanner-api/src/tests/env.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { validateEnv } from '../plugins/env.js'; + +describe('Environment Validation', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('should use default values when env vars are missing', () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + process.env.REDIS_URL = 'redis://localhost:6379'; + + const env = validateEnv(); + + expect(env.NODE_ENV).toBe('development'); + expect(env.PORT).toBe(3000); + expect(env.TRANSCRIPTION_MODE).toBe('local'); + expect(env.ENABLE_AUTH).toBe(false); + }); + + it('should parse valid environment variables', () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + process.env.REDIS_URL = 'redis://localhost:6379'; + process.env.NODE_ENV = 'production'; + process.env.PORT = '8080'; + process.env.TRANSCRIPTION_MODE = 'remote'; + process.env.ENABLE_AUTH = 'true'; + + const env = validateEnv(); + + expect(env.NODE_ENV).toBe('production'); + expect(env.PORT).toBe(8080); + expect(env.TRANSCRIPTION_MODE).toBe('remote'); + expect(env.ENABLE_AUTH).toBe(true); + }); + + it('should reject invalid enum values', () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + process.env.REDIS_URL = 'redis://localhost:6379'; + process.env.TRANSCRIPTION_MODE = 'invalid_mode'; + + expect(() => validateEnv()).toThrow(); + }); + + it('should transform string boolean to boolean', () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + process.env.REDIS_URL = 'redis://localhost:6379'; + process.env.ENABLE_AUTH = 'true'; + + const env = validateEnv(); + + expect(env.ENABLE_AUTH).toBe(true); + }); + + it('should require DATABASE_URL', () => { + process.env.REDIS_URL = 'redis://localhost:6379'; + + expect(() => validateEnv()).toThrow(); + }); + + it('should require REDIS_URL', () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + + expect(() => validateEnv()).toThrow(); + }); +}); \ No newline at end of file diff --git a/scanner-api/src/tests/geocoding.test.ts b/scanner-api/src/tests/geocoding.test.ts new file mode 100644 index 0000000..4623536 --- /dev/null +++ b/scanner-api/src/tests/geocoding.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; + +describe('Geocoding Service', () => { + it('should extract addresses from transcripts', () => { + const patterns = [ + /(?:at|on|in|address is|located at)\s+(\d+\s+[\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd|way|court|ct|place|pl))/i, + /(\d{3,5}\s+[\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd|way|court|ct|place|pl))/i + ]; + + const transcript = 'Unit 12 respond to 123 Main Street for a medical emergency'; + const match = transcript.match(patterns[1]); + + expect(match).not.toBeNull(); + expect(match?.[1]).toContain('123 Main Street'); + }); + + it('should validate coordinate bounds', () => { + const validCoords = { lat: 40.7128, lon: -74.0060 }; + const invalidCoords = { lat: 100, lon: 200 }; + + expect(validCoords.lat).toBeLessThanOrEqual(90); + expect(validCoords.lat).toBeGreaterThanOrEqual(-90); + expect(validCoords.lon).toBeLessThanOrEqual(180); + expect(validCoords.lon).toBeGreaterThanOrEqual(-180); + + expect(invalidCoords.lat).toBeGreaterThan(90); + expect(invalidCoords.lon).toBeGreaterThan(180); + }); + + it('should handle intersection addresses', () => { + const intersectionPattern = /(?:crossing|intersection of)\s+([\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr)\s+(?:and|at|with)\s+[\w\s]+(?:street|st|avenue|ave|road|rd|drive|dr))/i; + + const transcript = 'Accident at the intersection of Main Street and Oak Avenue'; + const match = transcript.match(intersectionPattern); + + expect(match).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/scanner-api/src/tests/talkgroups.test.ts b/scanner-api/src/tests/talkgroups.test.ts new file mode 100644 index 0000000..f872800 --- /dev/null +++ b/scanner-api/src/tests/talkgroups.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; + +describe('Talkgroups Schema', () => { + it('should validate talkgroup data structure', () => { + const talkgroup = { + id: '1234', + hex: '0x12', + alphaTag: 'Fire Dispatch', + mode: 'digital', + description: 'Primary fire dispatch channel', + tag: 'Fire', + county: 'Los Angeles' + }; + + expect(talkgroup.id).toBeDefined(); + expect(typeof talkgroup.id).toBe('string'); + expect(talkgroup.hex).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('should validate bulk talkgroup import', () => { + const talkgroups = [ + { id: '1000', alphaTag: 'Fire Dispatch', tag: 'Fire' }, + { id: '2000', alphaTag: 'Police Dispatch', tag: 'Police' }, + { id: '3000', alphaTag: 'EMS Dispatch', tag: 'EMS' } + ]; + + expect(talkgroups).toHaveLength(3); + talkgroups.forEach(tg => { + expect(tg.id).toBeDefined(); + expect(tg.alphaTag).toBeDefined(); + }); + }); + + it('should validate search parameters', () => { + const searchParams = { + tag: 'fire', + county: 'los angeles', + search: 'dispatch' + }; + + expect(typeof searchParams.tag).toBe('string'); + expect(typeof searchParams.county).toBe('string'); + expect(typeof searchParams.search).toBe('string'); + }); +}); \ No newline at end of file diff --git a/scanner-api/src/websocket/handler.ts b/scanner-api/src/websocket/handler.ts new file mode 100644 index 0000000..747a279 --- /dev/null +++ b/scanner-api/src/websocket/handler.ts @@ -0,0 +1,130 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import type Redis from 'ioredis'; +import { WebSocket } from 'ws'; + +type SocketMessage = { + type: string; + channel?: string; + token?: string; +}; + +const clients = new Set(); +const subscriptions = new WeakMap>(); + +let relayInitialized = false; + +function send(socket: WebSocket, payload: unknown) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)); + } +} + +function broadcast(channel: string, type: string, payload: unknown) { + for (const socket of clients) { + const channels = subscriptions.get(socket); + if (channels?.has(channel)) { + send(socket, { type, payload }); + } + } +} + +export const websocketPlugin: FastifyPluginAsync = async (fastify) => { + fastify.get('/ws', { websocket: true }, (socket) => { + clients.add(socket); + subscriptions.set(socket, new Set()); + + socket.on('message', (raw) => { + let message: SocketMessage; + + try { + message = JSON.parse(raw.toString()) as SocketMessage; + } catch { + send(socket, { type: 'error', message: 'Invalid message format' }); + return; + } + + if (message.type === 'authenticate') { + send(socket, { type: 'authenticated', success: true }); + return; + } + + if (message.type === 'subscribe' && message.channel) { + subscriptions.get(socket)?.add(message.channel); + send(socket, { type: 'subscribed', channel: message.channel }); + return; + } + + if (message.type === 'ping') { + send(socket, { type: 'pong', timestamp: Date.now() }); + } + }); + + socket.on('close', () => { + clients.delete(socket); + subscriptions.delete(socket); + }); + }); +}; + +export function setupWebSocketRelay(fastify: FastifyInstance) { + if (relayInitialized) { + return; + } + + relayInitialized = true; + + const { redisSub, prisma } = fastify; + + void redisSub.subscribe('calls:new', 'calls:updated', 'calls:deleted', 'calls:purged', 'transcription:complete'); + + redisSub.on('message', async (channel: string, message: string) => { + const eventTypeByChannel: Record = { + 'calls:new': 'newCall', + 'calls:updated': 'updatedCall', + 'calls:deleted': 'deletedCall', + 'calls:purged': 'purgedCalls' + }; + + if (channel === 'transcription:complete') { + let payload: { callId?: string; transcription?: string; success?: boolean } | null = null; + + try { + payload = JSON.parse(message) as { callId?: string; transcription?: string; success?: boolean }; + } catch { + return; + } + + if (!payload?.callId || !payload.success || !payload.transcription) { + return; + } + + try { + const updatedCall = await prisma.call.update({ + where: { id: payload.callId }, + data: { transcription: payload.transcription }, + include: { talkgroup: true } + }); + + broadcast('calls', 'updatedCall', updatedCall); + } catch { + return; + } + + return; + } + + const eventType = eventTypeByChannel[channel]; + if (!eventType) { + return; + } + + let payload: unknown = null; + try { + payload = JSON.parse(message); + } catch { + payload = message; + } + + broadcast('calls', eventType, payload); + }); +} diff --git a/scanner-api/tsconfig.json b/scanner-api/tsconfig.json new file mode 100644 index 0000000..ce6e814 --- /dev/null +++ b/scanner-api/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/tests"] +} diff --git a/scanner-api/vitest.config.ts b/scanner-api/vitest.config.ts new file mode 100644 index 0000000..8bf3821 --- /dev/null +++ b/scanner-api/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}); \ No newline at end of file diff --git a/scanner-discord/Dockerfile b/scanner-discord/Dockerfile new file mode 100644 index 0000000..4719b94 --- /dev/null +++ b/scanner-discord/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src/ +RUN npm run build + +FROM node:20-alpine +RUN apk add --no-cache dumb-init +WORKDIR /app +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +USER node +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/scanner-discord/package.json b/scanner-discord/package.json new file mode 100644 index 0000000..6dc3933 --- /dev/null +++ b/scanner-discord/package.json @@ -0,0 +1,15 @@ +{ + "name": "scanner-discord", + "version": "1.0.0", + "type": "module", + "scripts": { "start": "node dist/index.js", "build": "tsc" }, + "dependencies": { + "discord.js": "^14.15.2", + "dotenv": "^16.4.5", + "ioredis": "^5.4.1" + }, + "devDependencies": { + "@types/node": "^20.14.2", + "typescript": "^5.4.5" + } +} \ No newline at end of file diff --git a/scanner-discord/src/commands/index.ts b/scanner-discord/src/commands/index.ts new file mode 100644 index 0000000..0df57f8 --- /dev/null +++ b/scanner-discord/src/commands/index.ts @@ -0,0 +1,55 @@ +import { SlashCommandBuilder, CommandInteraction } from 'discord.js'; + +export const talkgroupCommand = { + data: new SlashCommandBuilder() + .setName('talkgroup') + .setDescription('Get information about a talkgroup') + .addStringOption(option => + option.setName('id') + .setDescription('Talkgroup ID') + .setRequired(true) + ), + async execute(interaction: CommandInteraction) { + await interaction.reply('Talkgroup info would be displayed here.'); + } +}; + +export const alertCommand = { + data: new SlashCommandBuilder() + .setName('alert') + .setDescription('Manage keyword alerts') + .addSubcommand(subcommand => + subcommand.setName('add') + .setDescription('Add a keyword alert') + .addStringOption(option => + option.setName('keyword') + .setDescription('Keyword to alert on') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand.setName('remove') + .setDescription('Remove a keyword alert') + .addStringOption(option => + option.setName('keyword') + .setDescription('Keyword to remove') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand.setName('list') + .setDescription('List all keyword alerts') + ), + async execute(interaction: CommandInteraction) { + await interaction.reply('Alert management would be handled here.'); + } +}; + +export const summaryCommand = { + data: new SlashCommandBuilder() + .setName('summary') + .setDescription('Get AI summary of recent calls'), + async execute(interaction: CommandInteraction) { + await interaction.reply('Summary of recent calls would be displayed here.'); + } +}; diff --git a/scanner-discord/src/index.ts b/scanner-discord/src/index.ts new file mode 100644 index 0000000..dbe3a17 --- /dev/null +++ b/scanner-discord/src/index.ts @@ -0,0 +1,42 @@ +import { Client, GatewayIntentBits, TextChannel } from 'discord.js'; +import Redis from 'ioredis'; + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] +}); + +const redisSub = new Redis(process.env.REDIS_URL!); +const redis = new Redis(process.env.REDIS_URL!); +const API_URL = process.env.API_URL || 'http://localhost:3000'; + +async function init() { + await client.login(process.env.DISCORD_TOKEN); + + client.on('ready', () => { + console.log(`Logged in as ${client.user?.tag}`); + redisSub.subscribe('calls:new', 'calls:updated'); + }); + + redisSub.on('message', async (channel, message) => { + const call = JSON.parse(message); + + if (channel === 'calls:new') { + const alertChannelId = process.env.DISCORD_ALERT_CHANNEL_ID; + if (!alertChannelId) return; + + const channel = await client.channels.fetch(alertChannelId); + if (!channel || channel.type !== 0) return; + + const embed = { + title: `Call - ${call.talkgroup?.alphaTag || call.talkgroupId}`, + description: (call.transcription || 'No transcription').slice(0, 4096), + color: call.category === 'fire' ? 0xff0000 : call.category === 'police' ? 0x0000ff : 0x888888, + timestamp: new Date().toISOString() + }; + + await (channel as TextChannel).send({ embeds: [embed] }); + } + }); +} + +init().catch(console.error); \ No newline at end of file diff --git a/scanner-discord/tsconfig.json b/scanner-discord/tsconfig.json new file mode 100644 index 0000000..ba85fdf --- /dev/null +++ b/scanner-discord/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/scanner-transcribe/Dockerfile b/scanner-transcribe/Dockerfile new file mode 100644 index 0000000..5d012c8 --- /dev/null +++ b/scanner-transcribe/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY src ./src/ +EXPOSE 8001 +CMD ["python", "-m", "uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8001"] \ No newline at end of file diff --git a/scanner-transcribe/requirements.txt b/scanner-transcribe/requirements.txt new file mode 100644 index 0000000..9ec3bd2 --- /dev/null +++ b/scanner-transcribe/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.111.0 +uvicorn==0.30.0 +faster-whisper==1.0.3 +python-dotenv==1.0.1 +numpy==1.26.4 +pydub==0.25.1 +redis==5.0.6 +requests==2.32.3 diff --git a/scanner-transcribe/src/__init__.py b/scanner-transcribe/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scanner-transcribe/src/api.py b/scanner-transcribe/src/api.py new file mode 100644 index 0000000..bf15403 --- /dev/null +++ b/scanner-transcribe/src/api.py @@ -0,0 +1,37 @@ +import asyncio +from fastapi import FastAPI +from .transcriber import load_model +from .transcription_service import handle_transcription_request, run_transcription_listener + +app = FastAPI() +listener_task = None + +@app.on_event("startup") +async def startup(): + global listener_task + load_model() + if listener_task is None or listener_task.done(): + listener_task = asyncio.create_task(run_transcription_listener()) + + +@app.on_event("shutdown") +async def shutdown(): + global listener_task + if listener_task is not None: + listener_task.cancel() + try: + await listener_task + except asyncio.CancelledError: + pass + +@app.post("/transcribe") +async def transcribe_audio(data: dict): + return await handle_transcription_request(data) + +@app.get("/health") +async def health(): + return {"status": "ok"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/scanner-transcribe/src/config.py b/scanner-transcribe/src/config.py new file mode 100644 index 0000000..82a0f5a --- /dev/null +++ b/scanner-transcribe/src/config.py @@ -0,0 +1,7 @@ +import os +REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379') +TRANSCRIPTION_MODE = os.getenv('TRANSCRIPTION_MODE', 'local') +TRANSCRIPTION_DEVICE = os.getenv('TRANSCRIPTION_DEVICE', 'cpu') +WHISPER_MODEL = os.getenv('WHISPER_MODEL', 'base') +OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '') +ENABLE_TONE_DETECTION = os.getenv('ENABLE_TONE_DETECTION', 'false').lower() == 'true' \ No newline at end of file diff --git a/scanner-transcribe/src/tone_detector.py b/scanner-transcribe/src/tone_detector.py new file mode 100644 index 0000000..295d8d5 --- /dev/null +++ b/scanner-transcribe/src/tone_detector.py @@ -0,0 +1,170 @@ +import os +import numpy as np +from pydub import AudioSegment +from pydub.utils import get_array_type +import struct + +class ToneDetector: + def __init__(self): + self.sample_rate = 8000 + self.two_tone_frequencies = [ + (2185.5, 1962.5), + (2185.5, 2454.5), + (1962.5, 2185.5), + (2454.5, 2185.5) + ] + self.pulse_tone_duration_ms = 500 + self.long_tone_min_duration_ms = 3000 + + def load_audio(self, audio_path: str) -> np.ndarray: + audio = AudioSegment.from_file(audio_path) + audio = audio.set_frame_rate(self.sample_rate).set_channels(1) + + samples = np.array(audio.get_array_of_samples(), dtype=np.float32) + samples = samples / np.iinfo(np.int16).max + + return samples + + def detect_two_tone(self, audio: np.ndarray) -> bool: + from scipy.signal import butter, filtfilt + + def bandpass_filter(data, lowcut, highcut, fs, order=5): + nyq = 0.5 * fs + low = lowcut / nyq + high = highcut / nyq + b, a = butter(order, [low, high], btype='band') + return filtfilt(b, a, data) + + duration_ms = len(audio) / self.sample_rate * 1000 + + for freq1, freq2 in self.two_tone_frequencies: + low = min(freq1, freq2) - 50 + high = max(freq1, freq2) + 50 + + filtered = bandpass_filter(audio, low, high, self.sample_rate) + + energy = np.sum(filtered ** 2) / len(filtered) + + if energy > 0.01 and duration_ms >= 300: + return True + + return False + + def detect_pulsed_tone(self, audio: np.ndarray) -> bool: + window_size = int(self.sample_rate * 0.1) + num_windows = len(audio) // window_size + + energies = [] + for i in range(num_windows): + window = audio[i * window_size:(i + 1) * window_size] + energy = np.sum(window ** 2) / len(window) + energies.append(energy) + + if not energies: + return False + + mean_energy = np.mean(energies) + threshold = mean_energy * 2 + + pulses = 0 + in_pulse = False + pulse_duration = 0 + + for energy in energies: + if energy > threshold: + if not in_pulse: + in_pulse = True + pulse_duration = 1 + else: + pulse_duration += 1 + else: + if in_pulse and 3 <= pulse_duration <= 7: + pulses += 1 + in_pulse = False + pulse_duration = 0 + + return pulses >= 2 + + def detect_long_tone(self, audio: np.ndarray) -> bool: + window_size = int(self.sample_rate * 0.5) + num_windows = len(audio) // window_size + + energies = [] + for i in range(num_windows): + window = audio[i * window_size:(i + 1) * window_size] + energy = np.sum(window ** 2) / len(window) + energies.append(energy) + + if not energies: + return False + + mean_energy = np.mean(energies) + threshold = mean_energy * 3 + + continuous_windows = 0 + max_continuous = 0 + + for energy in energies: + if energy > threshold: + continuous_windows += 1 + max_continuous = max(max_continuous, continuous_windows) + else: + continuous_windows = 0 + + duration_ms = (max_continuous * window_size / self.sample_rate) * 1000 + return duration_ms >= self.long_tone_min_duration_ms + + def detect(self, audio_path: str, mode: str = 'auto') -> dict: + audio = self.load_audio(audio_path) + duration_ms = len(audio) / self.sample_rate * 1000 + + result = { + 'has_tone': False, + 'tone_type': None, + 'duration_ms': duration_ms, + 'confidence': 0.0 + } + + if mode == 'two_tone': + result['has_tone'] = self.detect_two_tone(audio) + result['tone_type'] = 'two_tone' if result['has_tone'] else None + result['confidence'] = 0.9 if result['has_tone'] else 0.0 + + elif mode == 'pulsed': + result['has_tone'] = self.detect_pulsed_tone(audio) + result['tone_type'] = 'pulsed' if result['has_tone'] else None + result['confidence'] = 0.85 if result['has_tone'] else 0.0 + + elif mode == 'long': + result['has_tone'] = self.detect_long_tone(audio) + result['tone_type'] = 'long' if result['has_tone'] else None + result['confidence'] = 0.8 if result['has_tone'] else 0.0 + + elif mode == 'both': + two_tone = self.detect_two_tone(audio) + pulsed = self.detect_pulsed_tone(audio) + long_tone = self.detect_long_tone(audio) + + result['has_tone'] = two_tone or pulsed or long_tone + result['tone_type'] = 'two_tone' if two_tone else ('pulsed' if pulsed else ('long' if long_tone else None)) + result['confidence'] = max(0.9 if two_tone else 0, 0.85 if pulsed else 0, 0.8 if long_tone else 0) + + else: + two_tone = self.detect_two_tone(audio) + pulsed = self.detect_pulsed_tone(audio) + long_tone = self.detect_long_tone(audio) + + result['has_tone'] = two_tone or pulsed or long_tone + if two_tone: + result['tone_type'] = 'two_tone' + result['confidence'] = 0.9 + elif pulsed: + result['tone_type'] = 'pulsed' + result['confidence'] = 0.85 + elif long_tone: + result['tone_type'] = 'long' + result['confidence'] = 0.8 + + return result + +tone_detector = ToneDetector() \ No newline at end of file diff --git a/scanner-transcribe/src/transcribe_cli.py b/scanner-transcribe/src/transcribe_cli.py new file mode 100644 index 0000000..5ffc358 --- /dev/null +++ b/scanner-transcribe/src/transcribe_cli.py @@ -0,0 +1,38 @@ +import asyncio +import sys +import json +import argparse +from . import transcriber + +async def process_transcription(audio_path: str, call_id: str, talkgroup_id: str): + try: + print(f"Transcribing: {audio_path}", file=sys.stderr) + text = await transcriber.transcribe(audio_path) + print(f"Transcription complete: {len(text)} chars", file=sys.stderr) + + result = { + 'callId': call_id, + 'transcription': text, + 'success': True + } + + print(json.dumps(result)) + sys.stdout.flush() + + except Exception as e: + error_result = { + 'callId': call_id, + 'error': str(e), + 'success': False + } + print(json.dumps(error_result), file=sys.stderr) + sys.stderr.flush() + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--audio', required=True, help='Path to audio file') + parser.add_argument('--call-id', required=True, help='Call ID') + parser.add_argument('--talkgroup-id', required=True, help='Talkgroup ID') + args = parser.parse_args() + + asyncio.run(process_transcription(args.audio, args.call_id, args.talkgroup_id)) diff --git a/scanner-transcribe/src/transcriber.py b/scanner-transcribe/src/transcriber.py new file mode 100644 index 0000000..7a2d214 --- /dev/null +++ b/scanner-transcribe/src/transcriber.py @@ -0,0 +1,34 @@ +import os +from faster_whisper import WhisperModel + +model = None +SUPPORTED_MODELS = { + 'tiny.en', 'tiny', 'base.en', 'base', 'small.en', 'small', 'medium.en', 'medium', + 'large-v1', 'large-v2', 'large-v3', 'large', 'distil-large-v2', 'distil-medium.en', + 'distil-small.en', 'distil-large-v3' +} + +def load_model(): + global model + device = os.getenv('TRANSCRIPTION_DEVICE', 'cpu') + model_size = os.getenv('WHISPER_MODEL', 'base') + if model_size not in SUPPORTED_MODELS: + print(f"Unsupported whisper model '{model_size}', falling back to 'base'") + model_size = 'base' + compute_type = 'float16' if device == 'cuda' else 'int8' + try: + model = WhisperModel(model_size, device=device, compute_type=compute_type) + except RuntimeError as exc: + if device == 'cuda': + print(f"CUDA model load failed ({exc}), falling back to CPU") + device = 'cpu' + model = WhisperModel(model_size, device=device, compute_type='int8') + else: + raise + print(f"Whisper model '{model_size}' loaded on {device}") + +async def transcribe(audio_path: str) -> str: + if not model: + load_model() + segments, _ = model.transcribe(audio_path, beam_size=5, vad_filter=True) + return ' '.join([s.text for s in segments]) diff --git a/scanner-transcribe/src/transcription_service.py b/scanner-transcribe/src/transcription_service.py new file mode 100644 index 0000000..773a8f4 --- /dev/null +++ b/scanner-transcribe/src/transcription_service.py @@ -0,0 +1,103 @@ +import os +import asyncio +import json +import tempfile +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlretrieve + +import redis.asyncio as redis + +from . import transcriber +from .config import REDIS_URL +from .tone_detector import tone_detector + +redis_client = redis.from_url(REDIS_URL, decode_responses=True) + + +async def resolve_audio_source(audio_source: str) -> tuple[str, str | None]: + parsed = urlparse(audio_source) + if parsed.scheme in {'http', 'https'}: + suffix = Path(parsed.path).suffix or '.audio' + fd, temp_path = tempfile.mkstemp(suffix=suffix) + Path(temp_path).unlink(missing_ok=True) + await asyncio.to_thread(urlretrieve, audio_source, temp_path) + return temp_path, temp_path + return audio_source, None + +async def process_audio(audio_path: str, call_id: str, talkgroup_id: str): + result = { + 'callId': call_id, + 'transcription': None, + 'toneDetection': None, + 'error': None, + 'success': True + } + + temp_path = None + + try: + resolved_audio_path, temp_path = await resolve_audio_source(audio_path) + enable_tone = os.getenv('ENABLE_TONE_DETECTION', 'false').lower() == 'true' + tone_mode = os.getenv('TONE_DETECTION_TYPE', 'auto') + + if enable_tone: + tone_result = tone_detector.detect(resolved_audio_path, mode=tone_mode) + result['toneDetection'] = tone_result + + if tone_result['has_tone']: + print(f"[Tone Detection] {tone_result['tone_type']} detected with {tone_result['confidence']:.2f} confidence") + else: + print(f"[Tone Detection] No tone detected") + + transcript = await transcriber.transcribe(resolved_audio_path) + result['transcription'] = transcript + + print(f"[Transcription] Completed: {len(transcript)} chars") + + except Exception as e: + result['error'] = str(e) + result['success'] = False + print(f"[Error] {e}") + finally: + if temp_path: + Path(temp_path).unlink(missing_ok=True) + + return result + +async def handle_transcription_request(data: dict): + audio_url = data.get('audioUrl') or data.get('audio_path') + call_id = data.get('callId') or data.get('call_id') + talkgroup_id = data.get('talkgroupId') or data.get('talkgroup_id') + + if not audio_url or not call_id: + return {'error': 'Missing audio_url or call_id', 'success': False} + + result = await process_audio(audio_url, call_id, talkgroup_id) + await redis_client.publish('transcription:complete', json.dumps(result)) + return result + +async def main(): + pubsub = redis_client.pubsub() + await pubsub.subscribe('transcription:request') + + print("[Transcription Service] Listening for requests...") + + async for message in pubsub.listen(): + if message['type'] == 'message': + try: + data = json.loads(message['data']) + print(f"[Request] Processing call {data.get('callId')}") + await handle_transcription_request(data) + except json.JSONDecodeError as e: + print(f"[Error] Invalid JSON: {e}") + except Exception as e: + print(f"[Error] {e}") + + +async def run_transcription_listener(): + await main() + +if __name__ == '__main__': + transcriber.load_model() + asyncio.run(main()) diff --git a/scanner-ui/Dockerfile b/scanner-ui/Dockerfile new file mode 100644 index 0000000..5156134 --- /dev/null +++ b/scanner-ui/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/scanner-ui/index.html b/scanner-ui/index.html new file mode 100644 index 0000000..c72a4c5 --- /dev/null +++ b/scanner-ui/index.html @@ -0,0 +1,14 @@ + + + + + + + Scanner Map + + + +
+ + + \ No newline at end of file diff --git a/scanner-ui/nginx.conf b/scanner-ui/nginx.conf new file mode 100644 index 0000000..9ce46b1 --- /dev/null +++ b/scanner-ui/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://scanner-api:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + + location /ws { + proxy_pass http://scanner-api:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'Upgrade'; + } +} \ No newline at end of file diff --git a/scanner-ui/package.json b/scanner-ui/package.json new file mode 100644 index 0000000..9041b1c --- /dev/null +++ b/scanner-ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "scanner-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build" + }, + "dependencies": { + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/leaflet": "^1.9.12", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.3.1" + } +} diff --git a/scanner-ui/postcss.config.js b/scanner-ui/postcss.config.js new file mode 100644 index 0000000..8409575 --- /dev/null +++ b/scanner-ui/postcss.config.js @@ -0,0 +1,6 @@ +import tailwindcss from 'tailwindcss'; +import autoprefixer from 'autoprefixer'; + +export default { + plugins: [tailwindcss(), autoprefixer()], +}; diff --git a/scanner-ui/src/App.tsx b/scanner-ui/src/App.tsx new file mode 100644 index 0000000..037e6fa --- /dev/null +++ b/scanner-ui/src/App.tsx @@ -0,0 +1,24 @@ +import { useSocket } from './hooks/useSocket'; +import { Map } from './components/Map'; +import { CallFeed } from './components/CallFeed'; +import { Header } from './components/Header'; + +export default function App() { + useSocket(); + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/scanner-ui/src/components/AudioPlayer.tsx b/scanner-ui/src/components/AudioPlayer.tsx new file mode 100644 index 0000000..53319ab --- /dev/null +++ b/scanner-ui/src/components/AudioPlayer.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '../store'; + +export function AudioPlayer() { + const { selectedCall } = useStore(); + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + + useEffect(() => { + setIsPlaying(false); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + }, [selectedCall]); + + const togglePlay = () => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + } + }; + + if (!selectedCall?.audioUrl) { + return ( +
+

No audio available for this call

+
+ ); + } + + return ( +
+
+ + +
+

{selectedCall.talkgroup?.alphaTag || 'Call Audio'}

+

+ {new Date(selectedCall.timestamp).toLocaleString()} +

+
+
+ +
+ ); +} \ No newline at end of file diff --git a/scanner-ui/src/components/CallFeed.tsx b/scanner-ui/src/components/CallFeed.tsx new file mode 100644 index 0000000..381381b --- /dev/null +++ b/scanner-ui/src/components/CallFeed.tsx @@ -0,0 +1,34 @@ +import { useStore } from '../store'; + +export function CallFeed() { + const { calls, setSelectedCall, selectedCall } = useStore(); + + return ( +
+
+

Recent Calls

+

{calls.length} calls

+
+
+ {calls.slice(0, 50).map(call => ( +
setSelectedCall(call)} + > +
+ {call.talkgroup?.alphaTag || call.talkgroupId} + {call.category || 'unknown'} +
+

{new Date(call.timestamp).toLocaleTimeString()}

+ {call.transcription &&

{call.transcription}

} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/scanner-ui/src/components/Header.tsx b/scanner-ui/src/components/Header.tsx new file mode 100644 index 0000000..f4707be --- /dev/null +++ b/scanner-ui/src/components/Header.tsx @@ -0,0 +1,19 @@ +import { useStore } from '../store'; + +export function Header() { + const { selectedCall } = useStore(); + + return ( +
+
+

Scanner Map

+ Real-time Emergency Monitor +
+ {selectedCall && ( +
+ {selectedCall.talkgroup?.alphaTag || selectedCall.talkgroupId} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/scanner-ui/src/components/Map.tsx b/scanner-ui/src/components/Map.tsx new file mode 100644 index 0000000..cac6fc7 --- /dev/null +++ b/scanner-ui/src/components/Map.tsx @@ -0,0 +1,37 @@ +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import L from 'leaflet'; +import { useStore } from '../store'; +import 'leaflet/dist/leaflet.css'; +import 'leaflet.markercluster/dist/MarkerCluster.css'; +import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; + +const defaultIcon = new L.Icon({ + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-grey.png', + iconSize: [25, 41], iconAnchor: [12, 41] +}); + +export function Map() { + const { calls, setSelectedCall } = useStore(); + + return ( + + + {calls.filter(c => c.lat && c.lon).map(call => ( + setSelectedCall(call) }} + > + +
+

{call.talkgroup?.alphaTag || call.talkgroupId}

+

{new Date(call.timestamp).toLocaleString()}

+ {call.transcription &&

{call.transcription.slice(0, 200)}

} +
+
+
+ ))} +
+ ); +} diff --git a/scanner-ui/src/hooks/useSocket.ts b/scanner-ui/src/hooks/useSocket.ts new file mode 100644 index 0000000..6c7a88a --- /dev/null +++ b/scanner-ui/src/hooks/useSocket.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react'; +import { useStore } from '../store'; + +const RECONNECT_DELAY = 2000; +const MAX_DELAY = 30000; + +export function useSocket() { + const socketRef = useRef(null); + const reconnectTimerRef = useRef(null); + const attemptRef = useRef(0); + const { setCalls, addCall, updateCall, removeCall } = useStore(); + + useEffect(() => { + let isActive = true; + + const fetchCalls = async () => { + const res = await fetch('/api/calls?limit=100'); + const data = await res.json(); + setCalls(data); + }; + + const scheduleReconnect = () => { + if (!isActive) { + return; + } + + const delay = Math.min(RECONNECT_DELAY * 2 ** attemptRef.current, MAX_DELAY); + reconnectTimerRef.current = window.setTimeout(() => { + attemptRef.current += 1; + connect(); + }, delay); + }; + + const connect = () => { + const token = localStorage.getItem('token'); + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + socketRef.current = new WebSocket(`${protocol}//${window.location.host}/ws`); + + socketRef.current.addEventListener('open', () => { + attemptRef.current = 0; + if (token) { + socketRef.current?.send(JSON.stringify({ type: 'authenticate', token })); + } + socketRef.current?.send(JSON.stringify({ type: 'subscribe', channel: 'calls' })); + }); + + socketRef.current.addEventListener('message', (event) => { + let message: { type: string; payload?: any }; + + try { + message = JSON.parse(event.data) as { type: string; payload?: any }; + } catch { + return; + } + + if (message.type === 'newCall' && message.payload) { + addCall(message.payload); + } else if (message.type === 'updatedCall' && message.payload) { + updateCall(message.payload); + } else if (message.type === 'deletedCall' && message.payload?.id) { + removeCall(message.payload.id); + } else if (message.type === 'purgedCalls') { + void fetchCalls(); + } + }); + + socketRef.current.addEventListener('close', () => { + socketRef.current = null; + scheduleReconnect(); + }); + + socketRef.current.addEventListener('error', () => { + socketRef.current?.close(); + }); + }; + + connect(); + void fetchCalls(); + + return () => { + isActive = false; + if (reconnectTimerRef.current !== null) { + window.clearTimeout(reconnectTimerRef.current); + } + socketRef.current?.close(); + }; + }, []); + + return { socket: socketRef.current }; +} diff --git a/scanner-ui/src/index.css b/scanner-ui/src/index.css new file mode 100644 index 0000000..2faf12b --- /dev/null +++ b/scanner-ui/src/index.css @@ -0,0 +1,6 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { margin: 0; background-color: #1a1a2e; color: white; } +.leaflet-container { height: 100%; width: 100%; background: #0a0a15; } \ No newline at end of file diff --git a/scanner-ui/src/main.tsx b/scanner-ui/src/main.tsx new file mode 100644 index 0000000..b3c11a7 --- /dev/null +++ b/scanner-ui/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); \ No newline at end of file diff --git a/scanner-ui/src/store.ts b/scanner-ui/src/store.ts new file mode 100644 index 0000000..9aefaa1 --- /dev/null +++ b/scanner-ui/src/store.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand'; + +interface Call { + id: string; + talkgroupId: string; + timestamp: string; + transcription: string | null; + audioUrl: string | null; + address: string | null; + lat: number | null; + lon: number | null; + category: string | null; + talkgroup?: any; +} + +interface State { + calls: Call[]; + selectedCall: Call | null; + setCalls: (calls: Call[]) => void; + addCall: (call: Call) => void; + updateCall: (call: Call) => void; + removeCall: (id: string) => void; + setSelectedCall: (call: Call | null) => void; +} + +export const useStore = create((set) => ({ + calls: [], + selectedCall: null, + setCalls: (calls) => set({ calls }), + addCall: (call) => set((state) => ({ calls: [call, ...state.calls] })), + updateCall: (call) => set((state) => ({ + calls: state.calls.map((c) => c.id === call.id ? call : c), + selectedCall: state.selectedCall?.id === call.id ? call : state.selectedCall + })), + removeCall: (id) => set((state) => ({ + calls: state.calls.filter((c) => c.id !== id), + selectedCall: state.selectedCall?.id === id ? null : state.selectedCall + })), + setSelectedCall: (call) => set({ selectedCall: call }), +})); \ No newline at end of file diff --git a/scanner-ui/src/types.ts b/scanner-ui/src/types.ts new file mode 100644 index 0000000..0148d6b --- /dev/null +++ b/scanner-ui/src/types.ts @@ -0,0 +1,40 @@ +export interface Call { + id: string; + talkgroupId: string; + timestamp: string; + transcription: string | null; + audioUrl: string | null; + address: string | null; + lat: number | null; + lon: number | null; + category: string | null; + talkgroup?: Talkgroup; +} + +export interface Talkgroup { + id: string; + hex: string | null; + alphaTag: string | null; + mode: string | null; + description: string | null; + tag: string | null; + county: string | null; +} + +export interface User { + id: string; + username: string; + isAdmin: boolean; +} + +export interface Config { + googleMapsApiKey: string; + locationIqApiKey: string; + geocoding: { + provider: string; + state: string; + country: string; + city: string; + targetCounties: string[]; + }; +} \ No newline at end of file diff --git a/scanner-ui/tailwind.config.js b/scanner-ui/tailwind.config.js new file mode 100644 index 0000000..1196bae --- /dev/null +++ b/scanner-ui/tailwind.config.js @@ -0,0 +1,4 @@ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { extend: { colors: { scanner: { dark: '#1a1a2e', light: '#16213e' } } } } +}; \ No newline at end of file diff --git a/scanner-ui/tsconfig.json b/scanner-ui/tsconfig.json new file mode 100644 index 0000000..d0104ed --- /dev/null +++ b/scanner-ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/scanner-ui/tsconfig.node.json b/scanner-ui/tsconfig.node.json new file mode 100644 index 0000000..4eb43d0 --- /dev/null +++ b/scanner-ui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/scanner-ui/vite.config.ts b/scanner-ui/vite.config.ts new file mode 100644 index 0000000..353da9f --- /dev/null +++ b/scanner-ui/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { target: process.env.API_URL || 'http://localhost:3000', changeOrigin: true }, + '/ws': { target: process.env.API_URL || 'http://localhost:3000', ws: true } + } + } +}); \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..46eadc6 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,40 @@ +$ErrorActionPreference = 'Stop' +$GithubOrg = if ($env:GITHUB_ORG) { $env:GITHUB_ORG } else { "Dadud" } +$ImageTag = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "latest" } + +Write-Host "=== Scanner Map Installer ===" -ForegroundColor Cyan +Write-Host "Registry: ghcr.io/$GithubOrg" +Write-Host "" + +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Host "Docker is required. Install: https://docs.docker.com/desktop/install/windows-install/" -ForegroundColor Red + exit 1 +} + +Set-Location $PSScriptRoot + +if (-not (Test-Path ".env")) { + @" +DISCORD_TOKEN= +DISCORD_ALERT_CHANNEL_ID= +DISCORD_SUMMARY_CHANNEL_ID= +LOCATIONIQ_API_KEY= +OPENAI_API_KEY= +POSTGRES_PASSWORD=change_this_password +JWT_SECRET=change_this_random_string +"@ | Out-File -FilePath ".env" -Encoding utf8 + Write-Host "Created .env - please edit with your values" -ForegroundColor Yellow + Read-Host "Press Enter when done" +} + +Write-Host "Pulling images..." -ForegroundColor Green +foreach ($img in @("api", "transcribe", "ui")) { + docker pull "ghcr.io/$GithubOrg/scanner-map-${img}:$ImageTag" 2>$null | Out-Null +} + +$env:GITHUB_ORG = $GithubOrg +$env:IMAGE_TAG = $ImageTag +docker-compose up -d + +Write-Host "" +Write-Host "Scanner Map running at http://localhost" -ForegroundColor Cyan \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..b64f9a9 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e + +GITHUB_ORG="${GITHUB_ORG:-Dadud}" +IMAGE_TAG="${IMAGE_TAG:-latest}" + +echo "=== Scanner Map Installer ===" +echo "Registry: ghcr.io/${GITHUB_ORG}" +echo "" + +if ! command -v docker &> /dev/null; then + echo "Docker is required. Install: https://docs.docker.com/get-docker/"; exit 1 +fi + +cd "$(dirname "$0")" + +if [ ! -f ".env" ]; then + cat > .env << 'EOF' +DISCORD_TOKEN= +DISCORD_ALERT_CHANNEL_ID= +DISCORD_SUMMARY_CHANNEL_ID= +LOCATIONIQ_API_KEY= +OPENAI_API_KEY= +POSTGRES_PASSWORD=change_this_password +JWT_SECRET=change_this_random_string +EOF + echo "Created .env - please edit with your values" + read -p "Press Enter when done..." +fi + +echo "Pulling images..." +for img in api transcribe ui; do + docker pull "ghcr.io/${GITHUB_ORG}/scanner-map-${img}:${IMAGE_TAG}" 2>/dev/null || true +done + +export GITHUB_ORG IMAGE_TAG +docker-compose up -d + +echo "" +echo "Scanner Map running at http://localhost" \ No newline at end of file diff --git a/scripts/smoke_runtime.py b/scripts/smoke_runtime.py new file mode 100644 index 0000000..d5d9cb0 --- /dev/null +++ b/scripts/smoke_runtime.py @@ -0,0 +1,166 @@ +import json +import os +import time + +import redis +import requests +import websocket + + +API_BASE = os.getenv("API_BASE", "http://localhost:3000") +UI_BASE = os.getenv("UI_BASE", "http://localhost") +TRANSCRIBE_BASE = os.getenv("TRANSCRIBE_BASE", "http://localhost:8001") +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + +def expect(condition, message): + if not condition: + raise AssertionError(message) + + +def wait_for_http(url, timeout=120): + deadline = time.time() + timeout + last_error = None + + while time.time() < deadline: + try: + response = requests.get(url, timeout=5) + if response.ok: + return response + except Exception as exc: + last_error = exc + time.sleep(2) + + raise RuntimeError(f"Timed out waiting for {url}: {last_error}") + + +def recv_json(ws, timeout=15): + ws.settimeout(timeout) + raw = ws.recv() + return json.loads(raw) + + +def recv_until(ws, predicate, timeout=20): + deadline = time.time() + timeout + last_message = None + while time.time() < deadline: + message = recv_json(ws, timeout=max(1, int(deadline - time.time()))) + last_message = message + if predicate(message): + return message + raise AssertionError(f"Timed out waiting for websocket message. Last message: {last_message}") + + +def wait_for_redis_message(pubsub, channel, predicate, timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1) + if not message: + continue + if message.get("channel") != channel: + continue + data = json.loads(message["data"]) + if predicate(data): + return data + raise AssertionError(f"Timed out waiting for Redis message on {channel}") + + +def main(): + wait_for_http(f"{API_BASE}/api/health") + wait_for_http(UI_BASE) + wait_for_http(f"{TRANSCRIBE_BASE}/health") + + calls_response = requests.get(f"{API_BASE}/api/calls?limit=5", timeout=10) + expect(calls_response.ok, "GET /api/calls failed") + expect(isinstance(calls_response.json(), list), "/api/calls did not return a list") + + ws = websocket.create_connection("ws://localhost:3000/ws", timeout=10) + ws.send(json.dumps({"type": "subscribe", "channel": "calls"})) + recv_until(ws, lambda message: message.get("type") == "subscribed" and message.get("channel") == "calls") + + create_response = requests.post( + f"{API_BASE}/api/calls", + json={ + "talkgroupId": "smoke", + "timestamp": "2026-04-15T18:00:00.000Z", + "category": "smoke-test" + }, + timeout=10, + ) + expect(create_response.status_code == 201, f"POST /api/calls failed: {create_response.text}") + call = create_response.json() + call_id = call["id"] + + recv_until(ws, lambda message: message.get("type") == "newCall" and message.get("payload", {}).get("id") == call_id) + + update_response = requests.put( + f"{API_BASE}/api/admin/markers/{call_id}/location", + json={"lat": 42.0, "lon": -71.0, "address": "Smoke Test"}, + timeout=10, + ) + expect(update_response.ok, f"PUT /api/admin/markers/{call_id}/location failed") + recv_until( + ws, + lambda message: message.get("type") == "updatedCall" + and message.get("payload", {}).get("id") == call_id + and message.get("payload", {}).get("address") == "Smoke Test", + ) + + delete_response = requests.delete(f"{API_BASE}/api/admin/markers/{call_id}", timeout=10) + expect(delete_response.status_code == 204, f"DELETE /api/admin/markers/{call_id} failed") + recv_until(ws, lambda message: message.get("type") == "deletedCall" and message.get("payload", {}).get("id") == call_id) + + purge_create = requests.post( + f"{API_BASE}/api/calls", + json={ + "talkgroupId": "smoke", + "timestamp": "2026-04-15T18:00:00.000Z", + "category": "purge-test" + }, + timeout=10, + ) + expect(purge_create.status_code == 201, "failed to create call for purge test") + recv_until(ws, lambda message: message.get("type") == "newCall") + + purge_response = requests.post( + f"{API_BASE}/api/admin/calls/purge", + json={"talkgroupId": "smoke", "olderThan": "2100-01-01T00:00:00.000Z"}, + timeout=10, + ) + expect(purge_response.ok, f"POST /api/admin/calls/purge failed: {purge_response.text}") + recv_until(ws, lambda message: message.get("type") == "purgedCalls") + + redis_client = redis.Redis.from_url(REDIS_URL, decode_responses=True) + pubsub = redis_client.pubsub() + pubsub.subscribe("transcription:complete") + time.sleep(1) + + webhook_response = requests.post( + f"{API_BASE}/api/webhook/call-upload", + json={ + "talkgroupId": "smoke", + "audioUrl": "/definitely/missing.wav", + "category": "transcription-smoke" + }, + timeout=10, + ) + expect(webhook_response.status_code == 201, f"POST /api/webhook/call-upload failed: {webhook_response.text}") + webhook_call_id = webhook_response.json()["callId"] + + transcription_event = wait_for_redis_message( + pubsub, + "transcription:complete", + lambda data: data.get("callId") == webhook_call_id, + timeout=60, + ) + expect(transcription_event.get("success") is False, "transcription smoke should fail for missing audio path") + + ws.close() + pubsub.close() + redis_client.close() + + print("Runtime smoke passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/start.ps1 b/scripts/start.ps1 new file mode 100644 index 0000000..ad453ab --- /dev/null +++ b/scripts/start.ps1 @@ -0,0 +1,60 @@ +$ErrorActionPreference = 'Stop' + +Write-Host "=== Scanner Map Docker Setup (Windows) ===" -ForegroundColor Cyan + +$ScriptDir = $PSScriptRoot +$RepoRoot = Split-Path $ScriptDir -Parent + +if (-not (Test-Path (Join-Path $RepoRoot ".env"))) { + Write-Host "Creating .env file from example..." -ForegroundColor Yellow + Copy-Item (Join-Path $RepoRoot ".env.example") (Join-Path $RepoRoot ".env") + Write-Host "" + Write-Host "IMPORTANT: Please edit .env and add your configuration values:" -ForegroundColor Red + Write-Host " - DISCORD_TOKEN (required for Discord bot)" + Write-Host " - DATABASE_URL (uses PostgreSQL credentials)" + Write-Host " - REDIS_URL (uses Redis)" + Write-Host " - LOCATIONIQ_API_KEY or GOOGLE_MAPS_API_KEY (for geocoding)" + Write-Host "" + $null = Read-Host "Press Enter when you've configured .env..." +} + +Write-Host "" +Write-Host "Starting Docker services..." -ForegroundColor Green + +$env:COMPOSE_PROJECT_NAME = "scannermap" +Push-Location $RepoRoot + +docker network create scanner-network 2>$null | Out-Null + +docker-compose up -d scanner-postgres scanner-redis + +Write-Host "" +Write-Host "Waiting for database to be ready..." -ForegroundColor Yellow +Start-Sleep -Seconds 10 + +docker-compose up -d scanner-api scanner-transcribe + +Write-Host "" +Write-Host "Initializing database..." -ForegroundColor Yellow +docker-compose exec -T scanner-api npx prisma db push --accept-data-loss 2>$null + +Write-Host "" +Write-Host "Starting UI..." -ForegroundColor Green +docker-compose up -d scanner-ui + +Write-Host "" +Write-Host "=== Scanner Map is starting up ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "Services:" -ForegroundColor White +Write-Host " UI: http://localhost" +Write-Host " API: http://localhost:3000" +Write-Host " API Docs: http://localhost:3000/docs" +Write-Host "" +Write-Host "To enable Discord bot, run:" -ForegroundColor Yellow +Write-Host " docker-compose --profile discord up -d" +Write-Host "" +Write-Host "View logs with:" -ForegroundColor Yellow +Write-Host " docker-compose logs -f" +Write-Host "" + +Pop-Location diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..4f05b67 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -e + +echo "=== Scanner Map Docker Setup ===" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_ROOT}" + +if [ ! -f ".env" ]; then + echo "Creating .env file from example..." + cp "${REPO_ROOT}/.env.example" "${REPO_ROOT}/.env" + echo "" + echo "IMPORTANT: Please edit .env and add your configuration values:" + echo " - DISCORD_TOKEN (required for Discord bot)" + echo " - DATABASE_URL (uses PostgreSQL credentials)" + echo " - REDIS_URL (uses Redis)" + echo " - LOCATIONIQ_API_KEY or GOOGLE_MAPS_API_KEY (for geocoding)" + echo "" + read -p "Press Enter when you've configured .env..." +else + echo ".env already exists, using existing configuration." +fi + +echo "" +echo "Starting Docker services..." + +docker network create scanner-network 2>/dev/null || true + +docker-compose up -d scanner-postgres scanner-redis + +echo "" +echo "Waiting for database to be ready..." +sleep 10 + +docker-compose up -d scanner-api scanner-transcribe + +echo "" +echo "Initializing database..." +docker-compose exec -T scanner-api npx prisma db push --accept-data-loss 2>/dev/null || true + +echo "" +echo "Starting UI..." +docker-compose up -d scanner-ui + +echo "" +echo "=== Scanner Map is starting up ===" +echo "" +echo "Services:" +echo " UI: http://localhost" +echo " API: http://localhost:3000" +echo " API Docs: http://localhost:3000/docs" +echo "" +echo "To enable Discord bot, run:" +echo " docker-compose --profile discord up -d" +echo "" +echo "View logs with:" +echo " docker-compose logs -f" +echo "" diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 new file mode 100644 index 0000000..97198a2 --- /dev/null +++ b/scripts/verify.ps1 @@ -0,0 +1,195 @@ +param( + [string]$Repo = 'Dadud/Scanner-map', + [string]$Branch = '', + [string]$ImageTag = '', + [switch]$AllowDirtyWorktree, + [switch]$SkipRemote, + [switch]$SkipLocalBuild, + [switch]$SkipDocker, + [switch]$RunDockerSmoke +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path $PSScriptRoot -Parent +$Failures = New-Object System.Collections.Generic.List[string] +$Skips = New-Object System.Collections.Generic.List[string] + +function Resolve-Tool { + param( + [string]$Name, + [string[]]$Fallbacks = @() + ) + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + foreach ($path in $Fallbacks) { + if (Test-Path $path) { + return $path + } + } + + return $null +} + +function Invoke-Step { + param( + [string]$Label, + [scriptblock]$Action + ) + + Write-Host "==> $Label" -ForegroundColor Cyan + try { + & $Action + Write-Host "PASS: $Label" -ForegroundColor Green + } catch { + $Failures.Add("${Label}: $($_.Exception.Message)") | Out-Null + Write-Host "FAIL: $Label" -ForegroundColor Red + } +} + +function Skip-Step { + param([string]$Label) + $Skips.Add($Label) | Out-Null + Write-Host "SKIP: $Label" -ForegroundColor Yellow +} + +$git = Resolve-Tool git @('C:\Program Files\Git\bin\git.exe') +$gh = Resolve-Tool gh @('C:\Program Files\GitHub CLI\gh.exe') +$docker = Resolve-Tool docker @('C:\Program Files\Docker\Docker\resources\bin\docker.exe') +$node = Resolve-Tool node +$npm = Resolve-Tool npm +$python = Resolve-Tool python + +if (-not $git) { + throw 'git is required to run verification.' +} + +Push-Location $RepoRoot + +$headSha = (& $git rev-parse HEAD).Trim() +if (-not $Branch) { + $Branch = (& $git rev-parse --abbrev-ref HEAD).Trim() +} +if (-not $ImageTag) { + $ImageTag = $headSha +} + +if ($AllowDirtyWorktree) { + Skip-Step 'Git worktree is clean (allow-dirty-worktree enabled)' +} else { + Invoke-Step 'Git worktree is clean' { + $status = (& $git status --porcelain).Trim() + if ($status) { + throw "worktree is dirty`n$status" + } + } +} + +if (-not $SkipRemote) { + if (-not $gh) { + Skip-Step 'Remote workflow verification (gh not installed)' + } else { + $authOk = $true + try { + & $gh auth status | Out-Null + } catch { + $authOk = $false + } + + if (-not $authOk) { + Skip-Step 'Remote workflow verification (gh not authenticated)' + } else { + Invoke-Step 'Build and Push Images workflow succeeded for HEAD' { + $runs = & $gh run list --repo $Repo --workflow 'Build and Push Images' --branch $Branch --limit 20 --json databaseId,headSha,conclusion,displayTitle,workflowName,createdAt + $entries = $runs | ConvertFrom-Json + $match = $entries | Where-Object { $_.headSha -eq $headSha -and $_.conclusion -eq 'success' } | Select-Object -First 1 + if (-not $match) { + throw "no successful Build and Push Images run found for $headSha" + } + } + } + } +} + +if (-not $SkipLocalBuild) { + if (-not $node -or -not $npm) { + Skip-Step 'Local Node builds (node/npm not installed)' + } else { + foreach ($service in @('scanner-api', 'scanner-ui', 'scanner-discord')) { + Invoke-Step "$service installs and builds" { + Push-Location (Join-Path $RepoRoot $service) + try { + & $npm install --package-lock=false + if ($service -eq 'scanner-api') { + & $npm exec -- prisma generate + } + & $npm run build + } finally { + Pop-Location + } + } + } + } + + if ($python) { + Invoke-Step 'scanner-transcribe Python sources compile' { + & $python -m compileall (Join-Path $RepoRoot 'scanner-transcribe\src') + } + } else { + Skip-Step 'scanner-transcribe Python compile check (python not installed)' + } +} + +if (-not $SkipDocker) { + if (-not $docker) { + Skip-Step 'Docker compose verification (docker not installed)' + } else { + Invoke-Step 'docker-compose.yml validates' { + & $docker compose -f (Join-Path $RepoRoot 'docker-compose.yml') config | Out-Null + } + + Invoke-Step 'docker-compose.prebuilt.yml validates' { + $env:IMAGE_TAG = $ImageTag + $env:DOCKER_ORG = 'dadud' + $env:DOCKER_REGISTRY = 'ghcr.io' + & $docker compose -f (Join-Path $RepoRoot 'docker-compose.prebuilt.yml') config | Out-Null + } + + if ($RunDockerSmoke) { + Invoke-Step 'Prebuilt stack pulls successfully' { + $env:IMAGE_TAG = $ImageTag + $env:DOCKER_ORG = 'dadud' + $env:DOCKER_REGISTRY = 'ghcr.io' + & $docker compose -f (Join-Path $RepoRoot 'docker-compose.prebuilt.yml') pull scanner-api scanner-ui scanner-transcribe scanner-discord + } + } + } +} + +Write-Host '' +Write-Host 'Verification summary' -ForegroundColor Cyan +Write-Host " Branch: $Branch" +Write-Host " HEAD: $headSha" + +if ($Skips.Count -gt 0) { + Write-Host ' Skipped:' -ForegroundColor Yellow + foreach ($skip in $Skips) { + Write-Host " - $skip" + } +} + +if ($Failures.Count -gt 0) { + Write-Host ' Failures:' -ForegroundColor Red + foreach ($failure in $Failures) { + Write-Host " - $failure" + } + Pop-Location + exit 1 +} + +Write-Host ' Result: PASS' -ForegroundColor Green +Pop-Location diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..48a7a3b --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,157 @@ +#!/bin/bash +set -euo pipefail + +REPO="Dadud/Scanner-map" +BRANCH="" +IMAGE_TAG="" +ALLOW_DIRTY_WORKTREE=0 +SKIP_REMOTE=0 +SKIP_LOCAL_BUILD=0 +SKIP_DOCKER=0 +RUN_DOCKER_SMOKE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO="$2" + shift 2 + ;; + --branch) + BRANCH="$2" + shift 2 + ;; + --image-tag) + IMAGE_TAG="$2" + shift 2 + ;; + --allow-dirty-worktree) + ALLOW_DIRTY_WORKTREE=1 + shift + ;; + --skip-remote) + SKIP_REMOTE=1 + shift + ;; + --skip-local-build) + SKIP_LOCAL_BUILD=1 + shift + ;; + --skip-docker) + SKIP_DOCKER=1 + shift + ;; + --run-docker-smoke) + RUN_DOCKER_SMOKE=1 + shift + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +failures=() +skips=() + +step() { + local label="$1" + shift + echo "==> ${label}" + if "$@"; then + echo "PASS: ${label}" + else + echo "FAIL: ${label}" + failures+=("${label}") + fi +} + +skip_step() { + local label="$1" + echo "SKIP: ${label}" + skips+=("${label}") +} + +cd "$REPO_ROOT" + +if ! command -v git >/dev/null 2>&1; then + echo "git is required to run verification." >&2 + exit 1 +fi + +HEAD_SHA="$(git rev-parse HEAD)" +if [[ -z "$BRANCH" ]]; then + BRANCH="$(git rev-parse --abbrev-ref HEAD)" +fi +if [[ -z "$IMAGE_TAG" ]]; then + IMAGE_TAG="$HEAD_SHA" +fi + +if [[ "$ALLOW_DIRTY_WORKTREE" -eq 1 ]]; then + skip_step "Git worktree is clean (allow-dirty-worktree enabled)" +else + step "Git worktree is clean" bash -lc '[[ -z "$(git status --porcelain)" ]]' +fi + +if [[ "$SKIP_REMOTE" -eq 0 ]]; then + if ! command -v gh >/dev/null 2>&1; then + skip_step "Remote workflow verification (gh not installed)" + elif ! gh auth status >/dev/null 2>&1; then + skip_step "Remote workflow verification (gh not authenticated)" + else + step "Build and Push Images workflow succeeded for HEAD" bash -lc "gh run list --repo '$REPO' --workflow 'Build and Push Images' --branch '$BRANCH' --limit 20 --json headSha,conclusion | python -c \"import json,sys; runs=json.load(sys.stdin); raise SystemExit(0 if any(run.get('headSha') == '$HEAD_SHA' and run.get('conclusion') == 'success' for run in runs) else 1)\"" + fi +fi + +if [[ "$SKIP_LOCAL_BUILD" -eq 0 ]]; then + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + skip_step "Local Node builds (node/npm not installed)" + else + step "scanner-api installs and builds" bash -lc 'cd scanner-api && npm install --package-lock=false && npm exec -- prisma generate && npm run build' + step "scanner-ui installs and builds" bash -lc 'cd scanner-ui && npm install --package-lock=false && npm run build' + step "scanner-discord installs and builds" bash -lc 'cd scanner-discord && npm install --package-lock=false && npm run build' + fi + + if command -v python >/dev/null 2>&1; then + step "scanner-transcribe Python sources compile" python -m compileall scanner-transcribe/src + else + skip_step "scanner-transcribe Python compile check (python not installed)" + fi +fi + +if [[ "$SKIP_DOCKER" -eq 0 ]]; then + if ! command -v docker >/dev/null 2>&1; then + skip_step "Docker compose verification (docker not installed)" + else + step "docker-compose.yml validates" docker compose -f docker-compose.yml config + step "docker-compose.prebuilt.yml validates" env DOCKER_ORG=dadud DOCKER_REGISTRY=ghcr.io IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.prebuilt.yml config + if [[ "$RUN_DOCKER_SMOKE" -eq 1 ]]; then + step "Prebuilt stack pulls successfully" env DOCKER_ORG=dadud DOCKER_REGISTRY=ghcr.io IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.prebuilt.yml pull scanner-api scanner-ui scanner-transcribe scanner-discord + fi + fi +fi + +echo +echo "Verification summary" +echo " Branch: ${BRANCH}" +echo " HEAD: ${HEAD_SHA}" + +if [[ ${#skips[@]} -gt 0 ]]; then + echo " Skipped:" + for item in "${skips[@]}"; do + echo " - ${item}" + done +fi + +if [[ ${#failures[@]} -gt 0 ]]; then + echo " Failures:" + for item in "${failures[@]}"; do + echo " - ${item}" + done + exit 1 +fi + +echo " Result: PASS"